@quario/layout 0.2.0 → 0.4.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,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - **Per-run typography, decoration and highlights.** A styled run resolves its
15
+ own face, size and colour, and a line takes the largest size among its runs.
16
+ `underline` and `strikethrough` are drawn per run — over that run's width, in
17
+ that run's colour — where they used to stroke the whole line in the first
18
+ piece's colour. A run's `background` paints a highlight rectangle behind its
19
+ text; a cell's own background is still painted once, at cell scope.
20
+
21
+ - **`checkFonts` is public.** It checks the shape of a `fonts` mapping without
22
+ loading a parser, and takes an optional name to prefix a failure with, the
23
+ way `pageBox` does — so a surface validating its own `fonts` property can
24
+ report the mistake against that property rather than against
25
+ `options.fonts`.
26
+
27
+ ### Fixed
28
+
29
+ - **An outline `Mark` carries only what it declares.** Every mark shipped an
30
+ extra `titled` boolean — bookkeeping for whether a header had claimed the
31
+ entry — which was never part of the `Mark` interface and which nothing
32
+ reads. It is the open group instance's own state now, and no longer travels
33
+ on the object a consumer receives.
34
+
35
+ ## [0.3.0] - 2026-09-07
36
+
37
+ ### Added
38
+
39
+ - The text join reads a cell's own `currency` code, ahead of the instance's
40
+ default, so every target built on this package presents a per-cell
41
+ denomination.
42
+
43
+ ### Changed
44
+
45
+ - **A group instance that renders nothing no longer takes up space.** A group
46
+ whose header and footer items all resolve `visible: false`, with nothing
47
+ visible under it either, used to open the same half-line gap as any other
48
+ instance and could push the content after it onto a new page. A run of them
49
+ spaced whatever followed by a half-line each, so collapsing a level left the
50
+ rows above it at uneven distances. Such an instance now occupies nothing at
51
+ all and adds no PDF bookmark, which is what makes a group collapsible while
52
+ its rows stay in the aggregates. A group that declares `break: "page"` or
53
+ `reset: "page"` still starts its page either way. Every document with such a
54
+ group renders slightly shorter than it did.
55
+
56
+ - **Numbers presented through `format` now show a fixed two fraction digits,
57
+ matching every other target** — `1,000.00` where `1,000` was rendered,
58
+ `21.00%` where `21%` was, and a currency's own minor units in place of a
59
+ universal two. Because this package presents text and then **measures** it,
60
+ a formatted cell is now up to three characters wider than it was: a line
61
+ that just fitted can wrap, which can move a page break in a PDF, the viewer,
62
+ or an editor preview. Nothing else about wrapping changed.
63
+
64
+ ### Fixed
65
+
66
+ - **An image failure now names the item that asked for the bytes.** A file too
67
+ short to carry a size failed saying only that the size could not be read,
68
+ naming no item, so a report with two pictures gave no way to tell which one
69
+ was bad. The message is now prefixed with the item's `source` path, as every
70
+ other render error is.
71
+
72
+ - **An image the browser cannot decode no longer costs the whole page.** A
73
+ PNG or JPEG whose pixel data is corrupt past the size in its header lays out
74
+ like any other — the size is all that is read of it — and used to throw out
75
+ of `paint()` before its first draw op, leaving the page blank: no white
76
+ fill, none of the other content, and no licence marking. The image is now
77
+ drawn as nothing and the page is drawn around it, so what a bad image costs
78
+ is the image.
79
+
10
80
  ## [0.2.0] - 2026-09-05
11
81
 
12
82
  ### Added
