@quario/pdf 0.3.0 → 0.4.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 +46 -1
- package/README.md +3 -2
- package/lib/embed.js +111 -0
- package/lib/index.d.ts +5 -14
- package/lib/index.js +34 -152
- package/lib/painter.js +205 -0
- package/package.json +6 -4
- package/lib/balance.js +0 -90
- package/lib/box.js +0 -111
- package/lib/canvas.js +0 -389
- package/lib/fonts.js +0 -223
- package/lib/image.js +0 -58
- package/lib/layout.js +0 -1804
- package/lib/style.js +0 -97
- package/lib/text.js +0 -258
package/lib/style.js
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The PDF target'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 target asks it directly.
|
|
10
|
-
*/
|
|
11
|
-
import { rgb } from "pdf-lib";
|
|
12
|
-
|
|
13
|
-
let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
14
|
-
|
|
15
|
-
// Leading as a fraction of the font size, cell padding, the gap between a
|
|
16
|
-
// page band and the body, and the gutter between two page-column strips.
|
|
17
|
-
// `example/print.css` is the reference look (10pt/1.4) — body type stays a
|
|
18
|
-
// host decision, so `@quario/html/style.css` carries no `body` rule and this
|
|
19
|
-
// pairs with the example rather than it.
|
|
20
|
-
//
|
|
21
|
-
// GUTTER is this target's structural default: SCHEMA.md gives page columns no
|
|
22
|
-
// gap knob deliberately, so the space between strips is the target's to pick
|
|
23
|
-
// and an author never writes it. Twice the cell padding, which is the widest
|
|
24
|
-
// space this target already puts between two columns of anything.
|
|
25
|
-
let LEAD = 1.4;
|
|
26
|
-
let PADX = 6;
|
|
27
|
-
let PADY = 2;
|
|
28
|
-
let BAND = 8;
|
|
29
|
-
let GUTTER = 2 * PADX;
|
|
30
|
-
|
|
31
|
-
let BLACK = rgb(0, 0, 0);
|
|
32
|
-
|
|
33
|
-
// Horizontal placement within a given leftover width: left keeps it, right
|
|
34
|
-
// takes all of it, center takes half. Own-key so an authored `constructor`
|
|
35
|
-
// cannot resolve an inherited member.
|
|
36
|
-
/** @type {Record<string, number>} */
|
|
37
|
-
let SHIFT = { right: 1, center: 0.5 };
|
|
38
|
-
/** @type {(align: any, extra: number) => number} */
|
|
39
|
-
let shift = (align, extra) => (Object.hasOwn(SHIFT, align) ? SHIFT[align] : 0) * extra;
|
|
40
|
-
|
|
41
|
-
// A declared colour as a pdf-lib colour, or null when the value is not one.
|
|
42
|
-
/** @type {(value: any) => any} */
|
|
43
|
-
let col = (value) => {
|
|
44
|
-
let match = typeof value === "string" && HEX.exec(value);
|
|
45
|
-
if (!match) return null;
|
|
46
|
-
// `#abc` is `#aabbcc`.
|
|
47
|
-
let digits = match[1].length === 3 ? match[1].replace(/./g, (d) => d + d) : match[1];
|
|
48
|
-
let [r, g, b] = [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16) / 255);
|
|
49
|
-
return rgb(r, g, b);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** @type {(style: any, base: number) => number} */
|
|
53
|
-
let sizeOf = (style, base) => (Number.isFinite(style.size) && style.size > 0 ? style.size : base);
|
|
54
|
-
|
|
55
|
-
// Style blocks layer outward-in: row under cell, split under slot. Those are
|
|
56
|
-
// the two innermost of the four layers; the outer two reach a node without
|
|
57
|
-
// being merged into it -- the band-role default through `roled` below, and the
|
|
58
|
-
// report default through the canvas, as `layout.js`'s `adoptDefault` explains.
|
|
59
|
-
/** @type {(under: any, over: any) => any} */
|
|
60
|
-
let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : over || {});
|
|
61
|
-
|
|
62
|
-
// Band-role omakase defaults — the third of those four layers, over the report
|
|
63
|
-
// default and under the author's own, which therefore always wins. Only the
|
|
64
|
-
// headline roles carry one; the XLSX target carries the same two,
|
|
65
|
-
// byte-identical, in `packages/xlsx/lib/index.js`, and they move together
|
|
66
|
-
// (SCHEMA.md states the pair for both). A target can only import public engine helpers, so there is
|
|
67
|
-
// nowhere to share this from and the two copies are synced by hand.
|
|
68
|
-
//
|
|
69
|
-
// `@quario/html` deliberately carries none of this, nor the leading, padding
|
|
70
|
-
// and band gap below: its consumer has a stylesheet and the `q-*` classes are
|
|
71
|
-
// the seam. The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
|
|
72
|
-
// a target supplies defaults only where its consumer has no seam to supply
|
|
73
|
-
// them. `@quario/html/style.css` and `packages/viewer/lib/style.js` are what supply
|
|
74
|
-
// them there; keep all four in agreement.
|
|
75
|
-
/** @type {Record<string, any>} */
|
|
76
|
-
let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
|
|
77
|
-
|
|
78
|
-
// One item event wearing its role's defaults. Events reach layout as events,
|
|
79
|
-
// so the default is folded into a copy rather than passed alongside.
|
|
80
|
-
/** @type {(event: any) => any} */
|
|
81
|
-
let roled = (event) =>
|
|
82
|
-
Object.hasOwn(ROLES, event.role)
|
|
83
|
-
? { ...event, style: merge(ROLES[event.role], event.style) }
|
|
84
|
-
: event;
|
|
85
|
-
|
|
86
|
-
// Whether a resolved style asks for either text decoration -- one reading for
|
|
87
|
-
// the wrapper that stamps it on a line and the canvas that draws it.
|
|
88
|
-
/** @type {(style: any) => boolean} */
|
|
89
|
-
let dressed = (style) => !!(style && (style.underline || style.strikethrough));
|
|
90
|
-
|
|
91
|
-
// Whether a resolved style asks for capitals. This target has no
|
|
92
|
-
// text-transform to defer to, so the reading is here beside the rest of the
|
|
93
|
-
// vocabulary and `text.js` applies it before measuring.
|
|
94
|
-
/** @type {(style: any) => boolean} */
|
|
95
|
-
let upper = (style) => !!(style && style.uppercase);
|
|
96
|
-
|
|
97
|
-
export { BAND, BLACK, GUTTER, LEAD, PADX, PADY, col, dressed, merge, roled, shift, sizeOf, upper };
|
package/lib/text.js
DELETED
|
@@ -1,258 +0,0 @@
|
|
|
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 target 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`: this
|
|
41
|
-
// 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 };
|