@quario/layout 0.1.0 → 0.2.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,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-05
11
+
12
+ ### Added
13
+
14
+ - **A spanning cell is drawn as one box** across the columns it covers,
15
+ starting where the first of them starts. It takes no part in allocating their
16
+ widths, and a column no cell votes on opens at the cell-padding floor, so an
17
+ empty table still shows its geometry. A spanning row too tall for the page it
18
+ is on slices like any other, each slice following the geometry of the page or
19
+ page-column strip it lands in.
20
+
21
+ - **`valign`** places a cell's lines, or a slot's lines or picture, in the slack
22
+ its row or split leaves over its own content: middle halves it, bottom takes
23
+ it. The box does not move, and a picture is not scaled. A row too tall for
24
+ any page, sliced across pages, paints from the top.
25
+
26
+ ### Changed
27
+
28
+ - **A row's box is drawn by the row's cells.** A box declared on a table row
29
+ used to be one rect across the summed column widths; it is now each covered
30
+ cell's own, so a row's `borderBottom` still reads as one continuous edge
31
+ while a row's `borderLeft` becomes an edge on every cell rather than one at
32
+ the row's outer left. A row's border also occupies height now, as a cell's
33
+ always has, so a bordered row is taller by its border's width.
34
+
35
+ - **Measuring TrueType families now needs `fontkit`, not `@pdf-lib/fontkit`.**
36
+ Install `fontkit` instead; nothing else about `options.fonts` changes. The
37
+ old package's bundle crashed with a bare `ReferenceError` on any OpenType
38
+ face needing a shaping state machine — which is every Devanagari, Bengali,
39
+ Tamil, Khmer or Myanmar webfont in practice, and some fifty further scripts
40
+ besides. Those faces now measure where they used to throw. Which scripts a
41
+ face supports remains the font's and the parser's to answer, not this
42
+ package's.
43
+
44
+ ### Fixed
45
+
46
+ - **A group header no longer strands above a split.** A split cannot be broken
47
+ across a page, so a header that introduces one has to keep the whole of it
48
+ company — but the keep-together test measured a split by its first two lines
49
+ of text, and a split holds its text in its slots, not in itself. It therefore
50
+ measured as nothing: the header was drawn at the foot of the page with room
51
+ reserved for none of what followed, the split moved on to the next page, and
52
+ the instance's header repeated above it there. A record card whose total row
53
+ is a split was drawn twice — once orphaned at a page bottom, once whole. The
54
+ header now moves with the split, exactly as it already moved with an image.
55
+
56
+ Where a split is taller than the page has left, the header behaves as it
57
+ always has before content no page can hold: it degrades to plain paginated
58
+ flow and repeats nothing.
59
+
10
60
  ## [0.1.0] - 2026-09-03
11
61
 
12
62
  ### 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
@@ -7,7 +7,7 @@
7
7
  * moves the cursor and marks nothing, which is how `probe` reserves a band's
8
8
  * height without emitting it. Measuring is therefore a choice of adapter
9
9
  * 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
10
+ * measuring canvas is built from a frame and the render's settings, there is no
11
11
  * parameter through which a list could reach it. Pagination is the one thing
12
12
  * measuring must never do: a page turn would reset the cursor mid-measure and
13
13
  * return a silently wrong height, so `newPage` throws there.
@@ -18,57 +18,77 @@
18
18
  * it lands: a list coordinate is measured from the page's top-left corner,
19
19
  * `y` descending, the way a screen reads. Points throughout.
20
20
  */
21
- import { baseSans } from "./fonts.js";
22
- import { BLACK, dressed, shift } from "./style.js";
21
+ import { baseSans, familyName } from "./fonts.js";
22
+ import { BLACK, dressed, shift, sizeOf } from "./style.js";
23
23
 
24
24
  /** @typedef {import('./text.js').Line} Line */
25
- /** @typedef {import('./text.js').Metrics} Metrics */
26
25
  /** @typedef {import('./style.js').Color} Color */
