@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/lib/page.js CHANGED
@@ -3,8 +3,15 @@
3
3
  * it is written. Owned here because every consumer of the list — the PDF
4
4
  * target, the viewer, the editor — lays out on the same page, and a size that
5
5
  * meant one thing on screen and another on paper would be a silent lie.
6
+ *
7
+ * The derivation belongs with the validation: `frame` below turns a validated
8
+ * page and margin into the geometry everything downstream lays out in, so a
9
+ * page becomes a content box here and `canvas.js` only takes the answer,
10
+ * rather than the drawing surface defining the page it draws on. Deriving a
11
+ * frame is not owning its lifetime, and this module claims only the first:
12
+ * `adopt` (`canvas.js`) narrows `top`/`bottom` once for the page bands, and
13
+ * `reserve` (`layout.js`) mints the frame it narrows from.
6
14
  */
7
- import { frame } from "./canvas.js";
8
15
 
9
16
  // The named sizes. `@quario/pdf` used to own this table, and the two element
10
17
  // packages restated it; the layout package is where all three now read it.
@@ -49,12 +56,6 @@ let marginOf = (margin = 54, width, height, path) => {
49
56
  return margin;
50
57
  };
51
58
 
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
59
  // Size is settled at the factory; margin may come from the document's
59
60
  // `report-start`, so a render peeks that event before opening a canvas.
60
61
  /** @type {(page: any) => any} */
@@ -86,14 +87,45 @@ export let pageBox = (page, at = "page") => {
86
87
  return { width, height, margin: marginOf(page?.margin, width, height, at + ".margin") };
87
88
  };
88
89
 
90
+ // The page box and the content box, and nothing whatever else: geometry, all
91
+ // of it derived below from a page and a margin. Fixed for the document — the
92
+ // one exception is `top`/`bottom`, which the page bands narrow once through
93
+ // `adopt` (`canvas.js`), while the first page is still untouched. Everything
94
+ // else that holds for a whole render is that module's `Settings`; a frame
95
+ // carrying either half of that was a type with two lifetimes, and the copy that
96
+ // kept a probe reading the same locale as the draw had to be written out by
97
+ // hand.
98
+ /**
99
+ * @typedef {{ width: number, height: number, margin: number,
100
+ * content: number, top: number, bottom: number }} Frame
101
+ */
102
+
103
+ // A frame from the page box: how `content`/`top`/`bottom` fall out of a page
104
+ // and a margin is derived here, once, so no caller and no suite has to restate
105
+ // it and drift from what a real render uses. A render reaches a frame through
106
+ // `geometry` below and never through this; the package entry carries neither,
107
+ // so no host reaches one at all. Exported for `layout.js`, which re-derives the
108
+ // page frame a canvas presents, and for the suites that build a fixture frame
109
+ // — the day that re-derivation goes, the export stands for the suites alone,
110
+ // and they should take `geometry` rather than keep it standing.
111
+ /** @type {(width: number, height: number, margin: number) => Frame} */
112
+ export let frame = (width, height, margin) => ({
113
+ width,
114
+ height,
115
+ margin,
116
+ content: width - 2 * margin,
117
+ top: height - margin,
118
+ bottom: margin,
119
+ });
120
+
89
121
  /**
90
122
  * The page box and the content box in one, for a render: the host's size,
91
123
  * and whichever margin the host and the opening event agreed on.
92
124
  *
93
- * @type {(page: any, opening?: any) => import('./canvas.js').Frame}
125
+ * @type {(page: any, opening?: any) => Frame}
94
126
  */
95
127
  export let geometry = (page = {}, opening) => {
96
128
  let { width, height } = pageBox(page, "options.page");
97
129
  let chosen = marginChoice(opening, page, "options.page");
98
- return frame(width, height, marginOf(chosen.value, width, height, chosen.path), BASE);
130
+ return frame(width, height, marginOf(chosen.value, width, height, chosen.path));
99
131
  };
package/lib/paint.js CHANGED
@@ -123,7 +123,9 @@ let loadFaces = async (fonts) => {
123
123
  };
124
124
 
125
125
  // Every bitmap decoded so far, by the bytes it was decoded from: one logo on
