// ==================== ZUGFeRD PDF/A-3 EMBEDDING ==================== // Nimmt ein bestehendes PDF (pdfkit, mit eingebetteten Fonts) und macht daraus // ein hybrides ZUGFeRD-PDF: factur-x.xml als AF /Data einbetten, sRGB- // OutputIntent, XMP-Metadaten (PDF/A-3B + Factur-X-Extension-Schema). // // WICHTIG: Vor produktivem Einsatz gegen einen ZUGFeRD-/Factur-X-Validator // prüfen. Feinheiten der PDF/A-3-Konformität (z.B. Trailer-ID, XMP-Details) // können nach dem ersten Validator-Lauf noch nachgezogen werden müssen. import fs from 'fs'; import path from 'path'; import { PDFDocument, AFRelationship, PDFName, PDFString } from 'pdf-lib'; const ICC_PATH = path.join(process.cwd(), 'assets', 'icc', 'sRGB_IEC61966_2_1.icc'); function xmpDate(d: Date): string { return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); } function buildXmp(title: string, date: Date): string { const d = xmpDate(date); return ` 3 B ${title.replace(/[<>&]/g, '')} OpenCRM ${d} ${d} Factur-X PDFA Extension Schema urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0# fx DocumentFileNameTextexternalName des eingebetteten XML DocumentTypeTextexternalINVOICE VersionTextexternalVersion des Factur-X-Profils ConformanceLevelTextexternalKonformitaetslevel INVOICE factur-x.xml 1.0 EN 16931 `; } export async function embedZugferd( basePdf: Buffer, xml: string, meta: { title: string; date: Date }, ): Promise { const pdfDoc = await PDFDocument.load(basePdf); // 1) XML als Associated File (AF /Data) einbetten. await pdfDoc.attach(Buffer.from(xml, 'utf-8'), 'factur-x.xml', { mimeType: 'text/xml', description: 'Factur-X/ZUGFeRD Rechnungsdaten', afRelationship: AFRelationship.Data, creationDate: meta.date, modificationDate: meta.date, }); // 2) Dokument-Info. pdfDoc.setTitle(meta.title); pdfDoc.setProducer('OpenCRM'); pdfDoc.setCreator('OpenCRM'); pdfDoc.setCreationDate(meta.date); pdfDoc.setModificationDate(meta.date); // 3) OutputIntent (sRGB) – Pflicht für PDF/A. const iccBytes = fs.readFileSync(ICC_PATH); const iccStream = pdfDoc.context.stream(iccBytes, { N: 3 }); const iccRef = pdfDoc.context.register(iccStream); const outputIntent = pdfDoc.context.obj({ Type: 'OutputIntent', S: 'GTS_PDFA1', OutputConditionIdentifier: PDFString.of('sRGB'), Info: PDFString.of('sRGB IEC61966-2.1'), DestOutputProfile: iccRef, }); const oiRef = pdfDoc.context.register(outputIntent); pdfDoc.catalog.set(PDFName.of('OutputIntents'), pdfDoc.context.obj([oiRef])); // 4) XMP-Metadaten (unkomprimiert, /Metadata /XML). const xmp = buildXmp(meta.title, meta.date); const metadataStream = pdfDoc.context.stream(Buffer.from(xmp, 'utf-8'), { Type: 'Metadata', Subtype: 'XML', }); const metaRef = pdfDoc.context.register(metadataStream); pdfDoc.catalog.set(PDFName.of('Metadata'), metaRef); // PDF/A: klassische XRef-Tabelle statt Object-Streams (validator-freundlicher). const out = await pdfDoc.save({ useObjectStreams: false }); return Buffer.from(out); }