@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/page.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The page box: what a host's `page` option means in points, validated where
3
+ * it is written. Owned here because every consumer of the list — the PDF
4
+ * target, the viewer, the editor — lays out on the same page, and a size that
5
+ * meant one thing on screen and another on paper would be a silent lie.
6
+ */
7
+ import { frame } from "./canvas.js";
8
+
9
+ // The named sizes. `@quario/pdf` used to own this table, and the two element
10
+ // packages restated it; the layout package is where all three now read it.
11
+ let SIZES = /** @type {Record<string, [number, number]>} */ ({
12
+ A4: [595.28, 841.89],
13
+ letter: [612, 792],
14
+ });
15
+
16
+ /** @type {(msg: string) => never} */
17
+ let err = (msg) => {
18
+ throw Error(msg);
19
+ };
20
+
21
+ /** @type {(value: number) => boolean} */
22
+ let positive = (value) => Number.isFinite(value) && value > 0;
23
+
24
+ /** @type {(size: any) => any} */
25
+ let known = (size) => (Object.hasOwn(SIZES, size) ? SIZES[size] : null);
26
+
27
+ /** @type {(size: any, at: string) => any} */
28
+ let named = (size = "A4", at) => {
29
+ let dimensions = Array.isArray(size) ? size : known(size);
30
+ // oxlint-disable-next-line no-unused-expressions
31
+ dimensions || err(at + '.size: unknown page size "' + size + '"');
32
+ return dimensions;
33
+ };
34
+
35
+ /** @type {(dimensions: any, at: string) => { width: number, height: number }} */
36
+ let pair = (dimensions, at) => {
37
+ let width = +dimensions[0],
38
+ height = +dimensions[1];
39
+ // oxlint-disable-next-line no-unused-expressions
40
+ (positive(width) && positive(height)) || err(at + ".size: expected finite positive dimensions");
41
+ return { width, height };
42
+ };
43
+
44
+ /** @type {(margin: any, width: number, height: number, path: string) => number} */
45
+ let marginOf = (margin = 54, width, height, path) => {
46
+ let fits = Number.isFinite(margin) && margin >= 0 && 2 * margin < Math.min(width, height);
47
+ // oxlint-disable-next-line no-unused-expressions
48
+ fits || err(path + ": expected a non-negative number smaller than half the page");
49
+ return margin;
50
+ };
51
+
52
+ // This layout's baseline type size. Not a host option: a document's type size
53
+ // is the document's own, so it is `style.size` on the report and the number
54
+ // here is only what text renders at when nothing declares one. The XLSX target
55
+ // carries the same 10 for the same reason (docs/adr/0014, docs/adr/0033).
56
+ let BASE = 10;
57
+
58
+ // Size is settled at the factory; margin may come from the document's
59
+ // `report-start`, so a render peeks that event before opening a canvas.
60
+ /** @type {(page: any) => any} */
61
+ let hostMargin = (page) => (Object.hasOwn(page, "margin") ? page.margin : undefined);
62
+ /** @type {(opening: any) => any} */
63
+ let docMargin = (opening) => opening?.margin;
64
+ /** @type {(doc: any, host: any) => boolean} */
65
+ let bothMargins = (doc, host) => doc != null && host !== undefined;
66
+ /** @type {(opening: any, page: any, at: string) => { value: any, path: string }} */
67
+ let marginChoice = (opening, page, at) => {
68
+ let host = hostMargin(page);
69
+ let doc = docMargin(opening);
70
+ if (bothMargins(doc, host)) err("page.margin: document and target both declare a margin");
71
+ return { value: doc ?? host, path: doc != null ? "page.margin" : at + ".margin" };
72
+ };
73
+
74
+ /**
75
+ * The page box a `page` option describes, validated: width, height and
76
+ * margin in points. `at` prefixes the failure — `options.page` for a target's
77
+ * option, `page` for an element's property — so a host hears where it wrote
78
+ * the mistake.
79
+ *
80
+ * @param {any} page The `page` option, or undefined for the defaults.
81
+ * @param {string} [at] The option's name in a failure.
82
+ * @returns {{ width: number, height: number, margin: number }}
83
+ */
84
+ export let pageBox = (page, at = "page") => {
85
+ let { width, height } = pair(named(page?.size, at), at);
86
+ return { width, height, margin: marginOf(page?.margin, width, height, at + ".margin") };
87
+ };
88
+
89
+ /**
90
+ * The page box and the content box in one, for a render: the host's size,
91
+ * and whichever margin the host and the opening event agreed on.
92
+ *
93
+ * @type {(page: any, opening?: any) => import('./canvas.js').Frame}
94
+ */
95
+ export let geometry = (page = {}, opening) => {
96
+ let { width, height } = pageBox(page, "options.page");
97
+ let chosen = marginChoice(opening, page, "options.page");
98
+ return frame(width, height, marginOf(chosen.value, width, height, chosen.path), BASE);
99
+ };
package/lib/paint.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The Canvas 2D painter, and the hit-test beside it: the browser half of the
3
+ * list, for the viewer's sheet and the editor's design surface. Everything
4
+ * the list says is in points from the page's top-left corner, so painting is
5
+ * one scale and a walk over the ops; nothing here re-measures, re-wraps or
6
+ * decides where anything goes.
7
+ *
8
+ * Text is drawn one of two ways, and the op says which. A base-14 family is
9
+ * drawn glyph by glyph at the advances the measurer recorded, because what the
10
+ * browser has is a [stand-in face](../../../CONTEXT.md#stand-in-face) — Arial
11
+ * for Helvetica — and the correction is what makes the words fill the same
12
+ * width and break on the same line as the PDF. A host-supplied family is
13
+ * registered as a `FontFace` from the same bytes the layout measured and the
14
+ * PDF embeds, and its runs are drawn whole: the browser is shaping the very
15
+ * font pdf-lib will, so correcting it would be replacing a good answer with a
16
+ * worse one — and a shaped run has no advance per character to correct with.
17
+ */
18
+
19
+ import { VARIANTS, bytesOf } from "./fonts.js";
20
+
21
+ /**
22
+ * CSS pixels per point at 100%: the browser's own 96 dpi over PostScript's
23
+ * 72. The one home for how a point becomes a length on screen, so every
24
+ * surface that shows a page shows it at the same size — a zoom is a factor
25
+ * on top of this, never a second answer to it.
26
+ */
27
+ export let PX_PER_POINT = 96 / 72;
28
+
29
+ /** @import { Box, FaceRef, Op, Page } from './index.d.ts' */
30
+ /** @typedef {import('./style.js').Color} Color */
31
+
32
+ // The screen stand-ins for the three generic families. Each is the face the
33
+ // base-14 metrics describe, then the platform's nearest.
34
+ /** @type {Record<string, string>} */
35
+ let GENERIC = {
36
+ sans: "Helvetica, Arial, sans-serif",
37
+ serif: '"Times New Roman", Times, serif',
38
+ mono: '"Courier New", Courier, monospace',
39
+ };
40
+
41
+ /** @type {(family: string) => string} */
42
+ let registered = (family) => "quario-" + family;
43
+
44
+ // Own-key lookup, and sans for anything else: a base-14 ref only ever names
45
+ // one of the three, but `paint` is public and the list it is handed is data.
46
+ /** @type {(family: string) => string} */
47
+ let generic = (family) => (Object.hasOwn(GENERIC, family) ? GENERIC[family] : GENERIC.sans);
48
+
49
+ /** @type {(color: Color) => string} */
50
+ let css = ({ r, g, b }) =>
51
+ "rgb(" + Math.round(r * 255) + "," + Math.round(g * 255) + "," + Math.round(b * 255) + ")";
52
+
53
+ // Which face to ask the browser for, from the ref's own account of itself. It
54
+ // turns on `embedded`, never on the family name: a host may map its family onto
55
+ // `sans`, `serif` or `mono` (SCHEMA.md), and reading the name would hand back
56
+ // the generic stand-in for a face whose bytes are registered and waiting.
57
+ /** @type {(font: FaceRef, size: number) => string} */
58
+ let fontOf = (font, size) => {
59
+ let family = font.embedded ? registered(font.family) : generic(font.family);
60
+ return (
61
+ (font.variant & 2 ? "italic " : "") + (font.variant & 1 ? "bold " : "") + size + "px " + family
62
+ );
63
+ };
64
+
65
+ // Every FontFace registered so far, so a family a host passes on every render
66
+ // is registered once. Keyed on the source object itself, never on the bytes:
67
+ // a host may hand over an ArrayBuffer, and `bytesOf` allocates a fresh view
68
+ // every call, so a bytes-keyed entry would never be found twice. That makes
69
+ // the host's own object the identity — the stability `LayoutOptions.fonts`
70
+ // asks for.
71
+ //
72
+ // A face is a family and a descriptor as much as it is bytes, so one file
73
+ // aliased across two families, or across a family's regular and bold, is two
74
+ // faces: the inner key carries the pair, and sharing one entry between them
75
+ // would leave the second name unregistered and its weight to the browser's
76
+ // synthesis.
77
+ /** @type {WeakMap<object, Map<string, Promise<FontFace>>>} */
78
+ let FACES = new WeakMap();
79
+
80
+ // The CSS descriptors of each variant, by the measurer's own index.
81
+ /** @type {(variant: number) => { weight: string, style: string }} */
82
+ let descriptors = (variant) => ({
83
+ weight: variant & 1 ? "bold" : "normal",
84
+ style: variant & 2 ? "italic" : "normal",
85
+ });
86
+
87
+ /** @type {(family: string, source: any, variant: number) => Promise<FontFace>} */
88
+ let register = (family, source, variant) => {
89
+ let registry = FACES.get(source);
90
+ if (!registry) FACES.set(source, (registry = new Map()));
91
+ let key = family + "/" + variant;
92
+ let loading = registry.get(key);
93
+ if (loading) return loading;
94
+ // The cast: TypeScript's `BufferSource` does not admit a `Uint8Array` over
95
+ // a shared buffer, which the bytes never are here.
96
+ let face = new FontFace(
97
+ registered(family),
98
+ /** @type {any} */ (bytesOf(source)),
99
+ descriptors(variant),
100
+ );
101
+ document.fonts.add(face);
102
+ loading = face.load();
103
+ // Written before the first await, so concurrent paints share one load.
104
+ registry.set(key, loading);
105
+ return loading;
106
+ };
107
+
108
+ // A missing variant is not registered: the layout measured it as the regular
109
+ // face, and the browser's synthesis would draw it differently. The CSS font
110
+ // string still asks for bold, so the face descriptor decides.
111
+ /** @type {(name: string, def: any) => Promise<FontFace>[]} */
112
+ let registerFamily = (name, def) => {
113
+ let family = name.toLowerCase();
114
+ return VARIANTS.map((key, variant) => [key, variant])
115
+ .filter(([key]) => def[key] != null)
116
+ .map(([key, variant]) => register(family, def[key], /** @type {number} */ (variant)));
117
+ };
118
+
119
+ /** @type {(fonts: any) => Promise<void>} */
120
+ let loadFaces = async (fonts) => {
121
+ if (!fonts || typeof FontFace === "undefined") return;
122
+ await Promise.all(Object.entries(fonts).flatMap(([name, def]) => registerFamily(name, def)));
123
+ };
124
+
125
+ // Every bitmap decoded so far, by the bytes it was decoded from: one logo on
126
+ // every page is decoded once.
127
+ /** @type {WeakMap<Uint8Array, Promise<ImageBitmap>>} */
128
+ let BITMAPS = new WeakMap();
129
+
130
+ /** @type {(op: { bytes: Uint8Array, format: string }) => Promise<ImageBitmap>} */
131
+ let bitmapOf = (op) => {
132
+ let decoding = BITMAPS.get(op.bytes);
133
+ if (decoding) return decoding;
134
+ decoding = createImageBitmap(
135
+ new Blob([/** @type {any} */ (op.bytes)], { type: "image/" + op.format }),
136
+ );
137
+ BITMAPS.set(op.bytes, decoding);
138
+ return decoding;
139
+ };
140
+
141
+ /** @type {(ctx: CanvasRenderingContext2D, op: any) => void} */
142
+ let paintRect = (ctx, op) => {
143
+ ctx.fillStyle = css(op.color);
144
+ ctx.fillRect(op.x, op.y, op.w, op.h);
145
+ };
146
+
147
+ /** @type {(ctx: CanvasRenderingContext2D, op: any) => void} */
148
+ let paintLine = (ctx, op) => {
149
+ ctx.strokeStyle = css(op.color);
150
+ ctx.lineWidth = op.width;
151
+ ctx.setLineDash(op.dash || []);
152
+ ctx.beginPath();
153
+ ctx.moveTo(op.x1, op.y1);
154
+ ctx.lineTo(op.x2, op.y2);
155
+ ctx.stroke();
156
+ };
157
+
158
+ // One of two placements, on `embedded` alone — inferring it from whether
159
+ // `advances` happens to be there would be a second answer to a question the
160
+ // ref already answers, which is the mistake this replaced.
161
+ //
162
+ // A host's face draws the run whole. Nothing to correct: the browser is
163
+ // shaping the same bytes, so its ligatures, its mark placement and its RTL
164
+ // order are the ones the document will have. A base-14 face draws character by
165
+ // character at the recorded advances, because the face on screen only stands
166
+ // in for the one measured and would otherwise drift across the run — and one
167
+ // advance per code point holds by construction, the encoding being a
168
+ // one-to-one table with no shaping behind it, so there is nothing to check.
169
+ /** @type {(ctx: CanvasRenderingContext2D, op: any) => void} */
170
+ let paintText = (ctx, op) => {
171
+ ctx.font = fontOf(op.font, op.size);
172
+ ctx.fillStyle = css(op.color);
173
+ ctx.textBaseline = "alphabetic";
174
+ let baseline = op.y + op.asc;
175
+ if (op.font.embedded) {
176
+ ctx.fillText(op.text, op.x, baseline);
177
+ return;
178
+ }
179
+ let x = op.x;
180
+ for (let [i, char] of Array.from(op.text).entries()) {
181
+ ctx.fillText(char, x, baseline);
182
+ x += op.advances[i];
183
+ }
184
+ };
185
+
186
+ /** @type {(ctx: CanvasRenderingContext2D, op: any, bitmap: ImageBitmap) => void} */
187
+ let paintImage = (ctx, op, bitmap) => ctx.drawImage(bitmap, op.x, op.y, op.w, op.h);
188
+
189
+ // The marking, as the PDF draws it: translucent grey along the page diagonal,
190
+ // turned counter-clockwise by the list's angle. The canvas reads down, so the
191
+ // turn is negative here.
192
+ /** @type {(ctx: CanvasRenderingContext2D, op: any) => void} */
193
+ let paintMark = (ctx, op) => {
194
+ ctx.save();
195
+ ctx.translate(op.x, op.y);
196
+ ctx.rotate((-op.angle * Math.PI) / 180);
197
+ ctx.globalAlpha = 0.15;
198
+ ctx.font = fontOf(op.font, op.size);
199
+ ctx.fillStyle = "rgb(128,128,128)";
200
+ ctx.textBaseline = "alphabetic";
201
+ ctx.fillText(op.text, 0, 0);
202
+ ctx.restore();
203
+ };
204
+
205
+ /** @type {Record<string, (ctx: CanvasRenderingContext2D, op: any) => void>} */
206
+ let PAINTERS = { rect: paintRect, line: paintLine, text: paintText, mark: paintMark };
207
+
208
+ /** @type {(ctx: CanvasRenderingContext2D, op: Op, bitmaps: Map<Op, ImageBitmap>) => void} */
209
+ let paintOp = (ctx, op, bitmaps) => {
210
+ if (op.kind === "image") paintImage(ctx, op, /** @type {ImageBitmap} */ (bitmaps.get(op)));
211
+ else PAINTERS[op.kind](ctx, op);
212
+ };
213
+
214
+ // Every image on the page, decoded together rather than one after another.
215
+ /** @type {(page: Page) => Promise<Map<Op, ImageBitmap>>} */
216
+ let decode = async (page) => {
217
+ let images = page.ops.filter((op) => op.kind === "image");
218
+ let bitmaps = await Promise.all(images.map(bitmapOf));
219
+ return new Map(images.map((op, i) => [op, bitmaps[i]]));
220
+ };
221
+
222
+ /**
223
+ * Paint one page of the list onto a Canvas 2D context: a white page, then
224
+ * every op in order. `scale` is device pixels per point — the caller sized
225
+ * the canvas, so it knows. `fonts` is the host's font mapping, the same
226
+ * record given to `layout()`, so a TrueType family draws in its own face.
227
+ *
228
+ * @param {CanvasRenderingContext2D} ctx The context to paint on.
229
+ * @param {Page} page One page of a `Layout`.
230
+ * @param {{ scale?: number, fonts?: any }} [options]
231
+ * @returns {Promise<void>} Settles once the page is painted — after the
232
+ * faces it needs have loaded and its images decoded.
233
+ */
234
+ export async function paint(ctx, page, { scale = 1, fonts } = {}) {
235
+ await loadFaces(fonts);
236
+ let bitmaps = await decode(page);
237
+ ctx.save();
238
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
239
+ ctx.fillStyle = "#fff";
240
+ ctx.fillRect(0, 0, page.width, page.height);
241
+ for (let op of page.ops) paintOp(ctx, op, bitmaps);
242
+ ctx.restore();
243
+ }
244
+
245
+ /** @type {(box: Box, x: number, y: number) => boolean} */
246
+ let inside = (box, x, y) => x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h;
247
+
248
+ /** @type {(best: Box | null, box: Box) => boolean} */
249
+ let smaller = (best, box) => !best || box.w * box.h < best.w * best.h;
250
+
251
+ /**
252
+ * The schema node drawn at a point on a page: the smallest box containing it,
253
+ * so a slot inside a split wins over the split. Points from the page's
254
+ * top-left corner.
255
+ *
256
+ * @param {Page} page One page of a `Layout`.
257
+ * @param {number} x
258
+ * @param {number} y
259
+ * @returns {Box | null} The box, or `null` where nothing was drawn.
260
+ */
261
+ export function hit(page, x, y) {
262
+ /** @type {Box | null} */
263
+ let best = null;
264
+ for (let box of page.boxes) if (inside(box, x, y) && smaller(best, box)) best = box;
265
+ return best;
266
+ }
package/lib/style.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The layout's reading of the closed style vocabulary — the counterpart of
3
+ * the CSS table in the HTML target package. Declarations are lenient at
4
+ * render: a value of the wrong shape contributes nothing rather than reaching
5
+ * the page.
6
+ *
7
+ * `HEX` is restated per target on purpose: the coercion is each target's own
8
+ * edge, never shared engine code. Finiteness is not one of those coercions —
9
+ * `Number.isFinite` never coerces, so this layout asks it directly.
10
+ */
11
+
12
+ /**
13
+ * A colour on the list: three channels in `0..1`, the way pdf-lib takes them.
14
+ * Format-neutral on purpose — the PDF painter wraps it in pdf-lib's `rgb`,
15
+ * the Canvas painter writes it as a CSS `rgb()`.
16
+ * @typedef {{ r: number, g: number, b: number }} Color
17
+ */
18
+
19
+ let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
20
+
21
+ // Leading as a fraction of the font size, cell padding, the gap between a
22
+ // page band and the body, and the gutter between two page-column strips.
23
+ // `example/print.css` is the reference look (10pt/1.4) — body type stays a
24
+ // host decision, so `@quario/html/style.css` carries no `body` rule and this
25
+ // pairs with the example rather than it.
26
+ //
27
+ // GUTTER is this layout's structural default: SCHEMA.md gives page columns no
28
+ // gap knob deliberately, so the space between strips is the layout's to pick
29
+ // and an author never writes it. Twice the cell padding, which is the widest
30
+ // space this target already puts between two columns of anything.
31
+ let LEAD = 1.4;
32
+ let PADX = 6;
33
+ let PADY = 2;
34
+ let BAND = 8;
35
+ let GUTTER = 2 * PADX;
36
+
37
+ /** @type {Color} */
38
+ let BLACK = { r: 0, g: 0, b: 0 };
39
+
40
+ // Horizontal placement within a given leftover width: left keeps it, right
41
+ // takes all of it, center takes half. Own-key so an authored `constructor`
42
+ // cannot resolve an inherited member.
43
+ /** @type {Record<string, number>} */
44
+ let SHIFT = { right: 1, center: 0.5 };
45
+ /** @type {(align: any, extra: number) => number} */
46
+ let shift = (align, extra) => (Object.hasOwn(SHIFT, align) ? SHIFT[align] : 0) * extra;
47
+
48
+ // A declared colour as a `Color`, or null when the value is not one.
49
+ /** @type {(value: any) => Color | null} */
50
+ let col = (value) => {
51
+ let match = typeof value === "string" && HEX.exec(value);
52
+ if (!match) return null;
53
+ // `#abc` is `#aabbcc`.
54
+ let digits = match[1].length === 3 ? match[1].replace(/./g, (d) => d + d) : match[1];
55
+ let [r, g, b] = [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16) / 255);
56
+ return { r, g, b };
57
+ };
58
+
59
+ /** @type {(style: any, base: number) => number} */
60
+ let sizeOf = (style, base) => (Number.isFinite(style.size) && style.size > 0 ? style.size : base);
61
+
62
+ // Style blocks layer outward-in: row under cell, split under slot. Those are
63
+ // the two innermost of the four layers; the outer two reach a node without
64
+ // being merged into it -- the band-role default through `roled` below, and the
65
+ // report default through the canvas, as `layout.js`'s `adoptDefault` explains.
66
+ /** @type {(under: any, over: any) => any} */
67
+ let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : over || {});
68
+
69
+ // Band-role omakase defaults — the third of those four layers, over the report
70
+ // default and under the author's own, which therefore always wins. Only the
71
+ // headline roles carry one; the XLSX target carries the same two,
72
+ // byte-identical, in `packages/xlsx/lib/index.js`, and they move together
73
+ // (SCHEMA.md states the pair for both). A target can only import public engine helpers, so there is
74
+ // nowhere to share this from and the two copies are synced by hand.
75
+ //
76
+ // `@quario/html` deliberately carries none of this, nor the leading, padding
77
+ // and band gap below: its consumer has a stylesheet and the `q-*` classes are
78
+ // the seam. The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
79
+ // a target supplies defaults only where its consumer has no seam to supply
80
+ // them. `@quario/html/style.css` is what supplies them there; keep the three in
81
+ // agreement.
82
+ /** @type {Record<string, any>} */
83
+ let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
84
+
85
+ // One item event wearing its role's defaults. Events reach layout as events,
86
+ // so the default is folded into a copy rather than passed alongside.
87
+ /** @type {(event: any) => any} */
88
+ let roled = (event) =>
89
+ Object.hasOwn(ROLES, event.role)
90
+ ? { ...event, style: merge(ROLES[event.role], event.style) }
91
+ : event;
92
+
93
+ // Whether a resolved style asks for either text decoration -- one reading for
94
+ // the wrapper that stamps it on a line and the canvas that draws it.
95
+ /** @type {(style: any) => boolean} */
96
+ let dressed = (style) => !!(style && (style.underline || style.strikethrough));
97
+
98
+ // Whether a resolved style asks for capitals. This target has no
99
+ // text-transform to defer to, so the reading is here beside the rest of the
100
+ // vocabulary and `text.js` applies it before measuring.
101
+ /** @type {(style: any) => boolean} */
102
+ let upper = (style) => !!(style && style.uppercase);
103
+
104
+ export { BAND, BLACK, GUTTER, LEAD, PADX, PADY, col, dressed, merge, roled, shift, sizeOf, upper };