@quario/pdf 0.2.0 → 0.3.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 +51 -0
- package/README.md +3 -2
- package/lib/box.js +111 -0
- package/lib/canvas.js +28 -15
- package/lib/fonts.js +69 -26
- package/lib/index.js +36 -15
- package/lib/layout.js +273 -132
- package/lib/style.js +10 -19
- package/lib/text.js +30 -19
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`format` stringifies at the edge from the instance locale.** A PDF
|
|
15
|
+
without a host locale still uses `en-US` / UTC, so the bytes stay
|
|
16
|
+
reproducible (`docs/adr/0041`, `docs/adr/0025`).
|
|
17
|
+
|
|
18
|
+
- **A height-declared report header pins from the page top.** Leftover sits
|
|
19
|
+
under the items; the next band starts at the pin. The half-line group gap
|
|
20
|
+
drops after it. `page.margin` on the document is the inset when the host
|
|
21
|
+
omits it; both is a render error.
|
|
22
|
+
|
|
23
|
+
- **`spaceBefore` / `spaceAfter` skip the cursor.** Adjacent gaps add.
|
|
24
|
+
`spaceBefore` drops at a fresh body page or strip top; page-band items
|
|
25
|
+
keep it.
|
|
26
|
+
|
|
27
|
+
- **The box is honoured in layout.** Padding and border inset wrap width; a
|
|
28
|
+
named padding `0` beats the cell omakase (`PADX` 6 / `PADY` 2) on that
|
|
29
|
+
side. Incomplete sides draw nothing.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **The last data row keeps the whole emitted total block.** A stack taller
|
|
34
|
+
than a page degrades like a tall row.
|
|
35
|
+
|
|
36
|
+
- **An unstyled table has no rules.** This target used to draw two 0.5 pt
|
|
37
|
+
rules, under the header and above the total, that no declaration could
|
|
38
|
+
ask for or refuse. Those strokes are gone. The strokes an author wants
|
|
39
|
+
are the box they declared. Cell padding omakase stays on undeclared cell
|
|
40
|
+
sides.
|
|
41
|
+
|
|
42
|
+
- **A visible text item occupies a line at its own `size`, empty or not.** An
|
|
43
|
+
empty or whitespace-only value used to sit at the report's base leading
|
|
44
|
+
(~14 pt at the 10 pt baseline). It now occupies `1.4 ×` the item's size, the
|
|
45
|
+
same as a glyph line. A literal newline is a line break; the blank line
|
|
46
|
+
among `"a\n\nb"` is that size too. A table cell that is empty or only
|
|
47
|
+
horizontal whitespace still has no content height.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **A missing TrueType glyph draws as `?`, not `.notdef`.** Characters
|
|
52
|
+
outside WinAnsi already substituted `?`; an embedded face's cmap holes
|
|
53
|
+
drew a box instead, and text extraction hid it. Same rule for every
|
|
54
|
+
face: a character the face cannot draw becomes `?`.
|
|
55
|
+
|
|
56
|
+
- **CR, LF, and CRLF are one hard line break.** Cell text already broke
|
|
57
|
+
on LF; a CRLF left a CR on the previous line, and a lone CR did not
|
|
58
|
+
break. All three are now one break, matching `SCHEMA.md` Cell values.
|
|
59
|
+
Wrapping still applies within each line.
|
|
60
|
+
|
|
10
61
|
## [0.2.0] - 2026-09-01
|
|
11
62
|
|
|
12
63
|
### Added
|
package/README.md
CHANGED
|
@@ -135,8 +135,9 @@ without authored margins.
|
|
|
135
135
|
|
|
136
136
|
The base-14 Helvetica, Times, and Courier families carry the default output (`family: "sans"`,
|
|
137
137
|
`"serif"`, `"mono"`, each with regular, bold, italic, and bold-italic faces), using WinAnsi
|
|
138
|
-
encoding. Characters
|
|
139
|
-
text is untrusted data and one stray character must not take a document down.
|
|
138
|
+
encoding. Characters a face cannot draw render as `?` rather than failing the report, because cell
|
|
139
|
+
text is untrusted data and one stray character must not take a document down. WinAnsi is the
|
|
140
|
+
base-14 limit; a TrueType face's cmap is its own.
|
|
140
141
|
|
|
141
142
|
For full Unicode, supply TrueType families and select them by name:
|
|
142
143
|
|
package/lib/box.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The box model this target honours: per-side padding and border, border-box,
|
|
3
|
+
* no collapse. Cell omakase (PADX / PADY) applies only where the author named
|
|
4
|
+
* no padding on that side; a named 0 wins. A border side contributes only when
|
|
5
|
+
* width, style and colour all resolve and width is positive — an incomplete
|
|
6
|
+
* result at render is nothing, not a solid black stroke.
|
|
7
|
+
*/
|
|
8
|
+
import { PADX, PADY, col } from "./style.js";
|
|
9
|
+
|
|
10
|
+
let SIDES = ["Top", "Right", "Bottom", "Left"];
|
|
11
|
+
/** @type {Record<string, number[] | null>} */
|
|
12
|
+
let DASH = { solid: null, dashed: [3, 2], dotted: [1, 1.5] };
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ t: number, r: number, b: number, l: number }} Inset */
|
|
15
|
+
|
|
16
|
+
/** @type {Inset} */
|
|
17
|
+
let CELL_PAD = { t: PADY, r: PADX, b: PADY, l: PADX };
|
|
18
|
+
/** @type {Inset} */
|
|
19
|
+
let NO_PAD = { t: 0, r: 0, b: 0, l: 0 };
|
|
20
|
+
|
|
21
|
+
/** @type {(name: string) => boolean} */
|
|
22
|
+
let isBox = (name) => name.startsWith("padding") || name.startsWith("border");
|
|
23
|
+
|
|
24
|
+
/** @type {(style: any) => any} */
|
|
25
|
+
let unbox = (style) => {
|
|
26
|
+
if (!style) return style;
|
|
27
|
+
let names = Object.keys(style).filter((name) => !isBox(name));
|
|
28
|
+
if (!names.length) return null;
|
|
29
|
+
return Object.fromEntries(names.map((name) => [name, style[name]]));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** @type {(style: any, side: string, fallback: number) => number} */
|
|
33
|
+
let padOf = (style, side, fallback) => {
|
|
34
|
+
let value = style?.["padding" + side];
|
|
35
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** @type {(width: any) => boolean} */
|
|
39
|
+
let isStroke = (width) => Number.isFinite(width) && width > 0;
|
|
40
|
+
/** @type {(line: any) => boolean} */
|
|
41
|
+
let isLine = (line) => typeof line === "string" && Object.hasOwn(DASH, line);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @type {(width: any, line: any, color: any) =>
|
|
45
|
+
* { width: number, dash: number[] | null, color: any } | null}
|
|
46
|
+
*/
|
|
47
|
+
let strokeOf = (width, line, color) => {
|
|
48
|
+
if (!isStroke(width) || !isLine(line) || !color) return null;
|
|
49
|
+
return { width, dash: DASH[line], color };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @type {(style: any, side: string) =>
|
|
54
|
+
* { width: number, dash: number[] | null, color: any } | null}
|
|
55
|
+
*/
|
|
56
|
+
let edgeOf = (style, side) => {
|
|
57
|
+
if (!style) return null;
|
|
58
|
+
return strokeOf(
|
|
59
|
+
style["border" + side + "Width"],
|
|
60
|
+
style["border" + side + "Style"],
|
|
61
|
+
col(style["border" + side + "Color"]),
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** @type {(style: any, side: string) => number} */
|
|
66
|
+
let thick = (style, side) => {
|
|
67
|
+
let edge = edgeOf(style, side);
|
|
68
|
+
return edge ? edge.width : 0;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** @type {(style: any, side: string, fallback: number) => number} */
|
|
72
|
+
let inset = (style, side, fallback) => padOf(style, side, fallback) + thick(style, side);
|
|
73
|
+
|
|
74
|
+
/** @type {(style: any, omakase: Inset) => Inset} */
|
|
75
|
+
let insetOf = (style, omakase) => ({
|
|
76
|
+
t: inset(style, "Top", omakase.t),
|
|
77
|
+
r: inset(style, "Right", omakase.r),
|
|
78
|
+
b: inset(style, "Bottom", omakase.b),
|
|
79
|
+
l: inset(style, "Left", omakase.l),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Endpoints of one inner-centred edge, as stroke() takes them. */
|
|
83
|
+
/** @type {Record<string, (x: number, yTop: number, w: number, h: number, half: number) => number[]>} */
|
|
84
|
+
let SPAN = {
|
|
85
|
+
Top: (x, yTop, w, _h, half) => [x, yTop - half, x + w, yTop - half],
|
|
86
|
+
Bottom: (x, yTop, w, h, half) => [x, yTop - h + half, x + w, yTop - h + half],
|
|
87
|
+
Left: (x, yTop, _w, h, half) => [x + half, yTop, x + half, yTop - h],
|
|
88
|
+
Right: (x, yTop, w, h, half) => [x + w - half, yTop, x + w - half, yTop - h],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** @type {(canvas: { stroke: Function }, x: number, yTop: number, w: number, h: number, style: any, side: string) => void} */
|
|
92
|
+
let paintEdge = (canvas, x, yTop, w, h, style, side) => {
|
|
93
|
+
let edge = edgeOf(style, side);
|
|
94
|
+
if (!edge) return;
|
|
95
|
+
canvas.stroke(...SPAN[side](x, yTop, w, h, edge.width / 2), edge.width, edge.color, edge.dash);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fill and stroke one border-box. Background is the whole rect; each edge
|
|
100
|
+
* sits inside it, centred on its own width, so a stroke does not spill past
|
|
101
|
+
* the box the caller measured.
|
|
102
|
+
*
|
|
103
|
+
* @type {(canvas: { rect: Function, stroke: Function }, x: number, yTop: number,
|
|
104
|
+
* w: number, h: number, style: any, bg: any) => void}
|
|
105
|
+
*/
|
|
106
|
+
let paintBox = (canvas, x, yTop, w, h, style, bg) => {
|
|
107
|
+
if (bg) canvas.rect(bg, x, yTop - h, w, h);
|
|
108
|
+
if (style) for (let side of SIDES) paintEdge(canvas, x, yTop, w, h, style, side);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export { CELL_PAD, NO_PAD, insetOf, paintBox, unbox };
|
package/lib/canvas.js
CHANGED
|
@@ -25,30 +25,36 @@ let NO_TURN = degrees(0);
|
|
|
25
25
|
|
|
26
26
|
// The page box and the content box. Settled at `report-start` and fixed
|
|
27
27
|
// thereafter: the geometry is the factory's and never moves, while `base` and
|
|
28
|
-
// `
|
|
29
|
-
// measured. The page bands are measured against the page box and take their
|
|
28
|
+
// `family` take the report default one event into the render, before anything
|
|
29
|
+
// is measured. The page bands are measured against the page box and take their
|
|
30
30
|
// height off `top`/`bottom` through `adopt` below, while the first page is
|
|
31
31
|
// still untouched.
|
|
32
32
|
/**
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* `family` is the report default's typeface, normalised, and `base` its size —
|
|
34
|
+
* the two declarations that default is narrowed to. They ride the frame so a
|
|
35
|
+
* measuring canvas built off it reads the same pair the drawing canvas does,
|
|
36
|
+
* and so the default reaches a node as a fallback rather than a merged layer;
|
|
37
|
+
* `layout.js`'s `adoptDefault` carries why. `family` is null when the report
|
|
36
38
|
* declares none.
|
|
37
39
|
*
|
|
38
40
|
* @typedef {{ width: number, height: number, margin: number, base: number,
|
|
39
|
-
* content: number, top: number, bottom: number,
|
|
41
|
+
* content: number, top: number, bottom: number, family: string | null,
|
|
42
|
+
* locale?: string, currency?: string, timeZone?: string }} Frame
|
|
40
43
|
*/
|
|
41
44
|
|
|
42
45
|
// A frame from the page box and the base size: how `content`/`top`/`bottom`
|
|
43
46
|
// fall out of a page and a margin is derived here, once, so no caller and no
|
|
44
47
|
// suite has to restate it and drift from what a real render uses.
|
|
45
|
-
/**
|
|
46
|
-
|
|
48
|
+
/**
|
|
49
|
+
* @type {(width: number, height: number, margin: number, base: number,
|
|
50
|
+
* family?: string | null) => Frame}
|
|
51
|
+
*/
|
|
52
|
+
let frame = (width, height, margin, base, family = null) => ({
|
|
47
53
|
width,
|
|
48
54
|
height,
|
|
49
55
|
margin,
|
|
50
56
|
base,
|
|
51
|
-
|
|
57
|
+
family,
|
|
52
58
|
content: width - 2 * margin,
|
|
53
59
|
top: height - margin,
|
|
54
60
|
bottom: margin,
|
|
@@ -60,7 +66,8 @@ let frame = (width, height, margin, base, style = null) => ({
|
|
|
60
66
|
* @typedef {Frame & Metrics & { y: number, fresh: boolean, count: number,
|
|
61
67
|
* newPage: () => void,
|
|
62
68
|
* rect: (color: any, x: number, y: number, w: number, h: number) => void,
|
|
63
|
-
*
|
|
69
|
+
* stroke: (x1: number, y1: number, x2: number, y2: number, thickness: number,
|
|
70
|
+
* color: any, dash: number[] | null) => void,
|
|
64
71
|
* picture: (bytes: Uint8Array, format: string, x: number, y: number,
|
|
65
72
|
* w: number, h: number) => void,
|
|
66
73
|
* drawLine: (line: Line, x: number, yTop: number, avail: number,
|
|
@@ -210,9 +217,15 @@ let drawing = (doc, box, fonts) => {
|
|
|
210
217
|
}
|
|
211
218
|
};
|
|
212
219
|
|
|
213
|
-
/** @type {Canvas['
|
|
214
|
-
let
|
|
215
|
-
page.drawLine({
|
|
220
|
+
/** @type {Canvas['stroke']} */
|
|
221
|
+
let stroke = (x1, y1, x2, y2, thickness, color, dash) =>
|
|
222
|
+
page.drawLine({
|
|
223
|
+
start: { x: x1, y: y1 },
|
|
224
|
+
end: { x: x2, y: y2 },
|
|
225
|
+
thickness,
|
|
226
|
+
color,
|
|
227
|
+
...(dash ? { dashArray: dash, dashPhase: 0 } : {}),
|
|
228
|
+
});
|
|
216
229
|
|
|
217
230
|
/** @type {(piece: Line['pieces'][number], x: number, y: number) => void} */
|
|
218
231
|
let writePiece = (piece, x, y) => {
|
|
@@ -284,7 +297,7 @@ let drawing = (doc, box, fonts) => {
|
|
|
284
297
|
},
|
|
285
298
|
newPage,
|
|
286
299
|
rect,
|
|
287
|
-
|
|
300
|
+
stroke,
|
|
288
301
|
picture,
|
|
289
302
|
pictures,
|
|
290
303
|
drawLine,
|
|
@@ -316,7 +329,7 @@ let measuring = (box, fonts) => ({
|
|
|
316
329
|
throw new Error("probe reached newPage: the measuring path must not paginate");
|
|
317
330
|
},
|
|
318
331
|
rect: MARKS_NOTHING,
|
|
319
|
-
|
|
332
|
+
stroke: MARKS_NOTHING,
|
|
320
333
|
picture: MARKS_NOTHING,
|
|
321
334
|
drawLine: MARKS_NOTHING,
|
|
322
335
|
});
|
package/lib/fonts.js
CHANGED
|
@@ -115,22 +115,43 @@ export async function embedFonts(doc, custom) {
|
|
|
115
115
|
return { families };
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// What a declared `family` normalises to before it is looked up: lower case,
|
|
119
|
+
// and nothing at all when the value is not a name. Exported because the report
|
|
120
|
+
// default is normalised once per render rather than once per cell, and two
|
|
121
|
+
// spellings of this would drift.
|
|
118
122
|
/** @type {(style: any) => string} */
|
|
119
|
-
let familyName = (style) =>
|
|
123
|
+
export let familyName = (style) =>
|
|
124
|
+
typeof style.family === "string" ? style.family.toLowerCase() : "";
|
|
120
125
|
|
|
121
126
|
// Own-key lookup: the family name is render data, so `constructor` must
|
|
122
127
|
// fall back to sans rather than resolve an inherited member.
|
|
123
|
-
/** @type {(fonts: Fonts, name: string) => any[]} */
|
|
128
|
+
/** @type {(fonts: Fonts, name: string | null) => any[]} */
|
|
124
129
|
let variantsOf = (fonts, name) =>
|
|
125
|
-
Object.hasOwn(fonts.families, name) ? fonts.families[name] : fonts.families.sans;
|
|
130
|
+
name && Object.hasOwn(fonts.families, name) ? fonts.families[name] : fonts.families.sans;
|
|
126
131
|
|
|
127
132
|
/** @type {(style: any) => number} */
|
|
128
133
|
let faceIndex = (style) => (style.bold ? 1 : 0) | (style.italic ? 2 : 0);
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Resolve a style block to one embedded face.
|
|
137
|
+
*
|
|
138
|
+
* `fallback` is the report default's family, which the canvas carries for the
|
|
139
|
+
* whole render (docs/adr/0033) — so text declaring no family is set in the
|
|
140
|
+
* document's.
|
|
141
|
+
*
|
|
142
|
+
* The fallback turns on whether a family was *declared*, never on whether the
|
|
143
|
+
* declared one resolves: a literal `family` is a non-empty string by the time
|
|
144
|
+
* the engine passes it, but an `=` expression is resolved at render and
|
|
145
|
+
* reaches here unchecked, so an item can declare `""`, a number or `null`.
|
|
146
|
+
* Those are the item's own declaration and fall to sans like any other name
|
|
147
|
+
* this target cannot resolve (SCHEMA.md) — the document's family is for text
|
|
148
|
+
* that declared none. Own-key, because the block is render data.
|
|
149
|
+
*
|
|
150
|
+
* @type {(fonts: Fonts, style: any, fallback: string | null) => any}
|
|
151
|
+
*/
|
|
152
|
+
export function face(fonts, style, fallback) {
|
|
153
|
+
let declared = Object.hasOwn(style, "family");
|
|
154
|
+
return variantsOf(fonts, declared ? familyName(style) : fallback)[faceIndex(style)];
|
|
134
155
|
}
|
|
135
156
|
|
|
136
157
|
// The distance from a line's top to its baseline: pdf-lib's ascender, which
|
|
@@ -143,38 +164,60 @@ export let ascOf = (font, size) => font.heightAtSize(size, { descender: false })
|
|
|
143
164
|
/** @type {(font: any, text: string, size: number) => number} */
|
|
144
165
|
export let width = (font, text, size) => font.widthOfTextAtSize(text, size);
|
|
145
166
|
|
|
146
|
-
// Printable ASCII, which every face encodes and which almost all report
|
|
147
|
-
// is. Asking the
|
|
167
|
+
// Printable ASCII, which every text face encodes and which almost all report
|
|
168
|
+
// text is. Asking the cmap instead costs a set walk per atom.
|
|
148
169
|
let ASCII = /^[\x20-\x7E]*$/;
|
|
149
170
|
|
|
171
|
+
// pdf-lib's getCharacterSet() is the cmap for both kinds of face: WinAnsi
|
|
172
|
+
// code points on the base-14, fontkit's characterSet on an embedded TTF.
|
|
173
|
+
// widthOfTextAtSize does not throw on a TrueType .notdef, so the old probe
|
|
174
|
+
// let cmap holes through as glyph id 0. A face with no cmap (the layout
|
|
175
|
+
// suite's measuring doubles) does not substitute: width is its whole contract.
|
|
176
|
+
let CHARSETS = new WeakMap();
|
|
177
|
+
let OPEN = { has: () => true };
|
|
178
|
+
|
|
179
|
+
/** @typedef {{ has: (code: number) => boolean }} Cmap */
|
|
180
|
+
|
|
181
|
+
/** @type {(font: any) => Cmap} */
|
|
182
|
+
let charsetOf = (font) => {
|
|
183
|
+
let set = CHARSETS.get(font);
|
|
184
|
+
if (set) return set;
|
|
185
|
+
set = typeof font.getCharacterSet === "function" ? new Set(font.getCharacterSet()) : OPEN;
|
|
186
|
+
CHARSETS.set(font, set);
|
|
187
|
+
return set;
|
|
188
|
+
};
|
|
189
|
+
|
|
150
190
|
/**
|
|
151
|
-
* Replace characters a face cannot
|
|
191
|
+
* Replace characters a face cannot draw with `?`.
|
|
152
192
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
193
|
+
* Cell text is untrusted data, so one stray character must not fail the
|
|
194
|
+
* render: SCHEMA.md promises `?` for any face — WinAnsi on the base-14, the
|
|
195
|
+
* cmap on an embedded TrueType. A face that also lacks `?` omits the
|
|
196
|
+
* character rather than drawing .notdef.
|
|
156
197
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* ASCII, so the guard never lets an unencodable character through.
|
|
198
|
+
* The ASCII guard carries the common case; only text with a character
|
|
199
|
+
* outside it pays for the cmap walk. Printable ASCII is a subset of WinAnsi
|
|
200
|
+
* and of every text face this target embeds.
|
|
161
201
|
*
|
|
162
202
|
* @type {(font: any, text: string) => string}
|
|
163
203
|
*/
|
|
164
204
|
export let printable = (font, text) => {
|
|
165
205
|
if (!text || ASCII.test(text) || encodable(font, text)) return text;
|
|
166
|
-
|
|
167
|
-
|
|
206
|
+
return replaceUndrawable(charsetOf(font), text);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** @type {(set: Cmap, text: string) => string} */
|
|
210
|
+
let replaceUndrawable = (set, text) => {
|
|
211
|
+
let sub = set.has(0x3f) ? "?" : "";
|
|
212
|
+
// Encodedness is a per-code-point question, so that is the unit to ask it
|
|
213
|
+
// in — the grapheme clusters the rule protects are undrawable anyway.
|
|
168
214
|
// oxlint-disable-next-line typescript/no-misused-spread
|
|
169
|
-
return [...text].map((char) => (
|
|
215
|
+
return [...text].map((char) => (set.has(char.codePointAt(0) ?? -1) ? char : sub)).join("");
|
|
170
216
|
};
|
|
171
217
|
|
|
172
218
|
/** @type {(font: any, text: string) => boolean} */
|
|
173
219
|
let encodable = (font, text) => {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
} catch {
|
|
178
|
-
return false;
|
|
179
|
-
}
|
|
220
|
+
let set = charsetOf(font);
|
|
221
|
+
// oxlint-disable-next-line typescript/no-misused-spread
|
|
222
|
+
return [...text].every((char) => set.has(char.codePointAt(0) ?? -1));
|
|
180
223
|
};
|
package/lib/index.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* headers, page bands, `break: "page"`, `reset: "page"` — while the writing is pdf-lib's, so
|
|
6
6
|
* this target carries a typesetter rather than a PDF implementation. Output
|
|
7
7
|
* is deterministic: the document carries no dates of its own, so the same
|
|
8
|
-
* input renders the same bytes. Page
|
|
9
|
-
* (`pdf({ page })`)
|
|
8
|
+
* input renders the same bytes. Page size is target configuration
|
|
9
|
+
* (`pdf({ page.size })`); `page.margin` may also be a document field.
|
|
10
10
|
*
|
|
11
11
|
* This file is options, fonts and geometry, the walk itself, and the passes
|
|
12
12
|
* that follow it over the finished pages — page furniture and the marking,
|
|
@@ -57,11 +57,11 @@ let pair = (dimensions) => {
|
|
|
57
57
|
return { width, height };
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
-
/** @type {(margin: any, width: number, height: number) => number} */
|
|
61
|
-
let marginOf = (margin = 54, width, height) => {
|
|
60
|
+
/** @type {(margin: any, width: number, height: number, path?: string) => number} */
|
|
61
|
+
let marginOf = (margin = 54, width, height, path = "options.page.margin") => {
|
|
62
62
|
let fits = Number.isFinite(margin) && margin >= 0 && 2 * margin < Math.min(width, height);
|
|
63
63
|
// oxlint-disable-next-line no-unused-expressions
|
|
64
|
-
fits || err("
|
|
64
|
+
fits || err(path + ": expected a non-negative number smaller than half the page");
|
|
65
65
|
return margin;
|
|
66
66
|
};
|
|
67
67
|
|
|
@@ -71,16 +71,30 @@ let marginOf = (margin = 54, width, height) => {
|
|
|
71
71
|
// carries the same 10 for the same reason (docs/adr/0014, docs/adr/0033).
|
|
72
72
|
let BASE = 10;
|
|
73
73
|
|
|
74
|
-
// The page box and the content box in one
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
/** @type {(
|
|
80
|
-
let
|
|
74
|
+
// The page box and the content box in one. Size is settled at the factory;
|
|
75
|
+
// margin may come from the document's `report-start`, so a render peeks that
|
|
76
|
+
// event before opening a canvas.
|
|
77
|
+
/** @type {(page: any) => any} */
|
|
78
|
+
let hostMargin = (page) => (Object.hasOwn(page, "margin") ? page.margin : undefined);
|
|
79
|
+
/** @type {(opening: any) => any} */
|
|
80
|
+
let docMargin = (opening) => opening?.margin;
|
|
81
|
+
/** @type {(doc: any, host: any) => boolean} */
|
|
82
|
+
let bothMargins = (doc, host) => doc != null && host !== undefined;
|
|
83
|
+
/** @type {(fromDoc: boolean) => string} */
|
|
84
|
+
let marginPath = (fromDoc) => (fromDoc ? "page.margin" : "options.page.margin");
|
|
85
|
+
/** @type {(opening: any, page: any) => { value: any, path: string }} */
|
|
86
|
+
let marginChoice = (opening, page) => {
|
|
87
|
+
let host = hostMargin(page);
|
|
88
|
+
let doc = docMargin(opening);
|
|
89
|
+
if (bothMargins(doc, host)) err("page.margin: document and target both declare a margin");
|
|
90
|
+
return { value: doc ?? host, path: marginPath(doc != null) };
|
|
91
|
+
};
|
|
92
|
+
/** @type {(options: any, opening?: any) => import('./canvas.js').Frame} */
|
|
93
|
+
let geometry = (options, opening) => {
|
|
81
94
|
let { page = {} } = options ?? {};
|
|
82
95
|
let { width, height } = pair(named(page.size));
|
|
83
|
-
|
|
96
|
+
let chosen = marginChoice(opening, page);
|
|
97
|
+
return frame(width, height, marginOf(chosen.value, width, height, chosen.path), BASE);
|
|
84
98
|
};
|
|
85
99
|
|
|
86
100
|
// Optional document information. Never a date: pdf-lib stamps the current time
|
|
@@ -136,7 +150,7 @@ let markPages = async (canvas, marking) => {
|
|
|
136
150
|
* The target (see SCHEMA.md, "Instances and targets").
|
|
137
151
|
*/
|
|
138
152
|
export function pdf(options) {
|
|
139
|
-
|
|
153
|
+
geometry(options);
|
|
140
154
|
let meta = options?.meta,
|
|
141
155
|
custom = options?.fonts;
|
|
142
156
|
/** @type {(stream: any) => (data?: any) => Promise<Uint8Array>} */
|
|
@@ -146,11 +160,18 @@ export function pdf(options) {
|
|
|
146
160
|
// Every face is embedded before the walk, so resolving a style to a
|
|
147
161
|
// font during layout stays synchronous.
|
|
148
162
|
let fonts = await embedFonts(doc, custom);
|
|
163
|
+
let gen = stream(data);
|
|
164
|
+
let first = gen.next();
|
|
165
|
+
let geo = geometry(options, first.done ? null : first.value);
|
|
149
166
|
let canvas = drawing(doc, geo, fonts);
|
|
150
167
|
// The band flow owns the placement state and every handler over it, and
|
|
151
168
|
// opens the first page as it is built; this file only hands it the stream.
|
|
152
169
|
let { handlers, finish } = flow(canvas);
|
|
153
|
-
|
|
170
|
+
function* events() {
|
|
171
|
+
if (!first.done) yield first.value;
|
|
172
|
+
yield* gen;
|
|
173
|
+
}
|
|
174
|
+
await walk(events(), handlers);
|
|
154
175
|
// The flow settled the opening event on its way past — it reserves the
|
|
155
176
|
// page bands off it — and hands it back with the marks. What is left on it
|
|
156
177
|
// is what the passes below want: the band closures to render per page, and
|