@quario/layout 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,101 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - The text join reads a cell's own `currency` code, ahead of the instance's
15
+ default, so every target built on this package presents a per-cell
16
+ denomination.
17
+
18
+ ### Changed
19
+
20
+ - **A group instance that renders nothing no longer takes up space.** A group
21
+ whose header and footer items all resolve `visible: false`, with nothing
22
+ visible under it either, used to open the same half-line gap as any other
23
+ instance and could push the content after it onto a new page. A run of them
24
+ spaced whatever followed by a half-line each, so collapsing a level left the
25
+ rows above it at uneven distances. Such an instance now occupies nothing at
26
+ all and adds no PDF bookmark, which is what makes a group collapsible while
27
+ its rows stay in the aggregates. A group that declares `break: "page"` or
28
+ `reset: "page"` still starts its page either way. Every document with such a
29
+ group renders slightly shorter than it did.
30
+
31
+ - **Numbers presented through `format` now show a fixed two fraction digits,
32
+ matching every other target** — `1,000.00` where `1,000` was rendered,
33
+ `21.00%` where `21%` was, and a currency's own minor units in place of a
34
+ universal two. Because this package presents text and then **measures** it,
35
+ a formatted cell is now up to three characters wider than it was: a line
36
+ that just fitted can wrap, which can move a page break in a PDF, the viewer,
37
+ or an editor preview. Nothing else about wrapping changed.
38
+
39
+ ### Fixed
40
+
41
+ - **An image failure now names the item that asked for the bytes.** A file too
42
+ short to carry a size failed saying only that the size could not be read,
43
+ naming no item, so a report with two pictures gave no way to tell which one
44
+ was bad. The message is now prefixed with the item's `source` path, as every
45
+ other render error is.
46
+
47
+ - **An image the browser cannot decode no longer costs the whole page.** A
48
+ PNG or JPEG whose pixel data is corrupt past the size in its header lays out
49
+ like any other — the size is all that is read of it — and used to throw out
50
+ of `paint()` before its first draw op, leaving the page blank: no white
51
+ fill, none of the other content, and no licence marking. The image is now
52
+ drawn as nothing and the page is drawn around it, so what a bad image costs
53
+ is the image.
54
+
55
+ ## [0.2.0] - 2026-09-05
56
+
57
+ ### Added
58
+
59
+ - **A spanning cell is drawn as one box** across the columns it covers,
60
+ starting where the first of them starts. It takes no part in allocating their
61
+ widths, and a column no cell votes on opens at the cell-padding floor, so an
62
+ empty table still shows its geometry. A spanning row too tall for the page it
63
+ is on slices like any other, each slice following the geometry of the page or
64
+ page-column strip it lands in.
65
+
66
+ - **`valign`** places a cell's lines, or a slot's lines or picture, in the slack
67
+ its row or split leaves over its own content: middle halves it, bottom takes
68
+ it. The box does not move, and a picture is not scaled. A row too tall for
69
+ any page, sliced across pages, paints from the top.
70
+
71
+ ### Changed
72
+
73
+ - **A row's box is drawn by the row's cells.** A box declared on a table row
74
+ used to be one rect across the summed column widths; it is now each covered
75
+ cell's own, so a row's `borderBottom` still reads as one continuous edge
76
+ while a row's `borderLeft` becomes an edge on every cell rather than one at
77
+ the row's outer left. A row's border also occupies height now, as a cell's
78
+ always has, so a bordered row is taller by its border's width.
79
+
80
+ - **Measuring TrueType families now needs `fontkit`, not `@pdf-lib/fontkit`.**
81
+ Install `fontkit` instead; nothing else about `options.fonts` changes. The
82
+ old package's bundle crashed with a bare `ReferenceError` on any OpenType
83
+ face needing a shaping state machine — which is every Devanagari, Bengali,
84
+ Tamil, Khmer or Myanmar webfont in practice, and some fifty further scripts
85
+ besides. Those faces now measure where they used to throw. Which scripts a
86
+ face supports remains the font's and the parser's to answer, not this
87
+ package's.
88
+
89
+ ### Fixed
90
+
91
+ - **A group header no longer strands above a split.** A split cannot be broken
92
+ across a page, so a header that introduces one has to keep the whole of it
93
+ company — but the keep-together test measured a split by its first two lines
94
+ of text, and a split holds its text in its slots, not in itself. It therefore
95
+ measured as nothing: the header was drawn at the foot of the page with room
96
+ reserved for none of what followed, the split moved on to the next page, and
97
+ the instance's header repeated above it there. A record card whose total row
98
+ is a split was drawn twice — once orphaned at a page bottom, once whole. The
99
+ header now moves with the split, exactly as it already moved with an image.
100
+
101
+ Where a split is taller than the page has left, the header behaves as it
102
+ always has before content no page can hold: it degrades to plain paginated
103
+ flow and repeats nothing.
104
+
10
105
  ## [0.1.0] - 2026-09-03
