quario 0.13.0 → 0.14.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,5 +1,30 @@
1
1
  # quario
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **`quario(options)` now refuses an option it does not understand, like every target factory.** Before, the engine read the keys it knew and ignored the rest. A typo such as `quario({ licence })` therefore marked every render as unlicensed, and nothing said why. Now an unknown key, a value of the wrong type, or an `options` that is not an object throws a `TypeError` at the call. The message names the option path, such as `options.license`.
8
+
9
+ **`locale`, `timeZone` and `currency` are now checked at the call.** Before, a value that `Intl` refused did not fail. Every formatted cell showed its raw value instead. Now `quario({ locale: "en_US" })` and `quario({ timeZone: "Europe/Amsterdm" })` throw a `TypeError`. `currency` must be three upper-case letters, the same rule that a cell's own `currency` declaration follows. A cell's own `currency` still fails soft.
10
+
11
+ **The `query` budgets are now checked at the call.** Before, the engine checked them only when a report compiled its first query. A report with no query never reported a bad budget. The error still comes from padvinder, in its own words.
12
+
13
+ **`plan(schema, functions, options)` now refuses an unknown key in `options`.** Before, `{ target: [...] }` checked nothing and said nothing. A `targets` that is not an array also throws a `TypeError`.
14
+
15
+ **A bad `schemes` allowlist now throws a `TypeError`, not an `Error`.** The message starts with `options.schemes:`, not `schemes:`. A `null` allowlist still throws.
16
+
17
+ **What this changes for you.** An options object that carries keys for another consumer stops working when you pass it to `quario()`. Pass `quario()` only the six keys it takes: `query`, `license`, `locale`, `currency`, `timeZone` and `schemes`.
18
+
19
+ - **A group can now order and cut its instances.** Declare `instances` beside `sort`, with an optional `sort` and an optional `take` inside it. They order and cut the group's instances, as the group's own `sort` and `take` order and cut the rows inside each instance. Each `instances.sort` key reads the group's own handle once per instance, so `instances: { sort: [{ by: "=region.subtotal", dir: "desc" }], take: 5 }` keeps the five regions with the largest subtotals, largest first. `@` and `run` are unbound there. A dropped instance never renders, and no running value steps over it. The totals around the group still count it, as they count a row that a group's `take` drops. A group without `instances` renders every instance in first-seen order, so existing reports render as before.
20
+ - **An image item now accepts SVG bytes.** Before, `source` had to yield PNG or JPEG bytes, and SVG bytes were a render error. Now the engine also names UTF-8 text whose root element is `<svg` as an SVG, and the image event carries `format: "svg"`. The engine reads the size from the root's `width` and `height` when both are absolute lengths, and else from its `viewBox`. An SVG that states no size this way is the same render error as a PNG that is too short to state its size. The message for bytes that are not an image now says "PNG, JPEG or SVG".
21
+
22
+ The HTML target puts an SVG in an `<img>` under `data:image/svg+xml`, so the SVG never becomes markup in the fragment. The PDF target draws an SVG as vector paths. It draws no picture that the SVG names, and it fetches nothing. SVG `<text>` draws in the standard Helvetica. The viewer paints an SVG through a `data:` URI, so a page that shows one needs the Content Security Policy grant `img-src data:`. A PNG or a JPEG still needs no grant there. The XLSX and DOCX targets cannot draw an SVG, so an SVG image is a render error that names the item.
23
+
24
+ ### Patch Changes
25
+
26
+ - **The package now carries edition 1.1 of the Quario License Agreement.** A license now covers one production deployment, not one developer. Each deployment needs one license key. Development, staging, CI and test environments need no key. A key covers every release dated up to one year after you buy it, and those releases stay licensed for good. A key is not a subscription, and it never renews automatically. A refunded or reversed payment voids the keys it bought. Software that your customers install or resell needs separate OEM terms.
27
+
3
28
  ## 0.13.0
4
29
 
5
30
  ### Minor Changes
package/LICENSE CHANGED
@@ -1,179 +1,200 @@
1
1
  # Quario License Agreement
2
2
 
3
3
  > **In short:** Quario is commercial software with visible source.
4
- > Evaluate it free for as long as you like. Evaluation output is
5
- > watermarked. Anything else needs a per-developer license, after
6
- > which deployment is unlimited and royalty-free. This summary is not
7
- > the license; the full terms below govern.
4
+ > You may evaluate it and develop with it free, for as long as you
5
+ > like. The Software marks output that no license key covers. Each
6
+ > production deployment needs one license key. A key covers one year
7
+ > of releases, and those releases stay licensed for good. This summary
8
+ > is not the license. The full terms below govern.
8
9
 
9
- Version 1.0, August 2026
10
+ Version 1.1, October 2026
10
11
 
11
12
  This agreement is between you and Webstronauts, trading as Quario,
12
- established in the Netherlands ("we", "us"), and governs the Software
13
+ established in the Netherlands ("we", "us"). It governs the Software
13
14
  defined below.
14
15
 
15
16
  Quario is commercial software. Its packages ship with readable source
16
- code, but it is not open source. No rights are granted beyond those in
17
- this agreement.
17
+ code, but it is not open source. This agreement grants no rights
18
+ beyond those it states.
18
19
 
19
20
  ## 1. Definitions
20
21
 
21
22
  - The **Software** is any software we publish under the Quario name,
22
23
  including the `quario` and `@quario/*` packages, except where a
23
24
  product states its own terms. Each release states the edition of this
24
- agreement that governs it.
25
- - **You** means the individual exercising rights under this agreement
26
- and, where that individual acts for a company or other organization,
27
- that organization. In that case the organization is the licensee, and
28
- the rights and limits in this agreement apply to it as a whole.
25
+ agreement that governs it. A license key bought under this edition
26
+ licenses every release it covers under this edition, including
27
+ releases that shipped with an earlier edition.
28
+ - **You** means the individual who exercises rights under this
29
+ agreement. Where that individual acts for a company or other
30
+ organization, **you** also means that organization. In that case the
31
+ organization is the licensee, and the rights and limits in this
32
+ agreement apply to it as a whole.
29
33
  - An **Application** is software you create that incorporates the
30
- Software and adds substantial functionality beyond it. A
31
- licensee-operated service that embeds the Software and renders
32
- reports for your End Users is an Application under this agreement,
33
- even if it does little more than render.
34
- - A **Developer** is any individual (employee or contractor working on
35
- your behalf) who writes or maintains code that uses the Software's
36
- APIs, or who creates or maintains report definitions for use with the
37
- Software. That includes people who never install or run the Software
38
- themselves, when their definitions are rendered by a service that
39
- embeds it.
40
- - A **Seat** is a paid license for one named Developer, purchased from
41
- us or an authorized reseller.
34
+ Software and adds substantial functionality beyond it. A service you
35
+ operate that embeds the Software and renders reports for your End
36
+ Users is an Application, even if it does little more than render.
37
+ - A **Deployment** is one production service, application, bot, worker
38
+ or MCP server that runs the Software under your control. All replicas
39
+ of it together are one Deployment. A service that only passes render
40
+ requests to another service does not run the Software, so it is not
41
+ a Deployment. A service that a host or contractor operates on your
42
+ behalf stays under your control. Development, staging, CI and test
43
+ environments are not Deployments. We never count the people, agents
44
+ or renders that a Deployment serves.
45
+ - A **License Key** is the signed key that evidences the license of
46
+ one Deployment. You buy it from us or from an authorized reseller.
42
47
  - **End Users** are people who interact with the Software only through
43
- an Application: running it, viewing the reports it produces, or
44
- supplying parameters or other input to existing report definitions
45
- the Application provides. Creating or maintaining report definitions
46
- makes someone a Developer. End Users do not purchase a license; their
47
- rights come from section 5.
48
+ an Application that runs in a Deployment. They run it, view the
49
+ reports it produces, or supply parameters, input or report
50
+ definitions to it. End Users do not buy a license. Their rights come
51
+ from section 5.
48
52
 
