@quario/pdf 0.1.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 CHANGED
@@ -7,6 +7,99 @@ 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
+
61
+ ## [0.2.0] - 2026-09-01
62
+
63
+ ### Added
64
+
65
+ - **The report default sets the document's face and base size.** A report's
66
+ top-level `style` replaces this target's own baseline, so its `size` scales
67
+ row heights and band gaps with the type rather than leaving them at a size
68
+ nothing is set in. It is the layer _under_ the band-role defaults: a report
69
+ declaring `size: 12` still renders its report header at 14, and an item's own
70
+ style wins over both.
71
+
72
+ - **`uppercase` draws capitals.** With no text-transform to defer to, this
73
+ target capitalises the string before measuring it, so wrapping and column
74
+ widths are those of the text actually drawn. The mapping is Unicode default
75
+ case, never the host's locale, so output stays byte-reproducible.
76
+
77
+ - **Splits lay out across the content width.** Each slot is measured and
78
+ wrapped inside its own share, all slots draw from a common top, and the
79
+ split takes the height of its tallest slot so the band below clears them
80
+ all. **A split is never broken across a page**: one that does not fit the
81
+ remaining height moves whole, exactly as an image does, and one taller than
82
+ any page renders in full past the bottom margin. The split's own style is
83
+ the layer under each slot's, and its `background` fills the whole split
84
+ behind them.
85
+
86
+ ### Changed
87
+
88
+ - **A bare `Date` draws as ISO 8601 UTC, the same on every machine.** Cell
89
+ text and outline bookmark titles for `Date` values used `String(date)`,
90
+ which bakes the host's timezone and locale into the document — at odds with
91
+ this target's byte-reproducibility guarantee. Both now render through the
92
+ engine's shared display rule, so a `Date` group key titles its bookmark
93
+ with the same ISO text its cells draw.
94
+
95
+ ### Removed
96
+
97
+ - **`options.baseSize`.** A document's type size is the document's own, so it
98
+ is the report's `style.size` — portable, travelling with the definition to
99
+ every target — rather than a host option one target honoured. Text with
100
+ nothing declared still renders at 10 points. Replace `pdf({ baseSize: 11 })`
101
+ with `"style": { "size": 11 }` on the report.
102
+
10
103
  ## [0.1.0] - 2026-08-27
11
104
 
12
105
  ### Added