126
- // every page is decoded once.
126
+ // every page is decoded once. A rejection is remembered too, so a file the
127
+ // browser refused is refused from memory ever after — a caller repainting the
128
+ // same page never pays for a second decode of bytes that will not decode.
127
129
  /** @type {WeakMap<Uint8Array, Promise<ImageBitmap>>} */
128
130
  let BITMAPS = new WeakMap();
129
131
 
@@ -183,9 +185,6 @@ let paintText = (ctx, op) => {
183
185
  }
184
186
  };
185
187
 
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
188
  // The marking, as the PDF draws it: translucent grey along the page diagonal,
190
189
  // turned counter-clockwise by the list's angle. The canvas reads down, so the
191
190
  // turn is negative here.
@@ -207,16 +206,30 @@ let PAINTERS = { rect: paintRect, line: paintLine, text: paintText, mark: paintM
207
206
 
208
207
  /** @type {(ctx: CanvasRenderingContext2D, op: Op, bitmaps: Map<Op, ImageBitmap>) => void} */
209
208
  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);
209
+ // An image with no bitmap did not decode, and is drawn as nothing. The map
210
+ // is the one place that is known: `decode` keeps no entry for a file the
211
+ // browser refused, so there is no second flag to read and no way to ask
212
+ // this question twice.
213
+ if (op.kind === "image") {
214
+ let bitmap = bitmaps.get(op);
215
+ if (bitmap) ctx.drawImage(bitmap, op.x, op.y, op.w, op.h);
216
+ } else PAINTERS[op.kind](ctx, op);
212
217
  };
213
218
 
214
- // Every image on the page, decoded together rather than one after another.
219
+ // Every image on the page, decoded together rather than one after another,
220
+ // and each settling on its own: gathering with `Promise.all` made one file the
221
+ // browser refused the whole page's failure, thrown before the first draw op.
222
+ // `paint`'s own doc says why that is the wrong price. An image that did not
223
+ // decode has no entry here, which is how `paintOp` knows.
215
224
  /** @type {(page: Page) => Promise<Map<Op, ImageBitmap>>} */
216
225
  let decode = async (page) => {
217
226
  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]]));
227
+ let bitmaps = await Promise.all(images.map((op) => bitmapOf(op).catch(() => null)));
228
+ return new Map(
229
+ /** @type {[Op, ImageBitmap][]} */ (
230
+ images.map((op, i) => [op, bitmaps[i]]).filter(([, bitmap]) => bitmap)
231
+ ),
232
+ );
220
233
  };
221
234
 