49
53
  ## 2. Acceptance
50
54
 
51
- This file is our standing offer. Anyone who exercises the evaluation
52
- rights in section 3 accepts them, subject to their conditions. All
53
- other rights arise when Seats are purchased. Where a purchase is made
54
- for an organization, the person completing it confirms that they are
55
- authorized to accept this agreement on the organization's behalf, and
56
- acceptance covers the whole organization. If you do not accept this
55
+ This file is our standing offer. Anyone who exercises the rights in
56
+ section 3 accepts them, subject to their conditions. All other rights
57
+ start when you buy a License Key. Where a person buys for an
58
+ organization, that person states that they have the authority to
59
+ accept this agreement on the organization's behalf. The acceptance
60
+ then covers the whole organization. If you do not accept this
57
61
  agreement, do not use the Software.
58
62
 
59
- ## 3. Evaluation
60
-
61
- You may download, install, and use the Software free of charge to
62
- evaluate it: assessing whether it suits your purposes, and building
63
- prototypes or proofs of concept. Evaluation covers the full feature
64
- set, needs no key, and has no fixed time limit. The Software marks
65
- evaluation output as described in section 6.
66
-
67
- Evaluation is limited to assessment: you may not distribute any
68
- Application containing the Software, use the Software in live business
69
- operations, or expose it in services offered to third parties. The
70
- moment your use goes beyond evaluating the Software, it requires Seats
71
- under section 4.
72
-
73
- ## 4. Developer licenses
74
-
75
- Any use of the Software beyond section 3 requires a Seat for every
76
- Developer, whether or not an Application has shipped. With those Seats
77
- in place:
78
-
79
- - Each licensed Developer may install and use the Software on any number
80
- of machines for developing Applications.
81
- - Automated build, test, and CI systems do not consume Seats.
82
- - Seats are per named individual. They may be reassigned when a
83
- Developer stops working with the Software, but not shared or pooled
84
- between concurrently active Developers. Contractors' work for you is
85
- covered by your Seats; work they do for other clients is not.
86
-
87
- ## 5. Distribution and runtime
88
-
89
- With Seats under section 4 in place, you may deploy and operate your
90
- Applications yourself, and distribute the Software as an embedded part
91
- of them, **royalty-free**: no additional fees or per-deployment
92
- licenses are required for any number of servers, copies, or End Users.
93
- You may embed your license key in an Application and ship it at no
94
- charge, so section 6 does not mark that Application's output.
95
-
96
- For that purpose, we grant you the right to pass through to each End
97
- User, and to anyone who distributes or operates your Application on your
98
- behalf, a limited, non-exclusive, non-transferable right to execute the
99
- Software only as embedded in the Application. You must not permit them
100
- to extract the Software from the Application or use it to develop
101
- software of their own.
102
-
103
- If you operate a service that embeds the Software and renders reports
104
- for your End Users, that service is an Application under section 1. You
105
- may run it royalty-free under this section, and you may put your license
106
- key in it as in any Application. Calling applications that only request
107
- renders from it need not incorporate the Software. People who use those
108
- applications only as End Users under section 1 get the pass-through
109
- rights above.
63
+ ## 3. Evaluation and development
110
64
 
111
- You may not:
65
+ You may download, install and use the Software free of charge in any
66
+ environment that is not a Deployment. That includes evaluation,
67
+ prototypes, proofs of concept, and the development, staging, CI and
68
+ test environments of an Application. This right covers the full
69
+ feature set, needs no key, and has no time limit. The Software marks
70
+ output that no License Key covers, as section 6 describes.
112
71
 
113
- - distribute the Software on its own, or as part of a product whose
114
- primary purpose is to provide report-engine, report-design, or
115
- document-generation capability to third-party developers;
116
- - expose the Software's APIs to third parties as a development tool or
117
- service that substitutes for those parties buying their own Seats. A
118
- licensee-operated service that embeds the Software and renders for
119
- your own End Users falls outside that ban;
120
- - fork, mirror, repackage, rename, or wrap the Software, or republish
121
- it on any package registry or code-hosting platform, other than as
122
- embedded in an Application.
72
+ This right does not let you use the Software in live business
73
+ operations, or expose it in services that third parties use. Each
74
+ Deployment needs a License Key under section 4.
75
+
76
+ ## 4. Deployment licenses
123
77
 
124
- For as long as this agreement is in force, you may not create any tool,
125
- library, product, or service that competes with the Software or any
126
- other Quario product, whether the competition is direct or indirect;
127
- this restriction does not survive termination. You may not remove or
128
- alter copyright or license notices in the Software. If your Application
129
- is itself a closed development product for third-party developers (OEM
130
- redistribution), contact us for separate terms.
78
+ Each Deployment needs its own License Key, from the first day it runs
79
+ in production. With that key in place:
131
80
 
132
- ## 6. License keys and output marking
81
+ - You may set the key in that Deployment and in the development,
82
+ staging, CI and test copies of that Deployment. The key then removes
83
+ the marking there too.
84
+ - One key never covers two Deployments at the same time.
85
+ - You may move a key to a new Deployment after the old Deployment no longer
86
+ runs the Software in production.
87
+ - Contractors who work for you may use your keys for that work. They
88
+ may not use your keys for work they do for other clients.
133
89
 
134
- The Software accepts a license key that evidences your Seats under
135
- section 4. Key checks run offline.
90
+ ## 5. Use and pass-through
136
91
 
137
- Without a valid key, or with a key whose validity period does not cover
138
- the installed version's release date, the Software runs with its full
139
- feature set but marks its output as unlicensed. Using a key you are not
140
- entitled to is a material breach of this agreement.
92
+ With a License Key under section 4 in place, you may operate the
93
+ Application in its Deployment **royalty-free**. We charge no fees for
94
+ servers, replicas, requests, renders, agents or End Users. You may
95
+ embed your License Key in the Application.
141
96
 
142
- You may not remove, disable, or circumvent the key check or the output
97
+ We grant you the right to pass through to each End User a limited,
98
+ non-exclusive, non-transferable right to use the Software only as
99
+ embedded in the Application that runs in your Deployment. You must not
100
+ let End Users extract the Software from the Application or use it to
101
+ develop software of their own.
102
+
103
+ Calling applications that only request renders from your Deployment do
104
+ not need to incorporate the Software. People who use those applications
105
+ only as End Users get the pass-through rights above.
106
+
107
+ You may not:
108
+
109
+ - Distribute the Software on its own, or as part of a product whose
110
+ primary purpose is to give report-engine, report-design or
111
+ document-generation capability to third-party developers.
112
+ - Expose the Software's APIs to third parties as a development tool or
113
+ service that replaces their own License Keys. A service you operate
114
+ that embeds the Software and renders for your own End Users is not
115
+ such a tool.
116
+ - Fork, mirror, repackage, rename or wrap the Software, or republish it
117
+ on any package registry or code-hosting platform, other than as
118
+ embedded in an Application.
119
+
120
+ **OEM distribution.** An Application that your customers install,
121
+ embed or resell, and that runs the Software outside your control, is
122
+ OEM distribution. A License Key does not cover OEM distribution, for
123
+ any number of copies. Contact us for separate terms. When you render
124
+ documents for your customers from a product that you host yourself,
125
+ that product is a Deployment, not OEM distribution.
126
+
127
+ While this agreement is in force, you may not create any tool,
128
+ library, product or service that competes with the Software or with
129
+ any other Quario product, directly or indirectly. This restriction
130
+ ends when this agreement terminates. You may not remove or change
131
+ copyright or license notices in the Software.
132
+
133
+ ## 6. License Keys and output marking
134
+
135
+ The Software accepts a License Key as evidence of a Deployment's
136
+ license under section 4. Key checks run offline.
137
+
138
+ Each License Key states an end date. A key covers every release of the
139
+ Software dated on or before that end date. Without a valid key, or with
140
+ a key that does not cover the installed release, the Software runs with
141
+ its full feature set but marks its output as unlicensed. A key that is
142
+ void under section 7 licenses nothing, even where the installed
143
+ release still accepts it. Use of a key that you have no right to use
144
+ is a material breach of this agreement.
145
+
146
+ A later release may refuse a key that we revoked. We revoke a key when
147
+ we refund its payment, or when a chargeback or similar process
148
+ reverses its payment. We also revoke a key that
149
+ someone publishes for reuse by others. In that case, we give the
150
+ licensee a free replacement key with the same end date. When you embed a
151
+ key in your Application under section 5, that embedding alone does not
152
+ publish the key for reuse.
153
+
154
+ You may not remove, disable or bypass the key check or the output
143
155
  marking, or distribute anything that does.
