Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Bump node version requirement to 20+
- Bump minimum supported browsers to Firefox 115, iOS/Safari 16
- Fix text with input x as null
- Added initial support for e-invoices (ZUGFeRD and Factur-X)

### [v0.18.0] - 2026-03-14

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ yarn add pdfkit
- Encryption
- Access privileges (printing, copying, modifying, annotating, form filling, content accessibility, document assembly)
- Accessibility support (marked content, logical structure, Tagged PDF, PDF/UA)
- Long-term preservation of electronic documents (PDF/A)
- E-invoice PDFs (ZUGFeRD, Factur-X)

## Coming soon!

Expand Down
45 changes: 45 additions & 0 deletions docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,51 @@ In order to verify the generated document for PDF/A and its subsets conformance,

Please note that PDF/A requires fonts to be embedded, as such the standard fonts PDFKit comes with cannot be used because they are in AFM format, which only provides neccessary metrics, without the font data. You should use `registerFont()` and use embeddable fonts such as `ttf`.

## E-invoices

Electronic invoices are hybrid PDFs that are both human-readable and machine-processable, which is achieved by embedding structured XML documents.

Currently, PDFKit aims to support ZUGFeRD v2.X and Factur-X, which both require the e-invoices to be embedded into a PDF/A-3 document. Make sure your `PDFDocument` is created with the `subset` set to either `PDF/A-3b` or `PDF/A-3a`:

const doc = new PDFDocument({ subset: 'PDF/A-3b', pdfVersion: '1.7' });

Call `doc.einvoice(format, src, options = {})` after creating the document and pass the invoice XML as a `Buffer`, `ArrayBuffer` or base64 encoded `string` or path to file:

// for ZUGFeRD
doc.einvoice('zugferd', '/invoices/invoice1234.xml');

// or for Factur-X
doc.einvoice('facturx', '/invoices/invoice1234.xml');

Note: only one e-invoice can be embedded per document and a second call will throw an error.

The following options are supported for both ZUGFeRD and Factur-X:

- `profile` a string indicating the conformance profile, defaults to 'EN 16931' and the value is case insensitive
- `documentType` a string indicating the type of document, e.g. 'INVOICE', 'ORDER'. Defaults to 'INVOICE'. See the ZUGFeRD/Factur-X specification for all accepted values.
- `version` a string to override the default version PDFKit chooses from the profile

The following profiles are supported for ZUGFeRD:

- `minimum` default version 2.4
- `basic wl` default version 2.4
- `basic` default version 2.4
- `en 16931` default version 2.4
- `extended` default version 2.4
- `xrechnung` default version 3.0

The following profiles are supported for Factur-X:

- `minimum` default version 1.0
- `basic wl` default version 1.0
- `basic` default version 1.0
- `en 16931` default version 1.0
- `extended` default version 1.0

PDFKit does not validate your XML against the selected profile, therefore please ensure you select the adequate profile based on your XML.

Note: Unknown profiles fall back to 'EN 16931'.

### Adding content

Once you've created a `PDFDocument` instance, you can add content to the
Expand Down
Binary file modified docs/guide.pdf
Binary file not shown.
2 changes: 2 additions & 0 deletions lib/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import LineWrapper from './line_wrapper';
import SubsetMixin from './mixins/subsets';
import TableMixin from './mixins/table';
import MetadataMixin from './mixins/metadata';
import EInvoiceMixin from './mixins/einvoice';