222
235
  /**
@@ -225,6 +238,21 @@ let decode = async (page) => {
225
238
  * the canvas, so it knows. `fonts` is the host's font mapping, the same
226
239
  * record given to `layout()`, so a TrueType family draws in its own face.
227
240
  *
241
+ * It awaits its faces and images before it draws, and it draws whatever
242
+ * happened in between: a canvas re-sized under a call still in flight is
243
+ * filled at that call's own `scale`, not the size it now has. A caller that
244
+ * repaints one canvas at changing scales owns that, by not letting a
245
+ * superseded call reach a canvas still on screen — which is what the viewer's
246
+ * stage retires a page for (docs/adr/0046).
247
+ *
248
+ * **An image the browser will not decode is drawn as nothing, and the page is
249
+ * drawn around it.** The engine vouched for the magic numbers and the layout
250
+ * read the size out of the header, so a file corrupt past that point is not
251
+ * known to be bad until here; costing the whole page for it — every other op
252
+ * and the marking with it — is a worse answer than costing the image. What is
253
+ * lost is what could not be drawn. A caller wanting the failure instead should
254
+ * decode before it paints.
255
+ *
228
256
  * @param {CanvasRenderingContext2D} ctx The context to paint on.
229
257
  * @param {Page} page One page of a `Layout`.
230
258
  * @param {{ scale?: number, fonts?: any }} [options]
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Everything that holds for a whole render and is not geometry — the render's
3
+ * `Settings`, and the one reading of what a report default amounts to.
4
+ *
5
+ * A `Frame` (canvas.js) is a page's geometry; a `Settings` is the other half:
6
+ * the faces to measure against, the report default narrowed to `base` and
7
+ * `family`, and the three intl facts a formatted value resolves in. A canvas
8
+ * holds one by reference, so a measuring canvas built for a probe reads exactly
9
+ * what the listing canvas reads and a band cannot be reserved against one
10
+ * locale and drawn in another. This module owns what a settings *is* and how a
11
+ * report's opening event becomes one; canvas.js draws with it, text.js measures
12
+ * against it, and neither has to know what `style.size` or `style.family`
13
+ * amount to.
14
+ */
15
+ import { familyName } from "./fonts.js";
16
+ import { sizeOf } from "./style.js";
17
+
18
+ // This layout's baseline type size. Not a host option: a document's type size
19
+ // is the document's own, so it is `style.size` on the report and the number
20
+ // here is only what text renders at when nothing declares one. The XLSX target
21
+ // carries the same 10 for the same reason (docs/adr/0014, docs/adr/0033).
22
+ let BASE = 10;
23
+
24
+ /**
25
+ * Everything that holds for a whole render and is not geometry: the faces to
26
+ * measure against, the report default narrowed to `family` and `size` (landing
27
+ * in `family` and `base` here), and the three intl facts a formatted value
28
+ * resolves in.
29
+ *
30
+ * A canvas holds one of these by reference, never a copy, which is the whole
31
+ * reason it is an object. A measuring canvas built off the same settings reads
32
+ * exactly what the listing canvas reads, so a page band cannot be reserved
33
+ * against one locale and drawn in another — an agreement that used to rest on
34
+ * a hand-written copy staying in step.
35
+ *
36
+ * `family` is null when the report declares none, and each of the intl three is
37
+ * undefined when the engine settled none.
38
+ *
39
+ * @typedef {{ fonts: import('./fonts.js').Fonts, base: number,
40
+ * family: string | null, locale?: string, currency?: string,
41
+ * timeZone?: string }} Settings
42
+ */
43
+
44
+ /**
45
+ * The render's settings, complete by construction from the opening event the
46
+ * target already peeked. The report default is narrowed to `family` and `size`,
47
+ * and each replaces this target's own baseline outright: row heights and band
48
+ * gaps scale with the document's type rather than staying at a size nothing is
49
+ * set in, and text declaring no family is set in the document's. Reading the
50
+ * pair here is the whole of this target's reading of docs/adr/0033 — the default
51
+ * reaches a node as a fallback the settings carry, never as a layer merged into
52
+ * its style, so a document-wide fact costs no allocation however many cells a
53
+ * report has.
54
+ *
55
+ * `sizeOf` and `familyName` are this target's one reading each of what a
56
+ * declared size and family amount to, so they read the default here too — both
57
+ * reach this unchecked from a computed style, and two spellings of that
58
+ * leniency would drift. Each falls back to this target's own baseline, so a
59
+ * default declaring one of the pair leaves the other at the baseline, and one
60
+ * whose value is unusable leaves the baseline standing.
61
+ *
62
+ * Built once and never settled again: these hold for a whole render, so there
63
+ * is no event that could set them a second time — a second settling is not
64
+ * refused, it is unexpressible. `opening` is null when the stream is empty (no
65
+ * report to read a default from), which yields the bare baseline: the target's
66
+ * own size, no family, and the intl three unset. Those three are read straight
67
+ * off the event whether or not it declares them — a formatted value is the only
68
+ * thing that reads them, and an empty stream produces none, so an unset one
69
+ * never reaches `Intl`.
70
+ *
71
+ * @param {import('./fonts.js').Fonts} fonts The loaded faces.
72
+ * @param {any} [opening] The peeked `report-start` event, or null.
73
+ * @returns {Settings}
74
+ */
75
+ let settings = (fonts, opening) => {
76
+ let event = opening || {};
77
+ let style = event.style || {};
78
+ return {
79
+ fonts,
80
+ base: sizeOf(style, BASE),
81
+ family: familyName(style) || null,
82
+ locale: event.locale,
83
+ currency: event.currency,
84
+ timeZone: event.timeZone,
85
+ };
86
+ };
87
+
88
+ export { settings };
package/lib/style.js CHANGED
@@ -44,6 +44,13 @@ let BLACK = { r: 0, g: 0, b: 0 };
44
44
  let SHIFT = { right: 1, center: 0.5 };
45
45
  /** @type {(align: any, extra: number) => number} */
46
46
  let shift = (align, extra) => (Object.hasOwn(SHIFT, align) ? SHIFT[align] : 0) * extra;