144
156
 
145
- ## 7. Term, updates, and perpetual fallback
157
+ ## 7. Term, updates and refunds
158
+
159
+ **No subscription.** You pay once for each License Key. The key covers
160
+ every release dated up to one year after you bought it, and you get
161
+ support until that end date. We give support through our issue tracker
162
+ and by email, with commercially reasonable effort. We do not guarantee
163
+ response times.
164
+
165
+ **Perpetual license.** Your license to the releases that a key covers
166
+ is **perpetual**. The Deployment may keep using those releases, and
167
+ the Application may keep running them, forever, at no further cost.
168
+ Releases dated after the key's end date need a new key.
146
169
 
147
- Seats are sold as an annual subscription. While the subscription is
148
- active, licensed Developers receive all Software versions we release,
149
- and support for the Software through our issue tracker and by email. We
150
- provide support with commercially reasonable effort; we do not guarantee
151
- specific response times.
170
+ **Renewal.** Renewal is a new purchase of a new key. It never occurs
171
+ automatically, and we never charge you without a new order.
152
172
 
153
- If the subscription lapses, your license to the versions released during
154
- your subscription is **perpetual, for the number of Seats held at the
155
- lapse**: up to that many Developers may keep using those versions, and
156
- your Applications may keep shipping them, forever, at no further cost.
157
- Versions released after the lapse require renewal. We encode this rule
158
- in purchased keys: a key remains valid for every version released
159
- during its validity period, with no end date.
173
+ **Refunds and reversals.** If we refund a payment, or a chargeback or
174
+ similar process reverses a payment, each key that the payment
175
+ bought is void. A partial refund states the keys that it voids. A void
176
+ key gives you no rights, including no perpetual license. You keep the
177
+ rights of section 3, and this agreement continues. Use of a void key in
178
+ a Deployment is a material breach of this agreement. Keys that the
179
+ refund or reversal does not void stay valid. If a payment dispute is
180
+ decided in our favour, we restore the license with a reissued key.
160
181
 
161
182
  ## 8. Source availability
162
183
 
163
- The Software's packages ship with readable source code so that you can
184
+ The Software's packages ship with readable source code, so you can
164
185
  review and debug what you run. Readable source does not grant extra
165
- rights: this agreement governs all use of the source, and you may
166
- publish the Software (modified or not) only as part of an Application
167
- under section 5.
186
+ rights. This agreement governs all use of the source. You may publish
187
+ the Software, modified or not, only as part of an Application under
188
+ section 5.
168
189
 
169
- ## 9. Ownership, trademarks, and feedback
190
+ ## 9. Ownership, trademarks and feedback
170
191
 
171
- We license the Software; we retain all intellectual property rights in
172
- it. Report definitions, data, and output you create with the Software
173
- are yours. "Quario" and the Quario logo are our trademarks; this
174
- agreement grants no rights to use them beyond identifying the Software.
175
- If you send us feedback or suggestions, we may use them without
176
- obligation.
192
+ We license the Software. We keep all intellectual property rights in
193
+ it. The report definitions, data and output that you create with the
194
+ Software are yours. "Quario" and the Quario logo are our trademarks.
195
+ This agreement grants no rights to use them, except to identify the
196
+ Software. If you send us feedback or suggestions, we may use them
197
+ without obligation.
177
198
 
178
199
  ## 10. Warranty and liability
179
200
 
@@ -181,11 +202,11 @@ We provide the Software as is. To the fullest extent the law allows, we
181
202
  disclaim all warranties and conditions, express or implied, including
182
203
  merchantability, fitness for a particular purpose, and non-infringement.
183
204
 
184
- To the fullest extent the law allows: we are not liable for indirect or
205
+ To the fullest extent the law allows, we are not liable for indirect or
185
206
  consequential damages, loss of profits, loss of data, or business
186
- interruption; and our total aggregate liability under this agreement is
207
+ interruption. Our total aggregate liability under this agreement is
187
208
  limited to the greater of EUR 100 and the fees you paid us in the
188
- twelve months preceding the event giving rise to the claim. These
209
+ twelve months before the event that gave rise to the claim. These
189
210
  limitations do not apply where the damage results from the intent or
190
211
  deliberate recklessness (opzet of bewuste roekeloosheid) of us or our
191
212
  management, or where mandatory law does not permit them.
@@ -193,23 +214,24 @@ management, or where mandatory law does not permit them.
193
214
  ## 11. Termination and survival
194
215
 
195
216
  If you materially breach this agreement, we may terminate it by notice.
196
- If the breach can be remedied, we will first allow 30 days from that
197
- notice to remedy it, and termination takes effect only if the breach
198
- remains. On termination, the licenses granted here end.
199
- Perpetual-fallback rights under section 7 survive termination only if
200
- they arose from a lapse that predates the breach, and section 5
201
- survives with them to the extent needed to keep exercising them.
202
- Sections 8 through 12 survive any termination.
217
+ If you can remedy the breach, we first give you 30 days from that
218
+ notice to remedy it. Termination then occurs only if the breach
219
+ continues. On termination, the licenses that this agreement grants end.
220
+ The perpetual license to the releases that a key covers survives
221
+ termination only if the end date of that key passed before the breach.
222
+ Section 5 survives with it, as far as you need section 5 to use that
223
+ license. Sections 8 through 12 survive any termination.
203
224
 
204
225
  ## 12. General
205
226
 
206
- Dutch law governs this agreement. Disputes go to the competent court for
207
- our registered office. You may not assign this agreement without our
208
- consent, except to a successor of your whole business; we may assign it
209
- to a successor of ours. If a provision is unenforceable, the remainder
210
- stays in force. Failure to enforce a provision does not waive it. This
211
- agreement, together with your order for Seats, is the entire agreement
212
- about the Software, unless we agree otherwise in writing.
227
+ Dutch law governs this agreement. The competent court for our
228
+ registered office decides disputes. You may not assign this agreement
229
+ without our consent, except to a successor of your whole business. We
230
+ may assign it to a successor of ours. If a provision is unenforceable,
231
+ the remainder stays in force. If we do not enforce a provision, we do
232
+ not waive it. This agreement, together with your order for License
233
+ Keys, is the entire agreement about the Software, unless we agree
234
+ otherwise in writing.
213
235
 
214
236
  ---
215
237
 
