Files
opencrm/backend/src/services/zugferdPdf.service.ts
T
duffyduckandClaude Opus 4.8 5423c83009 Gutschriften Phase 3b/2: ZUGFeRD/Factur-X (hybrides PDF/A-3)
- zugferd.service.ts: CII-XML (EN 16931, urn:cen.eu:en16931:2017,
  Dokumenttyp 381 Gutschrift). Steuerkategorie S bei USt, sonst E mit
  Befreiungsgrund. Verkaeufer=Firma (CompanyProfile), Kaeufer=Kunde.
- zugferdPdf.service.ts: bettet factur-x.xml als AF /Data ein, setzt
  sRGB-OutputIntent + XMP (PDF/A-3B pdfaid + Factur-X-Extension-Schema)
  via pdf-lib.
- creditNotePdf: eingebettete DejaVuSans-Fonts (Pflicht fuer PDF/A)
  statt Standard-Helvetica; nach PDF-Erzeugung ZUGFeRD-Embedding.
- assets/fonts (DejaVuSans + Bold) + assets/icc (sRGB) ins Repo;
  Dockerfile kopiert backend/assets ins Runtime-Image.

Lokal strukturell verifiziert: 1 Seite, /AF, /Metadata, /OutputIntents,
EmbeddedFiles, Font eingebettet, XML wohlgeformt (xmllint), TypeCode
381, GrandTotal korrekt.

WICHTIG: vor Prod gegen einen ZUGFeRD-/Factur-X-Validator pruefen
(Staging + echte Firmendaten). Feinheiten (Trailer-ID, XMP, XML-MIME)
ggf. nach erstem Validator-Lauf nachziehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-07 12:56:12 +02:00

117 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==================== 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 `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<pdfaid:part>3</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">${title.replace(/[<>&]/g, '')}</rdf:li></rdf:Alt></dc:title>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<xmp:CreatorTool>OpenCRM</xmp:CreatorTool>
<xmp:CreateDate>${d}</xmp:CreateDate>
<xmp:ModifyDate>${d}</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
<pdfaExtension:schemas>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:property>
<rdf:Seq>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Name des eingebetteten XML</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Version des Factur-X-Profils</pdfaProperty:description></rdf:li>
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>Konformitaetslevel</pdfaProperty:description></rdf:li>
</rdf:Seq>
</pdfaSchema:property>
</rdf:li>
</rdf:Bag>
</pdfaExtension:schemas>
</rdf:Description>
<rdf:Description rdf:about="" xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
<fx:DocumentType>INVOICE</fx:DocumentType>
<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>
<fx:Version>1.0</fx:Version>
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>`;
}
export async function embedZugferd(
basePdf: Buffer,
xml: string,
meta: { title: string; date: Date },
): Promise<Buffer> {
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);
}