@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/CHANGELOG.md +84 -0
- package/LICENSE +219 -0
- package/README.md +94 -0
- package/lib/balance.js +90 -0
- package/lib/box.js +143 -0
- package/lib/canvas.js +335 -0
- package/lib/fonts.js +409 -0
- package/lib/image.js +58 -0
- package/lib/index.d.ts +220 -0
- package/lib/index.js +114 -0
- package/lib/layout.js +1913 -0
- package/lib/page.js +99 -0
- package/lib/paint.js +266 -0
- package/lib/style.js +104 -0
- package/lib/text.js +258 -0
- package/package.json +71 -0
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 };
|