- 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>
117 lines
5.7 KiB
TypeScript
117 lines
5.7 KiB
TypeScript
// ==================== 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);
|
||
}
|