47
+ // The same for the leftover height -- the slack a box has over its content,
48
+ // which is the only place `valign` is legal (CONTEXT.md "Box"). Top keeps it,
49
+ // bottom takes all of it, middle takes half; undeclared is top.
50
+ /** @type {Record<string, number>} */
51
+ let VSHIFT = { bottom: 1, middle: 0.5 };
52
+ /** @type {(valign: any, extra: number) => number} */
53
+ let vshift = (valign, extra) => (Object.hasOwn(VSHIFT, valign) ? VSHIFT[valign] : 0) * extra;
47
54
 
48
55
  // A declared colour as a `Color`, or null when the value is not one.
49
56
  /** @type {(value: any) => Color | null} */
@@ -62,7 +69,7 @@ let sizeOf = (style, base) => (Number.isFinite(style.size) && style.size > 0 ? s
62
69
  // Style blocks layer outward-in: row under cell, split under slot. Those are
63
70
  // the two innermost of the four layers; the outer two reach a node without
64
71
  // 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.
72
+ // report default through the render's settings, as `settings.js` explains.
66
73
  /** @type {(under: any, over: any) => any} */
67
74
  let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : over || {});
68
75
 
@@ -101,4 +108,19 @@ let dressed = (style) => !!(style && (style.underline || style.strikethrough));
101
108
  /** @type {(style: any) => boolean} */
102
109
  let upper = (style) => !!(style && style.uppercase);
103
110
 
104
- export { BAND, BLACK, GUTTER, LEAD, PADX, PADY, col, dressed, merge, roled, shift, sizeOf, upper };
111
+ export {
112
+ BAND,
113
+ BLACK,
114
+ GUTTER,
115
+ LEAD,
116
+ PADX,
117
+ PADY,
118
+ col,
119
+ dressed,
120
+ merge,
121
+ roled,
122
+ shift,
123
+ sizeOf,
124
+ upper,
125
+ vshift,
126
+ };
package/lib/text.js CHANGED
@@ -7,15 +7,12 @@ import { display, format } from "quario";
7
7
  import { ascOf, face, printable, width } from "./fonts.js";
8
8
  import { LEAD, col, dressed, sizeOf, upper } from "./style.js";
9
9
 
10
- // What measuring needs and no more: the embedded faces to measure against, and
11
- // the base size and family a style falls back to — the report default's two
12
- // declarations, which the canvas carries for the whole render. A `Canvas`
13
- // satisfies it, so callers pass theirs straight in but nothing here can
14
- // touch a page.
15
- /**
16
- * @typedef {{ fonts: import('./fonts.js').Fonts, base: number,
17
- * family: string | null, locale?: string, currency?: string, timeZone?: string }} Metrics
18
- */
10
+ // What measuring needs and no more, and it is exactly the render's settings:
11
+ // the faces to measure against, the base size and family a style falls back to,
12
+ // and the intl three a formatted value resolves in. Declared in `settings.js`,
13
+ // the module that owns them; a caller hands over `canvas.settings` rather than
14
+ // the canvas, so nothing here can touch a page.
15
+ /** @typedef {import('./settings.js').Settings} Settings */
19
16
 
20
17
  /**
21
18
  * @typedef {{ text: string, font: any, size: number, color: any,
@@ -29,10 +26,10 @@ import { LEAD, col, dressed, sizeOf, upper } from "./style.js";
29
26
 
30
27
  /** @typedef {{ cur: Atom[], w: number, lines: Line[], base: number }} Wrap */
31
28
 
