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