@reportwright/pdf 0.1.0-beta.0 → 0.1.0-beta.2
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 +377 -23
- package/dist/index.js +765 -292
- package/examples/accessible.mjs +28 -0
- package/examples/encrypt.mjs +23 -0
- package/examples/factur-x.mjs +37 -0
- package/examples/fill-form.mjs +33 -0
- package/examples/harfbuzz-shaper.mjs +27 -0
- package/examples/indic.mjs +20 -0
- package/examples/invoice-facturx.mjs +40 -0
- package/examples/merge-split.mjs +29 -0
- package/examples/sign.mjs +29 -0
- package/examples/write.mjs +53 -0
- package/index.d.ts +9 -3
- package/package.json +3 -2
|
@@ -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,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,37 @@
|
|
|
1
|
+
// A Factur-X (ZUGFeRD 2.1+) invoice: a tagged PDF/A-3a page with the CII XML embedded as factur-x.xml.
|
|
2
|
+
//
|
|
3
|
+
// node examples/factur-x.mjs invoice.xml font.ttf out.pdf [profile]
|
|
4
|
+
//
|
|
5
|
+
// The XML must be a UN/CEFACT CrossIndustryInvoice whose GuidelineSpecifiedDocumentContextParameter ID names the
|
|
6
|
+
// profile (EN 16931 by default: urn:cen.eu:en16931:2017). pdf.facturX checks that, refuses any DOCTYPE or entity, and
|
|
7
|
+
// writes the fx XMP schema; it does not validate the invoice itself (run the Factur-X XSD and Schematron for that).
|
|
8
|
+
// The page here only shows a few fields; a real one draws the whole invoice from the same data as the XML.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { createPdf } from '../dist/index.js';
|
|
11
|
+
|
|
12
|
+
const [xmlFile, fontFile, outFile, profile = 'EN 16931'] = process.argv.slice(2);
|
|
13
|
+
if (!outFile) { console.error('node examples/factur-x.mjs invoice.xml font.ttf out.pdf [profile]'); process.exit(2); }
|
|
14
|
+
const xml = fs.readFileSync(xmlFile);
|
|
15
|
+
const text = xml.toString('utf8');
|
|
16
|
+
/** the first <ram:tag> after anchor (display only: the page shows a few fields of the XML) */
|
|
17
|
+
const field = (tag, anchor = '') => new RegExp(`${anchor}[\\s\\S]*?<ram:${tag}[^>]*>([^<]*)<`).exec(text)?.[1] ?? '';
|
|
18
|
+
const number = field('ID', '<rsm:ExchangedDocument>'), seller = field('Name', '<ram:SellerTradeParty>');
|
|
19
|
+
|
|
20
|
+
const pdf = createPdf(fs.createWriteStream(outFile), {
|
|
21
|
+
title: `Invoice ${number}`, author: seller, subject: 'Factur-X invoice', keywords: `invoice, Factur-X, ${profile}`,
|
|
22
|
+
pdfa: { part: 3, conformance: 'a' }, tagged: { lang: 'en' },
|
|
23
|
+
});
|
|
24
|
+
const font = await pdf.embedFont(fs.readFileSync(fontFile));
|
|
25
|
+
const page = pdf.addPage({ trimBox: [0, 0, 595.28, 841.89] });
|
|
26
|
+
const line = (s, y, size = 11) => page.text(s, { x: 56, y, font, size });
|
|
27
|
+
page.artifact({ type: 'Pagination', subtype: 'Header' }, () => line('Factur-X invoice: the XML inside this PDF is the legal invoice', 24, 8));
|
|
28
|
+
page.tag('H1', () => line(`Invoice ${number}`, 60, 22));
|
|
29
|
+
page.tag('P', () => line(`Seller: ${seller}`, 110));
|
|
30
|
+
page.tag('Table', () => {
|
|
31
|
+
page.tag('TR', () => ['Net', 'VAT', 'Total'].forEach((h, i) => page.tag('TH', () => page.text(h, { x: 56 + i * 140, y: 150, font }), { scope: 'Column', id: `h${i}` })));
|
|
32
|
+
page.tag('TR', () => [field('TaxBasisTotalAmount'), field('TaxTotalAmount'), field('GrandTotalAmount')].forEach((v, i) =>
|
|
33
|
+
page.tag('TD', () => page.text(`${v} ${field('InvoiceCurrencyCode')}`, { x: 56 + i * 140, y: 170, font }), { headers: [`h${i}`] })));
|
|
34
|
+
});
|
|
35
|
+
await pdf.facturX(xml, { profile });
|
|
36
|
+
await pdf.end();
|
|
37
|
+
console.log(`wrote ${outFile}: PDF/A-3a with factur-x.xml (${profile})`);
|
|
@@ -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,27 @@
|
|
|
1
|
+
// A shaper for @reportwright/pdf's embedFont(bytes, { shaper }) built on harfbuzzjs (npm i harfbuzzjs). harfbuzzjs is
|
|
2
|
+
// not a dependency of @reportwright/pdf: copy this file, or import it where harfbuzzjs is installed.
|
|
3
|
+
//
|
|
4
|
+
// import { harfbuzzShaper } from './harfbuzz-shaper.mjs';
|
|
5
|
+
// const bytes = fs.readFileSync('NotoSansArabic-Regular.ttf');
|
|
6
|
+
// const arabic = await pdf.embedFont(bytes, { shaper: harfbuzzShaper(bytes) });
|
|
7
|
+
// page.text('مرحبا بالعالم', { x: 40, y: 40, font: arabic, size: 18, direction: 'rtl' });
|
|
8
|
+
//
|
|
9
|
+
// The shaper gets one run (one font, one direction) and returns its glyphs in visual order, in font units:
|
|
10
|
+
// { g: glyph ID, cl: cluster (UTF-16 index of its first character), ax: x advance, dx, dy: offsets }.
|
|
11
|
+
import * as hb from 'harfbuzzjs';
|
|
12
|
+
|
|
13
|
+
const NO_KERN = [new hb.Feature('kern', 0)];
|
|
14
|
+
|
|
15
|
+
/** @param {Uint8Array} fontBytes the same bytes passed to embedFont */
|
|
16
|
+
export function harfbuzzShaper(fontBytes) {
|
|
17
|
+
const font = new hb.Font(new hb.Face(new hb.Blob(fontBytes))); // scale: units per em, so positions are font units
|
|
18
|
+
return (text, _font, { direction, kerning }) => {
|
|
19
|
+
const b = new hb.Buffer();
|
|
20
|
+
b.addText(text); // clusters are UTF-16 indices, as JavaScript strings count
|
|
21
|
+
b.guessSegmentProperties(); // script and language from the text
|
|
22
|
+
b.setDirection(direction === 'rtl' ? hb.Direction.RTL : hb.Direction.LTR);
|
|
23
|
+
hb.shape(font, b, kerning ? [] : NO_KERN);
|
|
24
|
+
const pos = b.getGlyphPositions();
|
|
25
|
+
return b.getGlyphInfos().map((g, i) => ({ g: g.codepoint, cl: g.cluster, ax: pos[i].xAdvance, dx: pos[i].xOffset, dy: pos[i].yOffset }));
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -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,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/index.d.ts
CHANGED
|
@@ -74,6 +74,11 @@ export interface PdfOptions {
|
|
|
74
74
|
pdfa?: boolean | { part?: 1 | 2 | 3; conformance?: 'a' | 'b' | 'u'; outputIntent?: Uint8Array | ArrayBuffer };
|
|
75
75
|
/** PDF/UA-1: a structure tree from page.tag(). Needs title; fonts must be embedded. */
|
|
76
76
|
tagged?: boolean | { lang?: string };
|
|
77
|
+
/**
|
|
78
|
+
* false: no XMP metadata stream, the Info dictionary only (about 1 KB smaller: a one-page invoice). Default true.
|
|
79
|
+
* Refused with pdfa or tagged: PDF/A and PDF/UA require the XMP.
|
|
80
|
+
*/
|
|
81
|
+
metadata?: boolean;
|
|
77
82
|
/** stop the export: pending and later calls reject with signal.reason */
|
|
78
83
|
signal?: AbortSignal;
|
|
79
84
|
/** stop the export after this many milliseconds (a TimeoutError, code 'ETIMEDOUT') */
|
|
@@ -641,8 +646,9 @@ export interface FormScope {
|
|
|
641
646
|
|
|
642
647
|
export interface Pdf {
|
|
643
648
|
standardFont(name: StandardFontName): Font;
|
|
644
|
-
/** a TrueType or OpenType/CFF font; subset by default; shaper: text goes through it (complex scripts)
|
|
645
|
-
|
|
649
|
+
/** a TrueType or OpenType/CFF font; subset by default; shaper: text goes through it (complex scripts);
|
|
650
|
+
* textMapping: 'carrier' (default: Firefox, pdf.js, search) or 'actualText' (a shaped cluster's text only in its ActualText: MuPDF, AI pipelines) */
|
|
651
|
+
embedFont(bytes: Uint8Array | ArrayBuffer, o?: { subset?: boolean | Subsetter; shaper?: Shaper; textMapping?: 'carrier' | 'actualText' }): Promise<Font>;
|
|
646
652
|
/** a JPEG or PNG, written now as an image XObject. colorSpace: an ICC colour space with the image's component count */
|
|
647
653
|
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>;
|
|
648
654
|
/** a spot colour: its name and the device colour (gray, RGB, CMYK) shown for it at full tint. One definition per name */
|
|
@@ -703,7 +709,7 @@ export interface ReadBudget {
|
|
|
703
709
|
maxSteps?: number;
|
|
704
710
|
/** cross-reference entries and scanned objects held (default 5,000,000) */
|
|
705
711
|
maxObjects?: number;
|
|
706
|
-
/** pieces of text one page's extraction holds (default
|
|
712
|
+
/** pieces of text one page's extraction holds (default 1,000,000; a page also stops at 16,000,000 characters) */
|
|
707
713
|
maxTextItems?: number;
|
|
708
714
|
}
|
|
709
715
|
export interface LoadOptions {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reportwright/pdf",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
4
4
|
"description": "A streaming PDF writer for Node and browsers with no runtime dependencies: flat memory, PDF/A-1b to 3a, PDF/UA-1, Factur-X, embedded TrueType with Unicode text.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
"publishConfig": { "access": "public" },
|
|
13
13
|
"exports": {
|
|
14
14
|
".": { "types": "./index.d.ts", "default": "./dist/index.js" },
|
|
15
|
+
"./examples/*": "./examples/*",
|
|
15
16
|
"./package.json": "./package.json"
|
|
16
17
|
},
|
|
17
18
|
"types": "./index.d.ts",
|
|
18
|
-
"files": ["dist", "index.d.ts", "README.md", "LICENSE", "THIRD-PARTY-NOTICES.md"],
|
|
19
|
+
"files": ["dist", "index.d.ts", "examples", "README.md", "LICENSE", "THIRD-PARTY-NOTICES.md"],
|
|
19
20
|
"engines": { "node": ">=20.16" },
|
|
20
21
|
"sideEffects": false
|
|
21
22
|
}
|