// ==================== 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);
}