package/lib/canvas.js CHANGED
@@ -1,6 +1,11 @@
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`
@@ -18,72 +23,19 @@
18
23
  * it lands: a list coordinate is measured from the page's top-left corner,
19
24
  * `y` descending, the way a screen reads. Points throughout.
20
25
  */
21
- import { baseSans, familyName } from "./fonts.js";
22
- import { BLACK, dressed, shift, sizeOf } from "./style.js";
26
+ import { baseSans } from "./fonts.js";
27
+ import { BLACK, shift } from "./style.js";
23
28
 
29
+ /** @typedef {import('./page.js').Frame} Frame */
24
30
  /** @typedef {import('./text.js').Line} Line */
25
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 */
26
35
  // The list's own shapes — `Op`, `Box`, `Page` — are described once, in the
27
36
  // hand-written public declarations, and read back here.
28
37
  /** @import { Box, Op, Page } from './index.d.ts' */
29
38
 
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
- /**
38
- * @typedef {{ width: number, height: number, margin: number,
39
- * content: number, top: number, bottom: number }} Frame
40
- */
41
-
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) => ({
47
- width,
48
- height,
49
- margin,
50
- content: width - 2 * margin,
51
- top: height - margin,
52
- bottom: margin,
53
- });
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
-
87
39
  // `y` is the cursor on the open page and `fresh` says nothing has been drawn on
88
40
  // it yet, which is what makes a break legal. `count` is how many pages exist.
89
41
  /**
@@ -93,8 +45,8 @@ let settings = (fonts) => ({ fonts, base: BASE, family: null });
93
45
  * rect: (color: Color, x: number, y: number, w: number, h: number) => void,
94
46
  * stroke: (x1: number, y1: number, x2: number, y2: number, thickness: number,
95
47
  * color: Color, dash: number[] | null) => void,
96
- * picture: (bytes: Uint8Array, format: string, x: number, y: number,
97
- * w: number, h: number) => void,
48
+ * picture: (path: string | undefined, bytes: Uint8Array, format: string,
49
+ * x: number, y: number, w: number, h: number) => void,
98
50
  * drawLine: (line: Line, x: number, yTop: number, avail: number,
99
51
  * align: any) => void,
100
52
  * box: (path: string | undefined, x: number, yTop: number, w: number,
@@ -119,27 +71,55 @@ let settings = (fonts) => ({ fonts, base: BASE, family: null });
119
71
  */
120
72
  let blank = (box, render) => ({ ...box, settings: render, y: 0, fresh: true });
121
73
 
122
- // Text decoration in the text colour. Thickness and offset come from the
123
- // line's ascender (the face metric already measured for baseline placement).
124
- // Empty lines (no width) draw nothing. Shared by the listing adapter and the
125
- // layout suite's recorder so both exercise the same path.
126
- /** @type {(line: Line) => Color} */
127
- let ink = (line) => (line.pieces[0] && line.pieces[0].color) || BLACK;
74
+ // A styled run's `background`, painted behind its text as a highlight over
75
+ // that run's width and the line's full height -- the same rectangle a cell's
76
+ // own background is, one stretch narrower. All of a line's highlights are
77
+ // painted before any of its glyphs, so a rectangle behind one run can never
78
+ // land on top of the run before it. Shared with the layout suite's recorder
79
+ // for the reason `decorateLine` is.
80
+ /**
81
+ * @type {(rect: (color: Color, x: number, y: number, w: number, h: number) => void,
82
+ * line: Line, left: number, yTop: number) => void}
83
+ */
84
+ let highlightLine = (rect, line, left, yTop) => {
85
+ let cursor = left;
86
+ for (let piece of line.pieces) {
87
+ if (piece.bg) rect(piece.bg, cursor, yTop - line.h, piece.w, line.h);
88
+ cursor += piece.w;
89
+ }
90
+ };
128
91
 
129
- /** @type {(line: Line) => boolean} */
130
- let wantsDeco = (line) => !!line.w && dressed(line);
92
+ // Text decoration, drawn per piece: over that piece's width, in that piece's
93
+ // colour. A rule across the whole line in the first piece's colour is what
94
+ // this used to be, and it is exactly what "underline a word" cannot mean
95
+ // (ADR 0061). Thickness and offset stay the line's, from the ascender already
96
+ // measured for baseline placement, so one line's rules sit at one height
97
+ // whatever sizes it mixes. Empty lines (no width) draw nothing. Shared by the
98
+ // listing adapter and the layout suite's recorder so both exercise the same
99
+ // path.
100
+ /**
101
+ * @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: Color) => void,
102
+ * piece: Line['pieces'][number], line: Line, left: number, baseline: number) => void}
103
+ */
104
+ let decoratePiece = (stroke, piece, line, left, baseline) => {
105
+ let thickness = Math.max(line.asc / 12, 0.5);
106
+ let right = left + piece.w;
107
+ let color = piece.color || BLACK;
108
+ if (piece.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
109
+ if (piece.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
110
+ };
131
111
 
132
112
  /**
133
113
  * @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: Color) => void,
134
114
  * line: Line, left: number, baseline: number) => void}
135
115
  */
136
116
  let decorateLine = (stroke, line, left, baseline) => {
137
- if (!wantsDeco(line)) return;
138
- let thickness = Math.max(line.asc / 12, 0.5);
139
- let right = left + line.w;
140
- let color = ink(line);
141
- if (line.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
142
- if (line.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
117
+ if (!line.w) return;
118
+ let cursor = left;
119
+ for (let piece of line.pieces) {
120
+ decoratePiece(stroke, piece, line, cursor, baseline);
121
+ cursor += piece.w;
122
+ }
143
123
  };
144
124
 
145
125
  /**
@@ -182,9 +162,12 @@ let listing = (box, render) => {
182
162
  dash,
183
163
  });
184
164
 
165
+ // `path` rides along for the one consumer that needs to name the item back
166
+ // to the author: a target whose embedder rejects the bytes has nothing else
167
+ // to say which image it was, the box beside the op being a different record.
185
168
  /** @type {Canvas['picture']} */
186
- let picture = (bytes, format, x, y, w, h) =>
187
- page.ops.push({ kind: "image", bytes, format, x, y: down(y + h), w, h });
169
+ let picture = (path, bytes, format, x, y, w, h) =>
170
+ page.ops.push({ kind: "image", path, bytes, format, x, y: down(y + h), w, h });
188
171
 
189
172
  // `advances` rides only where a painter can use it: a base-14 face's
190
173
  // one-per-code-point shaping, which is what lets a screen stand-in be
@@ -217,6 +200,7 @@ let listing = (box, render) => {
217
200
  /** @type {Canvas['drawLine']} */
218
201
  let drawLine = (line, x, yTop, avail, align) => {
219
202
  let left = x + shift(align, avail - line.w);
203
+ highlightLine(rect, line, left, yTop);
220
204
  let cursor = left;
221
205
  for (let piece of line.pieces) {
222
206
  writePiece(piece, line, cursor, yTop);
@@ -314,42 +298,6 @@ let adopt = (canvas, box) => {
314
298
  canvas.y = canvas.top;
315
299
  };
316
300
 
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
-
353
301
  // The unlicensed-output marking (LICENSE section 6): one translucent line
354
302
  // drawn corner-to-corner across the finished page — over the content, not
355
303
  // under it, so no filled table header or background rectangle can cover it.
@@ -388,4 +336,4 @@ let stamp = (canvas, text) => {
388
336
  };
389
337
  };
390
338
 
391
- export { adopt, adoptSettings, decorateLine, frame, listing, measuring, settings, stamp };
339
+ export { adopt, decorateLine, highlightLine, listing, measuring, stamp };
package/lib/fonts.js CHANGED
@@ -161,9 +161,9 @@ let useFontkit = async () => {
161
161
  }
162
162
  };
163
163
 
164
- /** @type {(name: string, def: any) => string} */
165
- let asFamily = (name, def) => {
166
- let path = "options.fonts." + name;
164
+ /** @type {(name: string, def: any, at: string) => string} */
165
+ let asFamily = (name, def, at) => {
166
+ let path = at + "." + name;
167
167
  if (!def || typeof def !== "object")
168
168
  throw Error(path + ": expected { regular, bold?, italic?, boldItalic? }");
169
169
  if (def.regular == null) throw Error(path + ".regular: required");
@@ -241,7 +241,9 @@ let remembered = (source, key, make) => {
241
241
  // resolvable once the family is named.
242
242
  /** @type {(fontkit: any, name: string, def: any) => Face[]} */
243
243
  let familyOf = (fontkit, name, def) => {
244
- let path = asFamily(name, def);
244
+ // Always the factory's own option here: loading happens behind `layout()`,
245
+ // and a surface that named it something else has already validated it.
246
+ let path = asFamily(name, def, "options.fonts");
245
247
  let family = name.toLowerCase();
246
248
  /** @type {Face[]} */
247
249
  let faces = [];
@@ -278,15 +280,23 @@ export async function loadFonts(custom) {
278
280
  }
279
281
 
280
282
  /**
281
- * Check the shape of a host's font mapping without loading a parser: the
282
- * factory call is where a malformed option is reported.
283
+ * Check the shape of a host's font mapping without loading a parser.
284
+ *
285
+ * `at` names the property in the message, the way `pageBox` takes one: the
286
+ * factory call reports `options.fonts`, but a surface element validating its
287
+ * own `fonts` property before any render has a different name for the same
288
+ * value, and a host mistake is named on the property that carries it.
289
+ *
290
+ * Only the shape. A face that will not parse, or a missing parser, is found
291
+ * while the report is measured and is a render failure there.
283
292
  *
284
293
  * @param {any} custom `options.fonts`, or null.
294
+ * @param {string} [at] What to call it in the message.
285
295
  */
286
- export let checkFonts = (custom) => {
296
+ export let checkFonts = (custom, at = "options.fonts") => {
287
297
  if (custom == null) return;
288
- if (typeof custom !== "object") throw Error("options.fonts: expected a record of families");
289
- for (let [name, def] of Object.entries(custom)) asFamily(name, def);
298
+ if (typeof custom !== "object") throw Error(at + ": expected a record of families");
299
+ for (let [name, def] of Object.entries(custom)) asFamily(name, def, at);
290
300
  };
291
301
 
292
302
  // What a declared `family` normalises to before it is looked up: lower case,
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];
@@ -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;
@@ -224,3 +232,13 @@ export function pageBox(
224
232
  page?: LayoutPage,
225
233
  at?: string,
226
234
  ): { width: number; height: number; margin: number };
235
+
236
+ /**
237
+ * Check the shape of a `fonts` mapping, throwing where it is malformed. `at`
238
+ * prefixes a failure with the option's name, so a surface validating its own
239
+ * `fonts` property names that rather than `options.fonts`.
240
+ *
241
+ * Only the shape: a face that will not parse, or a missing parser, is found
242
+ * while the report is measured and is a render failure there.
243
+ */
244
+ export function checkFonts(fonts?: LayoutFonts | null, at?: string): void;
package/lib/index.js CHANGED
@@ -21,13 +21,15 @@
21
21
  * file adds none. The package publishes `lib/` verbatim.
22
22
  */
23
23
  import { breathe, walk } from "quario";
24
- import { listing, settings, stamp } from "./canvas.js";
24
+ 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";
32
+ export { checkFonts } from "./fonts.js";
31
33
 
32
34
  // The options are described once, in the hand-written public declarations, and
33
35
  // read back here — a second copy in JSDoc is a copy that drifts.
@@ -78,8 +80,13 @@ export function layout(options) {
78
80
  let fonts = await loadFonts(custom);
79
81
  let gen = stream(data);
80
82
  let first = gen.next();
81
- let geo = geometry(options?.page, first.done ? null : first.value);
82
- let canvas = listing(geo, settings(fonts));
83
+ // The opening event carries both halves this render is built from: the page
84
+ // frame and the document-wide settings. Peeked once here — the settings are
85
+ // complete and immutable by construction, so no later event can settle them
86
+ // a second time.
87
+ let opening = first.done ? null : first.value;
88
+ let geo = geometry(options?.page, opening);
89
+ let canvas = listing(geo, settings(fonts, opening));
83
90
  // The band flow owns the placement state and every handler over it, and
84
91
  // opens the first page as it is built; this file only hands it the stream.
85
92
  let { handlers, finish } = flow(canvas);
@@ -88,21 +95,21 @@ export function layout(options) {
88
95
  yield* gen;
89
96
  }
90
97
  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 || {};
98
+ // The flow reserved the page bands off the opening event on its way past;
99
+ // the passes below want the rest of it the band closures to render per
100
+ // page, and the marking's wording to stamp. It is the same event peeked
101
+ // above, so this file reads it straight rather than through `finish`.
102
+ // Empty when nothing was peeked, which reads as a document owing neither.
103
+ let { marks, pages } = finish();
104
+ let doc = opening || {};
98
105
  // The passes below run over the finished pages, not the stream — no walk
99
106
  // at all — so they open each page themselves and breathe on their own
100
107
  // rather than through the driver.
101
- await furnish(canvas, opening.page, pages);
108
+ await furnish(canvas, doc.page, pages);
102
109
  // The unlicensed marking goes on last, over content and page furniture
103
110
  // alike, once per page (LICENSE section 6). Its wording rode in on
104
111
  // `report-start`; only the placement is this layout's.
105
- await markPages(canvas, opening.marking);
112
+ await markPages(canvas, doc.marking);
106
113
  return /** @type {Layout} */ ({
107
114
  width: geo.width,
108
115
  height: geo.height,