@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/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 };
|