/** * 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