import { encode } from 'html-entities';
import PDFDocument from '../PDFDocument';
import { AttachmentOptions } from '../PDFDocumentOptions';
import { AFRelationship } from '../../core/embedders/FileEmbedder';
import {
assertIs,
assertIsOneOfOrUndefined,
assertOrUndefined,
toUint8Array,
BinaryData,
} from '../../utils';
import { readCatalogPDFAConformance } from './catalogMetadata';
/**
* Factur-X / ZUGFeRD 2.1+ profile advertised in `fx:ConformanceLevel`.
* Must match the profile URN embedded in the invoice XML.
*
* `'BASIC_WL'` is accepted as an alias of `'BASIC WL'`, the value Factur-X
* writes in XMP.
*/
export type FacturXConformanceLevel =
| 'MINIMUM'
| 'BASIC WL'
| 'BASIC_WL'
| 'BASIC'
| 'EN 16931'
| 'EXTENDED'
| 'XRECHNUNG';
const FACTUR_X_CONFORMANCE_LEVELS: FacturXConformanceLevel[] = [
'MINIMUM',
'BASIC WL',
'BASIC_WL',
'BASIC',
'EN 16931',
'EXTENDED',
'XRECHNUNG',
];
/** Map API aliases to the `fx:ConformanceLevel` value Factur-X expects. */
const canonicalizeFacturXConformanceLevel = (
level: FacturXConformanceLevel,
): string => (level === 'BASIC_WL' ? 'BASIC WL' : level);
/**
* Options for [[embedFacturX]].
*/
export interface EmbedFacturXOptions {
/**
* Filename of the embedded XML invoice. Defaults to `'factur-x.xml'`.
* Must match `fx:DocumentFileName` (set automatically from this value).
*/
fileName?: string;
/**
* Factur-X XML schema version advertised in XMP. Defaults to `'1.0'`.
*/
version?: string;
/**
* Hybrid document type in capital letters. Defaults to `'INVOICE'`.
*/
documentType?: string;
/**
* Factur-X / ZUGFeRD profile. Defaults to `'EN 16931'`.
* `'BASIC_WL'` is normalised to `'BASIC WL'` in XMP.
*/
conformanceLevel?: FacturXConformanceLevel;
/**
* Human-readable description of the attached file.
*/
description?: string;
/**
* Associated-file relationship. Defaults to [[AFRelationship.Alternative]],
* which is what Factur-X / ZUGFeRD expect for the embedded invoice XML.
*/
afRelationship?: AFRelationship;
/**
* Attachment creation / modification dates (forwarded to [[PDFDocument.attach]]).
*/
creationDate?: Date;
modificationDate?: Date;
}
/** Namespace URI for Factur-X / ZUGFeRD 2.1+ XMP properties (prefix `fx`). */
export const FACTUR_X_NAMESPACE_URI =
'urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#';
/**
* Fixed PDF/A extension schema description for the Factur-X `fx` properties.
* Required so PDF/A validators accept the custom metadata.
*
* Based on the PDFlib / Factur-X sample extension schema.
*/
export const FACTUR_X_EXTENSION_SCHEMA =
'' +
'' +
'Factur-X PDFA Extension Schema' +
`${FACTUR_X_NAMESPACE_URI}` +
'fx' +
'' +
'' +
'DocumentFileName' +
'Text' +
'external' +
'name of the embedded XML invoice file' +
'' +
'' +
'DocumentType' +
'Text' +
'external' +
'INVOICE' +
'' +
'' +
'Version' +
'Text' +
'external' +
'The actual version of the Factur-X XML schema' +
'' +
'' +
'ConformanceLevel' +
'Text' +
'external' +
'The conformance level of the embedded Factur-X data' +
'' +
'' +
'' +
'';
/**
* Build the `fx:` `rdf:Description` fragment for Factur-X / ZUGFeRD XMP.
*/
export const buildFacturXDescription = (options: {
fileName: string;
version: string;
documentType: string;
conformanceLevel: FacturXConformanceLevel;
}): string =>
`` +
`${encode(options.documentType)}` +
`${encode(options.fileName)}` +
`${encode(options.version)}` +
`${encode(
canonicalizeFacturXConformanceLevel(options.conformanceLevel),
)}` +
'';
/**
* Embed a Factur-X / ZUGFeRD invoice XML into a PDF as a PDF/A-3 attachment
* with the required XMP metadata.
*
* This helper:
* 1. Ensures PDF/A-3 (converts to 3B if needed; keeps an existing 3U/3B level).
* 2. Adds the Factur-X `fx:` properties and PDF/A extension schema to XMP.
* 3. Attaches the invoice XML with an associated-file relationship.
*
* It does **not** generate or validate the Cross Industry Invoice XML — pass
* a complete `factur-x.xml` (or equivalent) produced by your invoicing stack.
* Drawn text must still use an **embedded** font for PDF/A compliance.
*
* For example:
* ```js
* import { PDFDocument, embedFacturX } from '@cantoo/pdf-lib'
* import fontkit from '@cantoo/fontkit'
*
* const pdfDoc = await PDFDocument.create()
* pdfDoc.registerFontkit(fontkit)
* const font = await pdfDoc.embedFont(fontBytes)
* // ... draw the human-readable invoice with `font` ...
*
* await embedFacturX(pdfDoc, invoiceXmlBytes, {
* conformanceLevel: 'EN 16931',
* })
*
* const pdfBytes = await pdfDoc.save()
* ```
*
* @param pdfDoc The document that will carry the hybrid invoice.
* @param invoiceXml The Factur-X / ZUGFeRD XML bytes to embed.
* @param options Embedding and XMP options.
*/
export const embedFacturX = async (
pdfDoc: PDFDocument,
invoiceXml: BinaryData,
options: EmbedFacturXOptions = {},
): Promise => {
assertIs(pdfDoc, 'pdfDoc', [[PDFDocument, 'PDFDocument']]);
assertIs(invoiceXml, 'invoiceXml', [
'string',
ArrayBuffer,
'ArrayBufferView',
]);
assertOrUndefined(options.fileName, 'options.fileName', ['string']);
assertOrUndefined(options.version, 'options.version', ['string']);
assertOrUndefined(options.documentType, 'options.documentType', ['string']);
assertIsOneOfOrUndefined(
options.conformanceLevel,
'options.conformanceLevel',
FACTUR_X_CONFORMANCE_LEVELS,
);
assertOrUndefined(options.description, 'options.description', ['string']);
assertIsOneOfOrUndefined(
options.afRelationship,
'options.afRelationship',
AFRelationship,
);
assertOrUndefined(options.creationDate, 'options.creationDate', [Date]);
assertOrUndefined(options.modificationDate, 'options.modificationDate', [
Date,
]);
const {
fileName = 'factur-x.xml',
version = '1.0',
documentType = 'INVOICE',
conformanceLevel = 'EN 16931',
description = 'Factur-X invoice data',
afRelationship = AFRelationship.Alternative,
creationDate,
modificationDate,
} = options;
const extensions = [
buildFacturXDescription({
fileName,
version,
documentType,
conformanceLevel,
}),
FACTUR_X_EXTENSION_SCHEMA,
];
const existing = readCatalogPDFAConformance(pdfDoc.catalog);
pdfDoc.convertToPDFA({
conformance:
existing?.part === 3 ? (existing.level === 'U' ? '3U' : '3B') : '3B',
extensions,
});
// Replace any prior attachment with the same name (e.g. re-running the helper).
pdfDoc.detach(fileName);
const attachOptions: AttachmentOptions = {
mimeType: 'text/xml',
description,
afRelationship,
creationDate,
modificationDate,
};
await pdfDoc.attach(toUint8Array(invoiceXml), fileName, attachOptions);
};