@quario/layout 0.1.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/lib/canvas.js ADDED
@@ -0,0 +1,335 @@
1
+ /**
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.
4
+ *
5
+ * Two adapters satisfy one interface. `listing` records marks onto the display
6
+ * list — pages of ops and hit boxes, which the painters consume; `measuring`
7
+ * moves the cursor and marks nothing, which is how `probe` reserves a band's
8
+ * height without emitting it. Measuring is therefore a choice of adapter
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
11
+ * parameter through which a list could reach it. Pagination is the one thing
12
+ * measuring must never do: a page turn would reset the cursor mid-measure and
13
+ * return a silently wrong height, so `newPage` throws there.
14
+ *
15
+ * The band flow works in the page's own coordinates — the origin at the
16
+ * bottom-left, `y` climbing, the convention the PDF target writes in. The
17
+ * list is the format-neutral half, so the recorder turns every mark over as
18
+ * it lands: a list coordinate is measured from the page's top-left corner,
19
+ * `y` descending, the way a screen reads. Points throughout.
20
+ */
21
+ import { baseSans } from "./fonts.js";
22
+ import { BLACK, dressed, shift } from "./style.js";
23
+
24
+ /** @typedef {import('./text.js').Line} Line */
25
+ /** @typedef {import('./text.js').Metrics} Metrics */
26
+ /** @typedef {import('./style.js').Color} Color */
27
+ // The list's own shapes — `Op`, `Box`, `Page` — are described once, in the
28
+ // hand-written public declarations, and read back here.
29
+ /** @import { Box, Op, Page } from './index.d.ts' */
30
+
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
+ // `y` is the cursor on the open page and `fresh` says nothing has been drawn on
69
+ // it yet, which is what makes a break legal. `count` is how many pages exist.
70
+ /**
71
+ * @typedef {Frame & Metrics & { y: number, fresh: boolean, count: number,
72
+ * newPage: () => void,
73
+ * rect: (color: Color, x: number, y: number, w: number, h: number) => void,
74
+ * stroke: (x1: number, y1: number, x2: number, y2: number, thickness: number,
75
+ * color: Color, dash: number[] | null) => void,
76
+ * picture: (bytes: Uint8Array, format: string, x: number, y: number,
77
+ * w: number, h: number) => void,
78
+ * drawLine: (line: Line, x: number, yTop: number, avail: number,
79
+ * align: any) => void,
80
+ * box: (path: string | undefined, x: number, yTop: number, w: number,
81
+ * h: number) => void }} Canvas
82
+ */
83
+
84
+ // The page passes on top of the interface: re-visiting finished pages and
85
+ // stamping them. Deliberately not on `Canvas` — the layout may not re-target a
86
+ // page mid-walk, and a measurement has no business doing any of it.
87
+ /**
88
+ * @typedef {Canvas & { select: (i: number) => void, pages: Page[],
89
+ * watermark: (mark: Mark) => void }} Listing
90
+ */
91
+
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.
96
+ /**
97
+ * @type {(box: Frame, fonts: import('./fonts.js').Fonts) =>
98
+ * Frame & Metrics & { y: number, fresh: boolean }}
99
+ */
100
+ let blank = (box, fonts) => ({ ...box, fonts, y: 0, fresh: true });
101
+
102
+ // Text decoration in the text colour. Thickness and offset come from the
103
+ // line's ascender (the face metric already measured for baseline placement).
104
+ // Empty lines (no width) draw nothing. Shared by the listing adapter and the
105
+ // layout suite's recorder so both exercise the same path.
106
+ /** @type {(line: Line) => Color} */
107
+ let ink = (line) => (line.pieces[0] && line.pieces[0].color) || BLACK;
108
+
109
+ /** @type {(line: Line) => boolean} */
110
+ let wantsDeco = (line) => !!line.w && dressed(line);
111
+
112
+ /**
113
+ * @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: Color) => void,
114
+ * line: Line, left: number, baseline: number) => void}
115
+ */
116
+ let decorateLine = (stroke, line, left, baseline) => {
117
+ if (!wantsDeco(line)) return;
118
+ let thickness = Math.max(line.asc / 12, 0.5);
119
+ let right = left + line.w;
120
+ let color = ink(line);
121
+ if (line.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
122
+ if (line.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
123
+ };
124
+
125
+ /**
126
+ * The adapter that records: a canvas whose marks become the display list.
127
+ *
128
+ * @type {(box: Frame, fonts: import('./fonts.js').Fonts) => Listing}
129
+ */
130
+ let listing = (box, fonts) => {
131
+ // A page under construction: its `number`/`total` are the target's to add
132
+ // once the count is known.
133
+ /** @type {any} */
134
+ let page;
135
+ /** @type {any[]} */
136
+ let pages = [];
137
+ // A page coordinate turned over: the list reads down from the top.
138
+ /** @type {(y: number) => number} */
139
+ let down = (y) => canvas.height - y;
140
+
141
+ // Open a fresh page and put the cursor at its top.
142
+ let newPage = () => {
143
+ page = { width: canvas.width, height: canvas.height, ops: [], boxes: [] };
144
+ pages.push(page);
145
+ canvas.y = canvas.top;
146
+ canvas.fresh = true;
147
+ };
148
+
149
+ /** @type {Canvas['rect']} */
150
+ let rect = (color, x, y, w, h) => page.ops.push({ kind: "rect", x, y: down(y + h), w, h, color });
151
+
152
+ /** @type {Canvas['stroke']} */
153
+ let stroke = (x1, y1, x2, y2, thickness, color, dash) =>
154
+ page.ops.push({
155
+ kind: "line",
156
+ x1,
157
+ y1: down(y1),
158
+ x2,
159
+ y2: down(y2),
160
+ width: thickness,
161
+ color,
162
+ dash,
163
+ });
164
+
165
+ /** @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 });
168
+
169
+ // `advances` rides only where a painter can use it: a base-14 face's
170
+ // one-per-code-point shaping, which is what lets a screen stand-in be
171
+ // corrected glyph by glyph. A host's own face has no `shape`, so the op
172
+ // carries none and a painter draws the run whole.
173
+ /** @type {(piece: Line['pieces'][number], line: Line, x: number, yTop: number) => void} */
174
+ let writePiece = (piece, line, x, yTop) => {
175
+ if (!piece.text) return;
176
+ page.ops.push({
177
+ kind: "text",
178
+ x,
179
+ y: down(yTop),
180
+ w: piece.w,
181
+ h: line.h,
182
+ asc: line.asc,
183
+ size: piece.size,
184
+ font: piece.font.ref,
185
+ color: piece.color || BLACK,
186
+ text: piece.text,
187
+ ...(piece.font.shape ? { advances: piece.font.shape(piece.text, piece.size).advances } : {}),
188
+ });
189
+ };
190
+
191
+ /** @type {(left: number, right: number, y: number, thickness: number, color: Color) => void} */
192
+ let strokeAt = (left, right, y, thickness, color) =>
193
+ stroke(left, y, right, y, thickness, color, null);
194
+
195
+ // Record one wrapped line with its baseline the line's ascender under
196
+ // `yTop`, horizontally placed by `align` within `avail` starting at `x`.
197
+ /** @type {Canvas['drawLine']} */
198
+ let drawLine = (line, x, yTop, avail, align) => {
199
+ let left = x + shift(align, avail - line.w);
200
+ let cursor = left;
201
+ for (let piece of line.pieces) {
202
+ writePiece(piece, line, cursor, yTop);
203
+ cursor += piece.w;
204
+ }
205
+ decorateLine(strokeAt, line, left, yTop - line.asc);
206
+ };
207
+
208
+ // A node's rectangle. Only a node with a path is a hit box: a split minted
209
+ // by the folder has none, and its slots carry their own.
210
+ /** @type {Canvas['box']} */
211
+ let box_ = (path, x, yTop, w, h) => {
212
+ if (path) page.boxes.push({ path, x, y: down(yTop), w, h });
213
+ };
214
+
215
+ /** @type {Listing['watermark']} */
216
+ let watermark = (mark) =>
217
+ page.ops.push({
218
+ kind: "mark",
219
+ x: mark.x,
220
+ y: down(mark.y),
221
+ angle: mark.angle,
222
+ size: mark.size,
223
+ font: mark.font.ref,
224
+ text: mark.text,
225
+ });
226
+
227
+ /** @type {Listing} */
228
+ let canvas = {
229
+ ...blank(box, fonts),
230
+ get count() {
231
+ return pages.length;
232
+ },
233
+ newPage,
234
+ rect,
235
+ stroke,
236
+ picture,
237
+ drawLine,
238
+ box: box_,
239
+ // Re-open a finished page, for the passes that run over them all.
240
+ select: (i) => {
241
+ page = pages[i];
242
+ },
243
+ pages,
244
+ watermark,
245
+ };
246
+ return canvas;
247
+ };
248
+
249
+ // Nothing at all — what every primitive does when the canvas only measures.
250
+ let MARKS_NOTHING = () => {};
251
+
252
+ /**
253
+ * The adapter that only measures: the same cursor arithmetic with every mark
254
+ * discarded. It holds no page, so a measurement cannot record.
255
+ *
256
+ * @type {(box: Frame, fonts: import('./fonts.js').Fonts) => Canvas}
257
+ */
258
+ let measuring = (box, fonts) => ({
259
+ ...blank(box, fonts),
260
+ count: 0,
261
+ newPage: () => {
262
+ throw new Error("probe reached newPage: the measuring path must not paginate");
263
+ },
264
+ rect: MARKS_NOTHING,
265
+ stroke: MARKS_NOTHING,
266
+ picture: MARKS_NOTHING,
267
+ drawLine: MARKS_NOTHING,
268
+ box: MARKS_NOTHING,
269
+ });
270
+
271
+ /**
272
+ * Take on a content box someone else worked out — how much page furniture
273
+ * needs is layout's policy (`reserve` there), while which of a frame's members
274
+ * 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.
279
+ *
280
+ * Legal only while the render has not committed to a page — the band flow
281
+ * adopts from its `report-start` handler, with its own first page open but
282
+ * empty. `fresh` alone would not say that: a page turn makes it true again on
283
+ * page five, where narrowing would silently mix two geometries in one document.
284
+ *
285
+ * @param {Canvas} canvas The canvas to narrow.
286
+ * @param {Frame} box The frame it takes its content box from.
287
+ */
288
+ let adopt = (canvas, box) => {
289
+ if (canvas.count > 1 || !canvas.fresh) throw Error("adopt: the content box is fixed");
290
+ canvas.top = box.top;
291
+ canvas.bottom = box.bottom;
292
+ // The open page has drawn nothing, so its cursor moves with the box — the
293
+ // same statement `newPage` makes, and nothing on the page can be lost by it.
294
+ canvas.y = canvas.top;
295
+ };
296
+
297
+ // The unlicensed-output marking (LICENSE section 6): one translucent line
298
+ // drawn corner-to-corner across the finished page — over the content, not
299
+ // under it, so no filled table header or background rectangle can cover it.
300
+ // The wording comes from the engine, on `report-start`; the layout owns the
301
+ // geometry, which depends only on the page size and never on key contents,
302
+ // so document bytes stay deterministic in both licensed states.
303
+
304
+ /**
305
+ * The per-render half of the marking, computed once: page geometry, the face
306
+ * and the wording are all fixed for a whole render, so the trig and the
307
+ * glyph-width walk never repeat per page. `x`/`y` are the baseline's start in
308
+ * page coordinates and `angle` the turn in degrees, counter-clockwise.
309
+ * @typedef {{ font: import('./fonts.js').Face, text: string, size: number,
310
+ * x: number, y: number, angle: number }} Mark
311
+ */
312
+ /**
313
+ * The marking is the licence boundary, not report content, so it takes the
314
+ * base-14 sans and never the host's mapping of that name: an author never
315
+ * styled it, and it must fail open (docs/adr/0002). Its size is solved from a
316
+ * face's widths, so inheriting a host family would size it against bytes a
317
+ * painter may fail to load and draw it in whatever the fallback is.
318
+ * @type {(canvas: Canvas, text: string) => Mark}
319
+ */
320
+ let stamp = (canvas, text) => {
321
+ let font = baseSans();
322
+ let angle = Math.atan2(canvas.height, canvas.width);
323
+ // Scale the line to three quarters of the page diagonal, whatever the size.
324
+ let span = Math.hypot(canvas.width, canvas.height) * 0.75;
325
+ return {
326
+ font,
327
+ text,
328
+ size: (54 * span) / font.widthOfTextAtSize(text, 54),
329
+ x: canvas.width / 2 - (span / 2) * Math.cos(angle),
330
+ y: canvas.height / 2 - (span / 2) * Math.sin(angle),
331
+ angle: (angle * 180) / Math.PI,
332
+ };
333
+ };
334
+
335
+ export { adopt, decorateLine, frame, listing, measuring, stamp };