@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 +93 -0
- package/README.md +6 -5
- package/lib/balance.js +1 -1
- package/lib/box.js +111 -0
- package/lib/canvas.js +38 -24
- package/lib/fonts.js +71 -29
- package/lib/image.js +2 -7
- package/lib/index.d.ts +0 -2
- package/lib/index.js +60 -62
- package/lib/layout.js +445 -140
- package/lib/outline.js +18 -22
- package/lib/style.js +25 -12
- package/lib/text.js +39 -22
- package/package.json +4 -4
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).
|
|
101
|
-
leading is 1.4× a line's
|
|
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
|
|
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
|
|
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.
|
|
27
|
-
//
|
|
28
|
-
// take
|
|
29
|
-
// page
|
|
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
|
|
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
|
-
/**
|
|
39
|
-
|
|
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
|
-
*
|
|
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) => !!
|
|
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
|
-
|
|
109
|
-
|
|
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['
|
|
213
|
-
let
|
|
214
|
-
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
|
+
});
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
148
|
-
// 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.
|
|
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
|
|
191
|
+
* Replace characters a face cannot draw with `?`.
|
|
153
192
|
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
-
|
|
168
|
-
|
|
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) => (
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|