@reportwright/pdf 0.1.0-beta.1 → 0.1.0-beta.11

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,32 +1,427 @@
1
- # @reportwright/pdf
1
+ <h1 align="center">@reportwright/pdf</h1>
2
+
3
+ <p align="center">
4
+ <b>Create, comply, sign, light edit.</b><br>
5
+ A streaming PDF library for Node and browsers with <b>zero runtime dependencies</b>.
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://www.npmjs.com/package/@reportwright/pdf"><img alt="npm" src="https://img.shields.io/npm/v/@reportwright/pdf?color=1f4e79"></a>
10
+ <a href="https://github.com/MrArun005/reportwright-pdf/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/MrArun005/reportwright-pdf/actions/workflows/ci.yml/badge.svg"></a>
11
+ <a href="./LICENSE"><img alt="license: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
12
+ <a href="./package.json"><img alt="runtime dependencies: 0" src="https://img.shields.io/badge/runtime%20dependencies-0-brightgreen"></a>
13
+ <a href="./package.json"><img alt="node >= 20.16" src="https://img.shields.io/badge/node-%3E%3D20.16-339933"></a>
14
+ <a href="./conformance"><img alt="PDF/A + PDF/UA: veraPDF clean" src="https://img.shields.io/badge/PDF%2FA%201b%E2%80%933a%20%2B%20UA--1-veraPDF%20clean-6f42c1"></a>
15
+ </p>
16
+
17
+ <p align="center">
18
+ <a href="https://mrarun005.github.io/reportwright-demo">Live demo</a> ·
19
+ <a href="https://mrarun005.github.io/reportwright-demo/play.html">Playground</a> ·
20
+ <a href="#benchmarks">Benchmarks</a> ·
21
+ <a href="#security-model">Security model</a>
22
+ </p>
23
+
24
+ Pages are written to your sink as they end, so memory stays flat however many pages you write: 20,000 pages hold
25
+ about the heap that 1,000 do. It is the writer inside ReportWright, extracted, plus a hardened reader for merging,
26
+ splitting, filling and signing existing files.
27
+
28
+ It does **not** render PDFs (use [pdf.js](https://mozilla.github.io/pdf.js/) or
29
+ [PDFium](https://pdfium.googlesource.com/pdfium/)), and for heavy rewriting, repair or raw speed MuPDF, qpdf and
30
+ pdfcpu are faster (see [Benchmarks](#benchmarks)).
31
+
32
+ ## Contents
33
+
34
+ [Install](#install) · [Quick start](#quick-start-30-seconds) · [Features](#features) · [Screenshots](#screenshots) ·
35
+ [Guide](#guide) · [Bundle size](#bundle-size) · [Examples](#examples) · [Benchmarks](#benchmarks) · [Options reference](#options-reference) ·
36
+ [Security model](#security-model) · [Known limits](#known-limits) · [Releasing](#releasing) · [Changes](#changes)
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ npm i @reportwright/pdf
42
+ ```
2
43
 
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.
44
+ ESM only. Node 20.16 or later, and modern browsers (the same `dist/index.js`; crypto comes from WebCrypto). Types are
45
+ included (`index.d.ts`). Optional, for complex scripts (Indic, Arabic) only: `npm i harfbuzzjs`.
5
46
 
6
- ## Quickstart
47
+ ## Quick start (30 seconds)
7
48
 
8
49
  ```js
50
+ // hello.mjs → node hello.mjs
9
51
  import fs from 'node:fs';
10
52
  import { createPdf } from '@reportwright/pdf';
11
53
 
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' });
54
+ const pdf = createPdf(fs.createWriteStream('hello.pdf'), { title: 'Hello' });
55
+ const page = pdf.addPage(); // A4, points, origin top-left
56
+ page.text('Hello, world', { x: 72, y: 72, font: pdf.standardFont('Helvetica-Bold'), size: 24, color: '#1f4e79' });
16
57
  page.line(72, 104, 523, 104, { color: 0.6, width: 0.5 });
58
+ await page.end(); // the page is written and freed
59
+ console.log(await pdf.end()); // { pages: 1, bytes: …, warnings: [] }
60
+ ```
61
+
62
+ In memory instead of a stream: `const bytes = await toBytes(async (pdf) => { … }, options)`.
63
+
64
+ ## Features
65
+
66
+ | Area | What you get |
67
+ |---|---|
68
+ | **Write** | Text in the 14 standard fonts or embedded TrueType/OpenType (subset, kerned, any Unicode); text boxes with wrapping and alignment; JPEG/PNG images (alpha as a soft mask); Canvas-style vector paths, gradients, patterns, layers |
69
+ | **Complex scripts** | Devanagari, Bengali, Tamil, Telugu, Kannada, Arabic… through a HarfBuzz shaper you plug in (`harfbuzzjs`), text still extractable |
70
+ | **Streaming** | Each page is written and freed when it ends; flat memory for 20,000+ pages; `timeoutMs` and `AbortSignal` |
71
+ | **Navigate** | Links, named destinations, bookmarks, page labels, annotations |
72
+ | **Forms** | Every AcroForm field type with appearance streams; fill and flatten existing forms |
73
+ | **Protect** | AES-256 / AES-128 encryption with permissions |
74
+ | **Sign** | PAdES B-B / B-T signatures while streaming; incremental countersignatures that keep earlier ones valid |
75
+ | **Comply** | PDF/A-1b, 2b/2u/2a, 3b/3u/3a and PDF/UA-1 (veraPDF clean); Factur-X / ZUGFeRD / XRechnung invoices |
76
+ | **Read and edit** | Load (also damaged or encrypted files), merge, split, reorder, extract text, fill, stamp, `saveIncremental` |
77
+ | **Bulk** | One task over many files in worker threads, each with a heap cap and a per-file timeout; one bad file never stops the batch |
78
+ | **Safe by default** | No JavaScript or launch actions can be written; active content is stripped on read; every input is budgeted |
79
+ | **Small** | Zero runtime dependencies; one ESM file plus a lazily loaded `fflate.js`; TypeScript types included |
80
+
81
+ ## Screenshots
82
+
83
+ Page 1 of the [examples](#examples), rendered by poppler (`pdftoppm`) on GitHub Actions straight from their output.
84
+
85
+ <table>
86
+ <tr>
87
+ <td align="center"><img src="https://raw.githubusercontent.com/MrArun005/reportwright-pdf/main/docs/screenshots/write.png" width="240" alt="write.mjs: a report with a logo, a justified paragraph, a table drawn with paths and a link"><br><code>write.mjs</code><br>text, image, table, link</td>
88
+ <td align="center"><img src="https://raw.githubusercontent.com/MrArun005/reportwright-pdf/main/docs/screenshots/invoice-facturx.png" width="240" alt="invoice-facturx.mjs: a Factur-X PDF/A-3a invoice in Inter"><br><code>invoice-facturx.mjs</code><br>Factur-X, PDF/A-3a</td>
89
+ <td align="center"><img src="https://raw.githubusercontent.com/MrArun005/reportwright-pdf/main/docs/screenshots/indic.png" width="240" alt="indic.mjs: Devanagari text shaped by HarfBuzz mixed with English"><br><code>indic.mjs</code><br>Devanagari via HarfBuzz</td>
90
+ </tr>
91
+ <tr>
92
+ <td align="center"><img src="https://raw.githubusercontent.com/MrArun005/reportwright-pdf/main/docs/screenshots/fill-form.png" width="240" alt="fill-form.mjs: a leave request form, filled in"><br><code>fill-form.mjs</code><br>a form, filled</td>
93
+ <td align="center"><img src="https://raw.githubusercontent.com/MrArun005/reportwright-pdf/main/docs/screenshots/accessible.png" width="240" alt="accessible.mjs: a tagged PDF/UA-1 and PDF/A-2a policy page"><br><code>accessible.mjs</code><br>PDF/UA-1 + PDF/A-2a, tagged</td>
94
+ <td></td>
95
+ </tr>
96
+ </table>
97
+
98
+ ## Guide
99
+
100
+ Every snippet below is cut from a file in [`examples/`](./examples), each of which was run and produces a PDF. The
101
+ examples import `../dist/index.js`; in your code import `@reportwright/pdf`. They also ship in the package
102
+ (`node_modules/@reportwright/pdf/examples/`).
103
+
104
+ ### Write: text, fonts, images, tables
105
+
106
+ ```js
107
+ const pdf = createPdf(fs.createWriteStream('report.pdf'), { title: 'Quarterly report', author: 'Example Ltd' });
108
+ const regular = pdf.standardFont('Helvetica'), bold = pdf.standardFont('Helvetica-Bold');
109
+ const inter = await pdf.embedFont(fs.readFileSync('Inter-Regular.ttf')); // subset, any Unicode
110
+ const logo = await pdf.embedImage(fs.readFileSync('logo.png')); // alpha becomes a soft mask
111
+
112
+ const page = pdf.addPage();
113
+ page.image(logo, { x: 40, y: 40, width: 32 });
114
+ page.text('Quarterly report', { x: 84, y: 44, font: bold, size: 22, color: '#1f4e79' });
115
+ const box = page.textBox(longText, { x: 40, y: 90, width: 515, font: inter, size: 11, align: 'justify' });
116
+ // box = { height, lines, overflow }: continue `overflow` in the next box or page
117
+ ```
118
+
119
+ **Tables are paths and text**: there is no table primitive, so you control every rule and alignment.
120
+
121
+ ```js
122
+ const rows = [['Region', 'Q1', 'Q2'], ['North', '1,204', '1,390'], ['South', '980', '1,122']];
123
+ const cols = [40, 300, 430], right = 555, rowH = 22, top = 100 + box.height;
124
+ page.fill(page.path().rect(40, top, right - 40, rowH), { fill: '#1f4e79' }); // header band
125
+ rows.forEach((r, i) => {
126
+ const y = top + i * rowH;
127
+ r.forEach((cell, c) => {
128
+ const font = i === 0 ? bold : regular;
129
+ const x = c === 0 ? cols[c] + 6 : (cols[c + 1] ?? right) - 6 - font.widthOfText(cell, 11); // right-align numbers
130
+ page.text(cell, { x, y: y + 5, font, size: 11, color: i === 0 ? '#ffffff' : 0 });
131
+ });
132
+ if (i > 0) page.stroke(page.path().moveTo(40, y + rowH).lineTo(right, y + rowH), { stroke: 0.75, width: 0.5 });
133
+ });
17
134
  await page.end();
18
- await pdf.end(); // ends the stream too
135
+ await pdf.end();
136
+ ```
137
+
138
+ Full file: [`examples/write.mjs`](./examples/write.mjs) (it also generates its PNG, so it needs no input files).
139
+
140
+ ### Indic and other complex scripts (HarfBuzz)
141
+
142
+ Pass a shaper to `embedFont` and conjuncts, reordered matras, Arabic joining and marks are drawn correctly; copied
143
+ and extracted text stays the source text, in logical order. The adapter for harfbuzzjs ships as
144
+ [`examples/harfbuzz-shaper.mjs`](./examples/harfbuzz-shaper.mjs).
145
+
146
+ ```js
147
+ import { harfbuzzShaper } from '@reportwright/pdf/examples/harfbuzz-shaper.mjs'; // needs: npm i harfbuzzjs
148
+
149
+ const bytes = fs.readFileSync('NotoSansDevanagari-Regular.ttf');
150
+ const hindi = await pdf.embedFont(bytes, { shaper: harfbuzzShaper(bytes) });
151
+ page.text('किताब हिन्दी क्षत्रिय', { x: 40, y: 40, font: hindi, size: 24 });
152
+ page.textBox('इस किताब में हिन्दी और English दोनों हैं।', { x: 40, y: 90, width: 400, font: hindi, fallback: [latin] });
19
153
  ```
20
154
 
21
- In memory: `const bytes = await toBytes((pdf) => { ... }, options)`.
155
+ `node examples/indic.mjs NotoSansDevanagari-Regular.ttf`. Right-to-left: `{ direction: 'rtl' }` (there is no bidi
156
+ algorithm: split mixed-direction lines into runs yourself).
157
+
158
+ ### Links and bookmarks
159
+
160
+ ```js
161
+ page.link(40, 300, 160, 14, { url: 'https://example.com/report' }); // http, https, mailto by default
162
+ pdf.destination('appendix', 2, { top: 40 }); // a named destination
163
+ page.link(40, 320, 160, 14, { dest: 'appendix' }, { alt: 'Go to the appendix' });
164
+ page.link(40, 340, 160, 14, { page: 2, fit: 'FitH', top: 0 });
165
+ pdf.outline([
166
+ { title: 'Quarterly report', page: 1 },
167
+ { title: 'Appendix', page: 2, level: 1 }, // level nests under the previous entry
168
+ ]);
169
+ pdf.pageLabels([{ start: 0, style: 'r' }, { start: 2, style: 'D' }]); // i, ii, 1, 2, …
170
+ ```
22
171
 
23
- ## Coordinates
172
+ ### Forms: create, fill, flatten
173
+
174
+ ```js
175
+ import { toBytes, loadPdf } from '@reportwright/pdf';
176
+
177
+ const form = await toBytes(async (pdf) => {
178
+ const font = pdf.standardFont('Helvetica'), page = pdf.addPage();
179
+ pdf.form.textField('name', { page, x: 40, y: 80, width: 240, height: 22, font, tooltip: 'Full name', required: true });
180
+ pdf.form.comboBox('type', { page, x: 40, y: 115, width: 160, height: 22, font, options: ['Annual', 'Sick', 'Unpaid'], value: 'Annual', tooltip: 'Leave type' });
181
+ pdf.form.checkbox('approved', { page, x: 40, y: 150, width: 14, height: 14, tooltip: 'Approved by manager' });
182
+ await page.end();
183
+ });
184
+
185
+ const doc = await loadPdf(form);
186
+ doc.fields; // [{ name: 'name', type: 'text', … }, …]
187
+ doc.fill({ name: 'Aanya Sharma', type: 'Sick', approved: true }); // appearances redrawn by the writer
188
+ const filled = (await doc.save()).bytes;
189
+
190
+ const flattened = (await (await loadPdf(form)).fill({ name: 'Aanya Sharma' }).flatten().save()).bytes; // no AcroForm left
191
+ ```
192
+
193
+ [`examples/fill-form.mjs`](./examples/fill-form.mjs) writes `form.pdf`, `filled.pdf` and `flattened.pdf`. To
194
+ flatten while writing: `createPdf(sink, { form: { flatten: true } })`.
195
+
196
+ ### Encryption
197
+
198
+ ```js
199
+ const bytes = await toBytes(build, {
200
+ encrypt: {
201
+ userPassword: 'open me', ownerPassword: 'full access',
202
+ algorithm: 'aes-256', // default; 'aes-128' for very old readers
203
+ permissions: { print: true, copy: false, modify: false }, // each defaults to true
204
+ },
205
+ });
206
+ const doc = await loadPdf(bytes, { password: 'open me' }); // doc.encryption.algorithm === 'aes-256'
207
+ ```
208
+
209
+ [`examples/encrypt.mjs`](./examples/encrypt.mjs). RC4 is read, never written.
210
+
211
+ ### Signing (PAdES)
212
+
213
+ ```js
214
+ import { createPdf, loadPdf, nodeSigner } from '@reportwright/pdf';
215
+
216
+ const signer = nodeSigner({ key: fs.readFileSync('key.pem', 'utf8'), certs: fs.readFileSync('cert.pem', 'utf8') });
217
+ const pdf = createPdf(sink, { title: 'Offer letter', sign: true });
218
+ const page = pdf.addPage();
219
+ const hr = pdf.form.signature('hr', { page, x: 40, y: 700, width: 200, height: 40, tooltip: 'HR signature' });
220
+ pdf.form.signature('candidate', { page, x: 300, y: 700, width: 200, height: 40, tooltip: 'Candidate signature' });
221
+ await pdf.sign(hr, { signer, reason: 'Issued', location: 'Bengaluru', certify: 2 });
222
+ await page.end();
223
+ await pdf.end(); // the signer runs here
224
+
225
+ // Countersign later: an incremental update, so the first signature stays valid.
226
+ const { bytes } = await (await loadPdf(signedBytes)).saveIncremental({ sign: { field: 'candidate', signer } });
227
+ ```
228
+
229
+ [`examples/sign.mjs`](./examples/sign.mjs) (make a test key with the `openssl` line at its top). Its output checks with
230
+ `pdfsig`: both signatures "Signature is Valid", the second "Total document signed". `signer` can be any async function
231
+ returning CMS bytes (an HSM, a cloud KMS: see `cmsSigner`); add `timestamp` for PAdES B-T.
232
+
233
+ ### PDF/A, PDF/UA and Factur-X
234
+
235
+ ```js
236
+ const pdf = createPdf(sink, {
237
+ title: 'Leave policy',
238
+ tagged: { lang: 'en-GB' }, // PDF/UA-1: structure tree, title, embedded fonts
239
+ pdfa: { part: 2, conformance: 'a' }, // or true (2b), { part: 1, conformance: 'b' }, { part: 3, … }
240
+ });
241
+ const font = await pdf.embedFont(fs.readFileSync('Inter-Regular.ttf'));
242
+ const page = pdf.addPage();
243
+ page.artifact({ type: 'Pagination', subtype: 'Header' }, () => page.text('HR policies', { x: 40, y: 20, font, size: 8 }));
244
+ page.tag('H1', () => page.text('Leave policy', { x: 40, y: 50, font, size: 22 }));
245
+ page.figure({ alt: 'Bar chart: annual leave 18 days' }, () => page.fill(page.path().rect(40, 180, 180, 16), { fill: '#1f4e79' }));
246
+ ```
247
+
248
+ A Factur-X / ZUGFeRD invoice is PDF/A-3 with the CII XML attached:
249
+
250
+ ```js
251
+ const pdf = createPdf(sink, { title: 'Invoice INV-2026-001', pdfa: { part: 3, conformance: 'a' }, tagged: { lang: 'en' } });
252
+ // … draw the invoice from the same data as the XML …
253
+ await pdf.facturX(xmlBytes, { profile: 'EN 16931' }); // MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED, XRECHNUNG
254
+ ```
255
+
256
+ [`examples/accessible.mjs`](./examples/accessible.mjs) (PDF/A-2a + PDF/UA-1) and
257
+ [`examples/invoice-facturx.mjs`](./examples/invoice-facturx.mjs) (self-contained PDF/A-3a invoice; `factur-x.mjs`
258
+ does the same from your own XML) both pass veraPDF with 0 failed rules (`2a` + `ua1`, `3a` + `ua1`).
259
+
260
+ ### Read: merge, split, extractText, saveIncremental
261
+
262
+ ```js
263
+ import { loadPdf, mergePdfs } from '@reportwright/pdf';
264
+
265
+ const a = await loadPdf(fs.readFileSync('contract.pdf')), b = await loadPdf(fs.readFileSync('annexure.pdf'));
266
+ const merged = await mergePdfs([a, b]); // { bytes, pages, stripped, warnings }
267
+
268
+ const all = await loadPdf(merged.bytes);
269
+ all.extractText(); // every page, joined by \f
270
+ all.page(0).extractText(); // one page, in lines
271
+
272
+ for (let i = 0; i < all.pageCount; i++) { // split: one file per page
273
+ fs.writeFileSync(`page-${i + 1}.pdf`, (await all.fork().reorder([i]).save()).bytes);
274
+ }
275
+
276
+ const doc = await loadPdf(merged.bytes);
277
+ doc.setInfo({ title: 'Contract (reviewed)' });
278
+ const { bytes } = await doc.saveIncremental(); // the original bytes + an appended update
279
+ ```
280
+
281
+ [`examples/merge-split.mjs`](./examples/merge-split.mjs). `save()` rewrites the file (and strips active content);
282
+ `saveIncremental()` appends (and refuses files with active content): see [Security model](#security-model).
283
+
284
+ ### Bulk: many files in worker threads
285
+
286
+ ```js
287
+ import { bulk } from '@reportwright/pdf';
288
+
289
+ const files = fs.readdirSync('in').map((f) => fs.readFileSync(`in/${f}`));
290
+ const texts = await bulk(files, 'extractText', { workers: 4 }); // [{ ok: true, value } | { ok: false, error }]
291
+ const locked = await bulk(files, 'encrypt', { encrypt: { userPassword: 'u', ownerPassword: 'o' } });
292
+ const pages = await bulk(files, 'split'); // value: one Uint8Array per page
293
+ const joined = await bulk([[a, b], [c, d]], 'merge'); // each input: the files to merge
294
+ ```
295
+
296
+ Tasks are names (`extractText`, `merge`, `split`, `encrypt`; `BULK_TASKS`), never functions, so only bytes and plain
297
+ options cross to a worker. Results come back in input order; a file that fails (malformed, wrong password, over the
298
+ budget) gives `{ ok: false, error: { name, code, message } }` and the batch goes on. Each worker's heap is capped
299
+ (`memoryMb`, default 512): a file that exhausts it kills only that worker, its item fails with `E_WORKER`, and a new
300
+ worker takes the next. `workers` defaults to `os.availableParallelism() - 1`. Node only: in browsers (no
301
+ `node:worker_threads`), and with `workers: 1`, the same tasks run one at a time in the calling thread, without the
302
+ heap cap. [`examples/bulk.mjs`](./examples/bulk.mjs).
303
+
304
+ Workers stay warm (unref'd, so they never keep the process alive) for 2 s after a call, so the next call skips their
305
+ start-up; small files are sent several at a time (up to 8, or 256 KB, a worker). Each item has `itemTimeoutMs` (default 120000) in a worker: past it the worker is ended and the item fails with `E_TIMEOUT`; a failed item is never retried, so a batch of stuck or worker-killing files ends with each failing alone (`workers` is 1–64, `memoryMb` 16–16384; with `workers: 1` there is no item timeout or heap cap). Pass `signal` to stop a call (or
306
+ `AbortSignal.timeout(ms)` for a time limit): its workers are ended and `bulk` rejects with the signal's reason. When
307
+ to use it: CPU-heavy items (encrypt, the text of long files) gain from the first call; cheap items on small files
308
+ (extractText or merge of 1–2 page files) gain only once the workers are warm: the first call pays ~25 ms a worker to
309
+ start them, so a one-off batch of under ~100 ms of work is quicker with `workers: 1`.
310
+
311
+ Measured on an Apple M2 (4 performance + 4 efficiency cores, 8 GB, busy laptop), Node 22, files made with
312
+ `toBytes` (small: 200 files of 2 pages, 0.4 MB in all; large: 20 files of 200 pages of text, 2.3 MB in all). Items/s
313
+ from the best of 3 calls in one process (warm), and the first call's ms (cold); results equal to `workers: 1`'s
314
+ (encrypt: the same text once opened). Before: one worker started per call, one item a round trip.
315
+
316
+ | files | task | workers | items/s before | items/s now | first call ms before | now | peak RSS MB |
317
+ |---|---|---|---|---|---|---|---|
318
+ | small | extractText | 1 | 4762 | 4762 | 80 | 78 | 135 |
319
+ | small | extractText | 2 | 1923 | 5882 | 106 | 99 | 246 |
320
+ | small | extractText | 4 | 1754 | 6452 | 123 | 100 | 351 |
321
+ | small | merge | 1 | 2174 | 2128 | 137 | 136 | 148 |
322
+ | small | merge | 4 | 1299 | 3175 | 171 | 142 | 429 |
323
+ | small | encrypt | 1 | 284 | 288 | 733 | 738 | 171 |
324
+ | small | encrypt | 4 | 563 | 813 | 370 | 373 | 465 |
325
+ | large | extractText | 1 | 11 | 11 | 1906 | 1891 | 218 |
326
+ | large | extractText | 4 | 29 | 37 | 684 | 653 | 422 |
327
+ | large | merge | 1 | 143 | 135 | 224 | 220 | 176 |
328
+ | large | merge | 4 | 77 | 196 | 276 | 249 | 429 |
329
+ | large | encrypt | 1 | 149 | 155 | 213 | 212 | 181 |
330
+ | large | encrypt | 4 | 93 | 217 | 218 | 210 | 416 |
331
+
332
+ ## Bundle size
333
+
334
+ Gzip (`gzip -9`) of the built files in `dist/`. `.` is `dist/index.js` (writer and reader); `./write` is `dist/write.js`
335
+ (writer only: `createPdf`, fonts, images, graphics, forms, standards, encryption, signing). fflate is a separate file
336
+ (`dist/fflate.js`) loaded on first use by either entry.
337
+
338
+ | entry | before (unminified) | after (minified) |
339
+ | --- | --- | --- |
340
+ | `.` (`dist/index.js`) | 166.8 KiB (170,811 B) | 133.4 KiB (136,583 B) |
341
+ | `./write` (`dist/write.js`) | not exported | 85.0 KiB (87,080 B) |
342
+ | `fflate` (`dist/fflate.js`) | 15.5 KiB (15,842 B) | 12.6 KiB (12,953 B) |
343
+
344
+ Import `@reportwright/pdf/write` when the code only writes PDFs: it leaves out the reader (`loadPdf`, `mergePdfs`) and
345
+ `bulk`. The two entries are separate bundles, so an app that imports both pays for the writer twice.
346
+
347
+ ## Examples
348
+
349
+ | file | what it shows | run |
350
+ |---|---|---|
351
+ | [`write.mjs`](./examples/write.mjs) | text, a PNG, a table from paths, a link, bookmarks | `node examples/write.mjs out.pdf` |
352
+ | [`indic.mjs`](./examples/indic.mjs) | Devanagari shaped by HarfBuzz, Latin fallback | `node examples/indic.mjs NotoSansDevanagari-Regular.ttf out.pdf` |
353
+ | [`harfbuzz-shaper.mjs`](./examples/harfbuzz-shaper.mjs) | the harfbuzzjs shaper adapter (a module) | imported by `indic.mjs` |
354
+ | [`fill-form.mjs`](./examples/fill-form.mjs) | create, fill and flatten a form | `node examples/fill-form.mjs outDir` |
355
+ | [`encrypt.mjs`](./examples/encrypt.mjs) | AES-256 with permissions, read back | `node examples/encrypt.mjs out.pdf` |
356
+ | [`sign.mjs`](./examples/sign.mjs) | PAdES signature while streaming + countersignature | `node examples/sign.mjs key.pem cert.pem out.pdf` |
357
+ | [`accessible.mjs`](./examples/accessible.mjs) | PDF/UA-1 + PDF/A-2a | `node examples/accessible.mjs font.ttf out.pdf` |
358
+ | [`invoice-facturx.mjs`](./examples/invoice-facturx.mjs) | self-contained Factur-X PDF/A-3a invoice | `node examples/invoice-facturx.mjs font.ttf out.pdf` |
359
+ | [`factur-x.mjs`](./examples/factur-x.mjs) | Factur-X from your own CII XML | `node examples/factur-x.mjs invoice.xml font.ttf out.pdf` |
360
+ | [`merge-split.mjs`](./examples/merge-split.mjs) | merge, split, extractText, saveIncremental | `node examples/merge-split.mjs outDir` |
361
+ | [`bulk.mjs`](./examples/bulk.mjs) | extractText and encrypt over many files in worker threads | `node examples/bulk.mjs outDir` |
362
+
363
+ Run from `node_modules/@reportwright/pdf` (or the package folder of the repository).
364
+
365
+ ## Benchmarks
366
+
367
+ From the deep benchmark of the ReportWright project: every library in its own process, median of 5 runs after a
368
+ warm-up, contestants interleaved on the same GitHub `ubuntu-latest` runner (2 vCPU), every output checked (qpdf,
369
+ MuPDF, pdf.js or the job's own text check) before it is ranked. Times are wall time from process start to exit, so they include each runtime's
370
+ start-up. Full tables, method and raw numbers:
371
+ **[bench/deep/RESULTS.md](https://github.com/MrArun005/reportwright/blob/bench-deep/bench/deep/RESULTS.md)**
372
+ (the `@reportwright/pdf` measured there is the 0.1.0-beta.7 code).
373
+
374
+ **Where it wins**
375
+
376
+ | Task | @reportwright/pdf | Next best |
377
+ |---|---|---|
378
+ | PDF/A-2b invoice (veraPDF-checked) | 255 ms · 75 MB | pdfkit 543 ms · 103 MB (the only other writer with PDF/A in the test) |
379
+ | Fill and flatten a form | **100 ms** | PyMuPDF / pypdf 139 ms, pdf-lib 296 ms, pdfbox 669 ms |
380
+ | Sign (PAdES/CMS) | **~100 ms** | pdfbox 705 ms, openpdf 760 ms, iText 1,113 ms |
381
+ | Incremental metadata change | 100-125 ms | PyMuPDF / pypdf 139 ms, pdfbox 463 ms |
382
+ | 1,000 pages of text, against other JS writers | 3.1 s · 135 MB | pdfkit 4.5 s, jsPDF 5.8 s, pdf-lib 215 s |
383
+ | Reading fuzzed PDFs (200 mutations) | 182 read, 18 clean errors, 0 crashes or hangs | pdfbox 176 read; most readers refuse far more |
384
+
385
+ **Where it loses** (honestly)
386
+
387
+ | Task | @reportwright/pdf | Fastest |
388
+ |---|---|---|
389
+ | Plain text pages (10 / 100 / 1,000) | 0.30 / 0.65 / 3.1 s | fpdf (PHP) 0.05 / 0.11 / 0.57 s: **5-6× faster** |
390
+ | Grouped report, 100 to 100,000 rows | 2.2-5.2× slower | fpdf |
391
+ | One invoice in a warm process | 11 ms | PDFsharp (.NET) 1.7× faster |
392
+ | Images (6 MP PNG + 24 MP JPEG) | 1.2 s | printpdf (Rust) 3.3× faster |
393
+ | Unicode (Hindi, Arabic, CJK) | 3.1× slower | fpdf |
394
+ | Text of a 1,200-page PDF | 1.0 s | pdftotext 0.65 s (1.5× faster) |
395
+ | Merge 100 files / split 100 pages / AES-256 encrypt | 204 / 217 / 147 ms | pdfcpu (Go) 3-4.3× faster |
396
+
397
+ In short: it is not the fastest PDF writer or reader. It is a fast JavaScript one with flat memory, and it is the
398
+ quickest of those measured at the jobs it is built for (compliance, forms, signing, incremental edits), where most
399
+ libraries have no support at all.
400
+
401
+ ## Options reference
402
+
403
+ The sections below are the full API: every option of `createPdf`, the drawing methods, forms, encryption, signatures,
404
+ PDF/A levels and the reader. The most used:
405
+
406
+ | function | key options |
407
+ |---|---|
408
+ | `createPdf(sink, o)` / `toBytes(build, o)` | `title`, `author`, `creationDate`, `compress`, `metadata`, `pdfa`, `tagged`, `encrypt`, `sign`, `form.flatten`, `signal`, `timeoutMs`, `id` ([table](#createpdfsink-options)) |
409
+ | `pdf.embedFont(bytes, o)` | `subset`, `shaper`, `textMapping` ([Fonts](#fonts), [Shaping](#shaping)) |
410
+ | `page.text` / `page.textBox` | `x`, `y`, `font`, `size`, `color`, `width`, `align`, `maxLines`, `fallback`, `direction`, `kerning` ([Text options](#pages)) |
411
+ | `pdf.embedImage` / `page.image` | `colorSpace`, `maxDecodedBytes`, `compression` / `width`, `height`, `fit`, `opacity`, `alt` ([Images](#images)) |
412
+ | `page.fill` / `stroke` | `fill`, `stroke`, `width`, `dash`, `cap`, `join`, `opacity`, `blendMode`, `rule` ([Vector graphics](#vector-graphics)) |
413
+ | `encrypt` | `userPassword`, `ownerPassword`, `algorithm`, `permissions`, `encryptMetadata` ([Encryption](#encryption-1)) |
414
+ | `pdf.sign(field, o)` | `signer`, `reason`, `location`, `name`, `certify`, `timestamp`, `subFilter`, `reserve` ([Signatures](#signatures)) |
415
+ | `loadPdf(bytes, o)` | `password`, `budget`, `timeoutMs`, `signal` ([Reading](#reading-and-modifying)) |
416
+ | `doc.save(o)` / `mergePdfs(docs, o)` | `compress`, `keepActiveContent`, `encrypt`, `pdfa`, `keepStructure`, `creationDate` |
417
+ | `doc.saveIncremental(o)` | `sign`, `keepActiveContent`, `allowFillAfterSigning`, `allowChangesAfterSigning` |
418
+
419
+ ### Coordinates
24
420
 
25
421
  PDF points (1/72 inch), **origin at the top-left corner of the page, y growing downwards** (as in CSS and canvas;
26
422
  PDF itself counts from the bottom-left — the library converts). For `page.text`, `y` is the **top of the line box**:
27
423
  the baseline sits at `y + font.ascent * size`.
28
424
 
29
- ## API
30
425
 
31
426
  ### `createPdf(sink, options?)`
32
427
 
@@ -54,14 +449,18 @@ writer to be ready. `pdf.end()` ends a Node stream and closes a `WritableStream`
54
449
  `Helvetica-BoldOblique`, `Times-Roman`, `Times-Bold`, `Times-Italic`, `Times-BoldItalic`, `Courier`,
55
450
  `Courier-Bold`, `Courier-Oblique`, `Courier-BoldOblique`, `Symbol`, `ZapfDingbats`). Not embedded; text is WinAnsi
56
451
  (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
452
+ - `await pdf.embedFont(bytes, { subset, shaper, textMapping })` — a TrueType (`glyf`) or OpenType/CFF (`.otf`) font. Any Unicode
58
453
  text (Identity-H with a ToUnicode map, so text can be copied and searched). TrueType embeds as `FontFile2`
59
454
  (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
455
+ `subset`: `true` (default) keeps only the glyphs used, both kinds built in (TrueType: the glyphs used, their
456
+ components and `.notdef` are renumbered 0…n and only `glyf loca head hhea hmtx maxp` and the hinting tables
457
+ `cvt fpgm prep` are kept; the content still draws by the original glyph ID, through a `/CIDToGIDMap` stream, so
458
+ ToUnicode and widths are unchanged. CFF: unused glyphs' charstrings become
61
459
  `endchar`, subroutines are kept, glyph IDs stay; a font using the deprecated `seac` accents embeds whole; a
62
460
  CID-keyed CFF gets an identity charset so every reader maps its glyphs alike); `false` embeds the whole font; or a
63
461
  function `(font, codePoints, { glyphs }) => Promise<Uint8Array>` (e.g. a HarfBuzz subsetter, which also
64
- desubroutinises CFF) that must keep glyph IDs. `shaper`: see Shaping.
462
+ desubroutinises CFF) that must keep glyph IDs (its font is embedded as returned, `/CIDToGIDMap /Identity`, as
463
+ `core` callers that write their own fonts expect). `shaper`: see Shaping.
65
464
  A variable font (an `fvar` table) is drawn at its default instance, which may be its thinnest weight (Noto Sans JP's
66
465
  is Thin), with a warning naming the axes' values: for another weight, embed a static instance of it (fonttools
67
466
  `varLib.instancer`, or `hb-subset --instance`).
@@ -134,6 +533,10 @@ dots) has its text on one glyph and WJ (U+2060) on the others, which are drawn a
134
533
  `CIDToGIDMap`; a CFF font cannot, so there it gets an ActualText span). The shaper's output is checked (glyph IDs in
135
534
  the font, finite numbers, clusters inside the text, at most 4 glyphs a character).
136
535
 
536
+ `textMapping` (left-to-right clusters in an ActualText span): `'carrier'` (default) also maps the cluster's text on its
537
+ carrier glyph, which suits Firefox, pdf.js and search (MuPDF reads such a cluster twice); `'actualText'` maps the
538
+ carrier to WJ so the text is only in the ActualText, which suits MuPDF/PyMuPDF and AI pipelines (pdf.js loses those clusters).
539
+
137
540
  An adapter for harfbuzzjs v1 (`npm i harfbuzzjs`; not a dependency of this package). The same file ships in the package
138
541
  as `examples/harfbuzz-shaper.mjs`:
139
542
 
@@ -260,10 +663,12 @@ const logo = await pdf.embedImage(fs.readFileSync('logo.png')); // alpha
260
663
  for (const y of [400, 500, 600]) page.image(logo, { x: 40, y, height: 24, opacity: 0.6 }); // written once
261
664
  ```
262
665
 
263
- - `await pdf.embedImage(bytes, { colorSpace, maxDecodedBytes })` — a JPEG or PNG, written to the file at once as an image XObject;
666
+ - `await pdf.embedImage(bytes, { colorSpace, maxDecodedBytes, compression })` — a JPEG or PNG, written to the file at once as an image XObject;
264
667
  returns a handle with `width` and `height` (pixels, after the EXIF orientation). `colorSpace`: an ICC colour space
265
668
  (`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).
669
+ decoded size allowed for a PNG that needs decoding (default 64 MB, 67,108,864 bytes). `compression`: the zlib level
670
+ for a PNG that is re-compressed (alpha or interlacing): `'default'` is 6, or 3 above 2 megapixels (about half the
671
+ time for 2–3% more bytes on photos); `'fast'` is 1; or a level 1–9. JPEGs and passed-through PNGs are not affected.
267
672
  - **JPEG**: baseline and progressive, gray, RGB and CMYK, embedded unchanged (DCTDecode, no re-encoding). Adobe's
268
673
  inverted CMYK (an APP14 "Adobe" segment) is drawn with `/Decode [1 0 1 0 1 0 1 0]`. The EXIF orientation (1–8)
269
674
  is honoured. 12-bit, lossless, hierarchical and arithmetic-coded JPEGs are refused.
@@ -281,6 +686,10 @@ for (const y of [400, 500, 600]) page.image(logo, { x: 40, y, height: 24, opacit
281
686
  `height`: the other keeps the aspect ratio; neither: one point per pixel. `fit`: `'fill'` (default: stretch to the
282
687
  box), `'contain'` (all of the image, centred), `'cover'` (fills the box, centred, cut to it). `opacity` 0–1. Tagged
283
688
  PDFs: `alt` makes the image a `Figure` with that alternate text; without `alt` it is an artifact (decoration).
689
+ - Images are deduplicated automatically: `embedImage` called again with the same bytes (and the same options) in the
690
+ same document returns the same handle and writes nothing new (keyed by a SHA-256 of the bytes; Node's `node:crypto`,
691
+ else WebCrypto, else a fast hash confirmed byte for byte). A different option (`colorSpace`, `maxDecodedBytes`, `compression`)
692
+ embeds it again.
284
693
  - An image drawn any number of times, on any pages, is one XObject in the file. Images are always XObjects, never
285
694
  inline images: an inline image would be repeated in every content stream and cannot have a soft mask.
286
695
  - Image handles, like fonts and gradients, only work in the PDF that made them.
@@ -450,6 +859,11 @@ f.signature('sig', { page, x: 40, y: 360, width: 200, height: 40, tooltip: 'Sign
450
859
  - An embedded font in a fillable field is subset to the characters drawn; for fields people will type into, embed it
451
860
  with `subset: false` so a viewer has every glyph.
452
861
 
862
+ A caller that draws its own appearances (pdf.core users) passes `appearance: { normal }` (a checkbox: `{ on, off }`)
863
+ of content-stream operators, written exactly, and `da` (`'/F1 12 Tf 0 g'`, a font it registered with
864
+ `core.resource('Font', …)`): with both, no `font` is needed. Each field's `widgetRef` is its widget's object number,
865
+ and `structParent` puts a `/StructParent` on it for a structure tree of the caller's own.
866
+
453
867
  ### Encryption
454
868
 
455
869
  ```js
@@ -511,6 +925,11 @@ await pdf.end(); // the signer runs here
511
925
  AcroForm gets `/SigFlags 3`. **One signature** a document (more need incremental updates).
512
926
  - Works with encryption (the signature's `/Contents` is not encrypted, as the spec requires) and with PDF/A-2b.
513
927
 
928
+ An invisible signature: `pdf.sign({ name: 'Signature1' }, { signer })` (no page, no size) is a zero-`/Rect` field in
929
+ the AcroForm only, so it works after every page is written (a document built through `pdf.core`); with `page` (still
930
+ open) it goes in that page's `/Annots`. A PKCS#12 (.p12/.pfx) file signs through `cmsSigner`:
931
+ `examples/p12-signer.mjs` (node-forge, which you install; not a dependency) gives `p12Signer(bytes, password)`.
932
+
514
933
  ### Tagged PDF
515
934
 
516
935
  `page.tag(type, fn, options)` options: `alt` (`/Alt`), `actualText` (`/ActualText`: the text the element stands for),
@@ -614,6 +1033,34 @@ default configuration `/D` (`/Name`, `/Order` listing every layer, `/ON`, `/OFF`
614
1033
  - `await pdf.end()` — ends open pages, writes fonts, outline, metadata and the cross-reference stream; resolves to
615
1034
  `{ pages, bytes, warnings }`.
616
1035
 
1036
+ ### Extension API (core)
1037
+
1038
+ `pdf.core` (typed `PdfCore` in index.d.ts) is the layer every feature module is written on, for a caller that writes
1039
+ its own content streams and objects beside the Pdf methods (the ReportWright engine does). Not covered by semver in
1040
+ 0.x; ARCHITECTURE.md ("Extension points") has the details.
1041
+
1042
+ ```js
1043
+ const pdf = createPdf(sink);
1044
+ const { core } = pdf;
1045
+ const n = core.reserve(); // an object number, written later
1046
+ await core.write(n, '<< /Type /ExtGState /ca 0.5 >>'); // small objects go into object streams
1047
+ const gs = core.resource('ExtGState', `${n} 0 R`); // a name in every page's shared Resources
1048
+ core.catalog('PageLabels', '<< /Nums [0 << /S /r >>] >>');
1049
+ core.onFinish(async () => {
1050
+ /* runs at pdf.end(), after the last page, before the fonts and the catalog */
1051
+ });
1052
+ ```
1053
+
1054
+ - `reserve()`, `write(n, dict, stream?)`, `writeStream(n, dict, raw)`, `writeBig(n, head, parts, tail)`,
1055
+ `object(dict, stream?)` — objects; a stream's bytes are already filtered (`/Length` is added).
1056
+ - `resource(kind, value, name?)`, `resourcesRef` — the shared Resources dictionary.
1057
+ - `imageResource(image)` → `{ name, ref, alpha, orientation }` — an image from `pdf.embedImage` for your own content
1058
+ stream: `q w 0 0 h x y cm <orientation> cm /<name> Do Q` (with `alpha`, give the page a transparency `/Group`).
1059
+ - `catalog(key, value)`, `onFinish(fn)`, `info`, `xmp` — the end of the document; `info` set to a dictionary's text
1060
+ replaces the Info made from the options.
1061
+ - `pageRef(i)`, `pageHeight(i)`, `pageCount`, `warn(msg)`, `serial(fn)`, `tags` — pages, warnings, ordering, tagging.
1062
+ - In an encrypted file, every string an object holds (literal or hex) is encrypted as it is written.
1063
+
617
1064
  ### Reading and modifying
618
1065
 
619
1066
  ```js
@@ -686,9 +1133,23 @@ const signed = await doc.saveIncremental({ sign: { field: { name: 'approval', pa
686
1133
  - Errors from the input are `PdfReadError` with a `code`: `E_MALFORMED`, `E_BUDGET`, `E_DEPTH`, `E_CYCLE`,
687
1134
  `E_TIMEOUT`, `E_ABORTED`, `E_PASSWORD`, `E_UNSUPPORTED`, `E_ACTIVE`.
688
1135
 
689
- ## Security
1136
+ ## Security model
1137
+
1138
+ In short:
690
1139
 
691
- What the library refuses, and why:
1140
+ - **No active content can be written.** There is no API for JavaScript, Launch, SubmitForm, ImportData, GoToR or any
1141
+ other action, no `/AA`, no form scripts; URLs are limited to http, https and mailto (widen per link with
1142
+ `allowSchemes`, never to `javascript:`, `data:`, `file:` or `vbscript:`). Every caller string is written escaped.
1143
+ - **Active content is stripped on read.** `save()` and `mergePdfs()` drop JavaScript, launch and submit actions, `/AA`,
1144
+ XFA, RichMedia and every annotation or action outside an allowlist, and report it in `stripped`
1145
+ (`keepActiveContent: true` opts out). `saveIncremental()` cannot strip (the old bytes stay), so it scans the whole
1146
+ file and refuses (`E_ACTIVE`) instead.
1147
+ - **Every input is budgeted.** Decoded bytes, parse steps, objects, nesting, reference chains, text items and time
1148
+ have caps (`budget`, `timeoutMs`, `signal`); exceeding one is a `PdfReadError` (`E_BUDGET`, `E_TIMEOUT`…), never
1149
+ a hang or an out-of-memory crash. Images, attachments and Factur-X XML have their own size caps.
1150
+ - **Platform crypto only**, no RC4 written, signed documents' DocMDP/FieldMDP permissions enforced on update.
1151
+
1152
+ The details, and what the library refuses and why:
692
1153
 
693
1154
  | Refused | Why |
694
1155
  |---|---|
@@ -742,6 +1203,12 @@ any active content, or if anything cannot be read (fail closed), unless `keepAct
742
1203
  strangers, `save()` is the safe path** (it rewrites the file without the active content; this invalidates existing
743
1204
  signatures).
744
1205
 
1206
+ **This is why `saveIncremental` is slower by design.** Appending a few objects could be done without reading the
1207
+ original, but then active content hidden in it (a shadow attack: an earlier revision, an unreferenced object, a
1208
+ duplicate, an object stream the latest cross-reference does not point at) would ride along under your new signature.
1209
+ So it reads and scans the whole file first, every time: a large file costs a full parse before a byte is appended.
1210
+ Safer, slower, on purpose.
1211
+
745
1212
  **Signed documents (signature policy).** `saveIncremental` reads every signature's permissions and takes the
746
1213
  strictest: DocMDP from the catalog `/Perms` and from each signature's `/Reference` in every revision (no `/P` means
747
1214
  P=2), FieldMDP from each `/Reference` and each signed field's `/Lock`, and ReadOnly from any ancestor field. Anything it
@@ -759,13 +1226,59 @@ after signing", and refilling widgets that existed before signing is how "shadow
759
1226
  Security 2021) make a signed document look different while its signature still verifies. As a second guard the filled
760
1227
  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
1228
 
762
- ## Not supported yet
1229
+ ## Known limits
1230
+
1231
+ - **Images**: image masks (stencils); embedded ICC profiles of PNG/JPEG files are ignored.
1232
+ - **Text**: no Unicode bidi algorithm, no hyphenation, no kerning of the standard fonts, no WOFF fonts; text
1233
+ extraction is reading order, not layout-perfect. With `textMapping: 'actualText'`, readers that ignore ActualText
1234
+ or close it early (pdf.js; a tester saw an older MuPDF drop a line's last Bengali cluster) lose the spanned
1235
+ clusters: the writer closes every span after its cluster's last glyph, and MuPDF 1.28.2 (PyMuPDF) reads Bengali,
1236
+ Devanagari and the other test scripts whole.
1237
+ - **Annotations**: no popup annotations.
1238
+ - **Crypto**: RC4 is refused (read only); public-key (certificate) encryption is neither written nor read; signatures
1239
+ are listed with their coverage, but their cryptography is not verified.
1240
+ - **Standards**: no PDF/A-1a or PDF/X; no attachment annotations in PDF/A-3 (use `pdf.attach`); a Factur-X invoice's
1241
+ content is not validated.
1242
+ - **Editing**: no page edits in an incremental update (use `save()`).
1243
+ - **Browsers**: reading encrypted files needs Node (WebCrypto has no synchronous AES).
763
1244
 
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.
1245
+ See NOTES.md for the plan, and ARCHITECTURE.md for extending the library.
1246
+
1247
+ ## Releasing
1248
+
1249
+ Releases are published to npm by GitHub Actions from a GitHub Release (tag `v<version>`). The one-time `NPM_TOKEN`
1250
+ setup and the steps are in [RELEASING.md](./RELEASING.md).
766
1251
 
767
1252
  ## Changes
768
1253
 
1254
+ Unreleased: the built-in TrueType subset renumbers its glyphs and drops the tables a PDF does not read (`name`,
1255
+ `OS/2`, `gasp`; OpenType/CFF also drops `name` and `OS/2`): a 19-character DejaVu Sans line is 6.5 KB, was 20.2 KB.
1256
+ Renders and extracted text are unchanged. A subsetter function, and `core`, still keep glyph IDs.
1257
+
1258
+ 0.1.0-beta.11: `pdf.core`, the low-level API under `createPdf`, is documented (`PdfCore` in the types), with
1259
+ `core.imageResource`; encryption now covers every literal and hex string in direct objects (it leaked some and threw
1260
+ on others); `readFont` and `checkSubset` read a font's metrics and check a subset before embedding; form fields take
1261
+ a caller's `appearance`, `da`, `structParent` and `widgetRef`; a document whose pages were written through `core` can
1262
+ be signed (an invisible field), with a PKCS#12 example (`examples/p12-signer.mjs`); `svgPathOps` turns SVG path data
1263
+ into path operators; the build is minified, and `@reportwright/pdf/write` exports the writer without the reader; the
1264
+ `page`, `width` and `height` of `SignatureOptions` are optional.
1265
+ It includes everything from beta.9 and beta.10.
1266
+
1267
+ 0.1.0-beta.10: a WritableStream's writer lock is released when the export ends, fails or is stopped (`timeoutMs`,
1268
+ `signal`), so the caller can reuse or cancel the stream; the abort reason is still the original error.
1269
+
1270
+ 0.1.0-beta.9: stamping every page (`page.draw` then `save()`) is about 35% faster on a 1,000-page file: a page's open
1271
+ graphics states are counted with a byte scan instead of a full parse, and FlateDecode inflates whole, valid zlib
1272
+ streams with Node's zlib (damaged, truncated, raw or over-budget streams still go through fflate as before); the output
1273
+ is byte-identical. `bulk` starts an item's `itemTimeoutMs` clock when its worker is ready, so a respawned worker's
1274
+ start-up no longer fails good files; a stopped export (`timeoutMs` or `signal`) aborts its WritableStream even when
1275
+ the stop lands between two calls.
1276
+
1277
+ 0.1.0-beta.2: a left-to-right shaped cluster in an ActualText span has its text only in the ActualText, so MuPDF
1278
+ no longer reads it twice (pdf.js, which ignores ActualText, now misses those clusters; poppler, PDFium and
1279
+ `extractText` read them); `extractText` drops WJ (U+2060) and reads a code a ToUnicode skips as nothing (U+FFFD
1280
+ only when the font has no mapping at all); `@reportwright/pdf/examples/*` can be imported.
1281
+
769
1282
  0.1.0-beta.1: shaped text (Indic, Arabic) extracts once and in order in pdf.js, poppler and `extractText`;
770
1283
  `extractText` honours ActualText, reads unmapped glyphs as U+FFFD, falls back to an embedded TrueType cmap, spaces
771
1284
  separate text objects (spreadsheet cells), orders a line along its baseline (rotated text too), and stops a