/**
* Merge a ZUGFeRD / Factur-X CII XML into an existing PDF (the one iDempiere
* generated) and return the new hybrid PDF buffer.
*
* Pragmatic v1 ("start pragmatic, harden later"): we do NOT convert the carrier
* PDF to strict PDF/A-3. We attach `factur-x.xml` with the correct
* AFRelationship (`Alternative`) and write the Factur-X XMP metadata packet so
* common accounting software (DATEV, Lexware, sevDesk, …) detects and parses the
* e-invoice. Strict PDF/A-3 conversion is a documented follow-up.
*
* Uses pdf-lib, already a project dependency (see invoices/batch-print.post.ts).
*/
import { PDFDocument, AFRelationship, PDFName } from 'pdf-lib'
const FACTURX_FILENAME = 'factur-x.xml'
// Full Factur-X XMP packet. The pdfaExtension "schemas" block is REQUIRED: without
// it, strict PDF/A-3 validators (portinvoice, veraPDF) don't recognise the custom
// fx: properties and report DocumentType/Version/ConformanceLevel as empty.
const buildXmpMetadata = (): string => `
3
B
INVOICE
${FACTURX_FILENAME}
1.0
EN 16931
Factur-X PDFA Extension Schema
urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#
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
`
/**
* @param pdfBuffer iDempiere's existing invoice PDF (already decoded from base64)
* @param xmlString the EN16931 CII XML from buildInvoiceXml()
* @returns a new PDF buffer with the XML embedded as factur-x.xml
*/
export const embedZugferdXml = async (pdfBuffer: Buffer, xmlString: string): Promise => {
const pdfDoc = await PDFDocument.load(pdfBuffer)
const now = new Date()
await pdfDoc.attach(new TextEncoder().encode(xmlString), FACTURX_FILENAME, {
mimeType: 'text/xml',
description: 'Factur-X/ZUGFeRD invoice (EN16931)',
creationDate: now,
modificationDate: now,
afRelationship: AFRelationship.Alternative,
})
// Write the Factur-X XMP metadata packet onto the catalog (uncompressed stream,
// as PDF/A expects). pdf-lib has no first-class XMP API, so set it low-level.
try {
const xmp = buildXmpMetadata()
const metadataStream = pdfDoc.context.stream(xmp, {
Type: PDFName.of('Metadata'),
Subtype: PDFName.of('XML'),
})
const metadataRef = pdfDoc.context.register(metadataStream)
pdfDoc.catalog.set(PDFName.of('Metadata'), metadataRef)
} catch (xmpErr) {
// XMP is best-effort; the embedded file + AFRelationship are what matters most.
console.error('[ZUGFeRD] Failed to set XMP metadata (continuing):', xmpErr)
}
const bytes = await pdfDoc.save({ useObjectStreams: false })
return Buffer.from(bytes)
}
export default embedZugferdXml