@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.
@@ -0,0 +1,28 @@
1
+ // PDF/UA-1 and PDF/A-2a in one file: a structure tree (headings, a list, a figure), artifacts for decoration.
2
+ //
3
+ // node examples/accessible.mjs font.ttf [out.pdf]
4
+ import fs from 'node:fs';
5
+ import { createPdf } from '../dist/index.js';
6
+
7
+ const [fontFile, out = 'accessible.pdf'] = process.argv.slice(2);
8
+ if (!fontFile) { console.error('node examples/accessible.mjs font.ttf [out.pdf]'); process.exit(2); }
9
+ const pdf = createPdf(fs.createWriteStream(out), {
10
+ title: 'Leave policy', tagged: { lang: 'en-GB' }, // PDF/UA-1: a title and tags are required
11
+ pdfa: { part: 2, conformance: 'a' }, // PDF/A-2a: archival and tagged
12
+ });
13
+ const font = await pdf.embedFont(fs.readFileSync(fontFile));
14
+ const page = pdf.addPage();
15
+ page.artifact({ type: 'Pagination', subtype: 'Header' }, () => page.text('Example Ltd · HR policies', { x: 40, y: 20, font, size: 8 }));
16
+ page.tag('H1', () => page.text('Leave policy', { x: 40, y: 50, font, size: 22 }));
17
+ page.tag('P', () => page.textBox('Every employee gets the following leave each calendar year.', { x: 40, y: 90, width: 500, font }));
18
+ page.tag('L', () => ['18 days of annual leave', '12 days of sick leave'].forEach((s, i) => page.tag('LI', () => {
19
+ page.tag('Lbl', () => page.text('•', { x: 40, y: 120 + i * 18, font }));
20
+ page.tag('LBody', () => page.text(s, { x: 56, y: 120 + i * 18, font }));
21
+ })), { listNumbering: 'Disc' });
22
+ page.figure({ alt: 'Bar chart: annual leave 18 days, sick leave 12 days' }, () => {
23
+ page.fill(page.path().rect(40, 180, 180, 16), { fill: '#1f4e79' });
24
+ page.fill(page.path().rect(40, 200, 120, 16), { fill: '#5b9bd5' });
25
+ });
26
+ await page.end();
27
+ const { warnings } = await pdf.end();
28
+ console.log(`wrote ${out}: PDF/UA-1 + PDF/A-2a`, warnings.length ? warnings : '');
@@ -0,0 +1,19 @@
1
+ // Many files at once: extract text and encrypt in worker threads; a broken file fails alone.
2
+ //
3
+ // node examples/bulk.mjs [outDir] writes locked-1.pdf … locked-N.pdf
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { toBytes, bulk } from '../dist/index.js';
7
+
8
+ const dir = process.argv[2] ?? '.';
9
+ const make = (label) => toBytes(async (pdf) => {
10
+ const p = pdf.addPage(); p.text(`${label}: total due 1,250.00`, { x: 40, y: 40, font: pdf.standardFont('Helvetica'), size: 14 }); await p.end();
11
+ });
12
+ const files = [await make('Invoice 1'), new Uint8Array([0x25, 0x50]), await make('Invoice 3')]; // the second is not a PDF
13
+
14
+ const texts = await bulk(files, 'extractText', { workers: 2 });
15
+ texts.forEach((r, i) => console.log(i + 1, r.ok ? r.value : `failed: ${r.error.code} ${r.error.message}`));
16
+
17
+ const locked = await bulk(files, 'encrypt', { workers: 2, encrypt: { userPassword: 'open me', ownerPassword: 'full access' } });
18
+ locked.forEach((r, i) => { if (r.ok) fs.writeFileSync(path.join(dir, `locked-${i + 1}.pdf`), r.value); });
19
+ console.log(`encrypted ${locked.filter((r) => r.ok).length} of ${files.length}`);
@@ -0,0 +1,23 @@
1
+ // AES-256 password protection with permissions, then reading it back with the password.
2
+ //
3
+ // node examples/encrypt.mjs [out.pdf]
4
+ import fs from 'node:fs';
5
+ import { toBytes, loadPdf } from '../dist/index.js';
6
+
7
+ const out = process.argv[2] ?? 'encrypted.pdf';
8
+ const bytes = await toBytes(async (pdf) => {
9
+ const page = pdf.addPage();
10
+ page.text('Salary statement: confidential', { x: 40, y: 40, font: pdf.standardFont('Helvetica'), size: 14 });
11
+ await page.end();
12
+ }, {
13
+ title: 'Salary statement',
14
+ encrypt: {
15
+ userPassword: 'open me', // asked when the file opens ('' opens without a prompt)
16
+ ownerPassword: 'full access', // lifts the permissions; left out: random
17
+ algorithm: 'aes-256', // or 'aes-128' for very old readers
18
+ permissions: { print: true, copy: false, modify: false }, // each defaults to true
19
+ },
20
+ });
21
+ fs.writeFileSync(out, bytes);
22
+ const doc = await loadPdf(bytes, { password: 'open me' });
23
+ console.log(`wrote ${out}: ${doc.encryption.algorithm}, copy allowed: ${doc.encryption.permissions.copy}; text: ${doc.extractText().trim()}`);
@@ -0,0 +1,33 @@
1
+ // Forms: create a fillable PDF, fill it after loading, and flatten it into a plain PDF.
2
+ //
3
+ // node examples/fill-form.mjs [outDir] writes form.pdf, filled.pdf, flattened.pdf
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { toBytes, loadPdf } from '../dist/index.js';
7
+
8
+ const dir = process.argv[2] ?? '.';
9
+ // 1. Create: every field gets its appearance streams, so every viewer shows the same thing.
10
+ const form = await toBytes(async (pdf) => {
11
+ const font = pdf.standardFont('Helvetica');
12
+ const page = pdf.addPage();
13
+ page.text('Leave request', { x: 40, y: 40, font: pdf.standardFont('Helvetica-Bold'), size: 18 });
14
+ const f = pdf.form;
15
+ f.textField('name', { page, x: 40, y: 80, width: 240, height: 22, font, tooltip: 'Full name', required: true });
16
+ f.comboBox('type', { page, x: 40, y: 115, width: 160, height: 22, font, options: ['Annual', 'Sick', 'Unpaid'], value: 'Annual', tooltip: 'Leave type' });
17
+ f.checkbox('approved', { page, x: 40, y: 150, width: 14, height: 14, tooltip: 'Approved by manager' });
18
+ page.text('Approved', { x: 60, y: 151, font, size: 11 });
19
+ await page.end();
20
+ });
21
+ fs.writeFileSync(path.join(dir, 'form.pdf'), form);
22
+
23
+ // 2. Fill: values by full field name; the appearances are drawn again by the writer.
24
+ const doc = await loadPdf(form);
25
+ console.log('fields:', doc.fields.map((x) => `${x.name} (${x.type})`).join(', '));
26
+ doc.fill({ name: 'Aanya Sharma', type: 'Sick', approved: true });
27
+ fs.writeFileSync(path.join(dir, 'filled.pdf'), (await doc.save()).bytes);
28
+
29
+ // 3. Flatten: the fields become page content; no AcroForm is left.
30
+ const flat = (await loadPdf(form)).fill({ name: 'Aanya Sharma', type: 'Sick', approved: true }).flatten();
31
+ const flattened = (await flat.save()).bytes;
32
+ fs.writeFileSync(path.join(dir, 'flattened.pdf'), flattened);
33
+ console.log('flattened text:', (await loadPdf(flattened)).page(0).extractText().replace(/\s+/g, ' '));
@@ -0,0 +1,20 @@
1
+ // Hindi (Devanagari) text shaped by HarfBuzz: conjuncts and reordered matras drawn right, copied text in logical order.
2
+ //
3
+ // npm i harfbuzzjs
4
+ // node examples/indic.mjs NotoSansDevanagari-Regular.ttf [out.pdf]
5
+ import fs from 'node:fs';
6
+ import { createPdf } from '../dist/index.js';
7
+ import { harfbuzzShaper } from './harfbuzz-shaper.mjs';
8
+
9
+ const [fontFile, out = 'indic.pdf'] = process.argv.slice(2);
10
+ if (!fontFile) { console.error('node examples/indic.mjs NotoSansDevanagari-Regular.ttf [out.pdf]'); process.exit(2); }
11
+ const pdf = createPdf(fs.createWriteStream(out), { title: 'हिन्दी' });
12
+ const bytes = fs.readFileSync(fontFile);
13
+ const hindi = await pdf.embedFont(bytes, { shaper: harfbuzzShaper(bytes) });
14
+ const latin = pdf.standardFont('Helvetica');
15
+ const page = pdf.addPage();
16
+ page.text('किताब हिन्दी क्षत्रिय', { x: 40, y: 40, font: hindi, size: 24 });
17
+ page.textBox('Mixed text: इस किताब में हिन्दी और English दोनों हैं।', { x: 40, y: 90, width: 400, font: hindi, fallback: [latin], size: 14 });
18
+ await page.end();
19
+ const { warnings } = await pdf.end();
20
+ console.log(`wrote ${out}`, warnings.length ? warnings : '');
@@ -0,0 +1,40 @@
1
+ // A self-contained Factur-X invoice: a tagged PDF/A-3a page drawn from the same data as the CII XML embedded in it.
2
+ // (examples/factur-x.mjs does the same from an XML file you already have.)
3
+ //
4
+ // node examples/invoice-facturx.mjs font.ttf [out.pdf] any TrueType font, e.g. Inter-Regular.ttf
5
+ //
6
+ // The XML below is a short EN 16931 skeleton for the example. pdf.facturX checks its root, profile ID and that it has
7
+ // no DOCTYPE or entities; it does not validate the invoice: run the Factur-X XSD and Schematron on real invoices.
8
+ import fs from 'node:fs';
9
+ import { createPdf } from '../dist/index.js';
10
+
11
+ const [fontFile, out = 'invoice-facturx.pdf'] = process.argv.slice(2);
12
+ if (!fontFile) { console.error('node examples/invoice-facturx.mjs font.ttf [out.pdf]'); process.exit(2); }
13
+ const inv = { number: 'INV-2026-001', date: '20261009', seller: 'Example Ltd', buyer: 'Acme GmbH', currency: 'EUR',
14
+ lines: [['Consulting, 10 h', '1000.00'], ['Licence', '250.00']], net: '1250.00', vat: '237.50', total: '1487.50' };
15
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
16
+ <rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
17
+ <rsm:ExchangedDocumentContext><ram:GuidelineSpecifiedDocumentContextParameter><ram:ID>urn:cen.eu:en16931:2017</ram:ID></ram:GuidelineSpecifiedDocumentContextParameter></rsm:ExchangedDocumentContext>
18
+ <rsm:ExchangedDocument><ram:ID>${inv.number}</ram:ID><ram:TypeCode>380</ram:TypeCode><ram:IssueDateTime><udt:DateTimeString format="102">${inv.date}</udt:DateTimeString></ram:IssueDateTime></rsm:ExchangedDocument>
19
+ <rsm:SupplyChainTradeTransaction><ram:ApplicableHeaderTradeAgreement><ram:SellerTradeParty><ram:Name>${inv.seller}</ram:Name></ram:SellerTradeParty><ram:BuyerTradeParty><ram:Name>${inv.buyer}</ram:Name></ram:BuyerTradeParty></ram:ApplicableHeaderTradeAgreement>
20
+ <ram:ApplicableHeaderTradeSettlement><ram:InvoiceCurrencyCode>${inv.currency}</ram:InvoiceCurrencyCode><ram:SpecifiedTradeSettlementHeaderMonetarySummation><ram:TaxBasisTotalAmount>${inv.net}</ram:TaxBasisTotalAmount><ram:TaxTotalAmount currencyID="${inv.currency}">${inv.vat}</ram:TaxTotalAmount><ram:GrandTotalAmount>${inv.total}</ram:GrandTotalAmount><ram:DuePayableAmount>${inv.total}</ram:DuePayableAmount></ram:SpecifiedTradeSettlementHeaderMonetarySummation></ram:ApplicableHeaderTradeSettlement></rsm:SupplyChainTradeTransaction>
21
+ </rsm:CrossIndustryInvoice>`;
22
+
23
+ const pdf = createPdf(fs.createWriteStream(out), {
24
+ title: `Invoice ${inv.number}`, author: inv.seller, pdfa: { part: 3, conformance: 'a' }, tagged: { lang: 'en' },
25
+ });
26
+ const font = await pdf.embedFont(fs.readFileSync(fontFile)); // PDF/A: every font embedded
27
+ const page = pdf.addPage();
28
+ const t = (s, x, y, size = 11) => page.text(s, { x, y, font, size });
29
+ page.tag('H1', () => t(`Invoice ${inv.number}`, 56, 56, 22));
30
+ page.tag('P', () => t(`From ${inv.seller} to ${inv.buyer}`, 56, 96));
31
+ page.tag('Table', () => {
32
+ page.tag('TR', () => ['Item', `Amount (${inv.currency})`].forEach((h, i) => page.tag('TH', () => t(h, 56 + i * 300, 140), { scope: 'Column', id: `h${i}` })));
33
+ [...inv.lines, ['Net', inv.net], ['VAT 19%', inv.vat], ['Total', inv.total]].forEach(([item, amount], r) =>
34
+ page.tag('TR', () => [item, amount].forEach((v, i) => page.tag('TD', () => t(v, 56 + i * 300, 162 + r * 20), { headers: [`h${i}`] }))));
35
+ });
36
+ page.stroke(page.path().moveTo(56, 156).lineTo(540, 156), { width: 0.5 }); // outside any tag: an artifact
37
+ await pdf.facturX(new TextEncoder().encode(xml), { profile: 'EN 16931' });
38
+ await page.end();
39
+ const { warnings } = await pdf.end();
40
+ console.log(`wrote ${out}: PDF/A-3a + factur-x.xml (EN 16931)`, warnings.length ? warnings : '');
@@ -0,0 +1,29 @@
1
+ // Read and modify: merge, split, extract text, and an incremental update that keeps the original bytes.
2
+ //
3
+ // node examples/merge-split.mjs [outDir] writes merged.pdf, page-1.pdf … page-N.pdf, updated.pdf
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { toBytes, loadPdf, mergePdfs } from '../dist/index.js';
7
+
8
+ const dir = process.argv[2] ?? '.';
9
+ const make = (label, n) => toBytes(async (pdf) => {
10
+ const font = pdf.standardFont('Helvetica');
11
+ for (let i = 1; i <= n; i++) { const p = pdf.addPage(); p.text(`${label}, page ${i}`, { x: 40, y: 40, font, size: 16 }); await p.end(); }
12
+ }, { title: label });
13
+ const a = await loadPdf(await make('Contract', 2)), b = await loadPdf(await make('Annexure', 1));
14
+
15
+ const merged = await mergePdfs([a, b]); // a new file, every page of each
16
+ fs.writeFileSync(path.join(dir, 'merged.pdf'), merged.bytes);
17
+ const all = await loadPdf(merged.bytes);
18
+ console.log(`merged: ${all.pageCount} pages; text:`, all.extractText().split('\f').map((t) => t.trim()));
19
+
20
+ for (let i = 0; i < all.pageCount; i++) { // split: a fork per page
21
+ const { bytes } = await all.fork().reorder([i]).save();
22
+ fs.writeFileSync(path.join(dir, `page-${i + 1}.pdf`), bytes);
23
+ }
24
+
25
+ const doc = await loadPdf(merged.bytes);
26
+ doc.setInfo({ title: 'Contract (reviewed)' });
27
+ const update = await doc.saveIncremental(); // original bytes + an appended update
28
+ fs.writeFileSync(path.join(dir, 'updated.pdf'), update.bytes);
29
+ console.log(`split into ${all.pageCount} files; update appended ${update.size - merged.bytes.length} bytes`);
@@ -0,0 +1,30 @@
1
+ // A signer for pdf.sign from a PKCS#12 file (.p12 / .pfx: a key and its certificates under a password), through
2
+ // cmsSigner and node-forge (npm i node-forge; it is not a dependency of @reportwright/pdf). RSA keys, PKCS#1 v1.5.
3
+ //
4
+ // import { p12Signer } from '@reportwright/pdf/examples/p12-signer.mjs';
5
+ // await pdf.sign({ name: 'Signature1' }, { signer: p12Signer(fs.readFileSync('id.p12'), 'password') });
6
+ import forge from 'node-forge';
7
+ import { cmsSigner } from '@reportwright/pdf';
8
+
9
+ /** @param {Uint8Array} p12 the file's bytes @param {string} password */
10
+ export function p12Signer(p12, password) {
11
+ const asn1 = forge.asn1.fromDer(forge.util.createBuffer(Buffer.from(p12).toString('binary')));
12
+ const box = forge.pkcs12.pkcs12FromAsn1(asn1, password); // a wrong password throws here
13
+ const bags = (type) => box.getBags({ bagType: type })[type] ?? [];
14
+ const key = [...bags(forge.pki.oids.pkcs8ShroudedKeyBag), ...bags(forge.pki.oids.keyBag)][0]?.key;
15
+ if (!key) throw new TypeError('p12Signer: no private key in the file');
16
+ const certs = bags(forge.pki.oids.certBag).map((b) => b.cert);
17
+ // the signer's certificate first: the one whose public key is the key's
18
+ const mine = certs.findIndex((c) => c.publicKey.n?.equals(key.n));
19
+ if (mine < 0) throw new TypeError("p12Signer: no certificate for the file's key");
20
+ certs.unshift(...certs.splice(mine, 1));
21
+ return cmsSigner({
22
+ certs: certs.map((c) => forge.pki.certificateToPem(c)).join('\n'),
23
+ keyAlgorithm: 'rsa',
24
+ sign(bytes, hash) {
25
+ const md = { 'SHA-256': forge.md.sha256, 'SHA-384': forge.md.sha384, 'SHA-512': forge.md.sha512 }[hash].create();
26
+ md.update(Buffer.from(bytes).toString('binary'));
27
+ return Uint8Array.from(Buffer.from(key.sign(md), 'binary'));
28
+ },
29
+ });
30
+ }
@@ -0,0 +1,29 @@
1
+ // PAdES B-B signature while the PDF streams out, then a second signature appended with saveIncremental.
2
+ //
3
+ // openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 -subj "/CN=Example Signer"
4
+ // node examples/sign.mjs key.pem cert.pem [out.pdf]
5
+ // For a real signature use a certificate from a CA your readers trust, and a timestamp (RFC 3161) for PAdES B-T.
6
+ import fs from 'node:fs';
7
+ import { createPdf, loadPdf, nodeSigner } from '../dist/index.js';
8
+
9
+ const [keyFile, certFile, out = 'signed.pdf'] = process.argv.slice(2);
10
+ if (!certFile) { console.error('node examples/sign.mjs key.pem cert.pem [out.pdf]'); process.exit(2); }
11
+ const signer = nodeSigner({ key: fs.readFileSync(keyFile, 'utf8'), certs: fs.readFileSync(certFile, 'utf8') });
12
+
13
+ const chunks = [];
14
+ const pdf = createPdf({ write: (b) => { chunks.push(Buffer.from(b)); } }, { title: 'Offer letter', sign: true });
15
+ const font = pdf.standardFont('Helvetica');
16
+ const page = pdf.addPage();
17
+ page.text('Offer letter', { x: 40, y: 40, font, size: 18 });
18
+ const hr = pdf.form.signature('hr', { page, x: 40, y: 700, width: 200, height: 40, tooltip: 'HR signature' });
19
+ pdf.form.signature('candidate', { page, x: 300, y: 700, width: 200, height: 40, tooltip: 'Candidate signature' });
20
+ await pdf.sign(hr, { signer, reason: 'Issued', location: 'Bengaluru', name: 'HR', certify: 2 }); // DocMDP P=2: signing allowed later
21
+ await page.end();
22
+ await pdf.end(); // the signer runs here, on the streamed bytes' digest
23
+
24
+ // Countersign: appends an update, so the first signature stays valid.
25
+ const doc = await loadPdf(Buffer.concat(chunks));
26
+ const { bytes } = await doc.saveIncremental({ sign: { field: 'candidate', signer, reason: 'Accepted' } });
27
+ fs.writeFileSync(out, bytes);
28
+ const signatures = (await loadPdf(bytes)).signatures;
29
+ console.log(`wrote ${out}:`, signatures.map((s) => `${s.field} (revision ${s.revision}, covers the whole file: ${s.coversWholeFile})`).join('; '));
@@ -0,0 +1,53 @@
1
+ // Text, an image, a table drawn with paths, a link and bookmarks, using the standard fonts (no files needed).
2
+ //
3
+ // node examples/write.mjs [out.pdf]
4
+ import fs from 'node:fs';
5
+ import zlib from 'node:zlib';
6
+ import { createPdf } from '../dist/index.js';
7
+
8
+ const out = process.argv[2] ?? 'write.pdf';
9
+ const pdf = createPdf(fs.createWriteStream(out), { title: 'Quarterly report', author: 'Example Ltd' });
10
+ const regular = pdf.standardFont('Helvetica'), bold = pdf.standardFont('Helvetica-Bold');
11
+
12
+ // A 32 x 32 RGBA PNG made here so the example needs no image file (use fs.readFileSync('logo.png') in real code).
13
+ function png(w, h, pixel) {
14
+ const chunk = (type, data) => {
15
+ const b = Buffer.alloc(12 + data.length); b.writeUInt32BE(data.length, 0); b.write(type, 4, 'latin1'); data.copy(b, 8);
16
+ b.writeUInt32BE(zlib.crc32(b.subarray(4, 8 + data.length)), 8 + data.length); return b;
17
+ };
18
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr.set([8, 6, 0, 0, 0], 8);
19
+ const raw = Buffer.alloc(h * (1 + w * 4));
20
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) raw.set(pixel(x, y), y * (1 + w * 4) + 1 + x * 4);
21
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
22
+ }
23
+ const logo = await pdf.embedImage(png(32, 32, (x, y) => [31, 78, 121, (x - 16) ** 2 + (y - 16) ** 2 < 256 ? 255 : 0]));
24
+
25
+ const page = pdf.addPage(); // A4; origin top-left, y down, in points
26
+ page.image(logo, { x: 40, y: 40, width: 32 });
27
+ page.text('Quarterly report', { x: 84, y: 44, font: bold, size: 22, color: '#1f4e79' });
28
+ const box = page.textBox('Revenue grew in every region. The table below is drawn with paths: a filled header row, ' +
29
+ 'rules between rows and right-aligned numbers.', { x: 40, y: 90, width: 515, font: regular, size: 11, align: 'justify' });
30
+
31
+ // A table from paths: no table primitive is needed.
32
+ const rows = [['Region', 'Q1', 'Q2'], ['North', '1,204', '1,390'], ['South', '980', '1,122'], ['West', '1,510', '1,488']];
33
+ const cols = [40, 300, 430], right = 555, rowH = 22, top = 100 + box.height;
34
+ page.fill(page.path().rect(40, top, right - 40, rowH), { fill: '#1f4e79' });
35
+ rows.forEach((r, i) => {
36
+ const y = top + i * rowH;
37
+ r.forEach((cell, c) => {
38
+ const font = i === 0 ? bold : regular, size = 11;
39
+ const x = c === 0 ? cols[c] + 6 : (cols[c + 1] ?? right) - 6 - font.widthOfText(cell, size);
40
+ page.text(cell, { x, y: y + 5, font, size, color: i === 0 ? '#ffffff' : 0 });
41
+ });
42
+ if (i > 0) page.stroke(page.path().moveTo(40, y + rowH).lineTo(right, y + rowH), { stroke: 0.75, width: 0.5 });
43
+ });
44
+
45
+ page.text('Read the full report online', { x: 40, y: top + rows.length * rowH + 20, font: regular, color: '#0645ad' });
46
+ page.link(40, top + rows.length * rowH + 20, 160, 14, { url: 'https://example.com/report' });
47
+
48
+ const p2 = pdf.addPage();
49
+ p2.text('Appendix', { x: 40, y: 40, font: bold, size: 18 });
50
+ pdf.outline([{ title: 'Quarterly report', page: 1 }, { title: 'Appendix', page: 2, level: 1 }]);
51
+ await page.end(); await p2.end();
52
+ const { pages, bytes, warnings } = await pdf.end();
53
+ console.log(`wrote ${out}: ${pages} pages, ${bytes} bytes`, warnings.length ? warnings : '');
package/fflate.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ // @reportwright/pdf/fflate: the fflate (MIT) this package bundles, for a bundle that also uses fflate (the ReportWright
2
+ // engine) to share one copy. Its types are fflate's own (npm i -D fflate@0.8.3 to have them).
3
+ export * from 'fflate';
package/index.d.ts CHANGED
@@ -64,6 +64,13 @@ export interface PdfOptions {
64
64
  creationDate?: Date;
65
65
  /** Flate-compress content, fonts, object streams (default true). */
66
66
  compress?: boolean;
67
+ /**
68
+ * The zlib level (0–9) the object streams (the small objects, packed together) and the cross-reference stream are
69
+ * compressed at. Unset: the object streams at 6 and the cross-reference stream by the bundled fflate at 6, as before
70
+ * beta.7. Set, both go through the platform's zlib (Node's) at that level: 9 makes the file a little smaller and
71
+ * slower. The same input and options give the same bytes.
72
+ */
73
+ objectStreamLevel?: number;
67
74
  /**
68
75
  * PDF/A: an output intent (sRGB, or outputIntent: your RGB or CMYK ICC output/monitor profile) and XMP
69
76
  * identification. Fonts must be embedded; device colour must match the intent (CMYK needs a CMYK intent, RGB an RGB one).
@@ -329,6 +336,9 @@ export interface Path {
329
336
  svg(d: string): this;
330
337
  }
331
338
 
339
+ /** SVG path data `d` as PDF path operators (m, l, c, h) in SVG user space; scaleY -1 flips y. Malformed data throws a TypeError. */
340
+ export function svgPathOps(d: string, opts?: { scaleY?: number }): string;
341
+
332
342
  /** A gradient or tiling pattern made by a page of this document (an opaque handle: copies and forgeries are refused). */
333
343
  export interface Gradient { readonly type: 'gradient' }
334
344
  export interface TilingPattern { readonly type: 'tiling' }
@@ -540,6 +550,22 @@ export interface Field {
540
550
  readonly type: 'field';
541
551
  readonly kind: 'text' | 'checkbox' | 'radio' | 'combo' | 'list' | 'button' | 'signature';
542
552
  readonly name: string;
553
+ /** the widget's object number (field and widget are one dictionary), for a structure tree's OBJR; 0 when flattened. Not on a radio group */
554
+ readonly widgetRef?: number;
555
+ }
556
+ /**
557
+ * A caller's own appearance for a field (pdf.core callers): each state's content-stream operators (Latin-1), written
558
+ * exactly as the normal appearance (/AP /N); resources: the object number of its Resources ("12 0 R", written through
559
+ * pdf.core), default the shared Resources (which hold every font registered with core.resource('Font', …)).
560
+ */
561
+ export interface OwnAppearance { normal: string; resources?: string }
562
+ /** A caller's appearance, /DA and structure link for a text-bearing field */
563
+ export interface OwnTextLook {
564
+ appearance?: OwnAppearance;
565
+ /** the default appearance written as /DA ("/F1 12 Tf 0 g": printable ASCII with Tf); with appearance, font is not needed */
566
+ da?: string;
567
+ /** /StructParent, for a caller's own structure tree (not with tagged: the built-in tagger links fields itself) */
568
+ structParent?: number;
543
569
  }
544
570
  /** Where a widget goes: a page of this PDF and a box (top-left space). */
545
571
  export interface WidgetBox { page: Page; x?: number; y?: number; width: number; height: number }
@@ -565,7 +591,9 @@ export interface FieldTextOptions {
565
591
  align?: 'left' | 'center' | 'right';
566
592
  }
567
593
  export type CheckStyle = 'check' | 'cross' | 'circle' | 'square' | 'diamond' | 'star';
568
- export interface TextFieldOptions extends FieldOptions, FieldTextOptions {
594
+ export interface TextFieldOptions extends FieldOptions, Omit<FieldTextOptions, 'font'>, OwnTextLook {
595
+ /** required unless both appearance and da are given */
596
+ font?: Font;
569
597
  /** at most 100,000 characters (and maxLen); line breaks only when multiline */
570
598
  value?: string;
571
599
  defaultValue?: string;
@@ -585,6 +613,10 @@ export interface CheckboxOptions extends FieldOptions {
585
613
  style?: CheckStyle;
586
614
  /** the mark's colour */
587
615
  color?: Color;
616
+ /** the caller's own on and off appearances (operators), instead of the drawn mark */
617
+ appearance?: { on: string; off: string; resources?: string };
618
+ /** /StructParent, for a caller's own structure tree */
619
+ structParent?: number;
588
620
  }
589
621
  export interface RadioGroupOptions extends Omit<FieldOptions, keyof WidgetBox> {
590
622
  /** 1–1000 buttons, each with its own export value (the same one only with radiosInUnison) */
@@ -600,7 +632,9 @@ export interface RadioGroupOptions extends Omit<FieldOptions, keyof WidgetBox> {
600
632
  }
601
633
  /** an option: one string (export value and display text), or [export value, display text] */
602
634
  export type ChoiceOption = string | readonly [string, string];
603
- export interface ChoiceOptions extends FieldOptions, FieldTextOptions {
635
+ export interface ChoiceOptions extends FieldOptions, Omit<FieldTextOptions, 'font'>, OwnTextLook {
636
+ /** required unless both appearance and da are given */
637
+ font?: Font;
604
638
  /** at most 10,000, in the order given (the Sort flag is never set); export values unique */
605
639
  options: ChoiceOption[];
606
640
  }
@@ -626,7 +660,11 @@ export interface ButtonOptions extends FieldOptions, Partial<FieldTextOptions> {
626
660
  /** the face when pressed (/D; not in PDF/A, whose appearances have /N only) */
627
661
  pressedBackground?: Color;
628
662
  }
629
- export interface SignatureOptions extends FieldOptions {
663
+ /** No width and height: an invisible field (a zero /Rect, /F 132), on page when given (still open), else on no page (AcroForm only) */
664
+ export interface SignatureOptions extends Omit<FieldOptions, 'page' | 'width' | 'height'> {
665
+ page?: Page;
666
+ width?: number;
667
+ height?: number;
630
668
  /** /Lock: the fields signing locks */
631
669
  lock?: { action?: 'All' } | { action: 'Include' | 'Exclude'; fields: (string | Field)[] };
632
670
  }
@@ -644,12 +682,85 @@ export interface FormScope {
644
682
  signature(name: string, o: SignatureOptions): Field;
645
683
  }
646
684
 
685
+ /**
686
+ * The extension API (`pdf.core`): the object writer, the shared Resources, the catalog and the end-of-document hooks
687
+ * that every feature module works through (ARCHITECTURE.md, "Extension points"). A caller writing its own content
688
+ * streams and objects (the ReportWright engine) uses it beside the Pdf methods. Not covered by semver in 0.x.
689
+ * Object text is PDF syntax: `"12 0 R"`, `"<< /Type /XObject … >>"`. In an encrypted file every string in it (literal
690
+ * or hex) is encrypted when the object is written.
691
+ */
692
+ export interface PdfCore {
693
+ /** a new object number, for an object written later with write() (or writeStream, writeBig) */
694
+ reserve(): number;
695
+ /** write object n now: a dictionary or any value, and for a stream its bytes already filtered (/Length is added). Small objects go into object streams */
696
+ write(n: number, dict: string, stream?: Uint8Array): Promise<void> | undefined;
697
+ /** write object n as a stream of raw bytes, Flate-compressed (adding /Filter) unless compress is off */
698
+ writeStream(n: number, dict: string, raw: Uint8Array): Promise<void> | undefined;
699
+ /** write object n whose text may be too long to hold (head, the parts as they come, tail) */
700
+ writeBig(n: number, head: string, parts: Iterable<string>, tail: string): Promise<void>;
701
+ /** reserve and write */
702
+ object(dict: string, stream?: Uint8Array): Promise<number>;
703
+ /** a name (without the slash) in the shared Resources dictionary of every page for a value ("12 0 R" or an inline dictionary); the same value gets the same name */
704
+ resource(kind: 'Font' | 'XObject' | 'ExtGState' | 'Pattern' | 'Shading' | 'ColorSpace' | 'Properties', value: string, name?: string): string;
705
+ /**
706
+ * an embedded image (pdf.embedImage) for a caller's own content stream: its name in the shared Resources, its object
707
+ * number, whether it has transparency (the page then needs a transparency /Group, as page.image sets) and its EXIF
708
+ * orientation as a unit-square matrix. Draw it with `q w 0 0 h x y cm <orientation> cm /<name> Do Q`
709
+ */
710
+ imageResource(image: Image): { name: string; ref: number; alpha: boolean; orientation: [number, number, number, number, number, number] };
711
+ /** the shared Resources dictionary's object number (a form XObject drawing with page resources uses it) */
712
+ readonly resourcesRef: number;
713
+ /** a catalog entry (key without the slash), read when the catalog is written: finishers may still set it */
714
+ catalog(key: string, value: string): void;
715
+ /** run fn at end(), after the last page is written and before the fonts and the catalog; in registration order */
716
+ onFinish(fn: () => Promise<void> | void): void;
717
+ /** a font object written after every finisher (see Font in src/document.js) */
718
+ addFont(font: unknown): void;
719
+ /** page i's object number (0-based), reserved ahead when the page does not exist yet */
720
+ pageRef(i: number): number;
721
+ /** page i's height, once the page exists */
722
+ pageHeight(i: number): number | undefined;
723
+ readonly pageCount: number;
724
+ /** a warning in end()'s result */
725
+ warn(message: string): void;
726
+ /** run fn after every write queued before it (writes outside a page or a finisher must go through it: the object writer is not re-entrant) */
727
+ serial<T>(fn: () => Promise<T>): Promise<T>;
728
+ /** the first failure of a queued write, or null */
729
+ readonly failed: unknown;
730
+ /** the options a module must respect */
731
+ readonly compress: boolean;
732
+ readonly pdfa: boolean;
733
+ readonly pdfaLevel: { part: 1 | 2 | 3; conformance: 'a' | 'b' | 'u'; unicode: boolean } | null;
734
+ readonly tagged: boolean;
735
+ readonly signal: AbortSignal;
736
+ /** the Info dictionary's text to write instead of the one made from the options (null: made from the options) */
737
+ info: string | null;
738
+ /** the XMP packet to write instead of the one made from the options */
739
+ xmp: Uint8Array | null;
740
+ /** more XMP schemas (standards/xmp.js Extension), pushed before the end */
741
+ xmpExtensions: unknown[];
742
+ /** made to be signed (createPdf's sign option): every byte is hashed */
743
+ readonly signing: boolean;
744
+ /** the AcroForm's /SigFlags (pdf.sign sets 3) */
745
+ sigFlags: number;
746
+ /** the signature dictionary written as the file's last object (security/sign.js); internal */
747
+ last: unknown;
748
+ /** the structure tree (tags/tags.js) when tagged, else null: tags.link(page, annotRef, 'Link' | 'Annot') gives an annotation its element (with an OBJR) and returns its /StructParent */
749
+ tags: { link(page: Page, annotRef: number, type?: 'Link' | 'Annot'): number; [k: string]: unknown } | null;
750
+ /** internal: the page queue (used by Page and createPdf) */
751
+ newPage(page: unknown): number;
752
+ opened(page: unknown): void;
753
+ queuePage(page: unknown): Promise<void>;
754
+ finish(blank: () => unknown): Promise<{ pages: number; bytes: number; warnings: string[] }>;
755
+ }
756
+
647
757
  export interface Pdf {
648
758
  standardFont(name: StandardFontName): Font;
649
- /** a TrueType or OpenType/CFF font; subset by default; shaper: text goes through it (complex scripts) */
650
- embedFont(bytes: Uint8Array | ArrayBuffer, o?: { subset?: boolean | Subsetter; shaper?: Shaper }): Promise<Font>;
759
+ /** a TrueType or OpenType/CFF font; subset by default; shaper: text goes through it (complex scripts);
760
+ * textMapping: 'carrier' (default: Firefox, pdf.js, search) or 'actualText' (a shaped cluster's text only in its ActualText: MuPDF, AI pipelines) */
761
+ embedFont(bytes: Uint8Array | ArrayBuffer, o?: { subset?: boolean | Subsetter; shaper?: Shaper; textMapping?: 'carrier' | 'actualText' }): Promise<Font>;
651
762
  /** a JPEG or PNG, written now as an image XObject. colorSpace: an ICC colour space with the image's component count */
652
- embedImage(bytes: Uint8Array | ArrayBuffer, o?: { colorSpace?: IccColorSpace; /** the decoded size a PNG with alpha or interlacing may have; default 64 MB */ maxDecodedBytes?: number }): Promise<Image>;
763
+ embedImage(bytes: Uint8Array | ArrayBuffer, o?: { colorSpace?: IccColorSpace; /** the decoded size a PNG with alpha or interlacing may have; default 64 MB */ maxDecodedBytes?: number; /** zlib level for a re-compressed PNG (alpha, interlacing): 'default' = 6, or 3 above 2 megapixels; 'fast' = 1; or 1–9 */ compression?: 'default' | 'fast' | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 }): Promise<Image>;
653
764
  /** a spot colour: its name and the device colour (gray, RGB, CMYK) shown for it at full tint. One definition per name */
654
765
  spotColor(name: string, alternate: number | string | readonly number[]): SpotColor;
655
766
  /** an ICC-based colour space from a gray, RGB or CMYK profile (its header is checked) */
@@ -683,7 +794,7 @@ export interface Pdf {
683
794
  /** end open pages, write the rest, end the sink */
684
795
  end(): Promise<{ pages: number; bytes: number; warnings: string[] }>;
685
796
  /** the extension API (ARCHITECTURE.md); not covered by semver in 0.x */
686
- readonly core: unknown;
797
+ readonly core: PdfCore;
687
798
  }
688
799
 
689
800
  export function createPdf(sink: Sink, options?: PdfOptions): Pdf;
@@ -691,6 +802,38 @@ export function createPdf(sink: Sink, options?: PdfOptions): Pdf;
691
802
  export function toBytes(build: (pdf: Pdf) => unknown, options?: PdfOptions): Promise<Uint8Array>;
692
803
  export const STANDARD_NAMES: StandardFontName[];
693
804
 
805
+ /** A font file read for metrics and glyph lookup. Throws a TypeError on bytes that are not a TrueType or OpenType font. */
806
+ export interface FontInfo {
807
+ /** glyph count (maxp) */
808
+ readonly numGlyphs: number;
809
+ /** PostScript name (name ID 6), as written in the file */
810
+ readonly postscriptName: string;
811
+ /** OS/2 fsType embedding flags (0 means installable embedding) */
812
+ readonly fsType: number;
813
+ readonly unitsPerEm: number;
814
+ /** hhea ascent, in font units */
815
+ readonly ascent: number;
816
+ /** hhea descent, in font units (negative) */
817
+ readonly descent: number;
818
+ /** true for an OpenType font with CFF outlines */
819
+ readonly isCFF: boolean;
820
+ /** horizontal advance of a glyph ID, in font units; 0 for an ID past numGlyphs */
821
+ advance(glyphId: number): number;
822
+ /** whether the Unicode cmap maps this code point to a glyph */
823
+ hasGlyph(codePoint: number): boolean;
824
+ /** the glyph ID for a code point, 0 when none */
825
+ glyphForCodePoint(codePoint: number): number;
826
+ }
827
+
828
+ /** Read a font file's metrics and cmap. */
829
+ export function readFont(bytes: Uint8Array): FontInfo;
830
+
831
+ /**
832
+ * Check a font subset before embedding it. Returns a description of the first problem (header, table directory, loca
833
+ * order, CFF INDEXes), or null when the subset is well formed. Never throws.
834
+ */
835
+ export function checkSubset(bytes: Uint8Array): string | null;
836
+
694
837
  // ---- reading and modifying (group H) ----
695
838
 
696
839
  export type PdfReadErrorCode = 'E_MALFORMED' | 'E_BUDGET' | 'E_DEPTH' | 'E_CYCLE' | 'E_TIMEOUT' | 'E_ABORTED' | 'E_PASSWORD' | 'E_ENCRYPTED' | 'E_UNSUPPORTED' | 'E_ACTIVE' | 'E_SIGNED';
@@ -845,3 +988,38 @@ export interface LoadedPdf {
845
988
  export function loadPdf(bytes: Uint8Array | ArrayBuffer, options?: LoadOptions): Promise<LoadedPdf>;
846
989
  /** every page of each document, in order, in a new file (the first one's catalog, with what the others bring) */
847
990
  export function mergePdfs(docs: LoadedPdf[], options?: SaveOptions): Promise<SaveResult & { bytes: Uint8Array }>;
991
+ /** the tasks bulk() runs, by name (no functions cross to a worker) */
992
+ export type BulkTask = 'extractText' | 'merge' | 'split' | 'encrypt';
993
+ export const BULK_TASKS: readonly BulkTask[];
994
+ /** what each task takes per item, and gives back */
995
+ export interface BulkTaskTypes {
996
+ extractText: { input: Uint8Array | ArrayBuffer; value: string };
997
+ merge: { input: (Uint8Array | ArrayBuffer)[]; value: Uint8Array };
998
+ split: { input: Uint8Array | ArrayBuffer; value: Uint8Array[] };
999
+ encrypt: { input: Uint8Array | ArrayBuffer; value: Uint8Array };
1000
+ }
1001
+ export type BulkResult<V> = { ok: true; value: V } | { ok: false; error: { name: string; code?: string; message: string } };
1002
+ export interface BulkOptions {
1003
+ /** worker threads (1 to 64); default os.availableParallelism() - 1 (at least 1). 1, or no node:worker_threads (browsers): one at a time, in this thread.
1004
+ * Workers are kept warm (unref'd) for 2 s after a call, for the next; small files are sent several at a time */
1005
+ workers?: number;
1006
+ /** each worker's heap cap in MB (resourceLimits.maxOldGenerationSizeMb), 16 to 16384, default 512: an item past it fails with E_WORKER, alone */
1007
+ memoryMb?: number;
1008
+ /** loadPdf's options, for every input */
1009
+ password?: string;
1010
+ budget?: ReadBudget;
1011
+ timeoutMs?: number;
1012
+ /** the encrypt task's encryption */
1013
+ encrypt?: EncryptOptions;
1014
+ /** save()'s options for merge, split and encrypt */
1015
+ save?: SaveOptions;
1016
+ /** each item's result as it finishes (index in inputs) */
1017
+ onResult?: (index: number, result: BulkResult<unknown>) => void;
1018
+ /** each item's time limit in a worker, default 120000, at most 2147483647: past it the worker is ended, the item fails with E_TIMEOUT and the rest go on.
1019
+ * With one input, workers: 1, or a host where workers resolve to 1 (2 CPUs), items run in the calling thread: no time limit and no heap cap apply there. */
1020
+ itemTimeoutMs?: number;
1021
+ /** stops the call: its workers are ended (not kept warm) and bulk rejects with the signal's reason (AbortSignal.timeout(ms) for a time limit) */
1022
+ signal?: AbortSignal;
1023
+ }
1024
+ /** One named task over many inputs, in parallel worker threads (Node); results in input order, a failing item never stops the rest. */
1025
+ export function bulk<T extends BulkTask>(inputs: Iterable<BulkTaskTypes[T]['input']>, task: T, options?: BulkOptions): Promise<BulkResult<BulkTaskTypes[T]['value']>[]>;