@reportwright/pdf 0.0.0-stage → 0.1.0-beta.1

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/README.md CHANGED
@@ -1,3 +1,773 @@
1
- # Temporary Holding Version
1
+ # @reportwright/pdf
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A streaming PDF writer for Node and browsers, with no runtime dependencies. Pages are written to your sink as
4
+ they end, so memory stays flat however many pages you write. It is the writer inside ReportWright, extracted.
5
+
6
+ ## Quickstart
7
+
8
+ ```js
9
+ import fs from 'node:fs';
10
+ import { createPdf } from '@reportwright/pdf';
11
+
12
+ const pdf = createPdf(fs.createWriteStream('hello.pdf'), { title: 'Hello', creationDate: new Date('2026-01-01') });
13
+ const bold = pdf.standardFont('Helvetica-Bold');
14
+ const page = pdf.addPage({ width: 595.28, height: 841.89 }); // A4, in points
15
+ page.text('Hello, world', { x: 72, y: 72, font: bold, size: 24, color: '#1f4e79' });
16
+ page.line(72, 104, 523, 104, { color: 0.6, width: 0.5 });
17
+ await page.end();
18
+ await pdf.end(); // ends the stream too
19
+ ```
20
+
21
+ In memory: `const bytes = await toBytes((pdf) => { ... }, options)`.
22
+
23
+ ## Coordinates
24
+
25
+ PDF points (1/72 inch), **origin at the top-left corner of the page, y growing downwards** (as in CSS and canvas;
26
+ PDF itself counts from the bottom-left — the library converts). For `page.text`, `y` is the **top of the line box**:
27
+ the baseline sits at `y + font.ascent * size`.
28
+
29
+ ## API
30
+
31
+ ### `createPdf(sink, options?)`
32
+
33
+ `sink` is a Node `Writable`, a WHATWG `WritableStream`, or any `{ write(bytes) }` (its return value is awaited if it
34
+ is a promise). Back-pressure is honoured: a Node stream's `false` waits for `'drain'`; a `WritableStream` waits for its
35
+ writer to be ready. `pdf.end()` ends a Node stream and closes a `WritableStream`; a plain `{ write }` sink gets
36
+ `close()` called if it has one.
37
+
38
+ | option | meaning |
39
+ |---|---|
40
+ | `title`, `author`, `subject`, `keywords` | document info, mirrored in the XMP metadata (every value escaped) |
41
+ | `metadata` | `true` (default): an XMP metadata stream mirrors the info. `false`: the Info dictionary only, no XMP (a one-page invoice goes from 2.5 KB to 1.6 KB). Refused with `pdfa` or `tagged`: PDF/A and PDF/UA require the XMP |
42
+ | `creationDate` | a `Date` (default: now). With the same input and date the output is byte-for-byte identical |
43
+ | `compress` | `true` (default): content, fonts, object streams and the xref stream are Flate-compressed |
44
+ | `pdfa` | `true`: PDF/A-2b (sRGB output intent bundled, XMP identification). Fonts must be embedded. `{ part, conformance, outputIntent }`: a level (see PDF/A levels) and/or your RGB or CMYK output profile instead of sRGB (a CMYK intent allows CMYK colour and refuses RGB) |
45
+ | `tagged` | `{ lang: 'en-GB' }`: PDF/UA-1 — a structure tree (`page.tag`), `title` required, fonts embedded |
46
+ | `signal`, `timeoutMs` | stop the export: pending and later calls reject with the signal's reason / a `TimeoutError` |
47
+ | `id` | the file identifier (32 hex digits); default: a hash of the file's bytes (random when encrypting) |
48
+ | `encrypt` | password protection, AES-256 or AES-128 (see Encryption) |
49
+ | `sign` | `true` or `{ hash }`: make the PDF signable with `pdf.sign` (see Signatures) |
50
+
51
+ ### Fonts
52
+
53
+ - `pdf.standardFont(name)` — one of the 14 standard fonts (`Helvetica`, `Helvetica-Bold`, `Helvetica-Oblique`,
54
+ `Helvetica-BoldOblique`, `Times-Roman`, `Times-Bold`, `Times-Italic`, `Times-BoldItalic`, `Courier`,
55
+ `Courier-Bold`, `Courier-Oblique`, `Courier-BoldOblique`, `Symbol`, `ZapfDingbats`). Not embedded; text is WinAnsi
56
+ (Latin-1 plus €, curly quotes, dashes…). Not allowed with `pdfa` or `tagged`.
57
+ - `await pdf.embedFont(bytes, { subset, shaper })` — a TrueType (`glyf`) or OpenType/CFF (`.otf`) font. Any Unicode
58
+ text (Identity-H with a ToUnicode map, so text can be copied and searched). TrueType embeds as `FontFile2`
59
+ (CIDFontType2); OpenType/CFF as `FontFile3 /Subtype /OpenType` (CIDFontType0; `pdffonts` says "CID Type 0C (OT)").
60
+ `subset`: `true` (default) keeps only the glyphs used, both kinds built in (CFF: unused glyphs' charstrings become
61
+ `endchar`, subroutines are kept, glyph IDs stay; a font using the deprecated `seac` accents embeds whole; a
62
+ CID-keyed CFF gets an identity charset so every reader maps its glyphs alike); `false` embeds the whole font; or a
63
+ function `(font, codePoints, { glyphs }) => Promise<Uint8Array>` (e.g. a HarfBuzz subsetter, which also
64
+ desubroutinises CFF) that must keep glyph IDs. `shaper`: see Shaping.
65
+ A variable font (an `fvar` table) is drawn at its default instance, which may be its thinnest weight (Noto Sans JP's
66
+ is Thin), with a warning naming the axes' values: for another weight, embed a static instance of it (fonttools
67
+ `varLib.instancer`, or `hb-subset --instance`).
68
+ - `font.widthOfText(text, size, { kerning, characterSpacing, wordSpacing, horizontalScaling, direction })` — the width
69
+ text is drawn at (kerned, shaped, spaced); `font.widthOf(text, size)` — plain advances; `font.lineHeight(size)` —
70
+ ascent − descent + line gap (1.2 × size for standard fonts); `font.ascent`, `font.descent` (fractions of the size).
71
+
72
+ **Kerning** is on by default: GPOS pair adjustment (formats 1 and 2, extension lookups too) of the `kern` feature,
73
+ else the legacy `kern` table (format 0). It is applied in measuring and drawing (TJ arrays); `kerning: false` turns it
74
+ off. Other GPOS positioning (marks, cursive) needs a shaper. The standard fonts are **not kerned** (the bundled AFM
75
+ metrics have no kerning pairs).
76
+
77
+ Characters a font has no glyph for are reported in `pdf.end()`'s `warnings` (standard fonts drop them; embedded fonts
78
+ draw the font's "missing" box, or drop it in PDF/A and PDF/UA, which forbid it).
79
+
80
+ ### Pages
81
+
82
+ `const page = pdf.addPage({ width, height, cropBox, bleedBox, trimBox, artBox, rotate, userUnit })` (default A4).
83
+ `page.number` is 1-based. The boxes are `[x, y, width, height]` in the page's top-left space, inside the page (the
84
+ MediaBox); `trimBox` and `artBox` must lie inside `bleedBox` when both are given (`pdfinfo -box` shows them). `rotate`
85
+ (0, 90, 180, 270) turns the page clockwise **as viewers show it**: drawing coordinates stay those of the unrotated page
86
+ (top-left of the MediaBox), so a page added with `{ width: 842, height: 595, rotate: 90 }` is drawn in landscape and
87
+ shown in portrait. `userUnit` (PDF 1.6, not in PDF/A-1) sets the default unit in 1/72 inch. A bad box throws and adds
88
+ no page.
89
+
90
+ - `page.text(str, { x, y, font, size = 12, color, … })` — one line (options below).
91
+ - `page.textBox(str, { x, y, width, height, font, size, align, lineHeight, maxLines, ellipsis, … })` — paragraphs
92
+ (split at `\n`) wrapped by word, a word longer than the line broken inside; `align`: `left` (default; `right` for
93
+ `direction: 'rtl'`), `right`, `center`, `justify` (word spacing: `Tw` for standard fonts, TJ adjustments at spaces
94
+ for embedded fonts; a paragraph's last line is not stretched). `maxLines` ends the last line with `…` (`...` when
95
+ no font has it) when text is left; `height` stops at the last whole line that fits, without an ellipsis, so the
96
+ rest can go on in another box. Returns `{ height, lines, overflow }` (`overflow`: the text not drawn, `''` when it
97
+ all fit). No hyphenation. At most 10,000,000 characters and 100,000 lines a box (the rest is overflow); layout is
98
+ linear in the text it reads and stops once the visible lines are known.
99
+ - `page.line(x1, y1, x2, y2, { color, width = 1, dash, opacity, blendMode })` — `dash`: `[on, off]`.
100
+ - `page.rect(x, y, w, h, { fill, stroke, width = 1, dash, opacity, fillOpacity, strokeOpacity, blendMode })` — fill, stroke or both (default: fill black). Opacity and blend mode work as in `page.fill` (an ExtGState inside `q`/`Q`; refused in PDF/A-1).
101
+ - `page.link(x, y, w, h, target, { alt, highlight, border, quadPoints, allowSchemes })` — see Links below.
102
+ - `page.tag(type, fn, options)` — when `tagged`: what `fn` draws becomes a structure element of `type`
103
+ (`P`, `H1`…`H6`, `Table`, `TR`, `TH`, `TD`, `L`, `LI`, `Link`, `Figure`…). Tags nest. Anything drawn outside a tag is
104
+ marked as an artifact (decoration). Without `tagged` it just calls `fn`. See Tagged PDF.
105
+ - `page.artifact({ type, subtype }, fn)` — what `fn` draws is a typed artifact: `type` `Pagination` (with `subtype`
106
+ `Header`, `Footer` or `Watermark`), `Layout` or `Page`. Outside any tag.
107
+ - `page.layer(layer, fn)` — what `fn` draws belongs to a layer (see Layers).
108
+ - `await page.end()` — the page is compressed and written; nothing of it stays in memory.
109
+
110
+ Text options (`page.text` and `page.textBox`):
111
+
112
+ | option | meaning |
113
+ |---|---|
114
+ | `kerning` | `true` (default) / `false` |
115
+ | `characterSpacing` | points after every glyph (`Tc`) |
116
+ | `wordSpacing` | points at every space (`Tw` for standard fonts, whose space is the one-byte code 32; TJ adjustments for embedded Identity-H fonts) |
117
+ | `horizontalScaling` | percent (`Tz`, default 100) |
118
+ | `rise` | points the baseline is raised (`Ts`; negative lowers it): super- and subscripts |
119
+ | `renderMode` | 0–7 or `'fill'`, `'stroke'`, `'fillStroke'`, `'invisible'`, `'fillClip'`, `'strokeClip'`, `'fillStrokeClip'`, `'clip'` (`Tr`). The clipping modes add the glyphs to the clip until the enclosing `page.restore()`, so wrap them in `save()`/`restore()`. `strokeColor`, `strokeWidth` (default: `color`, 1) for the stroking modes |
120
+ | `rotate` | radians, clockwise, about (x, y) (as `page.rotate`); any transform (`page.rotate`, `scale`, `skew`) applies to text too |
121
+ | `fallback` | `[fontB, fontC]`: characters the font lacks are drawn in the first of these that has them, a run at a time (white space stays in the run it is in). Each run keeps its ToUnicode, so tagged PDFs stay extractable |
122
+ | `direction` | `'rtl'`: the text is one right-to-left run (passed to the shaper; without one, glyphs reversed). A run of only right-to-left letters (Arabic, Hebrew) in a line is drawn right to left by itself. There is **no Unicode bidi algorithm** (UAX #9): mixed-direction text must be split into runs in visual order by the caller |
123
+
124
+ ### Shaping
125
+
126
+ `pdf.embedFont(bytes, { shaper })`: text in that font goes through `shaper(text, font, { direction, kerning })`, which
127
+ returns the glyphs in visual order, in font units: `[{ g, cl, ax, dx, dy }]` (glyph ID, cluster = UTF-16 index of
128
+ its first character, x advance, offsets: what HarfBuzz's `hb_shape` gives). So Arabic, Indic and other complex
129
+ scripts draw correctly. The glyphs are drawn by ID with their advances and offsets (TJ adjustments, `Ts` for vertical
130
+ offsets); copied and extracted text is the source text, once, in logical order (checked with Acrobat-style
131
+ ActualText readers, poppler and pdf.js): a cluster of several glyphs (a conjunct, a reordered matra, a letter and its
132
+ dots) has its text on one glyph and WJ (U+2060) on the others, which are drawn after the run or inside an
133
+ `/ActualText` span, and a glyph that stands for different text in different places gets a code for each (a
134
+ `CIDToGIDMap`; a CFF font cannot, so there it gets an ActualText span). The shaper's output is checked (glyph IDs in
135
+ the font, finite numbers, clusters inside the text, at most 4 glyphs a character).
136
+
137
+ An adapter for harfbuzzjs v1 (`npm i harfbuzzjs`; not a dependency of this package). The same file ships in the package
138
+ as `examples/harfbuzz-shaper.mjs`:
139
+
140
+ ```js
141
+ import * as hb from 'harfbuzzjs';
142
+ function harfbuzzShaper(bytes) {
143
+ const font = new hb.Font(new hb.Face(new hb.Blob(bytes))); // positions in font units
144
+ return (text, _font, { direction, kerning }) => {
145
+ const buf = new hb.Buffer();
146
+ buf.addText(text); // clusters are UTF-16 indices
147
+ buf.guessSegmentProperties(); // script and language from the text
148
+ buf.setDirection(direction === 'rtl' ? hb.Direction.RTL : hb.Direction.LTR);
149
+ hb.shape(font, buf, kerning ? [] : [new hb.Feature('kern', 0)]);
150
+ const pos = buf.getGlyphPositions();
151
+ return buf.getGlyphInfos().map((g, i) => ({ g: g.codepoint, cl: g.cluster, ax: pos[i].xAdvance, dx: pos[i].xOffset, dy: pos[i].yOffset }));
152
+ };
153
+ }
154
+
155
+ const bytes = fs.readFileSync('NotoSansDevanagari-Regular.ttf');
156
+ const hindi = await pdf.embedFont(bytes, { shaper: harfbuzzShaper(bytes) });
157
+ page.text('किताब हिन्दी', { x: 40, y: 40, font: hindi, size: 18 });
158
+ page.textBox(longText, { x: 40, y: 80, width: 300, font: inter, fallback: [hindi], align: 'justify' });
159
+ ```
160
+
161
+ Colours (fills, strokes, text, gradient stops):
162
+
163
+ | form | colour space |
164
+ |---|---|
165
+ | a gray level `0`–`1` | DeviceGray |
166
+ | `'#rgb'`, `'#rrggbb'`, `'rgb(255, 128, 0)'`, `'rgb(100% 50% 0%)'`, `[r, g, b]` (0–1) | DeviceRGB |
167
+ | `cmyk(c, m, y, k)` (import it) or `[c, m, y, k]` (0–1) | DeviceCMYK |
168
+ | `pdf.spotColor(name, alternate)`, or `.tint(t)` of it | Separation: a named ink, shown as its device `alternate` (gray, RGB or CMYK) at full tint, lighter tints towards white |
169
+ | `(await pdf.iccColorSpace(profileBytes)).color(...components)` | ICCBased: a gray, RGB or CMYK ICC profile (its header is checked; `/N` comes from it) |
170
+
171
+ ```js
172
+ import { createPdf, cmyk } from '@reportwright/pdf';
173
+ const pantone = pdf.spotColor('PANTONE 185 C', cmyk(0, 0.91, 0.76, 0)); // written as /PANTONE#20185#20C
174
+ page.rect(40, 40, 100, 20, { fill: pantone }); // full tint
175
+ page.rect(40, 70, 100, 20, { fill: pantone.tint(0.4) });
176
+ const p3 = await pdf.iccColorSpace(fs.readFileSync('Display P3.icc'));
177
+ page.text('Vivid', { x: 40, y: 100, font, color: p3.color(1, 0.2, 0.1) });
178
+ ```
179
+
180
+ A spot colour's name is written exactly (PDF 1.7 §7.3.5: white space, delimiters, `#` and non-ASCII bytes as `#xx`, so
181
+ `PANTONE 185 C` is `/PANTONE#20185#20C`, which every reader and RIP decodes back to `PANTONE 185 C`; at most 127 bytes). One name has one definition per document. A gradient's stops share one colour
182
+ space: gray and RGB mix (as RGB), otherwise all CMYK, all tints of one spot colour, or all from one ICC space.
183
+
184
+ **PDF/A** needs device colour to match the output intent (ISO 19005-2, 6.2.4.3, as veraPDF checks): with the default
185
+ sRGB intent, CMYK colour (and CMYK spot alternates, CMYK gradients and CMYK JPEGs) throws a clear error; with
186
+ `pdfa: { outputIntent: cmykProfile }`, RGB colour throws instead. Gray, spot colours with a matching alternate and
187
+ ICC-based colours are allowed with either.
188
+
189
+ ### Vector graphics
190
+
191
+ Paths are built as with the Canvas 2D API, in the same top-left, y-down space (so `rotate` and arc angles, in
192
+ radians, turn **clockwise** on the page, as in canvas). A path is not tied to a page; draw it with `fill`, `stroke`,
193
+ `fillAndStroke` or use it to `clip`.
194
+
195
+ ```js
196
+ const p = page.path()
197
+ .moveTo(40, 40).lineTo(140, 40)
198
+ .bezierCurveTo(160, 40, 160, 80, 140, 80).quadraticCurveTo(90, 120, 40, 80)
199
+ .closePath();
200
+ page.fillAndStroke(p, { fill: '#cfe2f3', stroke: '#1f4e79', width: 2, join: 'round' });
201
+
202
+ page.fill(page.path().roundedRect(40, 140, 200, 60, [12, 12, 0, 0]), { fill: page.linearGradient(40, 0, 240, 0, [[0, '#1f4e79'], [0.6, '#5b9bd5'], [1, '#ffffff']]) });
203
+
204
+ page.save(); // transform, clip and styles until restore()
205
+ page.translate(300, 200).rotate(Math.PI / 6);
206
+ page.clip(page.path().arc(0, 0, 50, 0, 2 * Math.PI));
207
+ page.shade(page.radialGradient(0, 0, 0, 0, 0, 50, [[0, '#fff'], [1, '#c00']])); // paint the clip area
208
+ page.restore();
209
+
210
+ page.svgPath('M10 300 h 80 a 20 20 0 0 1 0 40 h -80 z', { fill: '#e2efda', stroke: 0, dash: [4, 2] });
211
+ page.figure({ alt: 'Revenue up 12% on last year' }, () => page.fill(page.path().rect(40, 380, 120, 30), { fill: '#5b9bd5', opacity: 0.8 }));
212
+ ```
213
+
214
+ - `page.path()` → a `Path`: `moveTo`, `lineTo`, `bezierCurveTo`, `quadraticCurveTo` (written as a cubic), `arc(x, y, r,
215
+ start, end, ccw?)`, `arcTo(x1, y1, x2, y2, r)`, `ellipse(x, y, rx, ry, rotation, start, end, ccw?)`, `rect`,
216
+ `roundedRect(x, y, w, h, radii)` (one radius or canvas roundRect's 1–4 per-corner forms, scaled down when they do not
217
+ fit), `closePath`, `svg(d)`. Each returns the path. At most 1,000,000 segments.
218
+ - `page.fill(path, o)`, `page.stroke(path, o)`, `page.fillAndStroke(path, o)`, `page.svgPath(d, o)` (fill, stroke or
219
+ both by which of `fill`/`stroke` you give; neither: filled black). Options:
220
+
221
+ | option | meaning |
222
+ |---|---|
223
+ | `fill`, `stroke` (or `color`) | a colour, a gradient or a tiling pattern (default black) |
224
+ | `rule` | `'nonzero'` (default) or `'evenodd'` |
225
+ | `width`, `dash`, `dashPhase` | stroke width (default 1); dash lengths (at most 100, not all 0) and their phase |
226
+ | `cap`, `join`, `miterLimit` | `'butt'`/`'round'`/`'square'`; `'miter'`/`'round'`/`'bevel'`; ≥ 1 (default 10) |
227
+ | `opacity`, `fillOpacity`, `strokeOpacity` | 0–1 (`opacity` multiplies both) |
228
+ | `blendMode` | any of the 16 PDF modes: `Normal`, `Multiply`, `Screen`, `Overlay`, `Darken`, `Lighten`, `ColorDodge`, `ColorBurn`, `HardLight`, `SoftLight`, `Difference`, `Exclusion`, `Hue`, `Saturation`, `Color`, `Luminosity` (or canvas names: `'color-dodge'`…) |
229
+
230
+ Styles are per call (an option left out is its default), so draws do not leak into each other. Opacity and blend
231
+ modes go into one ExtGState per distinct value in the document, and give the page a transparency group (as PDF/A-2
232
+ asks).
233
+ - `page.clip(path, rule?)` — intersect the clip with the path until the enclosing `restore()`.
234
+ - `page.save()`, `page.restore()` — nest at most 1000 deep (27 in PDF/A, whose limit is 28). A `restore()` without a
235
+ `save()` throws; saves still open when the page ends are closed there, with a warning in `pdf.end()`'s result.
236
+ - `page.translate(x, y)`, `page.rotate(angle)`, `page.scale(sx, sy = sx)`, `page.skew(ax, ay = 0)`,
237
+ `page.transform(a, b, c, d, e, f)` — as canvas; they apply to everything drawn after (text too) until `restore()`.
238
+ - `page.linearGradient(x0, y0, x1, y1, stops, { extend })`, `page.radialGradient(x0, y0, r0, x1, y1, r1, stops,
239
+ { extend })` — `stops`: `[[offset, colour], …]` or `[{ offset, color }, …]` (1–256, sorted, offsets clamped to
240
+ 0–1, equal offsets make a hard stop; colours in one space: see Colours). Use as a `fill`/`stroke`, or `page.shade(gradient)` to paint the
241
+ current clip. Gradients follow the transform current when they are painted.
242
+ - `page.tilingPattern({ width, height, spacing }, (cell) => …)` — `cell` has the page's drawing methods in the cell's
243
+ own top-left space (shapes, gradients, nested patterns; not text or links); cells repeat every `width + spacing`.
244
+ With no spacing the cell's `/BBox` is written 0.001 pt larger than the step: PDFium (Chrome, Edge) otherwise draws a
245
+ cell whose `/BBox` equals `/XStep` × `/YStep` one device pixel too far apart (a 20 pt cell every 21 pt at 72 dpi).
246
+ - `page.figure({ alt }, fn)` — tagged PDFs: what `fn` draws is a `Figure` with that alt text (required). Graphics drawn
247
+ outside any tag are artifacts, so a tagged page of charts and rules stays PDF/UA-1 clean. Untagged, it just runs `fn`.
248
+
249
+ Gradients, patterns, fonts, pages, images, spot colours and ICC colour spaces are handles: only ones made by this document are accepted (a copy, a
250
+ hand-made `{ type: 'gradient', … }` or one from another PDF throws). NaN, Infinity and numbers of 1e9 or more
251
+ throw before anything is drawn; malformed SVG path data throws a `SyntaxError` naming the position.
252
+
253
+ ### Images
254
+
255
+ ```js
256
+ const photo = await pdf.embedImage(fs.readFileSync('team.jpg')); // { width, height } in pixels
257
+ page.image(photo, { x: 40, y: 40, width: 200 }); // height from the aspect ratio
258
+ page.image(photo, { x: 260, y: 40, width: 120, height: 120, fit: 'cover', alt: 'The team at the 2026 offsite' });
259
+ const logo = await pdf.embedImage(fs.readFileSync('logo.png')); // alpha → a soft mask
260
+ for (const y of [400, 500, 600]) page.image(logo, { x: 40, y, height: 24, opacity: 0.6 }); // written once
261
+ ```
262
+
263
+ - `await pdf.embedImage(bytes, { colorSpace, maxDecodedBytes })` — a JPEG or PNG, written to the file at once as an image XObject;
264
+ returns a handle with `width` and `height` (pixels, after the EXIF orientation). `colorSpace`: an ICC colour space
265
+ (`pdf.iccColorSpace`) with the image's number of components, instead of the device space. `maxDecodedBytes`: the
266
+ decoded size allowed for a PNG that needs decoding (default 64 MB, 67,108,864 bytes).
267
+ - **JPEG**: baseline and progressive, gray, RGB and CMYK, embedded unchanged (DCTDecode, no re-encoding). Adobe's
268
+ inverted CMYK (an APP14 "Adobe" segment) is drawn with `/Decode [1 0 1 0 1 0 1 0]`. The EXIF orientation (1–8)
269
+ is honoured. 12-bit, lossless, hierarchical and arithmetic-coded JPEGs are refused.
270
+ - **PNG**: gray, RGB, palette, gray + alpha, RGBA; 1, 2, 4, 8 and 16 bits; Adam7 interlacing; tRNS. Without alpha
271
+ and not interlaced, the compressed data is copied through (FlateDecode with the PNG predictor). Alpha (and a
272
+ palette's or a 16-bit colour key's tRNS) becomes a soft mask (`/SMask`); an 8-bit-or-less colour key a `/Mask`;
273
+ interlaced images are de-interlaced. Those are re-compressed. Other chunks (gamma, ICC, text, eXIf) are ignored.
274
+ - Limits: each side ≤ 65,535 pixels, ≤ 100 megapixels, ≤ 256 MB of file, and ≤ 64 MB decoded (`maxDecodedBytes`)
275
+ for a PNG that needs decoding (alpha or interlacing). Such a PNG is decoded and re-compressed a row at a time: a
276
+ non-interlaced one is never held whole (memory: a strip of rows plus the compressed result), an interlaced one is
277
+ held once, de-interlaced. A JPEG is cut at the EOI that ends its image data and a passed-through PNG at the end of
278
+ its zlib stream: bytes appended after them are not embedded. Truncated or corrupt files, lying chunk or marker lengths, bad CRCs and
279
+ data that inflates past its header's size (a zip bomb) throw a clear error.
280
+ - `page.image(img, { x, y, width, height, fit, opacity, alt })` — top-left `(x, y)` (default 0, 0). Only `width` or
281
+ `height`: the other keeps the aspect ratio; neither: one point per pixel. `fit`: `'fill'` (default: stretch to the
282
+ box), `'contain'` (all of the image, centred), `'cover'` (fills the box, centred, cut to it). `opacity` 0–1. Tagged
283
+ PDFs: `alt` makes the image a `Figure` with that alternate text; without `alt` it is an artifact (decoration).
284
+ - An image drawn any number of times, on any pages, is one XObject in the file. Images are always XObjects, never
285
+ inline images: an inline image would be repeated in every content stream and cannot have a soft mask.
286
+ - Image handles, like fonts and gradients, only work in the PDF that made them.
287
+
288
+ ### Links and destinations
289
+
290
+ A destination is a page and a fit, in the top-left space: `{ page, fit, left, top, right, bottom, zoom }` where `fit`
291
+ is `XYZ` (left, top, `zoom`: 1 = 100%), `Fit`, `FitH` (top), `FitV` (left), `FitR` (left, top, right, bottom), `FitB`,
292
+ `FitBH`, `FitBV`. Without `fit`: `XYZ` when left, top or zoom is given, else `Fit`. `page` is 1-based or a page object
293
+ (it may not exist yet).
294
+
295
+ ```js
296
+ pdf.destination('appendix', 12, { top: 80 }); // a named destination
297
+ page.link(40, 100, 120, 14, { dest: 'appendix' }, { alt: 'Go to the appendix' });
298
+ page.link(40, 120, 120, 14, { page: 3, fit: 'FitH', top: 200 }, { highlight: 'push' });
299
+ page.link(40, 140, 120, 14, { url: 'https://example.com/report' }, { border: { width: 1, style: 'dashed', dash: [3, 2], color: '#0645ad' } });
300
+ page.link(40, 160, 120, 14, { named: 'NextPage' });
301
+ ```
302
+
303
+ - `page.link(x, y, w, h, target, o)` — `target`: `{ url }`, `{ page, …fit }`, `{ dest: name }` or `{ named:
304
+ 'NextPage' | 'PrevPage' | 'FirstPage' | 'LastPage' }`. Options: `alt` (what screen readers say; default the URL or
305
+ "Page n"), `highlight` (`none`, `invert`, `outline`, `push`), `border` (`{ width, style: solid | dashed | beveled |
306
+ inset | underline, dash, color }`; default none), `quadPoints` (rectangles the link covers, e.g. a wrapped line),
307
+ `allowSchemes`.
308
+ - `pdf.destination(name, page, fit)` — written at the end to the catalog's `Names` tree as a balanced name tree (sorted,
309
+ 64 a node, `Kids` and `Limits` when larger). Names are unique (a second definition throws), at most 1,024 UTF-8 bytes
310
+ and 100,000 a document; one that is used but never defined, or points past the last page, is a warning.
311
+
312
+ **No active content.** A library that writes PDFs must not be a vector for it, so there is **no way to write a
313
+ JavaScript, Launch, GoToR, GoToE, SubmitForm, ImportData or any other action**: link targets and the open action are
314
+ destinations (plus the four navigation actions above), and anything else throws. URLs are limited to `http`, `https`
315
+ and `mailto` by default; `allowSchemes: ['tel']` widens that per link, but `javascript:`, `vbscript:`, `data:` and
316
+ `file:` are never written, however spelled. A URL with a control character is refused (browsers drop them, which
317
+ hides schemes like `java\tscript:`); the rest is normalised by the WHATWG URL parser, percent-encoded (quotes,
318
+ parentheses and angle brackets too) and written as a hex string. Every caller's string (contents, titles, names) is a
319
+ hex string and every name is escaped, so no text can inject PDF syntax.
320
+
321
+ ### Outline, page labels and viewer
322
+
323
+ - `pdf.outline({ title, page | dest, level, open, color, bold, italic, …fit })` (or an array) — bookmarks. `level`
324
+ (0–63) nests: an entry is the child of the nearest one before it at a lower level. `open: false` shows it closed.
325
+ `color`: RGB or gray. At most 100,000 entries, titles of 4,096 characters.
326
+ - `pdf.pageLabels([{ start, style, prefix, first }])` — labels viewers show for pages from the 0-based index `start`:
327
+ `style` `D` (1, 2, 3), `r`/`R` (i, ii / I, II), `a`/`A` (a, b / A, B) or none (the prefix alone), `prefix`, `first` (the
328
+ first number). Page 0 gets decimal labels when no range starts there.
329
+ ```js
330
+ pdf.pageLabels([{ start: 0, style: 'r' }, { start: 4, style: 'D' }, { start: 40, style: 'A', prefix: 'Appendix ' }]);
331
+ ```
332
+ - `pdf.viewer({ … })` — `hideToolbar`, `hideMenubar`, `hideWindowUI`, `fitWindow`, `centerWindow`, `displayDocTitle`
333
+ (always true when tagged), `direction` (`L2R`/`R2L`), `printScaling` (`None`/`AppDefault`), `duplex` (`Simplex`,
334
+ `DuplexFlipShortEdge`, `DuplexFlipLongEdge`), `pickTrayByPDFSize`, `numCopies` (1–5); `pageMode` (`UseNone`,
335
+ `UseOutlines`, `UseThumbs`, `FullScreen`, `UseOC`, `UseAttachments`); `pageLayout` (`SinglePage`, `OneColumn`,
336
+ `TwoColumnLeft`, `TwoColumnRight`, `TwoPageLeft`, `TwoPageRight`); `openAt: { page, …fit }` (the open action: a
337
+ destination only). Unknown options throw.
338
+
339
+ ### Annotations
340
+
341
+ Each annotation method takes a box in the page's top-left space and returns a handle. Shared options: `color` (gray,
342
+ RGB or CMYK), `contents` (its text; required in tagged PDFs, where it is the description), `author` (`/T`),
343
+ `modified` (a Date), `flags` (`{ print, hidden, noView, locked, readOnly, noZoom, noRotate, … }`; default print).
344
+ Every annotation gets an **appearance stream** drawn by the library, so it looks the same in every viewer and passes
345
+ PDF/A (which needs one, and printed, visible annotations: PDF/A refuses `hidden`, `noView` or `print: false`).
346
+
347
+ ```js
348
+ const n = page.note(500, 60, { contents: 'Check this figure', author: 'Ada', icon: 'Comment', open: true });
349
+ page.note(525, 60, { contents: 'Done', author: 'Bo', replyTo: n });
350
+ page.highlight([{ x: 40, y: 100, width: 300, height: 16 }, { x: 40, y: 116, width: 120, height: 16 }], { contents: 'Key result' });
351
+ page.freeText('Sign here', { x: 40, y: 200, width: 120, height: 30, font, size: 12, background: '#fff8c4', border: { width: 1 } });
352
+ page.markup('Line', { x1: 40, y1: 260, x2: 200, y2: 300 }, { lineEndings: ['None', 'OpenArrow'], color: '#c00', contents: 'This one' });
353
+ page.stamp({ x: 380, y: 700, width: 160, height: 44, name: 'Approved', font });
354
+ page.attachFile(csvBytes, { x: 560, y: 60, name: 'figures.csv', mimeType: 'text/csv', description: 'The data' });
355
+ ```
356
+
357
+ - `page.note(x, y, { icon, open, replyTo })` — a sticky note (Text), 20 × 20, not zoomed or rotated. `icon`: `Comment`,
358
+ `Key`, `Note`, `Help`, `NewParagraph`, `Paragraph`, `Insert`. `replyTo`: another annotation (`/IRT`).
359
+ - `page.highlight(rects)`, `page.underline`, `page.strikeOut`, `page.squiggly` — text markup over one rectangle or
360
+ several (up to 10,000; QuadPoints are computed from them). A highlight multiplies, so the text shows through.
361
+ - `page.freeText(text, { x, y, width, height, font, size, textColor, align, border, background, padding })` — text in
362
+ a box, wrapped and drawn with the package's own text.
363
+ - `page.markup(type, geometry, { interior, lineWidth, dash, lineEndings })` — `Square` and `Circle` (`{ x, y, width,
364
+ height }`), `Line` (`{ x1, y1, x2, y2 }`, `lineEndings: [start, end]`: `None`, `Square`, `Circle`, `Diamond`,
365
+ `OpenArrow`, `ClosedArrow`, `Butt`, `ROpenArrow`, `RClosedArrow`, `Slash`), `Polygon` and `PolyLine` (`[[x, y], …]`),
366
+ `Ink` (`[[[x, y], …], …]`, strokes). `color` is the line, `interior` the fill. At most 100,000 points.
367
+ - `page.stamp({ x, y, width, height, name, font })` — a standard stamp (`Approved`, `Experimental`, `NotApproved`,
368
+ `AsIs`, `Expired`, `NotForPublicRelease`, `Confidential`, `Final`, `Sold`, `Departmental`, `ForComment`, `TopSecret`,
369
+ `Draft`, `ForPublicRelease`) with its label drawn in `font`; or `draw: (ap) => …` to draw your own appearance on `ap`,
370
+ a page the size of the box.
371
+ - `page.attachFile(bytes, { x, y, name, mimeType, description, created, modified, icon, allowTypes, allowArchives })` —
372
+ a FileAttachment annotation with the file embedded (`/Params` with its size, MD5 checksum and dates). Not in PDF/A-2,
373
+ which allows only PDF/A attachments (refused with an error). The **file name is checked** as an OS and a viewer would
374
+ read it: control, format and bidi characters (`invoice\u202Efdp.exe`), Windows-invalid characters and NTFS streams
375
+ (`report.pdf:evil.exe`), dot look-alikes, a leading dot, Windows device names and more than 255 UTF-8 bytes throw;
376
+ only passive types are allowed (pdf, images, text, csv, json, Office documents without macros, ics, vcf, eml…; not
377
+ rtf, msg or xml, which can carry embedded objects or external entities; zip with `allowArchives`), so anything else
378
+ needs `allowTypes: ['ext']`, **by which you accept that type's risk** (`pdf.facturX` embeds its checked XML itself);
379
+ slash look-alikes (`docs/x.pdf`), spaces other than U+0020 and runs of 3 spaces throw; double extensions
380
+ (`invoice.pdf.scr`) throw; a `mimeType` must be one of the extension's types (`invoice.pdf` as
381
+ `application/x-msdownload` throws; with no `mimeType` none is written; a type in `allowTypes` takes any MIME type, at
382
+ your risk). `/F` holds an ASCII form, checked with the same rules.
383
+ - Tagged PDFs: each annotation becomes an `Annot` structure element (links a `Link`) with an object reference, pages
384
+ with annotations get `/Tabs /S`; veraPDF UA-1 and A-2b pass with notes, highlights, FreeText, shapes and stamps.
385
+ - At most 100,000 annotations a page.
386
+
387
+ ### Forms
388
+
389
+ `pdf.form` places interactive fields (AcroForm) on pages. Each field gets **appearance streams for every state** it can
390
+ show, drawn with the package's own text and graphics (NeedAppearances is false, so every viewer shows the same thing);
391
+ `/DA` names the field's font, and `/DR` is the shared Resources, which hold every font used. Each method returns a
392
+ field handle.
393
+
394
+ ```js
395
+ const font = await pdf.embedFont(interBytes);
396
+ const page = pdf.addPage();
397
+ const f = pdf.form;
398
+ f.textField('name', { page, x: 40, y: 40, width: 200, height: 22, font, value: 'Ada Lovelace', tooltip: 'Full name', required: true });
399
+ const address = f.group('address'); // children are address.city, address.zip
400
+ address.textField('city', { page, x: 40, y: 80, width: 200, height: 22, font, value: 'Paris', tooltip: 'City' });
401
+ f.textField('pin', { page, x: 40, y: 120, width: 80, height: 22, font, comb: true, maxLen: 4, tooltip: 'PIN' });
402
+ f.checkbox('agree', { page, x: 40, y: 160, width: 14, height: 14, checked: true, value: 'Agreed', style: 'check', tooltip: 'I agree' });
403
+ f.radioGroup('size', { value: 'M', tooltip: 'Size', buttons: ['S', 'M', 'L'].map((v, i) => ({ page, x: 40 + i * 30, y: 190, width: 14, height: 14, value: v })) });
404
+ f.comboBox('country', { page, x: 40, y: 220, width: 150, height: 22, font, options: [['FR', 'France'], ['DE', 'Germany']], value: 'DE', tooltip: 'Country' });
405
+ f.listBox('langs', { page, x: 40, y: 250, width: 150, height: 60, font, options: ['English', 'French'], value: ['French'], multiSelect: true, tooltip: 'Languages' });
406
+ f.button('clear', { page, x: 40, y: 320, width: 80, height: 24, font, label: 'Clear', action: { reset: true }, tooltip: 'Clear the form' });
407
+ f.signature('sig', { page, x: 40, y: 360, width: 200, height: 40, tooltip: 'Sign here', lock: { action: 'All' } });
408
+ ```
409
+
410
+ - Shared options: `page`, `x`, `y`, `width`, `height` (the widget's box), `border` (`{ width, color }`, default 1 pt
411
+ black; `false`: none), `background`, `tooltip` (`/TU`, also the accessible name: **required in tagged PDFs**),
412
+ `readOnly`, `required`. Text options (text, choice, button labels): `font`, `fallback` (fonts tried for characters
413
+ the font lacks), `size` (0, the default, is auto: `0 Tf` in `/DA`, and the appearance uses the largest size up to 12
414
+ that fits), `textColor`, `align` (`/Q`: `left`, `center`, `right`). A value with a character no font has **throws**.
415
+ - `textField` — `value`, `defaultValue`, `multiline`, `password` (takes no value: readers must never store a password
416
+ in the file), `comb` with `maxLen` (1–1000 cells), `maxLen`, `doNotSpellCheck`, `doNotScroll`.
417
+ - `checkbox` — `checked`, `value` (the export value, the on state's name, default `Yes`; not `Off`), `style`
418
+ (`check`, `cross`, `circle`, `square`, `diamond`, `star`, drawn in the appearance), `color` (the mark's).
419
+ - `radioGroup` — one field, a widget per entry of `buttons` (each `{ page, x, y, width, height, value }`, its own
420
+ export value), `value` (the selected one), `noToggleToOff` (default true), `radiosInUnison`, `style` (default
421
+ `circle`), `color`.
422
+ - `comboBox` (`editable`: any value; else the value must be an option's export value) and `listBox` (`multiSelect`:
423
+ several values, written with their indexes in `/I`). Options are strings or `[export, display]` pairs, written in the
424
+ given order (the Sort flag is never set); export values are unique; at most 10,000 options.
425
+ - `button` — a push button with a `label` (needs `font`) and/or an `icon` (an image from `pdf.embedImage`), a pressed
426
+ appearance (`/D`, `pressedBackground`) and an optional `action`: `{ reset: true }` (ResetForm, every field),
427
+ `{ reset: ['name', 'address.city', fieldHandle], exclude }`, or `{ named: 'NextPage' | 'PrevPage' | 'FirstPage' |
428
+ 'LastPage' }`. **Nothing else.**
429
+ - `signature` — an empty signature field (a framed widget, `/FT /Sig`) with an optional `lock` (`{ action: 'All' }`, or
430
+ `Include`/`Exclude` with `fields`), to sign into later.
431
+ - `pdf.form.group(name)` — a parent field; its children are named `name.child` and written as a parent/kids tree
432
+ (at most 32 levels).
433
+ - **Flattening**: `createPdf(sink, { form: { flatten: true } })` draws every field's appearance into its page and
434
+ writes no AcroForm and no widgets: a filled form as a plain PDF (text extracts with pdftotext). In a tagged PDF each
435
+ flattened field is a Figure whose alternate text is the tooltip and the value.
436
+ - **Tagged PDFs**: each widget is a `Form` structure element (with the tooltip as `/Alt`) holding an object reference
437
+ to it; pages with fields get `/Tabs /S`. veraPDF UA-1 and A-2b pass with every field type, and flattened.
438
+ - **PDF/A-2**: fields are allowed with their appearance streams and NeedAppearances false, but ISO 19005-2 forbids
439
+ any action on a widget (6.4.1) and ResetForm itself (6.5.1), so a button with an `action` **throws** in PDF/A;
440
+ appearance dictionaries hold `/N` only (no pressed `/D`), a push button's as a one-state subdictionary.
441
+ - **Names**: a partial name cannot contain `.` (the hierarchy separator: use `group`), control, format, bidi or
442
+ separator characters, and is at most 256 characters; full names are unique (a duplicate throws). Every name, value,
443
+ option and tooltip is written as a hex text string, on-states and export values through the name writer.
444
+ - **No JavaScript, ever.** There is no option for `/AA` (additional actions), `/JS`, format, keystroke, validate or
445
+ calculate scripts, SubmitForm or ImportData: each field takes a fixed set of options, and anything else throws
446
+ (script-like keys with a message saying why). Calculation order (`/CO`) does not apply: it only orders calculate
447
+ scripts, and there are none.
448
+ - Limits: 10,000 fields and groups a document, 1,000 buttons a radio group and 50,000 in all, 10,000 options (1,000,000
449
+ characters in all), values of 100,000 characters, tooltips and options of 4,096.
450
+ - An embedded font in a fillable field is subset to the characters drawn; for fields people will type into, embed it
451
+ with `subset: false` so a viewer has every glyph.
452
+
453
+ ### Encryption
454
+
455
+ ```js
456
+ const pdf = createPdf(sink, {
457
+ encrypt: {
458
+ userPassword: 'open me', // '' (default): opens without a prompt, the permissions still apply
459
+ ownerPassword: 'full access', // left out: a random one (nobody can lift the permissions)
460
+ algorithm: 'aes-256', // default; 'aes-128' (V4/R4) for readers older than Acrobat 9
461
+ permissions: { print: true, printHighQuality: false, modify: false, copy: false, annotate: true,
462
+ fillForms: true, accessibility: true, assemble: false }, // each defaults to true
463
+ encryptMetadata: true, // false: the XMP metadata stays readable
464
+ },
465
+ });
466
+ ```
467
+
468
+ - **AES-256** (V5/R6, ISO 32000-2, `/CFM /AESV3`) is the default; **AES-128** (V4/R4, `/CFM /AESV2`) is the option for old
469
+ readers. Every string and stream gets a fresh random 16-byte IV (`crypto.getRandomValues`), AES-CBC with PKCS#7
470
+ padding. R6 writes `/U`, `/UE`, `/O`, `/OE` and `/Perms`, and the catalog's Adobe extension level 8.
471
+ - Encryption streams like everything else: strings and streams are encrypted as their objects are written; the
472
+ `/Encrypt` dictionary and the file ID are fixed at the start. The ID is therefore **random** (it can no longer be a
473
+ hash of the content) unless you pass `id`; and an encrypted file's bytes differ on every run anyway (random IVs
474
+ and salts), as they must.
475
+ - Passwords: at most 1024 characters are accepted; AES-256 keeps 127 UTF-8 bytes after SASLprep (non-ASCII spaces
476
+ become spaces, NFKC, and control, private-use, unassigned and bidi characters are refused; the RFC 3454 bidi rule is
477
+ not applied), AES-128 keeps 32 printable Latin-1 characters.
478
+ - **PDF/A** refuses encryption (ISO 19005 forbids it). In a **tagged** PDF (PDF/UA) the accessibility permission is
479
+ forced on, with a warning.
480
+ - Runs on the platform's crypto only: WebCrypto (browsers, Node) with node:crypto as Node's fast path.
481
+
482
+ ### Signatures
483
+
484
+ ```js
485
+ import { createPdf, nodeSigner } from '@reportwright/pdf';
486
+ const pdf = createPdf(sink, { sign: true }); // or { sign: { hash: 'SHA-384' } }
487
+ const page = pdf.addPage();
488
+ const field = pdf.form.signature('approval', { page, x: 40, y: 700, width: 200, height: 40 });
489
+ await pdf.sign(field, {
490
+ signer: nodeSigner({ key: privateKeyPem, certs: certificateChainPem }), // RSA, RSA-PSS (pss: true), ECDSA P-256/P-384
491
+ reason: 'Approved', location: 'Paris', name: 'A. Signer', contactInfo: 'a@example.com',
492
+ certify: 2, // optional DocMDP: 1 no changes, 2 form filling and signing, 3 also annotations
493
+ timestamp: async (signatureValue) => tokenFromYourTsa, // optional RFC 3161 TimeStampToken (PAdES B-T)
494
+ });
495
+ await pdf.end(); // the signer runs here
496
+ ```
497
+
498
+ - **PAdES baseline B-B** (B-T with `timestamp`): CMS SignedData, detached, `/SubFilter /ETSI.CAdES.detached` (or
499
+ `subFilter: 'adbe.pkcs7.detached'`), with the signed attributes content-type, message-digest and ESS
500
+ signing-certificate-v2; signing-time only for `adbe.pkcs7.detached` (PAdES puts the time in `/M`).
501
+ - **Streaming**: the file is never held or patched. Every byte is hashed as it goes out; the signature dictionary is
502
+ the file's last object, and it, the xref and the trailer are built in memory, so the `/ByteRange` is known before
503
+ they are written: the digest of everything but `/Contents` goes to the signer, and its CMS fills `/Contents`
504
+ (hex, zero-padded to `reserve` bytes, default 16384; a CMS that does not fit is a clear error). In a browser
505
+ (no incremental SHA in WebCrypto) the file's bytes are held until the digest.
506
+ - `signer` is any `async ({ digest, hash, subFilter, date, timestamp, signal }) => cmsBytes`: an HSM, a cloud KMS or
507
+ node-forge. `cmsSigner({ certs, keyAlgorithm, sign(bytes, hash) })` builds the CMS around a raw signature function
508
+ (what a KMS gives); `nodeSigner({ key, certs, pss })` signs with node:crypto. A signer that does not answer is stopped by
509
+ `signal`/`timeoutMs`; when a timestamp is asked for, a CMS without one is refused.
510
+ - `field` is a signature field from `pdf.form.signature`, or `{ name, page, x, y, width, height }` to make one. The
511
+ AcroForm gets `/SigFlags 3`. **One signature** a document (more need incremental updates).
512
+ - Works with encryption (the signature's `/Contents` is not encrypted, as the spec requires) and with PDF/A-2b.
513
+
514
+ ### Tagged PDF
515
+
516
+ `page.tag(type, fn, options)` options: `alt` (`/Alt`), `actualText` (`/ActualText`: the text the element stands for),
517
+ `expansion` (`/E`: an abbreviation's expansion), `lang` (a language tag), `id` (`/ID`, unique, in the IDTree), and per
518
+ type: `TH` `scope` (`Row`, `Column`, `Both`); `TH`/`TD` `headers` (the ids of the TH cells that head it), `rowSpan`,
519
+ `colSpan`; `L` `listNumbering` (`None`, `Disc`, `Circle`, `Square`, `Decimal`, `UpperRoman`, `LowerRoman`,
520
+ `UpperAlpha`, `LowerAlpha`). Checked as you build: `TR` in `Table`/`THead`/`TBody`/`TFoot`, `TH`/`TD` in `TR`,
521
+ `THead`/`TBody`/`TFoot` in `Table`, `LI` in `L`, `Lbl`/`LBody` in `LI`; headings start at `H1` and never skip a level
522
+ going down (`H1` → `H3` throws; going back up is fine), and a document uses `H` or `H1`–`H6`, not both (PDF/UA 7.4.2,
523
+ 7.4.4; checked in the order you tag). A `headers` id no element has fails `pdf.end()`.
524
+
525
+ ```js
526
+ page.artifact({ type: 'Pagination', subtype: 'Header' }, () => page.text('Quarterly report', { x: 40, y: 20, font, size: 8 }));
527
+ page.tag('H1', () => page.text('Sales', { x: 40, y: 50, font, size: 20 }));
528
+ page.tag('Table', () => {
529
+ page.tag('THead', () => page.tag('TR', () => ['Region', 'Q1'].forEach((h, i) =>
530
+ page.tag('TH', () => page.text(h, { x: 40 + i * 120, y: 90, font }), { scope: 'Column', id: `h${i}` }))));
531
+ page.tag('TBody', () => page.tag('TR', () => ['North', '1,204'].forEach((v, i) =>
532
+ page.tag('TD', () => page.text(v, { x: 40 + i * 120, y: 110, font }), { headers: [`h${i}`] }))));
533
+ });
534
+ page.tag('L', () => ['First', 'Second'].forEach((t, i) => page.tag('LI', () => {
535
+ page.tag('Lbl', () => page.text(`${i + 1}.`, { x: 40, y: 140 + i * 18, font }));
536
+ page.tag('LBody', () => page.text(t, { x: 60, y: 140 + i * 18, font }));
537
+ })), { listNumbering: 'Decimal' });
538
+ page.tag('P', () => page.tag('Span', () => page.text('CFO', { x: 40, y: 190, font }), { expansion: 'Chief Financial Officer' }));
539
+ ```
540
+
541
+ ### PDF/A levels
542
+
543
+ `pdfa: { part, conformance }` (with `outputIntent` as before; `pdfa: true` is PDF/A-2b):
544
+
545
+ | level | what changes |
546
+ |---|---|
547
+ | `{ part: 1, conformance: 'b' }` | PDF 1.4: `%PDF-1.4`, a classic cross-reference table and trailer (no object or xref streams), subset fonts carry a `/CIDSet`, OpenType/CFF fonts go in as their bare CFF (`FontFile3 /CIDFontType0C`). Refused: transparency (opacity, blend modes, highlights' Multiply, images with alpha), 16-bit images, layers, attachments, `userUnit` |
548
+ | part 2, `'b'` | as before |
549
+ | part 2 or 3, `'u'` | every character drawn must map to Unicode: a character the font (and its fallbacks) lacks **throws** instead of being dropped with a warning |
550
+ | part 2 or 3, `'a'` | `'u'` plus tagged: `tagged` is required (use `page.tag` for the content) |
551
+ | part 3, `'b'`/`'u'`/`'a'` | embedded files allowed (`pdf.attach`, `pdf.facturX`), each with `/AFRelationship` and listed in the catalog's `/AF` |
552
+
553
+ Every level in this table passes veraPDF (`--flavour 1b`, `2b`, `2u`, `2a`, `3b`, `3u`, `3a`) with 0 failed rules on
554
+ a document with text, a table, a list, headings, images, links, a note, form fields, layers and attachments as the
555
+ level allows. PDF/A-1a is not offered (its structure rules predate PDF/UA; use 2a or 3a). Encryption is refused at
556
+ every level. PDF/X is not offered (no validator was available to check it).
557
+
558
+ ### Embedded files
559
+
560
+ ```js
561
+ await pdf.attach(csvBytes, { name: 'lines.csv', mimeType: 'text/csv', description: 'Invoice lines', relationship: 'Data' });
562
+ ```
563
+
564
+ Document-level files, in the catalog's `/Names /EmbeddedFiles` tree (sorted, balanced: 64 a leaf), each with a file
565
+ specification (`/F`, `/UF`, `/Desc`, `/AFRelationship`) and an embedded-file stream (`/Subtype` the MIME type, `/Params`
566
+ with `/Size`, an MD5 `/CheckSum`, `/CreationDate`, `/ModDate`: default the document's date). `pdfdetach -list` shows
567
+ them. Options: `name` (checked like `page.attachFile`'s: spoofing and look-alike characters, device names, an allowlist
568
+ of passive types, `allowTypes`/`allowArchives` to widen it), `mimeType` (must agree with the extension; **required in
569
+ PDF/A-3**), `description`, `relationship` (`Source`, `Data`, `Alternative`, `Supplement`, `Unspecified`; default
570
+ `Unspecified` in PDF/A-3), `created`, `modified`, `maxBytes` (refuse a larger file before it is copied; default and at
571
+ most 256 MB). At most 10,000 files; a name used twice throws. The bytes are written at the call (copied, so the checksum
572
+ is of what is written); nothing is held. PDF/A-1 and PDF/A-2 refuse attachments (PDF/A-2 allows only PDF/A files,
573
+ which cannot be checked here); in PDF/A-3 every file is listed in `/AF`.
574
+
575
+ ### Factur-X / ZUGFeRD / XRechnung
576
+
577
+ ```js
578
+ const pdf = createPdf(sink, { title: 'Invoice 2026-001', pdfa: { part: 3, conformance: 'b' } });
579
+ // … draw the invoice …
580
+ await pdf.facturX(ciiXmlBytes, { profile: 'EN 16931' }); // MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED, XRECHNUNG
581
+ ```
582
+
583
+ Needs PDF/A-3 (`b`, `u` or `a`). The XML is embedded as `factur-x.xml` (`xrechnung.xml` for `XRECHNUNG`), MIME
584
+ `text/xml`, `/AFRelationship /Data` for MINIMUM and BASIC WL (they are not a full invoice) and `/Alternative` for the
585
+ others, and listed in `/AF`; the XMP gets the `fx:` properties (`DocumentType INVOICE`, `DocumentFileName`, `Version
586
+ 1.0`, `ConformanceLevel` the profile) with the PDF/A extension schema that describes them (Factur-X 1.0.07; ZUGFeRD 2.1
587
+ and later use the same). `version` is `'1.0'` (the only one). One invoice a document.
588
+
589
+ The XML is **data**: it is never handed to an XML parser. A size-capped (10 MB), linear scan refuses any `DOCTYPE`,
590
+ `ENTITY`, `ELEMENT` or `ATTLIST` declaration (no entity can expand), requires UTF-8, checks that the root element is
591
+ `CrossIndustryInvoice` and that its `GuidelineSpecifiedDocumentContextParameter` ID names the profile given (BASIC
592
+ needs `urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic`, and so on; XRECHNUNG any
593
+ `…#compliant#urn:xeinkauf.de:kosit:xrechnung_` version). It does **not** validate the invoice: run the Factur-X XSD and
594
+ Schematron (or the KoSIT validator for XRechnung) before embedding. Checked: veraPDF A-3b/3u/3a with 0 failed rules,
595
+ `pdfdetach`, and the factur-x Python package (akretion) extracting `factur-x.xml` from the PDF and passing its XSD.
596
+ `examples/factur-x.mjs` writes a tagged PDF/A-3a invoice.
597
+
598
+ ### Layers
599
+
600
+ ```js
601
+ const notes = pdf.layer('Reviewer notes', { visible: false });
602
+ page.layer(notes, () => page.text('Check this total', { x: 400, y: 300, font }));
603
+ ```
604
+
605
+ `pdf.layer(name, { visible = true, printable = visible, locked = false })` makes an optional content group;
606
+ `page.layer(layer, fn)` puts what `fn` draws (synchronously; `save()`/`restore()` must balance inside) into it, as
607
+ `/OC … BDC … EMC`; layers nest (at most 32 deep). The catalog gets `/OCProperties` with every layer in `/OCGs` and the
608
+ default configuration `/D` (`/Name`, `/Order` listing every layer, `/ON`, `/OFF`, `/Locked`). `visible` and
609
+ `printable` differing makes a screen-only or print-only layer (`/Usage` and the `/AS` auto-states); PDF/A forbids
610
+ `/AS`, so there they must agree. Not in PDF/A-1 (refused). At most 10,000 layers.
611
+
612
+ ### Document
613
+
614
+ - `await pdf.end()` — ends open pages, writes fonts, outline, metadata and the cross-reference stream; resolves to
615
+ `{ pages, bytes, warnings }`.
616
+
617
+ ### Reading and modifying
618
+
619
+ ```js
620
+ import { loadPdf, mergePdfs } from '@reportwright/pdf';
621
+
622
+ const doc = await loadPdf(bytes, { password, budget: { maxDecoded: 256 << 20 }, timeoutMs: 10_000, signal });
623
+ doc.pageCount; doc.info; doc.xmp; doc.pdfa; // { part: 3, conformance: 'a' } or null
624
+ doc.page(0).extractText(); // content order, in lines
625
+ doc.outlines; doc.fields; doc.attachments; doc.signatures;
626
+
627
+ doc.copyPages(other, [0, 2], { at: 1 }).removePages([4]).movePage(0, 3);
628
+ doc.page(0).rotate(90);
629
+ doc.page(0).draw((p, { fonts }) => p.text('DRAFT', { x: 200, y: 400, font: fonts.f, size: 60, color: '#c00000' }), { fonts: { f: 'Helvetica-Bold' } });
630
+ doc.fill({ 'name': 'Ada', 'agree': true, 'size': 'L', 'tags': ['a', 'b'] }, { font: ttfBytes });
631
+ doc.setInfo({ title: 'Signed copy' });
632
+ const { bytes, stripped, warnings } = await doc.save({ encrypt: { userPassword: 'pw' } });
633
+ const merged = await mergePdfs([a, b, c]);
634
+ const one = doc.fork().reorder([5]); await one.save(); // split: a fork per page
635
+
636
+ const signed = await doc.saveIncremental({ sign: { field: { name: 'approval', page: 0, x: 40, y: 700, width: 180, height: 40 }, signer: nodeSigner({ key, certs }) } });
637
+ ```
638
+
639
+ - `loadPdf(bytes, { password, budget, timeoutMs, signal })` reads lazily: the cross-reference (classic tables, xref
640
+ streams, hybrid files, incremental updates — the latest wins — object streams), the trailer, `/Encrypt`, `/ID` and
641
+ the page tree; an object is parsed when asked for, a stream decoded when needed (Flate, LZW, ASCII85, ASCIIHex,
642
+ RunLength, PNG and TIFF predictors; DCT, JPX, JBIG2 and CCITT are passed through undecoded). A broken
643
+ cross-reference is rebuilt by scanning for `n g obj` (`doc.recovered`), a wrong `/Length` found again from the next
644
+ `endstream` (`doc.warnings` says what was repaired). Encrypted files: the user or the owner password; RC4 (40–128
645
+ bit), AES-128 and AES-256 are read (`doc.encryption`); only AES is ever written.
646
+ - `doc.page(i)`: `width`, `height`, the five boxes (`[x0, y0, x1, y1]`, PDF space), `rotation`, `userUnit`,
647
+ `extractText()`, `annotations()`, `draw(fn, { fonts, images })`, `rotate(deg)`, `removeAnnotations()`.
648
+ - `extractText()` maps each code to Unicode through the font's ToUnicode CMap, else its encoding (WinAnsi, MacRoman,
649
+ Standard, `/Differences` glyph names), places it with the text and graphics matrices (form XObjects included) and
650
+ groups lines (a new line when the baseline moves by half the size; a space at a gap of a quarter of it, or a wide
651
+ `TJ` adjustment). It is **not layout-perfect**: no columns, no reading order beyond the stream's, no RTL reordering.
652
+ - `doc.fields`: full names, types (text, checkbox, radio, pushbutton, combo, list, signature), values, options and
653
+ widgets. `doc.attachments`: `rawName`, `name` (checked by the same file-name rules as the writer; `null` and
654
+ `refused` when it would spoof or is executable), `mimeType`, `bytes()`. `doc.signatures`: each signed field with its
655
+ `/ByteRange`, `coversWholeFile`, `revision`, `bytesAfter` and the CMS (not verified: `signedBytes()` gives the bytes
656
+ to verify it against).
657
+ - Changes are a plan, written by `save()` or `saveIncremental()`. `copyPages(src, indexes, { at })`, `merge(docs)`,
658
+ `removePages`, `reorder`, `movePage`, `rotate`, `draw`, `fill(values, { font })`, `flatten()`, `setInfo`,
659
+ `setXmp`, `removeAnnotations`; `fork()` copies the plan (the source is shared).
660
+ - **`save(options)`** writes a NEW file through the writer (`saveTo(sink, options)` streams it): pages are deep-copied
661
+ as they are written (fonts, images, XObjects, annotations), equal objects written once (also across merged files),
662
+ destinations and links remapped to the new pages (a link to a page not copied is dropped), unused objects left out,
663
+ uncompressed streams compressed, object streams on. Drawn content (`page.draw`, flattened fields) is a form XObject
664
+ after the page's own content, which is wrapped in `q … Q` (closing any `q` it leaves open), so nothing the old
665
+ content does to the graphics state reaches it. The first document gives the catalog (form with every contributing
666
+ document's fields, outline of each, embedded files merged, structure tree when every page is its own, layers, viewer
667
+ preferences). PDF/A is kept when every document declares the same level and output intent (its profile, XMP and
668
+ identification kept; overlays obey the writer's PDF/A rules); otherwise dropped with a warning. A rewrite invalidates
669
+ signatures, so their values are dropped (reported in `stripped`). Options: `compress`, `keepActiveContent`,
670
+ `encrypt`, `pdfa: 'keep' | false`, `keepStructure`, `creationDate`, `signal`, `timeoutMs`.
671
+ - `fill`: text and combo values are strings, check boxes booleans, radio groups an export value, list boxes a string
672
+ or strings. Text and choice appearances are made again by the writer's form code at the widget's size and look
673
+ (`/DA` font, size and colour, `/Q`, `/MK` border and background, multiline, comb): with the `/DA` standard font, or
674
+ `font` (bytes; required in PDF/A and for text outside WinAnsi). A character the font lacks throws, naming the field.
675
+ - **`saveIncremental({ sign, keepActiveContent })`** appends only the changed objects, a new cross-reference section
676
+ (the file's kind) with `/Prev`, so EXISTING SIGNATURES STAY VALID (poppler then reports the first one as "not total
677
+ document signed" while its own bytes still verify). It can fill fields, change the info, the XMP and rotation, and
678
+ sign: an empty signature field by name, or a new one (`{ name, page, x, y, width, height }`; no size: invisible), with
679
+ group F's signers, the `/ByteRange` over the whole file including the original bytes. An encrypted file's new objects
680
+ use its own AES key; RC4 files cannot be updated (writing RC4 is refused).
681
+ - `mergePdfs(docs, options)`: every page of each, into a new file. **A merged PDF/UA (or PDF/A-a) file is no longer
682
+ tagged**: the structure tree is kept only when every page comes from one document (and the form is not flattened),
683
+ so a merge drops it with `/MarkInfo` and `/Lang`, the XMP's PDF/UA identification (`pdfuaid`) goes too, PDF/A-a is
684
+ claimed as PDF/A-b, and `warnings` says "the structure tree (tags) was dropped … the result is not tagged (no
685
+ longer PDF/UA …)". Tag the merged content again with the writer if the result must be PDF/UA.
686
+ - Errors from the input are `PdfReadError` with a `code`: `E_MALFORMED`, `E_BUDGET`, `E_DEPTH`, `E_CYCLE`,
687
+ `E_TIMEOUT`, `E_ABORTED`, `E_PASSWORD`, `E_UNSUPPORTED`, `E_ACTIVE`.
688
+
689
+ ## Security
690
+
691
+ What the library refuses, and why:
692
+
693
+ | Refused | Why |
694
+ |---|---|
695
+ | RC4 encryption (40- and 128-bit, V1–V3 / R2–R3) | broken: a biased key stream and brute-forceable 40-bit keys; it only looks like protection |
696
+ | Encryption in PDF/A | ISO 19005 forbids it: an archive must open anywhere, forever |
697
+ | Turning off the accessibility permission in PDF/UA | ISO 14289 needs assistive technology to read the content (forced on, with a warning) |
698
+ | An empty owner password | it would give everyone full access; leave it out for a random one |
699
+ | Passwords over 1024 characters; control, bidi, private-use or unassigned characters (AES-256); non-Latin-1 (AES-128) | bounded input, and SASLprep, so every reader derives the same key |
700
+ | A signer's CMS that is not one DER SignedData, is larger than `reserve`, or (with `timestamp`) carries no token | garbage must not land in `/Contents`; a dropped timestamp would downgrade B-T to B-B silently |
701
+ | A second signature in one document as it is created | it needs an incremental update: `saveIncremental({ sign })` |
702
+ | A Factur-X XML with a DOCTYPE, ENTITY or other markup declaration, over 10 MB, or not UTF-8 | the XML is data: nothing may expand (billion laughs, external entities); it is never given to an XML parser |
703
+ | Caller text inserted into XMP unescaped | title, author, subject and keywords are escaped (`& < > " '`; characters XML cannot carry dropped); schema prefixes and names are checked as XML names |
704
+ | An attachment over `maxBytes`, or with a spoofed name | the size is checked before any copy; names go through the same checker as `page.attachFile` |
705
+
706
+ Crypto comes from the platform only (WebCrypto, node:crypto in Node); no AES or SHA is hand-written. The key
707
+ derivations of the standard security handler (ISO 32000-2 Algorithm 2.B; ISO 32000-1 Algorithms 2, 3, 5) are written
708
+ here, as the spec composes them, and checked against vectors from qpdf. Passwords and keys never appear in an error.
709
+ JavaScript, launch and submit actions cannot be written at all (see Forms and Links).
710
+
711
+ **Reading files from strangers.** The reader treats every byte as hostile:
712
+
713
+ | Bound | Default (`budget` option) |
714
+ |---|---|
715
+ | Decoded stream bytes, the whole document / one stream (every filter and predictor counts as it produces, so a zip bomb stops at the cap: a 302-byte stream that inflates to 10 GB stops at 128 MB) | `maxDecoded` 512 MB / `maxStream` 128 MB |
716
+ | Parsing steps (tokens, xref rows, tree nodes, operators); time | `maxSteps` 200 M; `timeoutMs`, `signal` |
717
+ | Cross-reference entries and scanned objects held; one page's text pieces | `maxObjects` 5 M; `maxTextItems` 1 M (and 16 M characters a page, ActualText included) |
718
+ | Nesting of arrays and dictionaries (an explicit stack, never recursion) | 256 |
719
+ | A chain of indirect references | 32 |
720
+
721
+ Every count and length is bounded by the bytes actually there (xref rows, `/N`, `/Length`, strings, `/Count` is never
722
+ trusted); loops are cut where they can occur (`/Prev`, page tree `/Kids`, outline `/Next`, AcroForm `/Kids`, an object
723
+ stream inside an object stream, form XObjects drawing themselves); recovery is one bounded pass.
724
+
725
+ **Active content is never carried over** unless `keepActiveContent: true`. One registry (`src/reader/policy.js`) says
726
+ what is allowed, and both save paths use it:
727
+
728
+ | Kept | Dropped (and reported in `stripped`) |
729
+ |---|---|
730
+ | Actions: GoTo, URI (plain-ASCII bytes accepted by the writer's URL check — http, https, mailto, ftp — and written as normalised), Named page moves, ResetForm | every other action (JavaScript, Launch, SubmitForm, ImportData, GoToR, GoToE, Hide, Movie, Sound, Rendition, RichMediaExecute, SetOCGState, Trans, GoTo3DView, Thread, unknown), wherever it is: `/A`, `/OpenAction`, `/Next` arrays, `/PA`, through references, or any dictionary whose `/S` is an action type |
731
+ | Annotations: Link, Text, FreeText, Line, Square, Circle, Polygon, PolyLine, Highlight, Underline, Squiggly, StrikeOut, Stamp, Caret, Ink, Popup, FileAttachment, Widget | RichMedia, Screen, Movie, Sound, 3D and anything else; every `/Annots` element and form widget is checked by position |
732
+ | File specifications whose every name key passes the writer's file-name check and agrees in extension (written back as the checked `/UF` and `/F`, with one embedded stream) | any other (`/F` "evil.exe" behind a clean `/UF`, `/DOS`, a spoofed double extension) |
733
+ | — | `/AA`, `/JS`, `/Names /JavaScript`, the AcroForm's `/XFA` |
734
+
735
+ An attachment that is itself a PDF is copied as data: its own content is not sanitised (opening it is a separate act).
736
+
737
+ **An incremental update cannot strip anything**: the original bytes stay, and a reader that rebuilds the
738
+ cross-reference, shows an earlier revision or follows `/Prev` finds them again. So `saveIncremental` checks the WHOLE
739
+ file first — every object in the bytes (the parser's own lexer plus a byte scan, every duplicate, every object stream,
740
+ every cross-reference entry) against the same registry — and refuses (`E_ACTIVE`, naming what it found) if there is
741
+ any active content, or if anything cannot be read (fail closed), unless `keepActiveContent: true`. **For files from
742
+ strangers, `save()` is the safe path** (it rewrites the file without the active content; this invalidates existing
743
+ signatures).
744
+
745
+ **Signed documents (signature policy).** `saveIncremental` reads every signature's permissions and takes the
746
+ strictest: DocMDP from the catalog `/Perms` and from each signature's `/Reference` in every revision (no `/P` means
747
+ P=2), FieldMDP from each `/Reference` and each signed field's `/Lock`, and ReadOnly from any ancestor field. Anything it
748
+ cannot read or does not know refuses the update (`E_SIGNED`, fail closed).
749
+
750
+ | The document | Allowed | Refused |
751
+ |---|---|---|
752
+ | certified P=1 | nothing | every update, including a new signature |
753
+ | certified P=2 or P=3 | fills (opt-in, below) and new signatures | rotation, Info/XMP changes |
754
+ | signed, no DocMDP | fills (opt-in) and new signatures | rotation, Info/XMP changes unless `allowChangesAfterSigning: true` |
755
+ | a locked (FieldMDP) or ReadOnly field | — | filling it |
756
+
757
+ Filling a form **after it was signed** needs `allowFillAfterSigning: true`. Viewers show such a document as "changed
758
+ after signing", and refilling widgets that existed before signing is how "shadow attacks" (Mainka et al., USENIX
759
+ Security 2021) make a signed document look different while its signature still verifies. As a second guard the filled
760
+ widgets may cover at most 40% of a page in all, and a widget with an opaque background may not lie over the page's text.
761
+
762
+ ## Not supported yet
763
+
764
+ Image masks (stencils) and colour-managed images (embedded ICC profiles of PNG/JPEG files are ignored); the Unicode
765
+ bidi algorithm, hyphenation, kerning of the standard fonts, WOFF fonts; popup annotations; RC4 encryption (refused; read only), public-key (certificate) encryption (neither written nor read), verifying signatures' cryptography (they are listed with their coverage); PDF/A-1a, PDF/X, attachment annotations in PDF/A-3 (use `pdf.attach`), validating a Factur-X invoice's content; layout-perfect text extraction; page edits in an incremental update (use `save()`); reading encrypted files in browsers (WebCrypto has no synchronous AES; Node only). See NOTES.md for the plan. Extending the library: ARCHITECTURE.md.
766
+
767
+ ## Changes
768
+
769
+ 0.1.0-beta.1: shaped text (Indic, Arabic) extracts once and in order in pdf.js, poppler and `extractText`;
770
+ `extractText` honours ActualText, reads unmapped glyphs as U+FFFD, falls back to an embedded TrueType cmap, spaces
771
+ separate text objects (spreadsheet cells), orders a line along its baseline (rotated text too), and stops a
772
+ 2 M-operator page at 170 MB (`maxTextItems` 1 M); standard-font text writes 40% faster; variable fonts warn; the
773
+ package ships `examples/`. The list with details: NOTES.md ("Changes") in the repository.