32
- /** @type {(token: any, style: any, metrics: Metrics) => string} */
33
- let rawOf = (token, style, metrics) => {
29
+ /** @type {(token: any, style: any, settings: Settings) => string} */
30
+ let rawOf = (token, style, settings) => {
34
31
  if ("literal" in token) return token.literal;
35
- return format(token.value, style?.format, metrics) ?? display(token.value);
32
+ return format(token.value, style, settings) ?? display(token.value);
36
33
  };
37
34
 
38
35
  // This layout has no text-transform to defer to, so `uppercase` is applied to
@@ -43,12 +40,9 @@ let rawOf = (token, style, metrics) => {
43
40
  /** @type {(text: string, style: any) => string} */
44
41
  let cased = (text, style) => (upper(style) ? text.toUpperCase() : text);
45
42
 
46
- /** @type {(font: any, line: string) => string[]} */
47
- let partsOf = (font, line) => printable(font, line).split(/( +)/).filter(Boolean);
48
-
49
43
  /** @type {(out: Atom[], font: any, size: number, color: any, line: string) => void} */
50
44
  let pushParts = (out, font, size, color, line) => {
51
- for (let part of partsOf(font, line))
45
+ for (let part of printable(font, line).split(/( +)/).filter(Boolean))
52
46
  out.push({ text: part, font, size, color, space: part[0] === " ", hard: false });
53
47
  };
54
48
 
@@ -58,12 +52,12 @@ let pushLine = (out, font, size, color, i, line) => {
58
52
  pushParts(out, font, size, color, line);
59
53
  };
60
54
 
61
- /** @type {(metrics: Metrics, style: any) => { font: any, size: number, color: any }} */
62
- let look = (metrics, style) => {
55
+ /** @type {(settings: Settings, style: any) => { font: any, size: number, color: any }} */
56
+ let look = (settings, style) => {
63
57
  let resolved = style || {};
64
58
  return {
65
- font: face(metrics.fonts, resolved, metrics.family),
66
- size: sizeOf(resolved, metrics.base),
59
+ font: face(settings.fonts, resolved, settings.family),
60
+ size: sizeOf(resolved, settings.base),
67
61
  color: col(resolved.color),
68
62
  };
69
63
  };
@@ -71,12 +65,12 @@ let look = (metrics, style) => {
71
65
  // Flatten a cell's tokens to word/space atoms carrying the cell's resolved
72
66
  // typography — one face, size and colour for the whole cell. CR, LF, and
73
67
  // CRLF are one hard break each (SCHEMA.md, Cell values).
74
- /** @type {(metrics: Metrics, tokens: any[], style: any) => Atom[]} */
75
- let atoms = (metrics, tokens, style) => {
68
+ /** @type {(settings: Settings, tokens: any[], style: any) => Atom[]} */
69
+ let atoms = (settings, tokens, style) => {
76
70
  let out = /** @type {Atom[]} */ ([]);
77
- let { font, size, color } = look(metrics, style);
71
+ let { font, size, color } = look(settings, style);
78
72
  for (let token of tokens)
79
- for (let [i, line] of cased(rawOf(token, style, metrics), style)
73
+ for (let [i, line] of cased(rawOf(token, style, settings), style)
80
74
  .split(/\r\n|\r|\n/)
81
75
  .entries())
82
76
  pushLine(out, font, size, color, i, line);
@@ -228,8 +222,8 @@ let placeAtom = (state, atom, avail) => {
228
222
  // breaks by character, hard breaks always break. A cell's atoms share one
229
223
  // typography, so a line's atoms merge into a single draw piece. `size` is the
230
224
  // empty-run height — a blank item, a hard-break hole.
231
- /** @type {(metrics: Metrics, list: Atom[], avail: number, size: number) => Line[]} */
232
- let wrap = (metrics, list, avail, size) => {
225
+ /** @type {(list: Atom[], avail: number, size: number) => Line[]} */
226
+ let wrap = (list, avail, size) => {
233
227
  /** @type {Wrap} */
234
228
  let state = { cur: [], w: 0, lines: [], base: size };
235
229
  for (let atom of list) placeAtom(state, atom, avail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/layout",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The paged display list for quario — the layout the PDF target writes and the viewer paints — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -40,18 +40,19 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@arethetypeswrong/cli": "^0.18.3",
43
- "@pdf-lib/fontkit": "^1.1.1",
44
43
  "@size-limit/preset-small-lib": "^13.0.3",
45
- "quario": "^0.4.0",
44
+ "@types/fontkit": "^2.0.9",
45
+ "fontkit": "^2.0.4",
46
+ "quario": "^0.6.0",
46
47
  "size-limit": "^13.0.3",
47
48
  "typescript": "^7.0.2"
48
49
  },
49
50
  "peerDependencies": {
50
- "@pdf-lib/fontkit": "^1.1.1",
51
- "quario": "^0.4.0"
51
+ "fontkit": "^2.0.4",
52
+ "quario": "^0.6.0"
52
53
  },
53
54
  "peerDependenciesMeta": {
54
- "@pdf-lib/fontkit": {
55
+ "fontkit": {
55
56
  "optional": true
56
57
  }
57
58
  },