class PDFDocument extends stream.Readable {
constructor(options = {}) {
Expand Down Expand Up @@ -387,6 +388,7 @@ mixin(AcroFormMixin);
mixin(AttachmentsMixin);
mixin(SubsetMixin);
mixin(TableMixin);
mixin(EInvoiceMixin);

PDFDocument.LineWrapper = LineWrapper;

Expand Down
35 changes: 20 additions & 15 deletions lib/metadata.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
class PDFMetadata {
constructor() {
this._metadata = `
<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
`;
this._body = '';
this._extraNamespaces = {};
this._metadata = '';
}

_closeTags() {
this._metadata = this._metadata.concat(`
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>
`);
registerNamespace(prefix, uri) {
this._extraNamespaces[prefix] = uri;
}

append(xml, newline = true) {
this._metadata = this._metadata.concat(xml);
if (newline) this._metadata = this._metadata.concat('\n');
this._body = this._body.concat(xml);
if (newline) this._body = this._body.concat('\n');
}

getXML() {
Expand All @@ -29,8 +23,19 @@ class PDFMetadata {
}

end() {
this._closeTags();
this._metadata = this._metadata.trim();
const extraNs = Object.entries(this._extraNamespaces)
.map(([p, u]) => `\n xmlns:${p}="${u}"`)
.join('');

this._metadata = `
<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"${extraNs}>
${this._body}
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>
`.trim();
}
}

Expand Down
46 changes: 46 additions & 0 deletions lib/mixins/einvoice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import zugferd from './zugferd';
import facturx from './facturx';

export default {
_importFormat(format) {
Object.assign(this, format);
},

/**
* Attach an electronic invoice and its required metadata.
* @param {string} format invoice format identifier (e.g. 'zugferd', 'facturx')
* @param {Buffer | ArrayBuffer | string} src invoice data to embed (Buffer, ArrayBuffer, base64 encoded string or path to file)
* @param {object} options format-specific options, see documentation for each supported format for details on required and optional fields
*/
einvoice(format, src, options = {}) {
if (this._einvoiceEmbedded) {
throw new Error(
'An e-invoice has already been embedded in this document',
);
}

if (!src) {
throw new Error('einvoice: src is required');
}

if (this.subset !== 3) {
const current = this.subset ? `PDF/A-${this.subset}` : 'none';
console.warn(
`einvoice: PDF/A-3 is required for e-invoice compliance (current subset: ${current})`,
);
}

switch (format.toLowerCase()) {
case 'zugferd':
this._einvoiceEmbedded = true;
this._importFormat(zugferd);
return this.embedZugferd(src, options);
case 'facturx':
this._einvoiceEmbedded = true;
this._importFormat(facturx);
return this.embedFacturX(src, options);
default:
throw new Error(`Unsupported e-invoice format: "${format}"`);
}
},
};
111 changes: 111 additions & 0 deletions lib/mixins/facturx.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
const FACTURX_NS = 'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#';

const PROFILES = {
minimum: {
label: 'MINIMUM',
filename: 'factur-x.xml',
version: '1.0',
},
'basic wl': {
label: 'BASIC WL',
filename: 'factur-x.xml',
version: '1.0',
},
basic: { label: 'BASIC', filename: 'factur-x.xml', version: '1.0' },
'en 16931': {
label: 'EN 16931',
filename: 'factur-x.xml',
version: '1.0',
},
extended: {
label: 'EXTENDED',
filename: 'factur-x.xml',
version: '1.0',
},
};

export default {
/**
* Embed a Factur-X XML invoice alongside the required XMP metadata.
* @param {Buffer | ArrayBuffer | string} src XML invoice data to embed (Buffer, ArrayBuffer, base64 data-URI or path to file)
* @param {object} options
* * options.profile: the conformance profile 'minimum' or 'basic wl' or 'basic' or 'en 16931' or 'extended' (default: 'en 16931')
* * options.version: override for spec version that is written to the metadata, each conformance profile has a built-in default
* * options.documentType: the document type written to the metadata 'INVOICE' or 'ORDER' or 'ORDER_RESPONSE' or 'ORDER_CHANGE' (default: 'INVOICE')
* @returns filespec reference
*/
embedFacturX(src, options = {}) {
const profile =
PROFILES[options.profile?.toLowerCase()] ?? PROFILES['en 16931'];
const version = options.version || profile.version;
const documentType = options.documentType || 'INVOICE';

this.registerXMPNamespace('fx', FACTURX_NS);

const filespec = this.file(src, {
name: profile.filename,
type: 'text/xml',
relationship: 'Alternative',
});

this.appendXML(
_getSchema(profile.filename, profile.label, version, documentType),
);

return filespec;
},
};

function _getSchema(filename, profile, version, documentType) {
return `
<rdf:Description rdf:about=""
xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
<pdfaExtension:schemas>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
<pdfaSchema:namespaceURI>${FACTURX_NS}</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:property>
<rdf:Seq>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The name of the embedded XML document</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>DocumentType</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The type of the embedded XML document</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>Version</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The version of the Factur-X standard used</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The conformance level of the Factur-X standard used</pdfaProperty:description>
</rdf:li>
</rdf:Seq>
</pdfaSchema:property>
</rdf:li>
</rdf:Bag>
</pdfaExtension:schemas>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:fx="${FACTURX_NS}">
<fx:DocumentType>${documentType}</fx:DocumentType>
<fx:DocumentFileName>${filename}</fx:DocumentFileName>
<fx:Version>${version}</fx:Version>
<fx:ConformanceLevel>${profile}</fx:ConformanceLevel>
</rdf:Description>
`;
}
4 changes: 4 additions & 0 deletions lib/mixins/metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export default {
this.metadata.append(xml, newline);
},

registerXMPNamespace(prefix, uri) {
this.metadata.registerNamespace(prefix, uri);
},

_addInfo() {
this.appendXML(`
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
Expand Down
Loading
Loading