11
106
 
12
107
  ### Added
package/README.md CHANGED
@@ -18,7 +18,7 @@ The engine is a peer. The one runtime dependency is `@pdf-lib/standard-fonts`, t
18
18
  base-14 PDF families. To lay out with your own TrueType families, add the optional peer:
19
19
 
20
20
  ```bash
21
- npm install @pdf-lib/fontkit
21
+ npm install fontkit
22
22
  ```
23
23
 
24
24
  ESM-only, Node 22+, and browser-ready through a standards-based bundler. CSP-safe: no
package/lib/box.js CHANGED
@@ -106,6 +106,10 @@ let paintEdge = (canvas, x, yTop, w, h, style, side) => {
106
106
 
107
107
  /** @type {Slice} */
108
108
  let WHOLE = { first: true, more: false };
109
+ // Owns both ends: a block, or a row, drawn in one piece. `sliced` builds fresh
110
+ // slices, so this is a predicate rather than identity against `WHOLE`.
111
+ /** @type {(slice: Slice) => boolean} */
112
+ let isWhole = ({ first, more }) => first && !more;
109
113
 
110
114
  // The sides one slice still owns. Left and right belong to every slice; the
111
115
  // top is the first slice's and the bottom the last's, so a middle slice
@@ -140,4 +144,4 @@ let paintBox = (canvas, x, yTop, w, h, style, bg, slice = WHOLE) => {
140
144
  if (style) for (let side of sidesOf(slice)) paintEdge(canvas, x, yTop, w, h, style, side);
141
145
  };
142
146
 
143
- export { CELL_PAD, NO_PAD, WHOLE, insetOf, paintBox, sliceInset, unbox };
147
+ export { CELL_PAD, NO_PAD, WHOLE, insetOf, isWhole, paintBox, sliceInset, unbox };
package/lib/canvas.js CHANGED
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * The drawing surface. Every mark the band flow makes goes through here, so
3
- * nothing above this module knows what a page is made of.
3
+ * nothing above this module knows how a page is drawn on. What a page *is* —
4
+ * its box, and the content box a margin leaves — is `page.js`'s `Frame`, which
5
+ * arrives here already derived: nothing below that module works one out. What
6
+ * this one owns is what becomes of it after — `blank` spreads it flat onto the
7
+ * canvas and `adopt` narrows `top`/`bottom` in place, which is why `layout.js`
8
+ * re-derives the untouched page frame it hangs a header from.
4
9
  *
5
10
  * Two adapters satisfy one interface. `listing` records marks onto the display
6
11
  * list — pages of ops and hit boxes, which the painters consume; `measuring`
7
12
  * moves the cursor and marks nothing, which is how `probe` reserves a band's
8
13
  * height without emitting it. Measuring is therefore a choice of adapter
9
14
  * rather than a mode every primitive has to remember to check — and since a
10
- * measuring canvas is built from a frame and a font registry, there is no
15
+ * measuring canvas is built from a frame and the render's settings, there is no
11
16
  * parameter through which a list could reach it. Pagination is the one thing
12
17
  * measuring must never do: a page turn would reset the cursor mid-measure and
13
18
  * return a silently wrong height, so `newPage` throws there.
@@ -21,60 +26,27 @@
21
26
  import { baseSans } from "./fonts.js";
22
27
  import { BLACK, dressed, shift } from "./style.js";
23
28
 
29
+ /** @typedef {import('./page.js').Frame} Frame */
24
30
  /** @typedef {import('./text.js').Line} Line */
25
- /** @typedef {import('./text.js').Metrics} Metrics */
26
31
  /** @typedef {import('./style.js').Color} Color */
32
+ // A canvas holds a render's settings and turns nothing into one: what a
33
+ // settings is, and how an opening event becomes it, is settings.js's.
34
+ /** @typedef {import('./settings.js').Settings} Settings */
27
35
  // The list's own shapes — `Op`, `Box`, `Page` — are described once, in the
28
36
  // hand-written public declarations, and read back here.
29
37
  /** @import { Box, Op, Page } from './index.d.ts' */
30
38
 
31
- // The page box and the content box. Settled at `report-start` and fixed
32
- // thereafter: the geometry is the factory's and never moves, while `base` and
33
- // `family` take the report default one event into the render, before anything
34
- // is measured. The page bands are measured against the page box and take their
35
- // height off `top`/`bottom` through `adopt` below, while the first page is
36
- // still untouched.
37
- /**
38
- * `family` is the report default's typeface, normalised, and `base` its size —
39
- * the two declarations that default is narrowed to. They ride the frame so a
40
- * measuring canvas built off it reads the same pair the listing canvas does,
41
- * and so the default reaches a node as a fallback rather than a merged layer;
42
- * `layout.js`'s `adoptDefault` carries why. `family` is null when the report
43
- * declares none.
44
- *
45
- * @typedef {{ width: number, height: number, margin: number, base: number,
46
- * content: number, top: number, bottom: number, family: string | null,
47
- * locale?: string, currency?: string, timeZone?: string }} Frame
48
- */
49
-
50
- // A frame from the page box and the base size: how `content`/`top`/`bottom`
51
- // fall out of a page and a margin is derived here, once, so no caller and no
52
- // suite has to restate it and drift from what a real render uses.
53
- /**
54
- * @type {(width: number, height: number, margin: number, base: number,
55
- * family?: string | null) => Frame}
56
- */
57
- let frame = (width, height, margin, base, family = null) => ({
58
- width,
59
- height,
60
- margin,
61
- base,
62
- family,
63
- content: width - 2 * margin,
64
- top: height - margin,
65
- bottom: margin,
66
- });
67
-
68
39
  // `y` is the cursor on the open page and `fresh` says nothing has been drawn on
69
40
  // it yet, which is what makes a break legal. `count` is how many pages exist.
70
41
  /**
71
- * @typedef {Frame & Metrics & { y: number, fresh: boolean, count: number,
42
+ * @typedef {Frame & { settings: Settings, y: number, fresh: boolean,
43
+ * count: number,
72
44
  * newPage: () => void,
73
45
  * rect: (color: Color, x: number, y: number, w: number, h: number) => void,
74
46
  * stroke: (x1: number, y1: number, x2: number, y2: number, thickness: number,
75
47
  * color: Color, dash: number[] | null) => void,
76
- * picture: (bytes: Uint8Array, format: string, x: number, y: number,
77
- * w: number, h: number) => void,
48
+ * picture: (path: string | undefined, bytes: Uint8Array, format: string,
49
+ * x: number, y: number, w: number, h: number) => void,
78
50
  * drawLine: (line: Line, x: number, yTop: number, avail: number,
79
51
  * align: any) => void,
80
52
  * box: (path: string | undefined, x: number, yTop: number, w: number,
@@ -89,15 +61,15 @@ let frame = (width, height, margin, base, family = null) => ({
89
61
  * watermark: (mark: Mark) => void }} Listing
90
62
  */
91
63
 
92
- // What every canvas starts as, whichever adapter it is: its frame and faces,
93
- // and a cursor that has drawn nothing yet. Shared because the parity test
94
- // compares member names, not their values — two hand-written copies of this
95
- // could drift in what they start from and nothing would notice.
64
+ // What every canvas starts as, whichever adapter it is: its frame and the
65
+ // render's settings, and a cursor that has drawn nothing yet. Shared because
66
+ // the parity test compares member names, not their values — two hand-written
67
+ // copies of this could drift in what they start from and nothing would notice.
96
68
  /**
97
- * @type {(box: Frame, fonts: import('./fonts.js').Fonts) =>
98
- * Frame & Metrics & { y: number, fresh: boolean }}
69
+ * @type {(box: Frame, render: Settings) =>
70
+ * Frame & { settings: Settings, y: number, fresh: boolean }}
99
71
  */
100
- let blank = (box, fonts) => ({ ...box, fonts, y: 0, fresh: true });
72
+ let blank = (box, render) => ({ ...box, settings: render, y: 0, fresh: true });
101
73
 
102
74
  // Text decoration in the text colour. Thickness and offset come from the
103
75
  // line's ascender (the face metric already measured for baseline placement).
@@ -125,9 +97,9 @@ let decorateLine = (stroke, line, left, baseline) => {
125
97
  /**
126
98
  * The adapter that records: a canvas whose marks become the display list.
127
99
  *
128
- * @type {(box: Frame, fonts: import('./fonts.js').Fonts) => Listing}
100
+ * @type {(box: Frame, render: Settings) => Listing}
129
101
  */
130
- let listing = (box, fonts) => {
102
+ let listing = (box, render) => {
131
103
  // A page under construction: its `number`/`total` are the target's to add
132
104
  // once the count is known.
133
105
  /** @type {any} */
@@ -162,9 +134,12 @@ let listing = (box, fonts) => {
162
134
  dash,
163
135
  });
164
136
 
137
+ // `path` rides along for the one consumer that needs to name the item back
138
+ // to the author: a target whose embedder rejects the bytes has nothing else
139
+ // to say which image it was, the box beside the op being a different record.
165
140
  /** @type {Canvas['picture']} */
166
- let picture = (bytes, format, x, y, w, h) =>
167
- page.ops.push({ kind: "image", bytes, format, x, y: down(y + h), w, h });
141
+ let picture = (path, bytes, format, x, y, w, h) =>
142
+ page.ops.push({ kind: "image", path, bytes, format, x, y: down(y + h), w, h });
168
143
 
169
144
  // `advances` rides only where a painter can use it: a base-14 face's
170
145
  // one-per-code-point shaping, which is what lets a screen stand-in be
@@ -226,7 +201,7 @@ let listing = (box, fonts) => {
226
201
 
227
202
  /** @type {Listing} */
228
203
  let canvas = {
229
- ...blank(box, fonts),
204
+ ...blank(box, render),
230
205
  get count() {
231
206
  return pages.length;
232
207
  },
@@ -253,10 +228,10 @@ let MARKS_NOTHING = () => {};
253
228
  * The adapter that only measures: the same cursor arithmetic with every mark
254
229
  * discarded. It holds no page, so a measurement cannot record.
255
230
  *
256
- * @type {(box: Frame, fonts: import('./fonts.js').Fonts) => Canvas}
231
+ * @type {(box: Frame, render: Settings) => Canvas}
257
232
  */
258
- let measuring = (box, fonts) => ({
259
- ...blank(box, fonts),
233
+ let measuring = (box, render) => ({
234
+ ...blank(box, render),
260
235
  count: 0,
261
236
  newPage: () => {
262
237
  throw new Error("probe reached newPage: the measuring path must not paginate");
@@ -272,10 +247,10 @@ let measuring = (box, fonts) => ({
272
247
  * Take on a content box someone else worked out — how much page furniture
273
248
  * needs is layout's policy (`reserve` there), while which of a frame's members
274
249
  * may move at all is this module's invariant. `top` and `bottom` are the two,
275
- * and the only two read here: the page box and the base size are fixed for the
276
- * whole document, so the rest of the frame that arrives is this canvas's own
277
- * and is left alone. Layout reads the bounds live, so every page the body then
278
- * flows through sees the narrowed box.
250
+ * and the only two read here: the page box is fixed for the whole document, so
251
+ * the rest of the frame that arrives is this canvas's own and is left alone.
252
+ * Layout reads the bounds live, so every page the body then flows through sees
253
+ * the narrowed box.
279
254
  *
280
255
  * Legal only while the render has not committed to a page — the band flow
281
256
  * adopts from its `report-start` handler, with its own first page open but
@@ -332,4 +307,4 @@ let stamp = (canvas, text) => {
332
307
  };
333
308
  };
334
309
 
335
- export { adopt, decorateLine, frame, listing, measuring, stamp };
310
+ export { adopt, decorateLine, listing, measuring, stamp };
package/lib/fonts.js CHANGED
@@ -20,25 +20,16 @@ import { Encodings, Font, FontNames } from "@pdf-lib/standard-fonts";
20
20
  // The three families the vocabulary names, four variants each, indexed by
21
21
  // `bold + 2 * italic` — the same ordering the schema's `bold`/`italic` flags
22
22
  // produce. Unknown family names fall back to sans, as SCHEMA.md specifies.
23
+ // A family's four names are one stem plus the weight and slant suffixes that
24
+ // family spells, so each family names its two and the four are walked out of
25
+ // them rather than written twelve times over.
26
+ /** @type {(stem: string, slant: string) => string[]} */
27
+ let variants = (stem, slant) =>
28
+ ["", "Bold", slant, "Bold" + slant].map((v) => /** @type {any} */ (FontNames)[stem + v]);
23
29
  let BASE = {
24
- sans: [
25
- FontNames.Helvetica,
26
- FontNames.HelveticaBold,
27
- FontNames.HelveticaOblique,
28
- FontNames.HelveticaBoldOblique,
29
- ],
30
- serif: [
31
- FontNames.TimesRoman,
32
- FontNames.TimesRomanBold,
33
- FontNames.TimesRomanItalic,
34
- FontNames.TimesRomanBoldItalic,
35
- ],
36
- mono: [
37
- FontNames.Courier,
38
- FontNames.CourierBold,
39
- FontNames.CourierOblique,
40
- FontNames.CourierBoldOblique,
41
- ],
30
+ sans: variants("Helvetica", "Oblique"),
31
+ serif: variants("TimesRoman", "Italic"),
32
+ mono: variants("Courier", "Oblique"),
42
33
  };
43
34
 
44
35
  /**
@@ -69,9 +60,6 @@ let BASE = {
69
60
  */
70
61
  /** @typedef {{ families: Record<string, Face[]> }} Fonts */
71
62
 
72
- /** @type {(text: string) => string[]} */
73
- let codePoints = (text) => Array.from(text);
74
-
75
63
  // A base-14 face: AFM widths and kerning over WinAnsi. The width of a string
76
64
  // is the sum of its glyph widths plus the kerning between neighbours — the
77
65
  // same arithmetic pdf-lib's standard-font embedder does, kept in step so the
@@ -88,7 +76,7 @@ let standard = (family, variant, name) => {
88
76
  let encoding = Encodings.WinAnsi;
89
77
  /** @type {(text: string) => { code: number, name: string }[]} */
90
78
  let glyphs = (text) =>
91
- codePoints(text).map((char) => encoding.encodeUnicodeCodePoint(char.codePointAt(0) ?? 0));
79
+ Array.from(text).map((char) => encoding.encodeUnicodeCodePoint(char.codePointAt(0) ?? 0));
92
80
  /** @type {(glyph: { name: string }, next?: { name: string }) => number} */
93
81
  let advance = (glyph, next) =>
94
82
  (font.getWidthOfGlyph(glyph.name) || 250) +
@@ -153,10 +141,6 @@ let baseFamilies = () =>
153
141
  ]),
154
142
  ));
155
143
 
156
- // A render's own copy, so adding a host's families cannot reach the memo.
157
- /** @type {() => Record<string, Face[]>} */
158
- let loadBase = () => ({ ...baseFamilies() });
159
-
160
144
  /**
161
145
  * The base-14 sans regular, whatever a host mapped `sans` to. Reached only by
162
146
  * the marking, which is the one thing on a page a host's font mapping must not
@@ -171,9 +155,9 @@ export let baseSans = () => baseFamilies().sans[0];
171
155
  /** @type {() => Promise<any>} */
172
156
  let useFontkit = async () => {
173
157
  try {
174
- return (await import("@pdf-lib/fontkit")).default;
158
+ return await import("fontkit");
175
159
  } catch {
176
- throw Error("options.fonts: install @pdf-lib/fontkit to measure TrueType families");
160
+ throw Error("options.fonts: install fontkit to measure TrueType families");
177
161
  }
178
162
  };
179
163
 
@@ -287,7 +271,8 @@ let loadCustom = async (custom, families) => {
287
271
  * @returns {Promise<Fonts>}
288
272
  */
289
273
  export async function loadFonts(custom) {
290
- let families = loadBase();
274
+ // A render's own copy, so adding a host's families cannot reach the memo.
275
+ let families = { ...baseFamilies() };
291
276
  await loadCustom(custom, families);
292
277
  return { families };
293
278
  }
@@ -397,7 +382,7 @@ let replaceUndrawable = (set, text) => {
397
382
  let sub = set.has(0x3f) ? "?" : "";
398
383
  // Encodedness is a per-code-point question, so that is the unit to ask it
399
384
  // in — the grapheme clusters the rule protects are undrawable anyway.
400
- return codePoints(text)
385
+ return Array.from(text)
401
386
  .map((char) => (set.has(char.codePointAt(0) ?? -1) ? char : sub))
402
387
  .join("");
403
388
  };
@@ -405,5 +390,5 @@ let replaceUndrawable = (set, text) => {
405
390
  /** @type {(font: any, text: string) => boolean} */
406
391
  let encodable = (font, text) => {
407
392
  let set = charsetOf(font);
408
- return codePoints(text).every((char) => set.has(char.codePointAt(0) ?? -1));
393
+ return Array.from(text).every((char) => set.has(char.codePointAt(0) ?? -1));
409
394
  };
package/lib/image.js CHANGED
@@ -53,6 +53,6 @@ export let intrinsic = (bytes, format) => {
53
53
  // The engine vouched for the magic numbers, not for the rest of the file:
54
54
  // a truncated header reaches here as a zero, and failing loudly beats
55
55
  // drawing an image with no size (SCHEMA.md, "Image item").
56
- if (!(w > 0 && h > 0)) throw Error("image: could not read the image's size from its bytes");
56
+ if (!(w > 0 && h > 0)) throw Error("could not read the image's size from its bytes");
57
57
  return { w: w * PER_PX, h: h * PER_PX };
58
58
  };
package/lib/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { Target } from "quario";
2
2
 
3
- /** Page geometry, in PostScript points. Host configuration, never schema. */
3
+ /**
4
+ * Page geometry, in PostScript points. Host configuration, except that a
5
+ * document may declare `page.margin` in the host's stead — never both.
6
+ */
4
7
  export interface LayoutPage {
5
8
  /** Named size or `[width, height]` in points. Default `'A4'`. */
6
9
  size?: "A4" | "letter" | [number, number];
@@ -26,7 +29,7 @@ export interface LayoutOptions {
26
29
  * TrueType families to measure against, selected from styles by
27
30
  * `family: '<name>'`. Pass the same record to `pdf({ fonts })` and to
28
31
  * `paint()`, so preview and document break their lines in the same places
29
- * and draw in the same faces. Requires the optional peer `@pdf-lib/fontkit`;
32
+ * and draw in the same faces. Requires the optional peer `fontkit`;
30
33
  * the base-14 families need nothing extra.
31
34
  *
32
35
  * Hold the record and the buffers in it across renders rather than building
@@ -81,9 +84,14 @@ export interface LineOp {
81
84
  dash: number[] | null;
82
85
  }
83
86
 
84
- /** An image, placed. `bytes` is the array the source expression yielded. */
87
+ /**
88
+ * An image, placed. `bytes` is the array the source expression yielded, and
89
+ * `path` names the schema node it came from — the one thing a target whose
90
+ * embedder rejects those bytes has to report the failure with.
91
+ */
85
92
  export interface ImageOp {
86
93
  kind: "image";
94
+ path: string | undefined;
87
95
  bytes: Uint8Array;
88
96
  format: "png" | "jpeg";
89
97
  x: number;
@@ -197,6 +205,12 @@ export const PX_PER_POINT: number;
197
205
  * Paint one page onto a Canvas 2D context: a white page, then every op in
198
206
  * order. `scale` is device pixels per point. `fonts` is the same record
199
207
  * given to `layout()`, so a TrueType family draws in its own face.
208
+ *
209
+ * It awaits its faces and images before it draws, and it draws whatever
210
+ * happened in between: a canvas re-sized under a call still in flight is
211
+ * filled at that call's own `scale`, not the size it now has. A caller that
212
+ * repaints one canvas at changing scales owns that, by not letting a
213
+ * superseded call reach a canvas still on screen.
200
214
  */
201
215
  export function paint(
202
216
  ctx: CanvasRenderingContext2D,
package/lib/index.js CHANGED
@@ -25,6 +25,7 @@ import { listing, stamp } from "./canvas.js";
25
25
  import { checkFonts, loadFonts } from "./fonts.js";
26
26
  import { flow, furniture } from "./layout.js";
27
27
  import { geometry } from "./page.js";
28
+ import { settings } from "./settings.js";
28
29
 
29
30
  export { PX_PER_POINT, hit, paint } from "./paint.js";
30
31
  export { pageBox } from "./page.js";
@@ -78,8 +79,13 @@ export function layout(options) {
78
79
  let fonts = await loadFonts(custom);
79
80
  let gen = stream(data);
80
81
  let first = gen.next();
81
- let geo = geometry(options?.page, first.done ? null : first.value);
82
- let canvas = listing(geo, fonts);
82
+ // The opening event carries both halves this render is built from: the page
83
+ // frame and the document-wide settings. Peeked once here — the settings are
84
+ // complete and immutable by construction, so no later event can settle them
85
+ // a second time.
86
+ let opening = first.done ? null : first.value;
87
+ let geo = geometry(options?.page, opening);
88
+ let canvas = listing(geo, settings(fonts, opening));
83
89
  // The band flow owns the placement state and every handler over it, and
84
90
  // opens the first page as it is built; this file only hands it the stream.
85
91
  let { handlers, finish } = flow(canvas);
@@ -88,21 +94,21 @@ export function layout(options) {
88
94
  yield* gen;
89
95
  }
90
96
  await walk(events(), handlers);
91
- // The flow settled the opening event on its way past — it reserves the
92
- // page bands off it and hands it back with the marks. What is left on it
93
- // is what the passes below want: the band closures to render per page, and
94
- // the marking's wording to stamp.
95
- // Null when nothing settled one, which reads as a document owing neither.
96
- let { marks, opening, pages } = finish();
97
- opening = opening || {};
97
+ // The flow reserved the page bands off the opening event on its way past;
98
+ // the passes below want the rest of it the band closures to render per
99
+ // page, and the marking's wording to stamp. It is the same event peeked
100
+ // above, so this file reads it straight rather than through `finish`.
101
+ // Empty when nothing was peeked, which reads as a document owing neither.
102
+ let { marks, pages } = finish();
103
+ let doc = opening || {};
98
104
  // The passes below run over the finished pages, not the stream — no walk
99
105
  // at all — so they open each page themselves and breathe on their own
100
106
  // rather than through the driver.
101
- await furnish(canvas, opening.page, pages);
107
+ await furnish(canvas, doc.page, pages);
102
108
  // The unlicensed marking goes on last, over content and page furniture
103
109
  // alike, once per page (LICENSE section 6). Its wording rode in on
104
110
  // `report-start`; only the placement is this layout's.
105
- await markPages(canvas, opening.marking);
111
+ await markPages(canvas, doc.marking);
106
112
  return /** @type {Layout} */ ({
107
113
  width: geo.width,
108
114
  height: geo.height,