@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/text.js ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Measuring and breaking text: tokens flatten to word and space atoms carrying
3
+ * their resolved typography, and a greedy breaker turns those into lines. Pure
4
+ * measurement against the font registry — nothing here touches the page.
5
+ */
6
+ import { display, format } from "quario";
7
+ import { ascOf, face, printable, width } from "./fonts.js";
8
+ import { LEAD, col, dressed, sizeOf, upper } from "./style.js";
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
+ */
19
+
20
+ /**
21
+ * @typedef {{ text: string, font: any, size: number, color: any,
22
+ * space: boolean, hard: boolean }} Atom
23
+ */
24
+ /**
25
+ * @typedef {{ pieces: { text: string, font: any, size: number, color: any,
26
+ * w: number }[], w: number, h: number, size: number, asc: number,
27
+ * underline?: boolean, strikethrough?: boolean }} Line
28
+ */
29
+
30
+ /** @typedef {{ cur: Atom[], w: number, lines: Line[], base: number }} Wrap */
31
+
32
+ /** @type {(token: any, style: any, metrics: Metrics) => string} */
33
+ let rawOf = (token, style, metrics) => {
34
+ if ("literal" in token) return token.literal;
35
+ return format(token.value, style?.format, metrics) ?? display(token.value);
36
+ };
37
+
38
+ // This layout has no text-transform to defer to, so `uppercase` is applied to
39
+ // the string before it is measured -- the widths have to be the widths of what
40
+ // is actually drawn. `toUpperCase` rather than `toLocaleUpperCase`: the
41
+ // PDF target's output is byte-reproducible, so the host's locale must not reach
42
+ // the glyphs.
43
+ /** @type {(text: string, style: any) => string} */
44
+ let cased = (text, style) => (upper(style) ? text.toUpperCase() : text);
45
+
46
+ /** @type {(font: any, line: string) => string[]} */
47
+ let partsOf = (font, line) => printable(font, line).split(/( +)/).filter(Boolean);
48
+
49
+ /** @type {(out: Atom[], font: any, size: number, color: any, line: string) => void} */
50
+ let pushParts = (out, font, size, color, line) => {
51
+ for (let part of partsOf(font, line))
52
+ out.push({ text: part, font, size, color, space: part[0] === " ", hard: false });
53
+ };
54
+
55
+ /** @type {(out: Atom[], font: any, size: number, color: any, i: number, line: string) => void} */
56
+ let pushLine = (out, font, size, color, i, line) => {
57
+ if (i) out.push({ text: "", font, size, color, space: false, hard: true });
58
+ pushParts(out, font, size, color, line);
59
+ };
60
+
61
+ /** @type {(metrics: Metrics, style: any) => { font: any, size: number, color: any }} */
62
+ let look = (metrics, style) => {
63
+ let resolved = style || {};
64
+ return {
65
+ font: face(metrics.fonts, resolved, metrics.family),
66
+ size: sizeOf(resolved, metrics.base),
67
+ color: col(resolved.color),
68
+ };
69
+ };
70
+
71
+ // Flatten a cell's tokens to word/space atoms carrying the cell's resolved
72
+ // typography — one face, size and colour for the whole cell. CR, LF, and
73
+ // 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) => {
76
+ let out = /** @type {Atom[]} */ ([]);
77
+ let { font, size, color } = look(metrics, style);
78
+ for (let token of tokens)
79
+ for (let [i, line] of cased(rawOf(token, style, metrics), style)
80
+ .split(/\r\n|\r|\n/)
81
+ .entries())
82
+ pushLine(out, font, size, color, i, line);
83
+ return out;
84
+ };
85
+
86
+ /** @type {(cur: Atom[]) => void} */
87
+ let trimEnd = (cur) => {
88
+ while (cur.length && cur[cur.length - 1].space) cur.pop();
89
+ };
90
+
91
+ /** @type {(pieces: Line['pieces'], atom: Atom, atomWidth: number) => void} */
92
+ let mergeAtom = (pieces, atom, atomWidth) => {
93
+ let last = pieces[pieces.length - 1];
94
+ if (last) {
95
+ last.text += atom.text;
96
+ last.w += atomWidth;
97
+ return;
98
+ }
99
+ pieces.push({
100
+ text: atom.text,
101
+ font: atom.font,
102
+ size: atom.size,
103
+ color: atom.color,
104
+ w: atomWidth,
105
+ });
106
+ };
107
+
108
+ /** @typedef {{ pieces: Line['pieces'], w: number, size: number, asc: number }} Run */
109
+
110
+ /** @type {(run: Run, atom: Atom) => void} */
111
+ let growPiece = (run, atom) => {
112
+ let atomWidth = width(atom.font, atom.text, atom.size);
113
+ run.w += atomWidth;
114
+ if (atom.size > run.size) run.size = atom.size;
115
+ let atomAsc = ascOf(atom.font, atom.size);
116
+ if (atomAsc > run.asc) run.asc = atomAsc;
117
+ mergeAtom(run.pieces, atom, atomWidth);
118
+ };
119
+
120
+ /** @type {(state: Wrap, run: Run) => void} */
121
+ let finish = (state, run) => {
122
+ if (!run.size) run.size = state.base;
123
+ // No `asc` fallback: a run with no atoms is a blank line, and an ascender
124
+ // only places pieces. `drawLine` loops over none and `decorateLine` skips a
125
+ // line with no width, so a measured one would be a face lookup nothing reads.
126
+ state.lines.push({
127
+ pieces: run.pieces,
128
+ w: run.w,
129
+ h: LEAD * run.size,
130
+ size: run.size,
131
+ asc: run.asc,
132
+ });
133
+ state.cur = [];
134
+ state.w = 0;
135
+ };
136
+
137
+ // `force` keeps deliberately blank lines (hard breaks, an empty value);
138
+ // a word-overflow close passes false so a line of pure trimmed spaces
139
+ // vanishes instead of becoming a phantom line.
140
+ /** @type {(state: Wrap, force?: boolean) => void} */
141
+ let emit = (state, force = true) => {
142
+ trimEnd(state.cur);
143
+ if (!force && !state.cur.length) {
144
+ state.w = 0;
145
+ return;
146
+ }
147
+ /** @type {Run} */
148
+ let run = { pieces: [], w: 0, size: 0, asc: 0 };
149
+ for (let atom of state.cur) growPiece(run, atom);
150
+ finish(state, run);
151
+ };
152
+
153
+ /** @type {(atom: Atom, rest: string[], avail: number) => number} */
154
+ let fitChars = (atom, rest, avail) => {
155
+ let n = 1;
156
+ while (n < rest.length) {
157
+ let longer = rest.slice(0, n + 1).join("");
158
+ if (width(atom.font, longer, atom.size) > avail) break;
159
+ n++;
160
+ }
161
+ return n;
162
+ };
163
+
164
+ /** @type {(state: Wrap, atom: Atom, rest: string[], avail: number) => string[]} */
165
+ let takeChunk = (state, atom, rest, avail) => {
166
+ let n = fitChars(atom, rest, avail);
167
+ let take = rest.slice(0, n).join("");
168
+ state.cur.push({ ...atom, text: take });
169
+ state.w = width(atom.font, take, atom.size);
170
+ return rest.slice(n);
171
+ };
172
+
173
+ // An over-wide word alone on its line breaks by character. Both callers
174
+ // enter with the line empty, and `emit()` empties it again between
175
+ // chunks, so each chunk appends to a bare line. The final chunk stays in
176
+ // `cur` (with `w` set) instead of closing, so following atoms may join its
177
+ // line. Split by code point, so an astral character is never halved.
178
+ /** @type {(state: Wrap, atom: Atom, avail: number) => void} */
179
+ let chunk = (state, atom, avail) => {
180
+ // Slicing is per code point throughout this module — `no-misused-spread`
181
+ // is warning about the grapheme clusters no base-14 face can encode.
182
+ // oxlint-disable-next-line typescript/no-misused-spread
183
+ let rest = [...atom.text];
184
+ while (rest.length) {
185
+ rest = takeChunk(state, atom, rest, avail);
186
+ if (rest.length) emit(state);
187
+ }
188
+ };
189
+
190
+ /** @type {(atom: Atom, atomWidth: number, avail: number) => boolean} */
191
+ let isWide = (atom, atomWidth, avail) => {
192
+ // oxlint-disable-next-line typescript/no-misused-spread
193
+ return atomWidth > avail && [...atom.text].length > 1 && !atom.space;
194
+ };
195
+
196
+ /** @type {(state: Wrap, atom: Atom, atomWidth: number, avail: number) => boolean} */
197
+ let canFit = (state, atom, atomWidth, avail) =>
198
+ state.w + atomWidth <= avail || !state.cur.length || atom.space;
199
+
200
+ /** @type {(state: Wrap, atom: Atom, atomWidth: number) => void} */
201
+ let append = (state, atom, atomWidth) => {
202
+ state.cur.push(atom);
203
+ state.w += atomWidth;
204
+ };
205
+
206
+ /** @type {(state: Wrap, atom: Atom, atomWidth: number, avail: number, wide: boolean) => void} */
207
+ let overflow = (state, atom, atomWidth, avail, wide) => {
208
+ emit(state, false);
209
+ if (atom.space) return;
210
+ if (wide) chunk(state, atom, avail);
211
+ else append(state, atom, atomWidth);
212
+ };
213
+
214
+ /** @type {(state: Wrap, wide: boolean) => boolean} */
215
+ let startWide = (state, wide) => !state.cur.length && wide;
216
+
217
+ /** @type {(state: Wrap, atom: Atom, avail: number) => void} */
218
+ let placeAtom = (state, atom, avail) => {
219
+ if (atom.hard) return emit(state);
220
+ let atomWidth = width(atom.font, atom.text, atom.size);
221
+ let wide = isWide(atom, atomWidth, avail);
222
+ if (!canFit(state, atom, atomWidth, avail)) return overflow(state, atom, atomWidth, avail, wide);
223
+ if (startWide(state, wide)) return chunk(state, atom, avail);
224
+ append(state, atom, atomWidth);
225
+ };
226
+
227
+ // Greedy wrap against `avail`: spaces never start a line, an over-wide word
228
+ // breaks by character, hard breaks always break. A cell's atoms share one
229
+ // typography, so a line's atoms merge into a single draw piece. `size` is the
230
+ // 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) => {
233
+ /** @type {Wrap} */
234
+ let state = { cur: [], w: 0, lines: [], base: size };
235
+ for (let atom of list) placeAtom(state, atom, avail);
236
+ emit(state);
237
+ return state.lines;
238
+ };
239
+
240
+ // Stamp cell-level decorations onto every wrapped fragment. Measurement does
241
+ // not need them; drawing does, and wrapping must not lose them.
242
+ /** @type {(line: Line, underline: boolean, strikethrough: boolean) => void} */
243
+ let stamp = (line, underline, strikethrough) => {
244
+ if (underline) line.underline = true;
245
+ if (strikethrough) line.strikethrough = true;
246
+ };
247
+
248
+ /** @type {(lines: Line[], style: any) => Line[]} */
249
+ let dress = (lines, style) => {
250
+ if (!dressed(style)) return lines;
251
+ for (let line of lines) stamp(line, !!style.underline, !!style.strikethrough);
252
+ return lines;
253
+ };
254
+
255
+ /** @type {(lines: Line[]) => number} */
256
+ let heightOf = (lines) => lines.reduce((total, line) => total + line.h, 0);
257
+
258
+ export { atoms, dress, heightOf, wrap };
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@quario/layout",
3
+ "version": "0.1.0",
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
+ "homepage": "https://getquario.com",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/getquario/quario.git",
10
+ "directory": "packages/layout"
11
+ },
12
+ "files": [
13
+ "CHANGELOG.md",
14
+ "lib"
15
+ ],
16
+ "type": "module",
17
+ "types": "lib/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./lib/index.d.ts",
21
+ "default": "./lib/index.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "check": "npm run size && npm test && npm run test:browser",
30
+ "size": "size-limit",
31
+ "test": "npm run test:unit && npm run test:types",
32
+ "test:browser": "node test/browser/setup.js",
33
+ "test:types": "tsc && attw --pack . --profile esm-only",
34
+ "test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
35
+ "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
36
+ "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
37
+ },
38
+ "dependencies": {
39
+ "@pdf-lib/standard-fonts": "^1.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@arethetypeswrong/cli": "^0.18.3",
43
+ "@pdf-lib/fontkit": "^1.1.1",
44
+ "@size-limit/preset-small-lib": "^13.0.3",
45
+ "quario": "^0.4.0",
46
+ "size-limit": "^13.0.3",
47
+ "typescript": "^7.0.2"
48
+ },
49
+ "peerDependencies": {
50
+ "@pdf-lib/fontkit": "^1.1.1",
51
+ "quario": "^0.4.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@pdf-lib/fontkit": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "size-limit": [
59
+ {
60
+ "path": "lib/index.js",
61
+ "ignore": [
62
+ "quario",
63
+ "@pdf-lib/standard-fonts"
64
+ ],
65
+ "limit": "12 kB"
66
+ }
67
+ ],
68
+ "engines": {
69
+ "node": ">=22.0.0"
70
+ }
71
+ }