package/README.md CHANGED
@@ -89,7 +89,6 @@ Page geometry is target configuration, never schema:
89
89
  ```js
90
90
  {
91
91
  page: { size: "A4", margin: 54 }, // 'A4' | 'letter' | [width, height] in points
92
- baseSize: 10, // base font size in points
93
92
  meta: { title, author, subject }, // optional /Info — strings only, never a date
94
93
  fonts: { Inter: { regular, bold, italic, boldItalic } }, // TrueType bytes
95
94
  }
@@ -97,8 +96,9 @@ Page geometry is target configuration, never schema:
97
96
 
98
97
  `size` defaults to `A4` (595.28 × 841.89 pt); an array is a custom `[width, height]` in points. An
99
98
  unknown size name is a definition error, thrown by the factory. `margin` applies to all four sides and defaults to 54
100
- (0.75 in). `baseSize` defaults to 10 and style `size` declarations override it per element; line
101
- leading is 1.4× a line's largest font size.
99
+ (0.75 in). Text renders at 10pt when nothing declares a size: a document's type size is the
100
+ document's own, so it is the report's `style.size`, not a host option. Line leading is 1.4× a line's
101
+ largest font size.
102
102
 
103
103
  ## Layout
104
104
 
@@ -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 outside WinAnsi render as `?` rather than failing the report, because cell
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/balance.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // The one number a balanced region decides: how tall each of its strips is
2
- // (ADR 0020 — balancing is a strip height, not a pass). Pure arithmetic over
2
+ // (ADR 0027 — balancing is a strip height, not a pass). Pure arithmetic over
3
3
  // what the buffer holds, so nothing here reaches a canvas or the band flow.
4
4
  //
5
5
  // Balancing by height alone under-fills, because most of what a region holds
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
@@ -12,7 +12,7 @@
12
12
  * mid-measure and return a silently wrong height, so `newPage` throws there.
13
13
  */
14
14
  import { degrees, drawImage, drawText, rgb } from "pdf-lib";
15
- import { BLACK, shift } from "./style.js";
15
+ import { BLACK, dressed, shift } from "./style.js";
16
16
 
17
17
  // The operator builder wants explicit rotation and skew; report text has none.
18
18
  let NO_TURN = degrees(0);
@@ -23,24 +23,38 @@ let NO_TURN = degrees(0);
23
23
  /** @typedef {import('pdf-lib').PDFFont} PDFFont */
24
24
  /** @typedef {import('pdf-lib').PDFName} PDFName */
25
25
 
26
- // The page box and the content box. Fixed for the whole render, from the moment
27
- // it commits to a page: the page bands are measured against the page box and
28
- // take their height off `top`/`bottom` through `adopt` below, while the first
29
- // page is still untouched.
26
+ // The page box and the content box. Settled at `report-start` and fixed
27
+ // thereafter: the geometry is the factory's and never moves, while `base` and
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
+ // height off `top`/`bottom` through `adopt` below, while the first page is
31
+ // still untouched.
30
32
  /**
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
38
+ * declares none.
39
+ *
31
40
  * @typedef {{ width: number, height: number, margin: number, base: number,
32
- * content: number, top: number, bottom: number }} Frame
41
+ * content: number, top: number, bottom: number, family: string | null,
42
+ * locale?: string, currency?: string, timeZone?: string }} Frame
33
43
  */
34
44
 
35
45
  // A frame from the page box and the base size: how `content`/`top`/`bottom`
36
46
  // fall out of a page and a margin is derived here, once, so no caller and no
37
47
  // suite has to restate it and drift from what a real render uses.
38
- /** @type {(width: number, height: number, margin: number, base: number) => Frame} */
39
- let frame = (width, height, margin, base) => ({
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) => ({
40
53
  width,
41
54
  height,
42
55
  margin,
43
56
  base,
57
+ family,
44
58
  content: width - 2 * margin,
45
59
  top: height - margin,
46
60
  bottom: margin,
@@ -52,7 +66,8 @@ let frame = (width, height, margin, base) => ({
52
66
  * @typedef {Frame & Metrics & { y: number, fresh: boolean, count: number,
53
67
  * newPage: () => void,
54
68
  * rect: (color: any, x: number, y: number, w: number, h: number) => void,
55
- * rule: (x1: number, x2: number, y: number) => void,
69
+ * stroke: (x1: number, y1: number, x2: number, y2: number, thickness: number,
70
+ * color: any, dash: number[] | null) => void,
56
71
  * picture: (bytes: Uint8Array, format: string, x: number, y: number,
57
72
  * w: number, h: number) => void,
58
73
  * drawLine: (line: Line, x: number, yTop: number, avail: number,
@@ -84,17 +99,11 @@ let GREY = rgb(0.5, 0.5, 0.5);
84
99
  // line's ascender (the face metric already measured for baseline placement).
85
100
  // Empty lines (no width) draw nothing. Shared by the drawing adapter and the
86
101
  // layout recorder so both exercise the same path.
87
- /** @type {{ flag: 'underline' | 'strikethrough', at: (baseline: number, asc: number) => number }[]} */
88
- let DECO = [
89
- { flag: "underline", at: (baseline, asc) => baseline - asc * 0.12 },
90
- { flag: "strikethrough", at: (baseline, asc) => baseline + asc * 0.35 },
91
- ];
92
-
93
102
  /** @type {(line: Line) => any} */
94
103
  let ink = (line) => (line.pieces[0] && line.pieces[0].color) || BLACK;
95
104
 
96
105
  /** @type {(line: Line) => boolean} */
97
- let wantsDeco = (line) => !!(line.w && (line.underline || line.strikethrough));
106
+ let wantsDeco = (line) => !!line.w && dressed(line);
98
107
 
99
108
  /**
100
109
  * @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: any) => void,
@@ -105,9 +114,8 @@ let decorateLine = (stroke, line, left, baseline) => {
105
114
  let thickness = Math.max(line.asc / 12, 0.5);
106
115
  let right = left + line.w;
107
116
  let color = ink(line);
108
- for (let { flag, at } of DECO) {
109
- if (line[flag]) stroke(left, right, at(baseline, line.asc), thickness, color);
110
- }
117
+ if (line.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
118
+ if (line.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
111
119
  };
112
120
 
113
121
  /**
@@ -209,9 +217,15 @@ let drawing = (doc, box, fonts) => {
209
217
  }
210
218
  };
211
219
 
212
- /** @type {Canvas['rule']} */
213
- let rule = (x1, x2, y) =>
214
- page.drawLine({ start: { x: x1, y }, end: { x: x2, y }, thickness: 0.5, color: BLACK });
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
+ });
215
229
 
216
230
  /** @type {(piece: Line['pieces'][number], x: number, y: number) => void} */
217
231
  let writePiece = (piece, x, y) => {
@@ -283,7 +297,7 @@ let drawing = (doc, box, fonts) => {
283
297
  },
284
298
  newPage,
285
299
  rect,
286
- rule,
300
+ stroke,
287
301
  picture,
288
302
  pictures,
289
303
  drawLine,
@@ -315,7 +329,7 @@ let measuring = (box, fonts) => ({
315
329
  throw new Error("probe reached newPage: the measuring path must not paginate");
316
330
  },
317
331
  rect: MARKS_NOTHING,
318
- rule: MARKS_NOTHING,
332
+ stroke: MARKS_NOTHING,
319
333
  picture: MARKS_NOTHING,
320
334
  drawLine: MARKS_NOTHING,
321
335
  });
package/lib/fonts.js CHANGED
@@ -49,8 +49,6 @@ let useFontkit = async (doc) => {
49
49
  doc.registerFontkit(fontkit);
50
50
  };
51
51
 
52
- let VARIANTS = ["regular", "bold", "italic", "boldItalic"];
53
-
54
52
  /** @type {(doc: any) => Promise<Record<string, any[]>>} */
55
53
  let embedBase = async (doc) => {
56
54
  /** @type {Record<string, any[]>} */
@@ -91,7 +89,8 @@ let familyOf = async (doc, name, def) => {
91
89
  let path = asFamily(name, def);
92
90
  /** @type {any[]} */
93
91
  let faces = [];
94
- for (let variant of VARIANTS) faces.push(await embedVariant(doc, def, path, variant, faces));
92
+ for (let variant of ["regular", "bold", "italic", "boldItalic"])
93
+ faces.push(await embedVariant(doc, def, path, variant, faces));
95
94
  return faces;
96
95
  };
97
96
 
@@ -116,22 +115,43 @@ export async function embedFonts(doc, custom) {
116
115
  return { families };
117
116
  }
118
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.
119
122
  /** @type {(style: any) => string} */
120
- let familyName = (style) => (typeof style.family === "string" ? style.family.toLowerCase() : "");
123
+ export let familyName = (style) =>
124
+ typeof style.family === "string" ? style.family.toLowerCase() : "";
121
125
 
122
126
  // Own-key lookup: the family name is render data, so `constructor` must
123
127
  // fall back to sans rather than resolve an inherited member.
124
- /** @type {(fonts: Fonts, name: string) => any[]} */
128
+ /** @type {(fonts: Fonts, name: string | null) => any[]} */
125
129
  let variantsOf = (fonts, name) =>
126
- Object.hasOwn(fonts.families, name) ? fonts.families[name] : fonts.families.sans;
130
+ name && Object.hasOwn(fonts.families, name) ? fonts.families[name] : fonts.families.sans;
127
131
 
128
132
  /** @type {(style: any) => number} */
129
133
  let faceIndex = (style) => (style.bold ? 1 : 0) | (style.italic ? 2 : 0);
130
134
 
131
- // Resolve a style block to one embedded face.
132
- /** @type {(fonts: Fonts, style: any) => any} */
133
- export function face(fonts, style) {
134
- return variantsOf(fonts, familyName(style))[faceIndex(style)];
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)];
135
155
  }
136
156
 
137
157
  // The distance from a line's top to its baseline: pdf-lib's ascender, which
@@ -144,38 +164,60 @@ export let ascOf = (font, size) => font.heightAtSize(size, { descender: false })
144
164
  /** @type {(font: any, text: string, size: number) => number} */
145
165
  export let width = (font, text, size) => font.widthOfTextAtSize(text, size);
146
166
 
147
- // Printable ASCII, which every face encodes and which almost all report text
148
- // is. Asking the face instead costs a full measurement per atom.
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.
149
169
  let ASCII = /^[\x20-\x7E]*$/;
150
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
+
151
190
  /**
152
- * Replace characters a face cannot encode with `?`.
191
+ * Replace characters a face cannot draw with `?`.
153
192
  *
154
- * The base-14 faces are WinAnsi and pdf-lib refuses anything outside it. Cell
155
- * text is untrusted data, so one stray character must not fail the render:
156
- * SCHEMA.md promises `?`, and full Unicode through `options.fonts`.
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.
157
197
  *
158
- * There is no way to ask a pdf-lib face what it can encode without measuring
159
- * against it, so the ASCII guard carries the common case and only text with a
160
- * character outside it pays for the answer. WinAnsi is a superset of printable
161
- * 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.
162
201
  *
163
202
  * @type {(font: any, text: string) => string}
164
203
  */
165
204
  export let printable = (font, text) => {
166
205
  if (!text || ASCII.test(text) || encodable(font, text)) return text;
167
- // WinAnsi encodability is a per-code-point question, so that is the unit to
168
- // ask it in — the grapheme clusters the rule protects are unencodable anyway.
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.
169
214
  // oxlint-disable-next-line typescript/no-misused-spread
170
- return [...text].map((char) => (encodable(font, char) ? char : "?")).join("");
215
+ return [...text].map((char) => (set.has(char.codePointAt(0) ?? -1) ? char : sub)).join("");
171
216
  };
172
217
 
173
218
  /** @type {(font: any, text: string) => boolean} */
174
219
  let encodable = (font, text) => {
175
- try {
176
- font.widthOfTextAtSize(text, 1);
177
- return true;
178
- } catch {
179
- return false;
180
- }
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));
181
223
  };
package/lib/image.js CHANGED
@@ -18,9 +18,6 @@ let PER_PX = 72 / 96;
18
18
  /** @type {(bytes: Uint8Array, at: number) => number} */
19
19
  let word = (bytes, at) => (bytes[at] << 8) | bytes[at + 1];
20
20
 
21
- /** @type {(bytes: Uint8Array) => { w: number, h: number }} */
22
- let pngSize = (bytes) => ({ w: word(bytes, 18), h: word(bytes, 22) });
23
-
24
21
  /** @type {(code: number) => boolean} */
25
22
  let inSof = (code) => code >= 0xc0 && code <= 0xcf;
26
23
 
@@ -42,9 +39,6 @@ let jpegSize = (bytes) => {
42
39
  return { w: 0, h: 0 };
43
40
  };
44
41
 
45
- /** @type {(bytes: Uint8Array, format: string) => { w: number, h: number }} */
46
- let pixels = (bytes, format) => (format === "png" ? pngSize(bytes) : jpegSize(bytes));
47
-
48
42
  /**
49
43
  * The image's intrinsic size in points. The format is the engine's sniff,
50
44
  * riding on the event, so nothing here decides it a second time.
@@ -54,7 +48,8 @@ let pixels = (bytes, format) => (format === "png" ? pngSize(bytes) : jpegSize(by
54
48
  * @returns {{ w: number, h: number }} The size, in points.
55
49
  */
56
50
  export let intrinsic = (bytes, format) => {
57
- let { w, h } = pixels(bytes, format);
51
+ // A PNG carries the two numbers in its IHDR at a fixed offset.
52
+ let { w, h } = format === "png" ? { w: word(bytes, 18), h: word(bytes, 22) } : jpegSize(bytes);
58
53
  // The engine vouched for the magic numbers, not for the rest of the file:
59
54
  // a truncated header reaches here as a zero, and failing loudly beats
60
55
  // drawing an image with no size (SCHEMA.md, "Image item").
package/lib/index.d.ts CHANGED
@@ -26,8 +26,6 @@ export interface PdfFontFamily {
26
26
  /** Host controls, taken and validated at the factory call. */
27
27
  export interface PdfOptions {
28
28
  page?: PdfPage;
29
- /** Base font size in points — the size styles override per element. Default `10`. */
30
- baseSize?: number;
31
29
  meta?: PdfMeta;
32
30
  /**
33
31
  * TrueType families to embed (subset), selected from styles by