@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/LICENSE +23 -0
- package/README.md +772 -2
- package/THIRD-PARTY-NOTICES.md +38 -0
- package/dist/index.js +18137 -0
- package/examples/factur-x.mjs +37 -0
- package/examples/harfbuzz-shaper.mjs +27 -0
- package/index.d.ts +847 -0
- package/package.json +19 -4
package/README.md
CHANGED
|
@@ -1,3 +1,773 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @reportwright/pdf
|
|
2
2
|
|
|
3
|
-
|
|
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.
|