package/lib/format.js CHANGED
@@ -94,7 +94,7 @@ let asMoney = (value, locale, decl, options, style) => {
94
94
  //
95
95
  // The form is Intl's own `dateStyle`, and it is always present: a declaration
96
96
  // that named none resolved to `medium` (docs/adr/0056). This is the half that
97
- // does not converge — the worksheet approximates it (CONTEXT.md, "Form").
97
+ // does not converge — the worksheet approximates it (GLOSSARY.md, "Form").
98
98
  // `date` leads for the same reason the number kinds do, so the two key shapes
99
99
  // live beside each other and "one piece at a time" is auditable in one place.
100
100
  /** @type {(locale: string, decl: any, zone: string) => string} */
package/lib/host.js CHANGED
@@ -6,10 +6,15 @@
6
6
  *
7
7
  * Here rather than in a target for the reason `imageError` carries beside it:
8
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.
9
+ * engine is what they all already have. The engine's own two option bags,
10
+ * `quario()`'s and `plan()`'s, are checked here too, by the same rule. Nothing
11
+ * an author writes reaches this module -- these are host API, and no located
12
+ * diagnostic is involved.
11
13
  */
12
14
 
15
+ import { query } from "padvinder";
16
+ import { isCode } from "./style.js";
17
+
13
18
  /** @type {(msg: string) => never} */
14
19
  let fail = (msg) => {
15
20
  throw TypeError(msg);
@@ -23,6 +28,12 @@ let fail = (msg) => {
23
28
  */
24
29
  let plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
25
30
 
31
+ // Nullish is absence; anything else must be a string.
32
+ /** @type {(value: unknown, at: string) => void} */
33
+ let string = (value, at) => {
34
+ if (value != null && typeof value !== "string") fail(at + ": expected a string");
35
+ };
36
+
26
37
  // Own keys only: an inherited enumerable property is the prototype's business,
27
38
  // and naming it back at the host would name something they never wrote.
28
39
  /** @type {(value: object, keys: readonly string[], at: string) => void} */
@@ -58,9 +69,7 @@ let PROPERTIES = ["title", "author", "subject"];
58
69
  // was never written.
59
70
  /** @type {(value: any, at: string) => void} */
60
71
  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");
72
+ for (let key of Object.keys(value)) string(value[key], at + "." + key);
64
73
  };
65
74
 
66
75
  /**
@@ -87,3 +96,73 @@ export let hostMeta = (value, at) => {
87
96
  everyString(value, at);
88
97
  return { ...value };
89
98
  };
99
+
100
+ /**
101
+ * Every key `quario()` takes. The factory closes its set against this list,
102
+ * and the conformance fixture in quario's suite is held to it, so the list and
103
+ * `QuarioOptions` cannot drift apart unnoticed (`docs/adr/0072`, amended).
104
+ */
105
+ export let INSTANCE_KEYS = ["query", "license", "locale", "currency", "timeZone", "schemes"];
106
+
107
+ // `Intl` owns which locales and zones exist, so it is asked rather than
108
+ // copied. What it refuses is a RangeError, which here is the host's mistake
109
+ // and wears the host-mistake class.
110
+ /** @type {(value: unknown, at: string, probe: (value: any) => unknown) => void} */
111
+ let intl = (value, at, probe) => {
112
+ if (value == null) return;
113
+ try {
114
+ probe(value);
115
+ } catch {
116
+ fail(at + ": " + JSON.stringify(value) + " is not one Intl accepts");
117
+ }
118
+ };
119
+
120
+ /**
121
+ * The engine factory's own options, refused at the call like a target's. The
122
+ * three names that present `format` are checked for validity and not just
123
+ * type, because a typo there does not fail: every formatted cell prints its
124
+ * raw value instead. A cell's own `style.currency` keeps failing soft -- that
125
+ * is the authoring surface, and this is the host's.
126
+ *
127
+ * `currency` takes the author's gate, three upper-case letters, because the
128
+ * xlsx target splices the code into a number format and an engine default the
129
+ * gate never saw would be the one code that skipped it (`docs/adr/0041`).
130
+ *
131
+ * `schemes` keeps its own null posture: a `null` allowlist is a host saying
132
+ * something, and reading it as the default would widen what they restricted
133
+ * (`docs/adr/0085`).
134
+ *
135
+ * `query` is padvinder's, and so is the list of what it may carry: compiling
136
+ * one trivial query here raises padvinder's own refusal at the call, where it
137
+ * would otherwise wait for the first query a document compiles.
138
+ *
139
+ * @param {any} options The `quario()` argument.
140
+ */
141
+ export let instanceOptions = (options) => {
142
+ let o = hostOptions(options, INSTANCE_KEYS, "options") ?? {};
143
+ query("$", undefined, o.query);
144
+ for (let key of ["license", "locale", "currency", "timeZone"]) string(o[key], "options." + key);
145
+ intl(o.locale, "options.locale", (v) => Intl.getCanonicalLocales(v));
146
+ intl(o.timeZone, "options.timeZone", (v) => Intl.DateTimeFormat("en", { timeZone: v }));
147
+ if (o.currency != null && !isCode(o.currency))
148
+ fail("options.currency: expected a three-letter ISO 4217 code");
149
+ let schemes = o.schemes;
150
+ if (schemes === undefined) return;
151
+ let listed =
152
+ Array.isArray(schemes) &&
153
+ schemes.every((scheme) => typeof scheme === "string" && /^[a-z][a-z0-9+.-]*:$/.test(scheme));
154
+ if (!listed)
155
+ fail("options.schemes: expected an array of lower-case URL schemes, each ending in a colon");
156
+ };
157
+
158
+ /**
159
+ * The third argument of an instance's `plan()`, closed like the factory's, so
160
+ * a misspelt `targets` is a throw rather than a plan that checked nothing and
161
+ * said so by saying nothing.
162
+ *
163
+ * @param {any} options The `plan()` options.
164
+ */
165
+ export let planOptions = (options) => {
166
+ let targets = hostOptions(options, ["targets"], "options")?.targets;
167
+ if (targets != null && !Array.isArray(targets)) fail("options.targets: expected an array");
168
+ };
package/lib/image.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * What the engine reads out of an image item's bytes: which format they are,
3
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.
4
+ * IHDR, a JPEG's frame header and an SVG's root tag, and nothing here looks at
5
+ * a pixel or draws a shape.
5
6
  *
6
7
  * Both answers ride on the image event, so no target reads these headers
7
8
  * again. That is the whole of `docs/adr/0067`: two targets used to keep a
@@ -16,6 +17,45 @@
16
17
  let PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
17
18
  let JPEG = [0xff, 0xd8, 0xff];
18
19
 
20
+ // SVG has no magic number: it is text, named by its root element. Fatal, so
21
+ // bytes that are not UTF-8 are not text at all. The decoder drops a leading
22
+ // BOM on its own.
23
+ let UTF8 = new TextDecoder("utf-8", { fatal: true });
24
+
25
+ // What may stand before the root, one at a time: whitespace, then a
26
+ // processing instruction (the XML declaration is one), a comment or a
27
+ // doctype with its optional internal subset. Sticky, so each match starts
28
+ // where the last one ended and nothing is skipped over.
29
+ let PROLOG = /\s*(?:<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^[>]*(?:\[[\s\S]*?\])?\s*>)/y;
30
+
31
+ // The root's start tag, whose attributes are the group. A quoted value may
32
+ // hold a `>`, so the tag ends at the first one outside quotes. `<svgx` and a
33
+ // prefixed `<s:svg` are other elements.
34
+ let ROOT = /\s*<svg(?=[\s/>])((?:[^>"']|"[^"]*"|'[^']*')*)>/y;
35
+
36
+ /**
37
+ * The text inside the root's start tag, when the bytes are UTF-8 whose first
38
+ * element after the prolog is `<svg`, else null. An HTML page holding an SVG
39
+ * further in is not one: the root decides (SCHEMA.md, "Image item").
40
+ *
41
+ * @type {(bytes: Uint8Array) => string | null}
42
+ */
43
+ let svgRoot = (bytes) => {
44
+ let text;
45
+ try {
46
+ text = UTF8.decode(bytes);
47
+ } catch {
48
+ return null;
49
+ }
50
+ // A sticky test that fails resets `lastIndex` to zero, so the end of the
51
+ // last prolog part is kept apart from it.
52
+ let at = 0;
53
+ PROLOG.lastIndex = 0;
54
+ while (PROLOG.test(text)) at = PROLOG.lastIndex;
55
+ ROOT.lastIndex = at;
56
+ return ROOT.exec(text)?.[1] ?? null;
57
+ };
58
+
19
59
  /**
20
60
  * The image seam: name the format a `source` expression's bytes hold, or null
21
61
  * when they are not image bytes at all -- including when they are not bytes.
@@ -23,12 +63,13 @@ let JPEG = [0xff, 0xd8, 0xff];
23
63
  * so no consumer repeats it.
24
64
  *
25
65
  * @param {any} bytes The value a `source` expression yielded.
26
- * @returns {"png" | "jpeg" | null} The format, or null when unreadable.
66
+ * @returns {"png" | "jpeg" | "svg" | null} The format, or null when unreadable.
27
67
  */
28
68
  export function sniff(bytes) {
29
69
  if (!(bytes instanceof Uint8Array)) return null;
30
70
  if (PNG.every((byte, i) => bytes[i] === byte)) return "png";
31
71
  if (JPEG.every((byte, i) => bytes[i] === byte)) return "jpeg";
72
+ if (svgRoot(bytes) != null) return "svg";
32
73
  return null;
33
74
  }
34
75
 
@@ -62,15 +103,67 @@ let jpegSize = (bytes) => {
62
103
  return { width: 0, height: 0 };
63
104
  };
64
105
 
106
+ // A plain decimal, as XML's attribute grammar writes one. Stricter than
107
+ // `Number`, which would also read hex, `Infinity` and the empty string.
108
+ let NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
109
+
110
+ /** @type {(text: string) => number} */
111
+ let number = (text) => (NUMBER.test(text) ? Number(text) : NaN);
112
+
113
+ // The absolute units, as how many of each make an inch. Pixels are the
114
+ // unit the event carries, so a unitless length and `px` pass as they are,
115
+ // and the rest convert at the 96 dpi the size is read at everywhere else.
116
+ let PER_INCH = new Map([
117
+ ["pt", 72],
118
+ ["in", 1],
119
+ ["cm", 2.54],
120
+ ["mm", 25.4],
121
+ ]);
122
+
123
+ /** @type {(value: string | undefined) => number | null} */
124
+ let length = (value) => {
125
+ let [, amount = "", unit = ""] = value?.trim().match(/^(.*?)(px|pt|in|cm|mm)?$/) ?? [];
126
+ let pixels = number(amount);
127
+ if (Number.isNaN(pixels)) return null;
128
+ let perInch = PER_INCH.get(unit);
129
+ return perInch ? (pixels / perInch) * 96 : pixels;
130
+ };
131
+
132
+ // One attribute of the root's start tag, double- or single-quoted.
133
+ let ATTRIBUTE = /([^\s=/]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
134
+
135
+ // An SVG's size is the root's `width` and `height` when both are absolute
136
+ // lengths, else its `viewBox`'s last two numbers. A percentage or a font-
137
+ // relative length is no size: it depends on a container the file never sees.
138
+ /** @type {(bytes: Uint8Array) => { width: number, height: number }} */
139
+ let svgSize = (bytes) => {
140
+ // `sniff` named these bytes SVG, so the root is there to read.
141
+ let root = /** @type {string} */ (svgRoot(bytes));
142
+ let attributes = new Map(
143
+ [...root.matchAll(ATTRIBUTE)].map(([, name, double, single]) => [name, double ?? single]),
144
+ );
145
+ let width = length(attributes.get("width"));
146
+ let height = length(attributes.get("height"));
147
+ if (width != null && height != null) return { width, height };
148
+ let box =
149
+ attributes
150
+ .get("viewBox")
151
+ ?.trim()
152
+ .split(/[\s,]+/)
153
+ .map(number) ?? [];
154
+ return box.length === 4 ? { width: box[2], height: box[3] } : { width: 0, height: 0 };
155
+ };
156
+
65
157
  // One reader per format `sniff` names.
66
158
  /** @type {Record<string, (bytes: Uint8Array) => { width: number, height: number }>} */
67
- let READERS = { png: pngSize, jpeg: jpegSize };
159
+ let READERS = { png: pngSize, jpeg: jpegSize, svg: svgSize };
68
160
 
69
161
  /**
70
162
  * The image's own size in pixels, or null when the header does not carry one
71
163
  * -- 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.
164
+ * dimensions, or an SVG whose root states no usable size. Read, never
165
+ * decoded: a PNG's IHDR, a JPEG's frame header and an SVG's root tag carry
166
+ * the two numbers, and nothing here looks at a pixel.
74
167
  *
75
168
  * The size rides on the image event beside the format, so every target places
76
169
  * a picture from a field rather than parsing the same two headers again, and a
@@ -78,7 +171,7 @@ let READERS = { png: pngSize, jpeg: jpegSize };
78
171
  * alike (`docs/adr/0067`).
79
172
  *
80
173
  * @param {Uint8Array} bytes The image file.
81
- * @param {"png" | "jpeg"} format The format `sniff` named.
174
+ * @param {"png" | "jpeg" | "svg"} format The format `sniff` named.
82
175
  * @returns {{ width: number, height: number } | null} The size, if readable.
83
176
  */
84
177
  export function dimensions(bytes, format) {
@@ -86,7 +179,10 @@ export function dimensions(bytes, format) {
86
179
  // format `sniff` learns later and this table does not would throw here
87
180
  // rather than be read silently as the one on the ternary's else branch.
88
181
  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;
182
+ // A zero is how a raster reader says "the header stopped before this" -- an
183
+ // absent byte reads as zero, and no image is zero wide. The SVG reader
184
+ // hands back whatever the root said, so a negative, a NaN and an infinity
185
+ // fail here too.
186
+ let placeable = (/** @type {number} */ n) => n > 0 && n < Infinity;
187
+ return placeable(size.width) && placeable(size.height) ? size : null;
92
188
  }
package/lib/index.d.ts CHANGED
@@ -161,6 +161,21 @@ export interface StyleDeclarations extends BoxDeclarations {
161
161
  spaceAfter?: ExpressionValue<Points>;
162
162
  }
163
163
 
164
+ /** How a group orders and cuts its instances. */
165
+ export interface Instances {
166
+ /**
167
+ * Order the instances. Each key reads the group's own handle once per
168
+ * instance, with `@` and `run` unbound.
169
+ */
170
+ sort?: SortKey[];
171
+ /**
172
+ * Keep this many instances after `sort`. Integer >= 1. A dropped instance
173
+ * never renders, so no runner steps over it, but the enclosing aggregates
174
+ * still count it.
175
+ */
176
+ take?: Count;
177
+ }
178
+
164
179
  export interface SortKey {
165
180
  by: Expression;
166
181
  dir?: "asc" | "desc";
@@ -349,9 +364,9 @@ export type ImageStyleDeclarations = Pick<
349
364
  export type ReportStyleDeclarations = Pick<StyleDeclarations, "family" | "size">;
350
365
 
351
366
  /**
352
- * A host-supplied raster graphic in the band flow. `source` is an expression
367
+ * A host-supplied graphic in the band flow. `source` is an expression
353
368
  * (never a literal — JSON has no way to write bytes) yielding the complete
354
- * image file as a `Uint8Array` of PNG or JPEG bytes; nullish skips the item
369
+ * image file as a `Uint8Array` of PNG, JPEG or SVG bytes; nullish skips the item
355
370
  * silently, anything else invalid is a located render error.
356
371
  */
357
372
  export interface ImageItem {
@@ -594,6 +609,12 @@ export interface Group {
594
609
  */
595
610
  columns?: ColumnCount;
596
611
  sort?: SortKey[];
612
+ /**
613
+ * Order and cut this group's instances, as the group's own `sort` and
614
+ * `take` order and cut the rows inside each. Absent, every instance renders
615
+ * in first-seen order.
616
+ */
617
+ instances?: Instances;
597
618
  /**
598
619
  * Keep this many rows after this group's `sort`. Integer >= 1. Rows past
599
620
  * the count are dropped from aggregates and runners, unlike `visible`.
@@ -931,7 +952,7 @@ export interface ItemEvent {
931
952
  }
932
953
 
933
954
  /** The format the engine sniffed from an image's bytes. */
934
- export type ImageFormat = "png" | "jpeg";
955
+ export type ImageFormat = "png" | "jpeg" | "svg";
935
956
 
936
957
  export interface ImageEvent {
937
958
  type: "image";
@@ -940,7 +961,10 @@ export interface ImageEvent {
940
961
  path: string;
941
962
  /** The bytes exactly as the `source` expression yielded them. */
942
963
  bytes: Uint8Array;
943
- /** Sniffed from the bytes' magic numbers, so no consumer repeats it. */
964
+ /**
965
+ * Sniffed from the bytes — a PNG's or a JPEG's magic number, an SVG's root
966
+ * element — so no consumer repeats it.
967
+ */
944
968
  format: ImageFormat;
945
969
  /**
946
970
  * The image's own width in pixels, read from the same header. Bytes too
@@ -1262,13 +1286,21 @@ export interface Quario {
1262
1286
  * The one traversal, whole: compiled report (null on problems), structured
1263
1287
  * problems, advisory warnings, and per-node anchor sets — one descent per
1264
1288
  * edit for a host that validates and renders in a loop.
1289
+ *
1290
+ * @throws {TypeError} For an unknown `options` key, or a `targets` that is
1291
+ * not an array — closed like the factory's own options.
1265
1292
  */
1266
1293
  plan(schema: unknown, functions?: FunctionRegistry, options?: PlanOptions): Plan;
1267
1294
  }
1268
1295
 
1269
1296
  /**
1270
1297
  * Create a configured instance: host-level options (license key, query
1271
- * budgets).
1298
+ * budgets, presentation, the `href` allowlist).
1299
+ *
1300
+ * @throws {TypeError} At the call, for an unknown option key, a value of the
1301
+ * wrong type, a `locale` or `timeZone` that `Intl` refuses, or a `currency`
1302
+ * that is not three upper-case letters. Nullish is absence, except for
1303
+ * `schemes`. The `query` budgets are refused by padvinder, in its own words.
1272
1304
  */
1273
1305
  export function quario(options?: QuarioOptions): Quario;
1274
1306
 
package/lib/index.js CHANGED
@@ -14,7 +14,8 @@
14
14
  */
15
15
  import { relocate as relocateQuery } from "padvinder";
16
16
  import { LEAD_REFUSED, isLead, leadPath } from "./band-height.js";
17
- import { MARKING, verify } from "./license.js";
17
+ import { instanceOptions, planOptions } from "./host.js";
18
+ import { MARKING, TRUST, verify } from "./license.js";
18
19
  import { err, fault, locate } from "./locate.js";
19
20
  import { BLOCKED, NAME, record } from "./names.js";
20
21
  import { checkParamType, jsonCopy, plan } from "./plan.js";
@@ -152,7 +153,7 @@ let calling = (target) => (typeof target?.target === "string" ? target.target :
152
153
  /** @type {readonly any[]} */
153
154
  let NO_TARGETS = Object.freeze([]);
154
155
  /** @type {(opts: any) => readonly any[]} */
155
- let named = (opts) => (Array.isArray(opts?.targets) ? opts.targets : NO_TARGETS);
156
+ let named = (opts) => opts?.targets ?? NO_TARGETS;
156
157
 
157
158
  // What a caller asked to check beyond the document: the required declarations
158
159
  // its targets lose.
@@ -175,7 +176,7 @@ let loss = (target, name) => {
175
176
  let unmet = (required, targets) =>
176
177
  required.flatMap((name) => targets.map((target) => loss(target, name)).filter(Boolean));
177
178
 
178
- // The per-node anchor sets, as frozen host data (CONTEXT.md, "Freeze"): which
179
+ // The per-node anchor sets, as frozen host data (GLOSSARY.md, "Freeze"): which
179
180
  // anchors and group handles each compiled source reads, keyed by schema path.
180
181
  /** @type {(anchors: Map<string, Set<string>>) => any} */
181
182
  let freezeAnchors = (anchors) =>
@@ -484,21 +485,6 @@ function assemble(planned, schema, marking, options) {
484
485
 
485
486
  /** @typedef {import("./license.js").LicenseInfo} LicenseInfo */
486
487
 
487
- // The `href` allowlist, checked once at construction rather than per cell.
488
- // It **throws** where every other host option is lenient: an allowlist is a
489
- // security control, and one the engine quietly replaced with its own default
490
- // would be a host believing it had restricted something it had not
491
- // (docs/adr/0085).
492
- /** @type {(schemes: unknown) => void} */
493
- let checkSchemes = (schemes) => {
494
- if (schemes === undefined) return;
495
- let listed =
496
- Array.isArray(schemes) &&
497
- schemes.every((scheme) => typeof scheme === "string" && /^[a-z][a-z0-9+.-]*:$/.test(scheme));
498
- if (!listed)
499
- throw Error("schemes: expected an array of lower-case URL schemes, each ending in a colon");
500
- };
501
-
502
488
  /**
503
489
  * Create a configured quario instance: the host-level options — license key,
504
490
  * query budgets. The key is verified once, here, and `license` settles with
@@ -507,19 +493,18 @@ let checkSchemes = (schemes) => {
507
493
  * ("Instances and targets").
508
494
  *
509
495
  * @param {{query?: {maxNodes?: number, maxDepth?: number, maxResults?: number},
510
- * license?: string, trust?: {publicKey?: string, release?: string},
511
- * locale?: string, currency?: string, timeZone?: string,
496
+ * license?: string, locale?: string, currency?: string, timeZone?: string,
512
497
  * schemes?: readonly string[]}} [options]
513
- * Host configuration. `trust` is internal, not API — see ./license.js.
514
- * `locale` / `currency` / `timeZone` present `format` (docs/adr/0041), and
498
+ * Host configuration, its key set closed (docs/adr/0072). `locale` / `currency` / `timeZone` present `format` (docs/adr/0041), and
515
499
  * `schemes` is the `href` allowlist (docs/adr/0085).
516
500
  * @returns {{license: Promise<LicenseInfo>,
517
501
  * report: (schema: any, funcs?: Record<string, Function>) => any,
518
- * plan: (schema: any, funcs?: Record<string, Function>) => any}} The instance.
502
+ * plan: (schema: any, funcs?: Record<string, Function>, opts?: any) => any}}
503
+ * The instance.
519
504
  */
520
505
  export function quario(options) {
521
- checkSchemes(options?.schemes);
522
- let { licensed, settled } = verify(options?.license, options?.trust);
506
+ instanceOptions(options);
507
+ let { licensed, settled } = verify(options?.license, /** @type {any} */ (options)?.[TRUST]);
523
508
  // The wording is the instance's, where the key is; the stream is what
524
509
  // states it, so nothing wraps a compiled stream to reach its first event.
525
510
  /** @type {() => string | false} */
@@ -571,7 +556,7 @@ export function quario(options) {
571
556
  report(schema, funcs) {
572
557
  return wrap(events(schema, funcs, options, marking));
573
558
  },
574
- // The plan (CONTEXT.md): both readings of the one descent, kept.
559
+ // The plan (GLOSSARY.md): both readings of the one descent, kept.
575
560
  // `report()` and `validate()` name what a caller wanted from it; this
576
561
  // hands over the whole thing — the compiled report (null while the
577
562
  // document has problems), every problem structurally, and the per-node
@@ -586,6 +571,7 @@ export function quario(options) {
586
571
  // makes the whole marking additive.
587
572
  /** @type {(schema: any, funcs?: Record<string, Function>, opts?: any) => any} */
588
573
  plan(schema, funcs, opts) {
574
+ planOptions(opts);
589
575
  let planned = plan(schema, funcs, options);
590
576
  // After the document's own, so a problem list still opens with what is
591
577
  // wrong with the report rather than with where it is going.
package/lib/license.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // though nothing reassigns them. A key is valid for every version released on
12
12
  // or before its `to` (LICENSE section 7), so validity compares ISO date
13
13
  // strings and never reads a clock — accepted output stays accepted.
14
- let RELEASE = "2026-10-01"; // release-date
14
+ let RELEASE = "2026-10-08"; // 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.
@@ -24,6 +24,18 @@ let PUBKEY =
24
24
  // draws it through a base-14 face, and the em dash is the one character here
25
25
  // that had to be checked. The assertion that would catch a bad one lives in
26
26
  // that target's suite (`packages/pdf/test/pdf.test.js`), not this package's.
27
+ /**
28
+ * The key a test suite writes its keypair under, in place of the shipped one.
29
+ * A symbol rather than an option name: the production private key never
30
+ * enters the repo, so the suites verify against freshly generated pairs, and
31
+ * a named option would hand every host the same seam -- sign your own key,
32
+ * pass your own public key. The closed option check reads string keys only,
33
+ * and nothing the package exports reaches this module, so a host has no
34
+ * spelling for it. The suites import it through the repository's `#license`
35
+ * specifier, which resolves to this file and not a copy of it.
36
+ */
37
+ export let TRUST = Symbol("quario.trust");
38
+
27
39
  export let MARKING = "Unlicensed evaluation — getquario.com";
28
40
 
29
41
  let ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
@@ -107,8 +119,7 @@ let failMsg = (error) => (error instanceof Error ? error.message : String(error)
107
119
  * @param {string | undefined} key The instance's `license` option.
108
120
  * @param {{publicKey?: string, release?: string}} [trust] What this instance
109
121
  * verifies against, defaulting to the shipped key and release date. Internal
110
- * — a test seam, not API: the production private key never enters the repo,
111
- * so suites verify against freshly generated pairs.
122
+ * — the `TRUST` seam above, never an option a host can name.
112
123
  * @returns {{licensed: () => boolean, settled: Promise<LicenseInfo>}}
113
124
  */
114
125
  export let verify = (key, trust) => {
package/lib/plan.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * constructs, so they must not appear even in a comment.)
11
11
  *
12
12
  * It is a traversal, never a walk: that word belongs to the event stream and
13
- * its driver (CONTEXT.md). Six layers hold it, each a factory named for what it
13
+ * its driver (GLOSSARY.md). Six layers hold it, each a factory named for what it
14
14
  * returns and taking the layers it builds on, composed by `plan()` at the foot
15
15
  * of the file:
16
16
  *
@@ -192,6 +192,7 @@ export let KEY_SETS = Object.freeze({
192
192
  "reset",
193
193
  "columns",
194
194
  "sort",
195
+ "instances",
195
196
  "take",
196
197
  "aggregates",
197
198
  "run",
@@ -199,6 +200,7 @@ export let KEY_SETS = Object.freeze({
199
200
  "footer",
200
201
  ],
201
202
  sortKey: ["by", "dir"],
203
+ instances: ["sort", "take"],
202
204
  param: ["default", "type", "label"],
203
205
  text: TEXT,
204
206
  textSlot: [...TEXT, "width"],
@@ -644,7 +646,7 @@ let runProp = (expression, path, source) => (scope) => {
644
646
  // have a text form, and that is the token seam.
645
647
  return refuseThenable(out, path, source, ONLY_A_CELL);
646
648
  };
647
- // The [live field](../../../CONTEXT.md#live-field): which page value a token
649
+ // The [live field](../../../GLOSSARY.md#live-field): which page value a token
648
650
  // *is*, for a target whose own format numbers pages. sjabloon offers each
649
651
  // interpolation's expression source once while compiling, the only place the
650
652
  // author's words still exist. Reading the source here instead is what hard
@@ -1269,9 +1271,14 @@ let readers = ({ bad, warn, attempt }, { prop, cell, parseFold }, /** @type {any
1269
1271
  checkDir(spec.dir, keyPath + ".dir");
1270
1272
  return [{ by, sign: spec.dir === "desc" ? -1 : 1 }];
1271
1273
  };
1272
- // A sort spec (array of { by, dir }) becomes a comparator over rows.
1273
- /** @type {(value: any, path: string) => ((rows: any[], scope: Scope) => any[]) | null} */
1274
- let sortOf = (value, path) => {
1274
+ // A sort spec (array of { by, dir }) becomes a comparator over rows, or over
1275
+ // whatever `bind` turns into the scope its keys read: a group's `instances`
1276
+ // binds each instance's own group scope, where no row is current.
1277
+ /**
1278
+ * @type {(value: any, path: string, bind?: (scope: Scope, item: any) => Scope)
1279
+ * => ((items: any[], scope: Scope) => any[]) | null}
1280
+ */
1281
+ let sortOf = (value, path, bind = withRow) => {
1275
1282
  let specs = arr(value, path);
1276
1283
  if (!specs || !specs.length) return null;
1277
1284
  let sortKeys = specs.flatMap((spec, i) => sortKey(spec, i, path));
@@ -1287,7 +1294,7 @@ let readers = ({ bad, warn, attempt }, { prop, cell, parseFold }, /** @type {any
1287
1294
  let resolved = [];
1288
1295
  for (let row of rows) {
1289
1296
  // One scope child per row: every key of that row reads the same one.
1290
- let bound = withRow(scope, row);
1297
+ let bound = bind(scope, row);
1291
1298
  for (let key of sortKeys) resolved.push(key.by(bound));
1292
1299
  }
1293
1300
  // `sort` is stable, so a full tie leaves the indices — and with them
@@ -1603,7 +1610,7 @@ let nodes = (
1603
1610
  // into a string -- and both answers ride on the event so no target reads
1604
1611
  // the same header a second time.
1605
1612
  let format = sniff(bytes);
1606
- if (!format) badImage(fields, "expected a Uint8Array of PNG or JPEG bytes");
1613
+ if (!format) badImage(fields, "expected a Uint8Array of PNG, JPEG or SVG bytes");
1607
1614
  let size = dimensions(bytes, format);
1608
1615
  // The one thing the engine reads past the magic numbers, and so the one
1609
1616
  // header failure every target shares: a file that cannot state its size
@@ -2196,6 +2203,27 @@ let bands = (
2196
2203
  rows,
2197
2204
  };
2198
2205
  };
2206
+ /** @type {(items: any[], fn: (item: any) => any) => Generator<any>} */
2207
+ let lazily = function* (items, fn) {
2208
+ for (let item of items) yield fn(item);
2209
+ };
2210
+ // A partition's instances, opened, in the order they render, and how many
2211
+ // render. Without `instances.sort` that is first-seen order, opened one at a
2212
+ // time as the walk reaches each. With it every instance opens first,
2213
+ // because its key reads the handles, and the group scopes are then what it
2214
+ // orders. `instances.take` cuts either order the way a row `take` cuts rows,
2215
+ // so an instance it drops never opens a band.
2216
+ /** @type {(ctx: any, parts: Map<any, any[]>, scope: Scope) => { count: number, each: Iterable<any> }} */
2217
+ let instancesOf = (ctx, parts, scope) => {
2218
+ let { sort, take } = ctx.instances;
2219
+ /** @type {(part: [any, any[]]) => any} */
2220
+ let open = ([key, groupRows]) => ({ key, ...openInstance(ctx, key, groupRows, scope) });
2221
+ let kept = (sort ? sort(Array.from(parts, open), scope) : [...parts]).slice(
2222
+ 0,
2223
+ take ?? undefined,
2224
+ );
2225
+ return { count: kept.length, each: sort ? kept : lazily(kept, open) };
2226
+ };
2199
2227
  // One instance's bands, in order, as the events they yield.
2200
2228
  /** @type {(ctx: any, groupScope: any, ordered: any[], runners: any) => Generator<any>} */
2201
2229
  let instanceBands = function* (ctx, groupScope, ordered, runners) {
@@ -2232,11 +2260,11 @@ let bands = (
2232
2260
  // run's own two edges fall on its first and its last. The run is this
2233
2261
  // call's partition, so a nested group's edges belong to the parent
2234
2262
  // instance holding it rather than to the document.
2235
- let last = parts.size - 1;
2263
+ let { count, each } = instancesOf(ctx, parts, scope);
2264
+ let last = count - 1;
2236
2265
  let index = 0;
2237
- for (let [key, groupRows] of parts) {
2238
- // A group band has no current row, so `@` stays unbound here.
2239
- let { groupScope, aggregates, rows: ordered } = openInstance(ctx, key, groupRows, scope);
2266
+ // A group band has no current row, so `@` stays unbound here.
2267
+ for (let { key, groupScope, aggregates, rows: ordered } of each) {
2240
2268
  yield opt(
2241
2269
  {
2242
2270
  type: "group-start",
@@ -2262,6 +2290,19 @@ let bands = (
2262
2290
  index++;
2263
2291
  }
2264
2292
  };
2293
+ /** @type {(scope: Scope, instance: any) => Scope} */
2294
+ let instanceScope = (_, instance) => instance.groupScope;
2295
+ // `instances` is the group's own `sort` and `take`, one level up: the same
2296
+ // two words, over its instances rather than its rows (docs/adr/0103).
2297
+ /** @type {(value: any, path: string) => { sort: any, take: number | null }} */
2298
+ let instancesSpec = (value, path) => {
2299
+ if (value == null || !obj(value, path)) return { sort: null, take: null };
2300
+ keys(value, KEY_SETS.instances, path);
2301
+ return {
2302
+ sort: sortOf(value.sort, path + ".sort", instanceScope),
2303
+ take: takeOf(value.take, path + ".take"),
2304
+ };
2305
+ };
2265
2306
  /** @type {(def: any, path: string, defs: any[], index: number, detail: any, columned?: boolean) => Band} */
2266
2307
  let compileGroup = (def, path, defs, index, detail, columned) => {
2267
2308
  keys(def, KEY_SETS.group, path);
@@ -2279,6 +2320,7 @@ let bands = (
2279
2320
  // styling a fragment of one would mean nothing anywhere it is spent.
2280
2321
  label: def.label == null ? null : textValue(def.label, path + ".label"),
2281
2322
  sort: sortOf(def.sort, path + ".sort"),
2323
+ instances: instancesSpec(def.instances, path + ".instances"),
2282
2324
  take: takeOf(def.take, path + ".take"),
2283
2325
  aggs: foldsOf(def.aggregates, path + ".aggregates", "aggregate", new Set(["key"])),
2284
2326
  running: foldsOf(def.run, path + ".run", "run", null, runNames),
package/lib/schema.json CHANGED
@@ -427,7 +427,7 @@
427
427
  },
428
428
  "required": ["type", "source"],
429
429
  "additionalProperties": false,
430
- "description": "A host-supplied raster graphic in the band flow. `source` is an expression (never a literal — JSON has no way to write bytes) yielding the complete image file as a `Uint8Array` of PNG or JPEG bytes; nullish skips the item silently, anything else invalid is a located render error."
430
+ "description": "A host-supplied graphic in the band flow. `source` is an expression (never a literal — JSON has no way to write bytes) yielding the complete image file as a `Uint8Array` of PNG, JPEG or SVG bytes; nullish skips the item silently, anything else invalid is a located render error."
431
431
  },
432
432
  "ImageFit": {
433
433
  "type": "string",
@@ -930,6 +930,10 @@
930
930
  "description": "Flow each instance's content in this many page columns (integer >= 2). Columned regions cannot nest."
931
931
  },
932
932
  "sort": { "type": "array", "items": { "$ref": "#/$defs/SortKey" } },
933
+ "instances": {
934
+ "anyOf": [{ "$ref": "#/$defs/Instances" }],
935
+ "description": "Order and cut this group's instances, as the group's own `sort` and `take` order and cut the rows inside each. Absent, every instance renders in first-seen order."
936
+ },
933
937
  "take": {
934
938
  "anyOf": [{ "$ref": "#/$defs/Count" }],
935
939
  "description": "Keep this many rows after this group's `sort`. Integer >= 1. Rows past the count are dropped from aggregates and runners, unlike `visible`."
@@ -949,6 +953,22 @@
949
953
  "required": ["name", "by"],
950
954
  "additionalProperties": false
951
955
  },
956
+ "Instances": {
957
+ "type": "object",
958
+ "properties": {
959
+ "sort": {
960
+ "type": "array",
961
+ "items": { "$ref": "#/$defs/SortKey" },
962
+ "description": "Order the instances. Each key reads the group's own handle once per instance, with `@` and `run` unbound."
963
+ },
964
+ "take": {
965
+ "anyOf": [{ "$ref": "#/$defs/Count" }],
966
+ "description": "Keep this many instances after `sort`. Integer >= 1. A dropped instance never renders, so no runner steps over it, but the enclosing aggregates still count it."
967
+ }
968
+ },
969
+ "additionalProperties": false,
970
+ "description": "How a group orders and cuts its instances."
971
+ },
952
972
  "TableDetail": {
953
973
  "type": "object",
954
974
  "properties": {
package/lib/stream.js CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * `breathe` sits here because the driver batches on it, not because handing the
8
8
  * loop back is a stream rule — the two are independent, and a target breathes
9
- * while stamping finished pages, which is no walk at all (CONTEXT.md).
9
+ * while stamping finished pages, which is no walk at all (GLOSSARY.md).
10
10
  */
11
11
  // The token-join sjabloon renders with — literals verbatim, values through
12
12
  // the scalar `display()` rule — re-exported together so event consumers
@@ -174,7 +174,7 @@ export function styledRuns(tokens) {
174
174
  return out;
175
175
  }
176
176
 
177
- // The report's own bands, by the role their items wear (CONTEXT.md, "Band").
177
+ // The report's own bands, by the role their items wear (GLOSSARY.md, "Band").
178
178
  // The page ones are in it although no walk emits them: the question is whose
179
179
  // band a role names, not what a walk hands over (ADR 0028).
180
180
  let REPORT_BANDS = new Set([
package/lib/style.js CHANGED
@@ -33,7 +33,7 @@ let isHex = (value) => typeof value === "string" && HEX.test(value);
33
33
  // diagnostic at the price of a data file that goes stale (docs/adr/0041).
34
34
  let CODE = /^[A-Z]{3}$/;
35
35
  /** @type {(value: any) => boolean} */
36
- let isCode = (value) => typeof value === "string" && CODE.test(value);
36
+ export let isCode = (value) => typeof value === "string" && CODE.test(value);
37
37
  // A declaration checks to a message or to null, and `spec` is the sugar for
38
38
  // the ordinary case: one predicate, one wording. `format` is the one entry
39
39
  // written out longhand, because a value with structure of its own has four
@@ -96,7 +96,7 @@ let FORMATS = ["number", "currency", "percent", "date"];
96
96
  // whole rule, and the two halves below -- the check and the resolution -- are
97
97
  // the only places it is spelled out.
98
98
 
99
- // The four names are Intl's `dateStyle`, and CONTEXT.md's "Form" is the term.
99
+ // The four names are Intl's `dateStyle`, and GLOSSARY.md's "Form" is the term.
100
100
  // A bare `date` means `medium`: the fifth shape Intl's no-option default used
101
101
  // to produce was nameable by nothing, so an author held one behaviour more
102
102
  // than the vocabulary described (docs/adr/0056).
@@ -427,7 +427,7 @@ export let isRunName = (name) => RUN.has(name);
427
427
  export let checkRunStyle = narrowed(RUN_STYLES, "a styled run");
428
428
  export let checkSlotStyle = withoutFlow(checkStyle, "a split slot");
429
429
  // `valign` is legal exactly where a box has height it did not ask for
430
- // (CONTEXT.md "Box": slack). A slot's box is the split's height, so a slot --
430
+ // (GLOSSARY.md "Box": slack). A slot's box is the split's height, so a slot --
431
431
  // text or image -- reads it; a band image's box is exactly its picture's
432
432
  // height, so it does not.
433
433
  // That is the one name an image accepts in a slot and nowhere else, the same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quario",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Tiny, CSP-safe report engine for JavaScript. One JSON document renders to HTML, PDF, XLSX, CSV and Word.",
5
5
  "keywords": [
6
6
  "csp",
@@ -62,7 +62,7 @@
62
62
  "sjabloon",
63
63
  "padvinder"
64
64
  ],
65
- "limit": "17.5 kB"
65
+ "limit": "18 kB"
66
66
  }
67
67
  ],
68
68
  "engines": {