27
26
  // The list's own shapes — `Op`, `Box`, `Page` — are described once, in the
28
27
  // hand-written public declarations, and read back here.
29
28
  /** @import { Box, Op, Page } from './index.d.ts' */
30
29
 
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.
30
+ // The page box and the content box, and nothing whatever else: geometry, all
31
+ // of it derived below from a page and a margin. Fixed for the document — the
32
+ // one exception is `top`/`bottom`, which the page bands narrow once through
33
+ // `adopt` below, while the first page is still untouched. Everything else that
34
+ // holds for a whole render is `Settings`, next door; a frame carrying either
35
+ // half of that was a type with two lifetimes, and the copy that kept a probe
36
+ // reading the same locale as the draw had to be written out by hand.
37
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
38
+ * @typedef {{ width: number, height: number, margin: number,
39
+ * content: number, top: number, bottom: number }} Frame
48
40
  */
49
41
 
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) => ({
42
+ // A frame from the page box: how `content`/`top`/`bottom` fall out of a page
43
+ // and a margin is derived here, once, so no caller and no suite has to restate
44
+ // it and drift from what a real render uses.
45
+ /** @type {(width: number, height: number, margin: number) => Frame} */
46
+ let frame = (width, height, margin) => ({
58
47
  width,
59
48
  height,
60
49
  margin,
61
- base,
62
- family,
63
50
  content: width - 2 * margin,
64
51
  top: height - margin,
65
52
  bottom: margin,
66
53
  });
67
54
 
55
+ // This layout's baseline type size. Not a host option: a document's type size
56
+ // is the document's own, so it is `style.size` on the report and the number
57
+ // here is only what text renders at when nothing declares one. The XLSX target
58
+ // carries the same 10 for the same reason (docs/adr/0014, docs/adr/0033).
59
+ let BASE = 10;
60
+
61
+ /**
62
+ * Everything that holds for a whole render and is not geometry: the faces to
63
+ * measure against, the report default narrowed to `family` and `size` (landing
64
+ * in `family` and `base` here), and the three intl facts a formatted value
65
+ * resolves in. Settled once, at
66
+ * `report-start`, by `adoptSettings` below, and only read thereafter.
67
+ *
68
+ * A canvas holds one of these by reference, never a copy, which is the whole
69
+ * reason it is an object. A measuring canvas built off the same settings reads
70
+ * exactly what the listing canvas reads, so a page band cannot be reserved
71
+ * against one locale and drawn in another — an agreement that used to rest on
72
+ * a hand-written copy staying in step.
73
+ *
74
+ * `family` is null when the report declares none, and the intl three are
75
+ * absent when the engine settled none.
76
+ *
77
+ * @typedef {{ fonts: import('./fonts.js').Fonts, base: number,
78
+ * family: string | null, locale?: string, currency?: string,
79
+ * timeZone?: string }} Settings
80
+ */
81
+
82
+ // What a render starts from: the loaded faces, and this target's own baseline
83
+ // standing in for a default no report has declared yet.
84
+ /** @type {(fonts: import('./fonts.js').Fonts) => Settings} */
85
+ let settings = (fonts) => ({ fonts, base: BASE, family: null });
86
+
68
87
  // `y` is the cursor on the open page and `fresh` says nothing has been drawn on
69
88
  // it yet, which is what makes a break legal. `count` is how many pages exist.
70
89
  /**
71
- * @typedef {Frame & Metrics & { y: number, fresh: boolean, count: number,
90
+ * @typedef {Frame & { settings: Settings, y: number, fresh: boolean,
91
+ * count: number,
72
92
  * newPage: () => void,
73
93
  * rect: (color: Color, x: number, y: number, w: number, h: number) => void,
74
94
  * stroke: (x1: number, y1: number, x2: number, y2: number, thickness: number,
@@ -89,15 +109,15 @@ let frame = (width, height, margin, base, family = null) => ({
89
109
  * watermark: (mark: Mark) => void }} Listing
90
110
  */
91
111
 
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.
112
+ // What every canvas starts as, whichever adapter it is: its frame and the
113
+ // render's settings, and a cursor that has drawn nothing yet. Shared because
114
+ // the parity test compares member names, not their values — two hand-written
115
+ // copies of this could drift in what they start from and nothing would notice.
96
116
  /**
97
- * @type {(box: Frame, fonts: import('./fonts.js').Fonts) =>
98
- * Frame & Metrics & { y: number, fresh: boolean }}
117
+ * @type {(box: Frame, render: Settings) =>
118
+ * Frame & { settings: Settings, y: number, fresh: boolean }}
99
119
  */
100
- let blank = (box, fonts) => ({ ...box, fonts, y: 0, fresh: true });
120
+ let blank = (box, render) => ({ ...box, settings: render, y: 0, fresh: true });
101
121
 
102
122
  // Text decoration in the text colour. Thickness and offset come from the
103
123
  // line's ascender (the face metric already measured for baseline placement).
@@ -125,9 +145,9 @@ let decorateLine = (stroke, line, left, baseline) => {
125
145
  /**
126
146
  * The adapter that records: a canvas whose marks become the display list.
127
147
  *
128
- * @type {(box: Frame, fonts: import('./fonts.js').Fonts) => Listing}
148
+ * @type {(box: Frame, render: Settings) => Listing}
129
149
  */
130
- let listing = (box, fonts) => {
150
+ let listing = (box, render) => {
131
151
  // A page under construction: its `number`/`total` are the target's to add
132
152
  // once the count is known.
133
153
  /** @type {any} */
@@ -226,7 +246,7 @@ let listing = (box, fonts) => {
226
246
 
227
247
  /** @type {Listing} */
228
248
  let canvas = {
229
- ...blank(box, fonts),
249
+ ...blank(box, render),
230
250
  get count() {
231
251
  return pages.length;
232
252
  },
@@ -253,10 +273,10 @@ let MARKS_NOTHING = () => {};
253
273
  * The adapter that only measures: the same cursor arithmetic with every mark
254
274
  * discarded. It holds no page, so a measurement cannot record.
255
275
  *
256
- * @type {(box: Frame, fonts: import('./fonts.js').Fonts) => Canvas}
276
+ * @type {(box: Frame, render: Settings) => Canvas}
257
277
  */
258
- let measuring = (box, fonts) => ({
259
- ...blank(box, fonts),
278
+ let measuring = (box, render) => ({
279
+ ...blank(box, render),
260
280
  count: 0,
261
281
  newPage: () => {
262
282
  throw new Error("probe reached newPage: the measuring path must not paginate");
@@ -272,10 +292,10 @@ let measuring = (box, fonts) => ({
272
292
  * Take on a content box someone else worked out — how much page furniture
273
293
  * needs is layout's policy (`reserve` there), while which of a frame's members
274
294
  * 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.
295
+ * and the only two read here: the page box is fixed for the whole document, so
296
+ * the rest of the frame that arrives is this canvas's own and is left alone.
297
+ * Layout reads the bounds live, so every page the body then flows through sees
298
+ * the narrowed box.
279
299
  *
280
300
  * Legal only while the render has not committed to a page — the band flow
281
301
  * adopts from its `report-start` handler, with its own first page open but
@@ -294,6 +314,42 @@ let adopt = (canvas, box) => {
294
314
  canvas.y = canvas.top;
295
315
  };
296
316
 
317
+ /**
318
+ * The document-wide facts off `report-start`, taken once, before anything is
319
+ * measured. The report default is narrowed to `family` and `size`, and each
320
+ * replaces this target's own baseline outright: row heights and band gaps scale
321
+ * with the document's type rather than staying at a size nothing is set in, and
322
+ * text declaring no family is set in the document's. Settling the pair here is
323
+ * the whole of this target's reading of docs/adr/0033 — the default reaches a
324
+ * node as a fallback the settings carry, never as a layer merged into its
325
+ * style, so a document-wide fact costs no allocation however many cells a
326
+ * report has.
327
+ *
328
+ * `sizeOf` and `familyName` are this target's one reading each of what a
329
+ * declared size and family amount to, so they read the default here too — both
330
+ * reach this unchecked from a computed style, and two spellings of that
331
+ * leniency would drift. Each falls back to what the settings already carry, so
332
+ * a default declaring one of the pair leaves the other alone, and one whose
333
+ * value is unusable leaves this target's own baseline standing.
334
+ *
335
+ * Settled once, the statement `adopt` makes about geometry: these hold for a
336
+ * whole render, so a second event setting them would mean two documents in one.
337
+ * The freeze is what enforces it, and the check is what names it.
338
+ *
339
+ * @param {Settings} render The render's settings.
340
+ * @param {any} event The `report-start` event.
341
+ */
342
+ let adoptSettings = (render, event) => {
343
+ if (Object.isFrozen(render)) throw Error("adoptSettings: the render settings are fixed");
344
+ let style = event.style || {};
345
+ render.base = sizeOf(style, render.base);
346
+ render.family = familyName(style) || render.family;
347
+ render.locale = event.locale;
348
+ render.currency = event.currency;
349
+ render.timeZone = event.timeZone;
350
+ Object.freeze(render);
351
+ };
352
+
297
353
  // The unlicensed-output marking (LICENSE section 6): one translucent line
298
354
  // drawn corner-to-corner across the finished page — over the content, not
299
355
  // under it, so no filled table header or background rectangle can cover it.
@@ -332,4 +388,4 @@ let stamp = (canvas, text) => {
332
388
  };
333
389
  };
334
390
 
335
- export { adopt, decorateLine, frame, listing, measuring, stamp };
391
+ export { adopt, adoptSettings, decorateLine, frame, listing, measuring, settings, 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/index.d.ts CHANGED
@@ -26,7 +26,7 @@ export interface LayoutOptions {
26
26
  * TrueType families to measure against, selected from styles by
27
27
  * `family: '<name>'`. Pass the same record to `pdf({ fonts })` and to
28
28
  * `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`;
29
+ * and draw in the same faces. Requires the optional peer `fontkit`;
30
30
  * the base-14 families need nothing extra.
31
31
  *
32
32
  * Hold the record and the buffers in it across renders rather than building
@@ -197,6 +197,12 @@ export const PX_PER_POINT: number;
197
197
  * Paint one page onto a Canvas 2D context: a white page, then every op in
198
198
  * order. `scale` is device pixels per point. `fonts` is the same record
199
199
  * given to `layout()`, so a TrueType family draws in its own face.
200
+ *
201
+ * It awaits its faces and images before it draws, and it draws whatever
202
+ * happened in between: a canvas re-sized under a call still in flight is
203
+ * filled at that call's own `scale`, not the size it now has. A caller that
204
+ * repaints one canvas at changing scales owns that, by not letting a
205
+ * superseded call reach a canvas still on screen.
200
206
  */
201
207
  export function paint(
202
208
  ctx: CanvasRenderingContext2D,
package/lib/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * file adds none. The package publishes `lib/` verbatim.
22
22
  */
23
23
  import { breathe, walk } from "quario";
24
- import { listing, stamp } from "./canvas.js";
24
+ import { listing, settings, 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";
@@ -79,7 +79,7 @@ export function layout(options) {
79
79
  let gen = stream(data);
80
80
  let first = gen.next();
81
81
  let geo = geometry(options?.page, first.done ? null : first.value);
82
- let canvas = listing(geo, fonts);
82
+ let canvas = listing(geo, settings(fonts));
83
83
  // The band flow owns the placement state and every handler over it, and
84
84
  // opens the first page as it is built; this file only hands it the stream.
85
85
  let { handlers, finish } = flow(canvas);