From 5558e438218e9cf7a3f86cbf3646810603526a02 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Fri, 5 Jun 2026 16:40:40 +0200 Subject: [PATCH] feat(pdfengines): inject Factur-X/ZUGFeRD XMP metadata --- .bruno/Chromium/Convert/HTML to PDF.bru | 1 + .bruno/Chromium/Convert/Markdown to PDF.bru | 1 + .bruno/Chromium/Convert/URL to PDF.bru | 1 + .bruno/LibreOffice/Convert to PDF.bru | 1 + .bruno/PDF Engines/Embed/Embed Files.bru | 1 + .../Factur-X/Inject Factur-X XMP.bru | 26 ++ .bruno/PDF Engines/Merge/Merge PDFs.bru | 1 + .bruno/PDF Engines/Split/Split PDF.bru | 1 + Makefile | 2 + compose.yaml | 1 + pkg/gotenberg/mocks.go | 5 + pkg/gotenberg/pdfengine.go | 59 ++++ pkg/modules/api/formdata.go | 83 +++++ pkg/modules/api/formdata_test.go | 110 ++++++ pkg/modules/chromium/routes.go | 16 +- pkg/modules/exiftool/exiftool.go | 5 + .../libreoffice/pdfengine/pdfengine.go | 5 + pkg/modules/libreoffice/routes.go | 6 + pkg/modules/pdfcpu/pdfcpu.go | 5 + pkg/modules/pdfengines/multi.go | 16 +- pkg/modules/pdfengines/pdfengines.go | 12 + pkg/modules/pdfengines/routes.go | 79 +++++ pkg/modules/pdftk/pdftk.go | 5 + pkg/modules/qpdf/facturx_test.go | 249 +++++++++++++ pkg/modules/qpdf/qpdf.go | 327 +++++++++++++++++- .../features/libreoffice_convert.feature | 37 ++ test/integration/scenario/scenario.go | 52 +++ 27 files changed, 1099 insertions(+), 8 deletions(-) create mode 100644 .bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru create mode 100644 pkg/modules/qpdf/facturx_test.go diff --git a/.bruno/Chromium/Convert/HTML to PDF.bru b/.bruno/Chromium/Convert/HTML to PDF.bru index 34f201c0..f1cf2a72 100644 --- a/.bruno/Chromium/Convert/HTML to PDF.bru +++ b/.bruno/Chromium/Convert/HTML to PDF.bru @@ -53,6 +53,7 @@ body:multipart-form { ~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml) ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} + ~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"} ~watermarkSource: text ~watermarkExpression: CONFIDENTIAL ~watermarkPages: diff --git a/.bruno/Chromium/Convert/Markdown to PDF.bru b/.bruno/Chromium/Convert/Markdown to PDF.bru index 3b49f6a5..0f06526e 100644 --- a/.bruno/Chromium/Convert/Markdown to PDF.bru +++ b/.bruno/Chromium/Convert/Markdown to PDF.bru @@ -54,6 +54,7 @@ body:multipart-form { ~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml) ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} + ~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"} ~watermarkSource: text ~watermarkExpression: CONFIDENTIAL ~watermarkPages: diff --git a/.bruno/Chromium/Convert/URL to PDF.bru b/.bruno/Chromium/Convert/URL to PDF.bru index d8fd1989..ef480913 100644 --- a/.bruno/Chromium/Convert/URL to PDF.bru +++ b/.bruno/Chromium/Convert/URL to PDF.bru @@ -53,6 +53,7 @@ body:multipart-form { ~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml) ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} + ~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"} ~watermarkSource: text ~watermarkExpression: CONFIDENTIAL ~watermarkPages: diff --git a/.bruno/LibreOffice/Convert to PDF.bru b/.bruno/LibreOffice/Convert to PDF.bru index 105e7a8e..51e52825 100644 --- a/.bruno/LibreOffice/Convert to PDF.bru +++ b/.bruno/LibreOffice/Convert to PDF.bru @@ -70,6 +70,7 @@ body:multipart-form { ~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml) ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} + ~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"} ~watermarkSource: text ~watermarkExpression: CONFIDENTIAL ~watermarkPages: diff --git a/.bruno/PDF Engines/Embed/Embed Files.bru b/.bruno/PDF Engines/Embed/Embed Files.bru index 6b450704..bb99286e 100644 --- a/.bruno/PDF Engines/Embed/Embed Files.bru +++ b/.bruno/PDF Engines/Embed/Embed Files.bru @@ -15,6 +15,7 @@ body:multipart-form { embeds: @file(../test/integration/testdata/embed_1.xml) embeds: @file(../test/integration/testdata/embed_2.xml) embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} + ~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"} ~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}] } diff --git a/.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru b/.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru new file mode 100644 index 00000000..59cc6f81 --- /dev/null +++ b/.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru @@ -0,0 +1,26 @@ +meta { + name: Inject Factur-X XMP + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/forms/pdfengines/factur-x + body: multipartForm + auth: none +} + +body:multipart-form { + files: @file(../../test/integration/testdata/page_1.pdf) + facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"} +} + +headers { + ~Gotenberg-Output-Filename: factur-x + ~Gotenberg-Webhook-Url: http://localhost:8080/webhook + ~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error + ~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events + ~Gotenberg-Webhook-Method: POST + ~Gotenberg-Webhook-Error-Method: POST + ~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"} +} diff --git a/.bruno/PDF Engines/Merge/Merge PDFs.bru b/.bruno/PDF Engines/Merge/Merge PDFs.bru index 62114aba..31c99a0d 100644 --- a/.bruno/PDF Engines/Merge/Merge PDFs.bru +++ b/.bruno/PDF Engines/Merge/Merge PDFs.bru @@ -31,6 +31,7 @@ body:multipart-form { ~stampOptions: {"scale":"0.5 abs","rot":"45"} ~rotateAngle: 90 ~rotatePages: + ~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"} } headers { diff --git a/.bruno/PDF Engines/Split/Split PDF.bru b/.bruno/PDF Engines/Split/Split PDF.bru index 51cf3c97..94ab7dea 100644 --- a/.bruno/PDF Engines/Split/Split PDF.bru +++ b/.bruno/PDF Engines/Split/Split PDF.bru @@ -31,6 +31,7 @@ body:multipart-form { ~stampOptions: {"scale":"0.5 abs","rot":"45"} ~rotateAngle: 90 ~rotatePages: + ~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"} } headers { diff --git a/Makefile b/Makefile index 68899989..ec4c0edb 100644 --- a/Makefile +++ b/Makefile @@ -81,6 +81,7 @@ PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu PDFENGINES_EMBED_METADATA_ENGINES=qpdf +PDFENGINES_FACTUR_X_ENGINES=qpdf PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true @@ -157,6 +158,7 @@ NO_CONCURRENCY=false # stamp # pdfengines-rotate # rotate +# factur-x # pdfengines-bookmarks # bookmarks # prometheus-metrics diff --git a/compose.yaml b/compose.yaml index 9988b271..b6423eaa 100644 --- a/compose.yaml +++ b/compose.yaml @@ -81,6 +81,7 @@ services: - "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}" - "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}" - "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}" + - "--pdfengines-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}" - "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}" - "--prometheus-namespace=${PROMETHEUS_NAMESPACE}" - "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}" diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index e0e8c34e..616db364 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -60,6 +60,7 @@ type PdfEngineMock struct { WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error + InjectFacturXXMPMock func(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error } func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { @@ -122,6 +123,10 @@ func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, in return engine.RotateMock(ctx, logger, inputPath, angle, pages) } +func (engine *PdfEngineMock) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error { + return engine.InjectFacturXXMPMock(ctx, logger, facturX, inputPath) +} + // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface. type PdfEngineProviderMock struct { PdfEngineMock func() (PdfEngine, error) diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index 7f0e9d29..96bae87b 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -35,6 +35,10 @@ var ( // ErrPdfRotateAngleNotSupported is returned when the rotation angle is // not supported. ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported") + + // ErrPdfFacturXValueNotSupported is returned when a Factur-X field value + // (e.g., an unknown conformance level or document type) is not supported. + ErrPdfFacturXValueNotSupported = errors.New("Factur-X value not supported") ) // PdfEngineInvalidArgsError represents an error returned by a PDF engine when @@ -151,6 +155,55 @@ type Bookmark struct { Children []Bookmark `json:"children,omitempty"` } +const ( + // FacturXConformanceMinimum represents the MINIMUM Factur-X conformance level. + FacturXConformanceMinimum string = "MINIMUM" + + // FacturXConformanceBasicWL represents the BASIC WL Factur-X conformance level. + FacturXConformanceBasicWL string = "BASIC WL" + + // FacturXConformanceBasic represents the BASIC Factur-X conformance level. + FacturXConformanceBasic string = "BASIC" + + // FacturXConformanceEN16931 represents the EN 16931 Factur-X conformance level. + FacturXConformanceEN16931 string = "EN 16931" + + // FacturXConformanceExtended represents the EXTENDED Factur-X conformance level. + FacturXConformanceExtended string = "EXTENDED" + + // FacturXConformanceXRechnung represents the XRECHNUNG Factur-X conformance level. + FacturXConformanceXRechnung string = "XRECHNUNG" + + // FacturXDocumentTypeInvoice represents the INVOICE Factur-X document type. + FacturXDocumentTypeInvoice string = "INVOICE" + + // FacturXDocumentTypeOrder represents the ORDER Factur-X document type. + FacturXDocumentTypeOrder string = "ORDER" + + // FacturXDocumentTypeOrderResponse represents the ORDER_RESPONSE Factur-X document type. + FacturXDocumentTypeOrderResponse string = "ORDER_RESPONSE" + + // FacturXDocumentTypeOrderChange represents the ORDER_CHANGE Factur-X document type. + FacturXDocumentTypeOrderChange string = "ORDER_CHANGE" +) + +// FacturX gathers the properties required by the Factur-X/ZUGFeRD standard for +// the document-level XMP metadata packet of a PDF/A-3. +type FacturX struct { + // ConformanceLevel is one of the FacturXConformance* values. + ConformanceLevel string + + // DocumentType is one of the FacturXDocumentType* values. + DocumentType string + + // DocumentFileName is the name of the embedded XML invoice (e.g., + // "factur-x.xml"). + DocumentFileName string + + // Version is the Factur-X version (e.g., "1.0"). + Version string +} + // PdfEngine provides an interface for operations on PDFs. Implementations // can use various tools like PDFtk, or implement functionality directly in // Go. @@ -216,6 +269,12 @@ type PdfEngine interface { // Rotate rotates pages of a PDF file by the given angle (90, 180, 270). // If pages is empty, all pages are rotated. Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error + + // InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the + // document-level XMP packet (Catalog /Metadata stream) of a PDF/A-3. It + // registers the fx namespace, the four fx properties, and the matching + // PDF/A extension schema so the result stays PDF/A-valid. + InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX FacturX, inputPath string) error } // PdfEngineProvider offers an interface to instantiate a [PdfEngine]. diff --git a/pkg/modules/api/formdata.go b/pkg/modules/api/formdata.go index 631f3ab1..302efe18 100644 --- a/pkg/modules/api/formdata.go +++ b/pkg/modules/api/formdata.go @@ -424,6 +424,89 @@ func (form *FormData) EmbedsMetadata(target *map[string]map[string]string) *Form return form } +// FacturX parses the "facturx" form field (a JSON string) into a +// [gotenberg.FacturX]. The "conformanceLevel" property is mandatory; the +// "documentType", "version", and "documentFileName" properties default to +// "INVOICE", "1.0", and "factur-x.xml" respectively. It leaves the target +// untouched when the field is absent. +// +// var facturX gotenberg.FacturX +// +// ctx.FormData().FacturX(&facturX, false) +func (form *FormData) FacturX(target *gotenberg.FacturX, mandatory bool) *FormData { + if form.errors != nil { + return form + } + + val, ok := form.values["facturx"] + if !ok || len(val) == 0 || val[0] == "" { + if mandatory { + form.append(fmt.Errorf("form field '%s' is required", "facturx")) + } + return form + } + + var parsed struct { + ConformanceLevel string `json:"conformanceLevel"` + DocumentType string `json:"documentType"` + DocumentFileName string `json:"documentFileName"` + Version string `json:"version"` + } + + err := json.Unmarshal([]byte(val[0]), &parsed) + if err != nil { + form.append(fmt.Errorf("form field 'facturx' is invalid: %w", err)) + return form + } + + facturX := gotenberg.FacturX{ + ConformanceLevel: parsed.ConformanceLevel, + DocumentType: parsed.DocumentType, + DocumentFileName: parsed.DocumentFileName, + Version: parsed.Version, + } + + if facturX.DocumentType == "" { + facturX.DocumentType = gotenberg.FacturXDocumentTypeInvoice + } + + if facturX.Version == "" { + facturX.Version = "1.0" + } + + if facturX.DocumentFileName == "" { + facturX.DocumentFileName = "factur-x.xml" + } + + switch facturX.ConformanceLevel { + case gotenberg.FacturXConformanceMinimum, + gotenberg.FacturXConformanceBasicWL, + gotenberg.FacturXConformanceBasic, + gotenberg.FacturXConformanceEN16931, + gotenberg.FacturXConformanceExtended, + gotenberg.FacturXConformanceXRechnung: + case "": + form.append(errors.New("form field 'facturx' is invalid: 'conformanceLevel' is required")) + return form + default: + form.append(fmt.Errorf("form field 'facturx' is invalid: unsupported 'conformanceLevel' '%s'", facturX.ConformanceLevel)) + return form + } + + switch facturX.DocumentType { + case gotenberg.FacturXDocumentTypeInvoice, + gotenberg.FacturXDocumentTypeOrder, + gotenberg.FacturXDocumentTypeOrderResponse, + gotenberg.FacturXDocumentTypeOrderChange: + default: + form.append(fmt.Errorf("form field 'facturx' is invalid: unsupported 'documentType' '%s'", facturX.DocumentType)) + return form + } + + *target = facturX + return form +} + // MandatoryPaths binds the absolute paths of form data files, according to a // list of file extensions, to a string slice variable. It populates an error // if there is no file for given file extensions. diff --git a/pkg/modules/api/formdata_test.go b/pkg/modules/api/formdata_test.go index ca575836..fbdab389 100644 --- a/pkg/modules/api/formdata_test.go +++ b/pkg/modules/api/formdata_test.go @@ -7,6 +7,8 @@ import ( "reflect" "testing" "time" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) func TestFormData_Validate(t *testing.T) { @@ -1783,3 +1785,111 @@ func TestFormData_Embeds(t *testing.T) { t.Errorf("expected %v but got %v", expected, actual) } } + +func TestFormData_FacturX(t *testing.T) { + for _, tc := range []struct { + scenario string + form *FormData + mandatory bool + expect gotenberg.FacturX + expectErr bool + }{ + { + scenario: "key does not exist, not mandatory", + form: &FormData{}, + mandatory: false, + expect: gotenberg.FacturX{}, + }, + { + scenario: "key does not exist, mandatory", + form: &FormData{}, + mandatory: true, + expectErr: true, + }, + { + scenario: "all fields provided", + form: &FormData{ + values: map[string][]string{ + "facturx": {`{"conformanceLevel":"EXTENDED","documentType":"ORDER","documentFileName":"order.xml","version":"2.0"}`}, + }, + }, + expect: gotenberg.FacturX{ + ConformanceLevel: gotenberg.FacturXConformanceExtended, + DocumentType: gotenberg.FacturXDocumentTypeOrder, + DocumentFileName: "order.xml", + Version: "2.0", + }, + }, + { + scenario: "only conformance level, defaults applied", + form: &FormData{ + values: map[string][]string{ + "facturx": {`{"conformanceLevel":"EN 16931"}`}, + }, + }, + expect: gotenberg.FacturX{ + ConformanceLevel: gotenberg.FacturXConformanceEN16931, + DocumentType: gotenberg.FacturXDocumentTypeInvoice, + DocumentFileName: "factur-x.xml", + Version: "1.0", + }, + }, + { + scenario: "invalid JSON", + form: &FormData{ + values: map[string][]string{ + "facturx": {`{not json`}, + }, + }, + expectErr: true, + }, + { + scenario: "missing conformance level", + form: &FormData{ + values: map[string][]string{ + "facturx": {`{"documentType":"INVOICE"}`}, + }, + }, + expectErr: true, + }, + { + scenario: "unsupported conformance level", + form: &FormData{ + values: map[string][]string{ + "facturx": {`{"conformanceLevel":"FOO"}`}, + }, + }, + expectErr: true, + }, + { + scenario: "unsupported document type", + form: &FormData{ + values: map[string][]string{ + "facturx": {`{"conformanceLevel":"BASIC","documentType":"RECEIPT"}`}, + }, + }, + expectErr: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + var actual gotenberg.FacturX + + tc.form.FacturX(&actual, tc.mandatory) + + if tc.expectErr { + if tc.form.errors == nil { + t.Error("expected an error but got none") + } + return + } + + if tc.form.errors != nil { + t.Errorf("expected no error but got: %v", tc.form.errors) + } + + if !reflect.DeepEqual(actual, tc.expect) { + t.Errorf("expected %+v but got %+v", tc.expect, actual) + } + }) + } +} diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index d8303331..bbe76501 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -454,6 +454,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { stampFile := pdfengines.FormDataPdfStampFile(form) rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) + facturX := pdfengines.FormDataPdfFacturX(form, false) var url string err := form. @@ -477,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate stamp: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, watermark, stamp, rotateAngle, rotatePages) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -542,6 +543,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { stampFile := pdfengines.FormDataPdfStampFile(form) rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) + facturX := pdfengines.FormDataPdfFacturX(form, false) var inputPath string err := form. @@ -562,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { url := fmt.Sprintf("file://%s", inputPath) options.AllowedFilePrefixes = []string{ctx.DirPath()} - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, watermark, stamp, rotateAngle, rotatePages) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -624,6 +626,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { stampFile := pdfengines.FormDataPdfStampFile(form) rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) + facturX := pdfengines.FormDataPdfFacturX(form, false) var ( inputPath string @@ -653,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } options.AllowedFilePrefixes = []string{ctx.DirPath()} - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, watermark, stamp, rotateAngle, rotatePages) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -778,7 +781,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) return fmt.Sprintf("file://%s", inputPath), nil } -func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error { +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error { outputPath := ctx.GeneratePath(".pdf") // See https://github.com/gotenberg/gotenberg/issues/1130. filename := ctx.OutputFilename(outputPath) @@ -887,6 +890,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("set embeds metadata: %w", err) } + err = pdfengines.InjectFacturXXMPStub(ctx, engine, facturX, convertOutputPaths) + if err != nil { + return fmt.Errorf("inject Factur-X XMP: %w", err) + } + err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index edbc696b..5e0755ed 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -523,6 +523,11 @@ func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Log return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// InjectFacturXXMP is not available in this implementation. +func (engine *ExifTool) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error { + return fmt.Errorf("inject Factur-X XMP with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*ExifTool)(nil) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index d451b5ac..d4a7a699 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -135,6 +135,11 @@ func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *slog.Log return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// InjectFacturXXMP is not available in this implementation. +func (engine *LibreOfficePdfEngine) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error { + return fmt.Errorf("inject Factur-X XMP with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*LibreOfficePdfEngine)(nil) diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index a192f772..54b596df 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -38,6 +38,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap stampFile := pdfengines.FormDataPdfStampFile(form) angle, rotatePages := pdfengines.FormDataPdfRotate(form, false) embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form) + facturX := pdfengines.FormDataPdfFacturX(form, false) zeroValuedSplitMode := gotenberg.SplitMode{} @@ -503,6 +504,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return fmt.Errorf("set embeds metadata: %w", err) } + err = pdfengines.InjectFacturXXMPStub(ctx, engine, facturX, outputPaths) + if err != nil { + return fmt.Errorf("inject Factur-X XMP: %w", err) + } + err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 76b07bb9..8f1c8b3f 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -452,6 +452,11 @@ func (engine *PdfCpu) EmbedFilesMetadata(ctx context.Context, logger *slog.Logge return fmt.Errorf("set embeds metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// InjectFacturXXMP is not available in this implementation. +func (engine *PdfCpu) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error { + return fmt.Errorf("inject Factur-X XMP with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // EmbedFiles embeds files into a PDF. All files are embedded as file attachments // without modifying the main PDF content. func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index be505fc7..41ed58c6 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -28,6 +28,7 @@ type multiPdfEngines struct { watermarkEngines []gotenberg.PdfEngine stampEngines []gotenberg.PdfEngine rotateEngines []gotenberg.PdfEngine + facturXEngines []gotenberg.PdfEngine } func newMultiPdfEngines( @@ -44,7 +45,8 @@ func newMultiPdfEngines( writeBookmarksEngines, watermarkEngines, stampEngines, - rotateEngines []gotenberg.PdfEngine, + rotateEngines, + facturXEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ mergeEngines: mergeEngines, @@ -61,6 +63,7 @@ func newMultiPdfEngines( watermarkEngines: watermarkEngines, stampEngines: stampEngines, rotateEngines: rotateEngines, + facturXEngines: facturXEngines, } } @@ -320,6 +323,17 @@ func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *sl ) } +// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata using the first +// available engine that supports it. +func (multi *multiPdfEngines) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error { + return runWithFallbackVoid(ctx, "pdfengines.InjectFacturXXMP", multi.facturXEngines, + func(ctx context.Context, engine gotenberg.PdfEngine) error { + return engine.InjectFacturXXMP(ctx, logger, facturX, inputPath) + }, + func(err error) error { return fmt.Errorf("inject Factur-X XMP with multi PDF engines: %w", err) }, + ) +} + // Interface guards. var ( _ gotenberg.PdfEngine = (*multiPdfEngines)(nil) diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 5f01286c..b1c22345 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -42,6 +42,7 @@ type PdfEngines struct { watermarkNames []string stampNames []string rotateNames []string + facturXNames []string engines []gotenberg.PdfEngine disableRoutes bool } @@ -66,6 +67,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all") fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all") fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all") + fs.StringSlice("pdfengines-factur-x-engines", []string{"qpdf"}, "Set the PDF engines and their order for the Factur-X XMP feature - empty means all") fs.Bool("pdfengines-disable-routes", false, "Disable the routes") // Deprecated flags. @@ -99,6 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines") stampNames := flags.MustStringSlice("pdfengines-stamp-engines") rotateNames := flags.MustStringSlice("pdfengines-rotate-engines") + facturXNames := flags.MustStringSlice("pdfengines-factur-x-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") engines, err := ctx.Modules(new(gotenberg.PdfEngine)) @@ -195,6 +198,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.rotateNames = rotateNames } + mod.facturXNames = defaultNames + if len(facturXNames) > 0 { + mod.facturXNames = facturXNames + } + return nil } @@ -250,6 +258,7 @@ func (mod *PdfEngines) Validate() error { findNonExistingEngines(mod.watermarkNames) findNonExistingEngines(mod.stampNames) findNonExistingEngines(mod.rotateNames) + findNonExistingEngines(mod.facturXNames) if len(nonExistingEngines) == 0 { return nil @@ -276,6 +285,7 @@ func (mod *PdfEngines) SystemMessages() []string { fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")), fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")), fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")), + fmt.Sprintf("Factur-X engines - %s", strings.Join(mod.facturXNames, " ")), } } @@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { engines(mod.watermarkNames), engines(mod.stampNames), engines(mod.rotateNames), + engines(mod.facturXNames), ), nil } @@ -340,6 +351,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { watermarkRoute(engine), stampRoute(engine), rotateRoute(engine), + facturXRoute(engine), }, nil } diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 9af8e3da..1a49f531 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -467,6 +467,31 @@ func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metada return nil } +// FormDataPdfFacturX extracts Factur-X parameters from form data. +// The "facturx" field is a JSON string with the four fx properties. +func FormDataPdfFacturX(form *api.FormData, mandatory bool) gotenberg.FacturX { + var facturX gotenberg.FacturX + form.FacturX(&facturX, mandatory) + return facturX +} + +// InjectFacturXXMPStub injects Factur-X XMP metadata into PDF files. If the +// Factur-X data is not set, it does nothing. +func InjectFacturXXMPStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX gotenberg.FacturX, inputPaths []string) error { + if facturX.ConformanceLevel == "" { + return nil + } + + for _, inputPath := range inputPaths { + err := engine.InjectFacturXXMP(ctx, ctx.Log(), facturX, inputPath) + if err != nil { + return fmt.Errorf("inject Factur-X XMP into PDF '%s': %w", inputPath, err) + } + } + + return nil +} + // FormDataPdfEncrypt extracts encryption parameters from form data. func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) { form.String("userPassword", &userPassword, "") @@ -707,6 +732,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { stampFile := FormDataPdfStampFile(form) angle, rotatePages := FormDataPdfRotate(form, false) embedsMetadata := FormDataPdfEmbedsMetadata(form) + facturX := FormDataPdfFacturX(form, false) var inputPaths []string var flatten bool @@ -830,6 +856,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("set embeds metadata: %w", err) } + err = InjectFacturXXMPStub(ctx, engine, facturX, outputPaths) + if err != nil { + return fmt.Errorf("inject Factur-X XMP: %w", err) + } + err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) @@ -866,6 +897,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { stampFile := FormDataPdfStampFile(form) angle, rotatePages := FormDataPdfRotate(form, false) embedsMetadata := FormDataPdfEmbedsMetadata(form) + facturX := FormDataPdfFacturX(form, false) var inputPaths []string var flatten bool @@ -940,6 +972,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("set embeds metadata: %w", err) } + err = InjectFacturXXMPStub(ctx, engine, facturX, convertOutputPaths) + if err != nil { + return fmt.Errorf("inject Factur-X XMP: %w", err) + } + err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) @@ -1265,6 +1302,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route { form := ctx.FormData() embedPaths := FormDataPdfEmbeds(form) embedsMetadata := FormDataPdfEmbedsMetadata(form) + facturX := FormDataPdfFacturX(form, false) var inputPaths []string err := form. @@ -1283,6 +1321,11 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("set embeds metadata: %w", err) } + err = InjectFacturXXMPStub(ctx, engine, facturX, inputPaths) + if err != nil { + return fmt.Errorf("inject Factur-X XMP: %w", err) + } + err = ctx.AddOutputPaths(inputPaths...) if err != nil { return fmt.Errorf("add output paths: %w", err) @@ -1413,3 +1456,39 @@ func rotateRoute(engine gotenberg.PdfEngine) api.Route { }, } } + +// facturXRoute returns an [api.Route] which injects Factur-X/ZUGFeRD XMP +// metadata into PDF/A-3 files. +func facturXRoute(engine gotenberg.PdfEngine) api.Route { + return api.Route{ + Method: http.MethodPost, + Path: "/forms/pdfengines/factur-x", + IsMultipart: true, + Handler: func(c echo.Context) error { + ctx := c.Get("context").(*api.Context) + + form := ctx.FormData() + facturX := FormDataPdfFacturX(form, true) + + var inputPaths []string + err := form. + MandatoryPaths([]string{".pdf"}, &inputPaths). + Validate() + if err != nil { + return fmt.Errorf("validate form data: %w", err) + } + + err = InjectFacturXXMPStub(ctx, engine, facturX, inputPaths) + if err != nil { + return fmt.Errorf("inject Factur-X XMP into PDFs: %w", err) + } + + err = ctx.AddOutputPaths(inputPaths...) + if err != nil { + return fmt.Errorf("add output paths: %w", err) + } + + return nil + }, + } +} diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index fad32cf8..f6a74217 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -500,6 +500,11 @@ func (engine *PdfTk) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger return fmt.Errorf("set embeds metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } +// InjectFacturXXMP is not available in this implementation. +func (engine *PdfTk) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error { + return fmt.Errorf("inject Factur-X XMP with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*PdfTk)(nil) diff --git a/pkg/modules/qpdf/facturx_test.go b/pkg/modules/qpdf/facturx_test.go new file mode 100644 index 00000000..031921ab --- /dev/null +++ b/pkg/modules/qpdf/facturx_test.go @@ -0,0 +1,249 @@ +package qpdf + +import ( + "errors" + "strings" + "testing" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +func TestValidateFacturX(t *testing.T) { + valid := gotenberg.FacturX{ + ConformanceLevel: gotenberg.FacturXConformanceEN16931, + DocumentType: gotenberg.FacturXDocumentTypeInvoice, + DocumentFileName: "factur-x.xml", + Version: "1.0", + } + + tests := []struct { + name string + mutate func(f *gotenberg.FacturX) + wantError bool + }{ + {name: "valid", mutate: func(*gotenberg.FacturX) {}}, + { + name: "valid BASIC WL conformance", + mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = gotenberg.FacturXConformanceBasicWL }, + }, + { + name: "valid ORDER document type", + mutate: func(f *gotenberg.FacturX) { f.DocumentType = gotenberg.FacturXDocumentTypeOrder }, + }, + { + name: "unsupported conformance level", + mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "FOO" }, + wantError: true, + }, + { + name: "empty conformance level", + mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "" }, + wantError: true, + }, + { + name: "unsupported document type", + mutate: func(f *gotenberg.FacturX) { f.DocumentType = "RECEIPT" }, + wantError: true, + }, + { + name: "empty document file name", + mutate: func(f *gotenberg.FacturX) { f.DocumentFileName = "" }, + wantError: true, + }, + { + name: "empty version", + mutate: func(f *gotenberg.FacturX) { f.Version = "" }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + facturX := valid + tt.mutate(&facturX) + + err := validateFacturX(facturX) + if tt.wantError { + if err == nil { + t.Fatal("expected an error, got nil") + } + if !errors.Is(err, gotenberg.ErrPdfFacturXValueNotSupported) { + t.Errorf("expected ErrPdfFacturXValueNotSupported, got %v", err) + } + return + } + + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + } +} + +func TestFindMetadataStream(t *testing.T) { + tests := []struct { + name string + input string + wantKey string + wantXMP string + wantError bool + }{ + { + name: "metadata stream found", + // "PFhtcD4=" is base64 for "". + input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"stream":{"dict":{"/Type":"/Metadata","/Subtype":"/XML"},"data":"PHhtcD4="}}}]}`, + wantKey: "obj:4 0 R", + wantXMP: "<xmp>", + }, + { + name: "catalog without metadata reference", + input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog"}}}]}`, + wantError: true, + }, + { + name: "metadata object missing", + input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}}}]}`, + wantError: true, + }, + { + name: "metadata object is not a stream", + input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"value":{"/Type":"/Metadata"}}}]}`, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objects, err := parsePdfObjects([]byte(tt.input)) + if err != nil { + t.Fatalf("parse objects: %v", err) + } + + key, dict, xmp, err := findMetadataStream(objects) + if tt.wantError { + if err == nil { + t.Fatal("expected an error, got nil") + } + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if key != tt.wantKey { + t.Errorf("key = %q, want %q", key, tt.wantKey) + } + if xmp != tt.wantXMP { + t.Errorf("xmp = %q, want %q", xmp, tt.wantXMP) + } + if dict == nil { + t.Error("expected a non-nil dict") + } + }) + } +} + +func TestInjectFacturXIntoXMP(t *testing.T) { + facturX := gotenberg.FacturX{ + ConformanceLevel: gotenberg.FacturXConformanceEN16931, + DocumentType: gotenberg.FacturXDocumentTypeInvoice, + DocumentFileName: "factur-x.xml", + Version: "1.0", + } + + // The packet a LibreOffice PDF/A-3b export produces: no pdfaExtension bag. + libreOfficeXMP := `<?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:RDF> +</x:xmpmeta> +<?xpacket end="w"?>` + + t.Run("creates extension schema when bag absent", func(t *testing.T) { + got, changed := injectFacturXIntoXMP(libreOfficeXMP, facturX) + if !changed { + t.Fatal("expected changed = true") + } + assertContains(t, got, facturXNamespaceURI) + assertContains(t, got, "<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>") + assertContains(t, got, "<fx:DocumentType>INVOICE</fx:DocumentType>") + assertContains(t, got, "<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>") + assertContains(t, got, "<fx:Version>1.0</fx:Version>") + assertContains(t, got, "pdfaExtension:schemas") + assertContains(t, got, "http://www.aiim.org/pdfa/ns/extension/") + // The fx blocks must land inside the RDF container. + if strings.Index(got, facturXNamespaceURI) > strings.LastIndex(got, "</rdf:RDF>") { + t.Error("fx content injected outside the rdf:RDF container") + } + }) + + t.Run("idempotent when fx already present", func(t *testing.T) { + once, _ := injectFacturXIntoXMP(libreOfficeXMP, facturX) + twice, changed := injectFacturXIntoXMP(once, facturX) + if changed { + t.Error("expected changed = false on a packet that already declares fx") + } + if twice != once { + t.Error("expected the packet to be left untouched") + } + if strings.Count(twice, "<pdfaExtension:schemas>") != 1 { + t.Error("expected exactly one pdfaExtension:schemas declaration") + } + }) + + t.Run("appends entry to an existing bag", func(t *testing.T) { + withBag := `<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: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:prefix>other</pdfaSchema:prefix></rdf:li> + </rdf:Bag> + </pdfaExtension:schemas> + </rdf:Description> + </rdf:RDF> +</x:xmpmeta>` + got, changed := injectFacturXIntoXMP(withBag, facturX) + if !changed { + t.Fatal("expected changed = true") + } + assertContains(t, got, facturXNamespaceURI) + if strings.Count(got, "<pdfaExtension:schemas>") != 1 { + t.Error("expected the existing pdfaExtension:schemas bag to be reused, not duplicated") + } + if strings.Count(got, "<pdfaSchema:prefix>") != 2 { + t.Error("expected both the existing and the fx schema entries") + } + }) + + t.Run("no rdf:RDF anchor leaves packet unchanged", func(t *testing.T) { + got, changed := injectFacturXIntoXMP("not an xmp packet", facturX) + if changed { + t.Error("expected changed = false") + } + if got != "not an xmp packet" { + t.Error("expected the packet to be left untouched") + } + }) + + t.Run("escapes runtime values", func(t *testing.T) { + escaped := facturX + escaped.DocumentFileName = "a&b<c>.xml" + got, _ := injectFacturXIntoXMP(libreOfficeXMP, escaped) + assertContains(t, got, "a&amp;b&lt;c&gt;.xml") + if strings.Contains(got, "<fx:DocumentFileName>a&b<c>.xml") { + t.Error("expected the document file name to be XML-escaped") + } + }) +} + +func assertContains(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("expected output to contain %q", needle) + } +} diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 8a7f76dc..3b000e45 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -3,7 +3,9 @@ package qpdf import ( "bytes" "context" + "encoding/base64" "encoding/json" + "encoding/xml" "errors" "fmt" "log/slog" @@ -527,8 +529,10 @@ func patchCatalogAF(catalogRef string, catalogValue map[string]any, filespecRefs } // writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes -// them to a temp file, and applies the update via --update-from-json. -func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error { +// them to a temp file, and applies the update via --update-from-json. extraArgs +// are appended to the QPDF command (e.g., --json-stream-data=inline when the +// update replaces stream data). +func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any, extraArgs ...string) error { updateJSON := map[string]any{ "qpdf": []any{ map[string]any{ @@ -560,9 +564,10 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger return fmt.Errorf("close temp file: %w", err) } - updateArgs := make([]string, 0, 5+len(engine.globalArgs)) + updateArgs := make([]string, 0, 5+len(engine.globalArgs)+len(extraArgs)) updateArgs = append(updateArgs, inputPath) updateArgs = append(updateArgs, engine.globalArgs...) + updateArgs = append(updateArgs, extraArgs...) updateArgs = append(updateArgs, "--newline-before-endstream") updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name()) updateArgs = append(updateArgs, "--replace-input") @@ -635,6 +640,322 @@ func stripQpdfStringPrefix(s string) string { return s } +// facturXNamespaceURI is the Factur-X/ZUGFeRD XMP namespace required by strict +// validators. +const facturXNamespaceURI = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#" + +// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the document-level +// XMP packet (Catalog /Metadata stream) of a PDF/A-3 using QPDF's JSON +// manipulation. It reads the existing XMP packet, splices in the fx +// rdf:Description plus the PDF/A extension-schema declaration, and writes the +// stream back uncompressed so the document stays PDF/A-valid. +// +// It assumes the input already carries a Catalog /Metadata stream (always true +// for a LibreOffice PDF/A export). The injection is idempotent: a packet that +// already declares the fx namespace is left untouched. +func (engine *QPdf) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.InjectFacturXXMP", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + ) + defer span.End() + + err := validateFacturX(facturX) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + logger.DebugContext(ctx, fmt.Sprintf("injecting Factur-X XMP into %s with QPDF", inputPath)) + + args := append([]string{inputPath}, engine.globalArgs...) + args = append(args, "--newline-before-endstream", "--json-output", "--json-stream-data=inline") + + output, err := engine.execCaptureOutput(ctx, args...) + if err != nil { + err = fmt.Errorf("get PDF JSON with QPDF: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + objects, err := parsePdfObjects(output) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + metaKey, metaDict, xmp, err := findMetadataStream(objects) + if err != nil { + err = fmt.Errorf("locate XMP metadata stream: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + newXmp, changed := injectFacturXIntoXMP(xmp, facturX) + if !changed { + logger.DebugContext(ctx, "Factur-X XMP already present, skipping injection") + span.SetStatus(codes.Ok, "") + return nil + } + + // PDF/A requires the metadata stream to be uncompressed and unfiltered. We + // provide the decoded XMP as the new stream data, so any existing filter + // must be dropped and the length left for QPDF to recompute. + delete(metaDict, "/Filter") + delete(metaDict, "/DecodeParms") + delete(metaDict, "/Length") + + updateObjects := map[string]any{ + metaKey: map[string]any{ + "stream": map[string]any{ + "dict": metaDict, + "data": base64.StdEncoding.EncodeToString([]byte(newXmp)), + }, + }, + } + + err = engine.writeAndApplyUpdate(ctx, logger, inputPath, updateObjects, "--json-stream-data=inline") + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + span.SetStatus(codes.Ok, "") + return nil +} + +// validateFacturX checks the Factur-X fields against the supported values. +func validateFacturX(facturX gotenberg.FacturX) error { + switch facturX.ConformanceLevel { + case gotenberg.FacturXConformanceMinimum, + gotenberg.FacturXConformanceBasicWL, + gotenberg.FacturXConformanceBasic, + gotenberg.FacturXConformanceEN16931, + gotenberg.FacturXConformanceExtended, + gotenberg.FacturXConformanceXRechnung: + default: + return fmt.Errorf("conformance level '%s': %w", facturX.ConformanceLevel, gotenberg.ErrPdfFacturXValueNotSupported) + } + + switch facturX.DocumentType { + case gotenberg.FacturXDocumentTypeInvoice, + gotenberg.FacturXDocumentTypeOrder, + gotenberg.FacturXDocumentTypeOrderResponse, + gotenberg.FacturXDocumentTypeOrderChange: + default: + return fmt.Errorf("document type '%s': %w", facturX.DocumentType, gotenberg.ErrPdfFacturXValueNotSupported) + } + + if facturX.DocumentFileName == "" { + return fmt.Errorf("document file name is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported) + } + + if facturX.Version == "" { + return fmt.Errorf("version is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported) + } + + return nil +} + +// findMetadataStream locates the document-level XMP metadata stream referenced +// by the Catalog /Metadata entry. It returns the object key (e.g. "obj:4 0 R"), +// the stream dict, and the decoded XMP packet. +func findMetadataStream(objects map[string]json.RawMessage) (string, map[string]any, string, error) { + var metadataRef string + for _, raw := range objects { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + continue + } + + valueRaw, ok := obj["value"] + if !ok { + continue + } + + var value map[string]any + if err := json.Unmarshal(valueRaw, &value); err != nil { + continue + } + + if typeVal, _ := value["/Type"].(string); typeVal == "/Catalog" { + metadataRef, _ = value["/Metadata"].(string) + break + } + } + + if metadataRef == "" { + return "", nil, "", errors.New("no /Metadata reference in the catalog") + } + + // References in values use the "4 0 R" form; object keys use "obj:4 0 R". + objKey := "obj:" + metadataRef + raw, ok := objects[objKey] + if !ok { + return "", nil, "", fmt.Errorf("metadata object '%s' not found", objKey) + } + + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return "", nil, "", fmt.Errorf("unmarshal metadata object: %w", err) + } + + streamRaw, ok := obj["stream"] + if !ok { + return "", nil, "", errors.New("metadata object is not a stream") + } + + var stream struct { + Dict map[string]any `json:"dict"` + Data string `json:"data"` + } + if err := json.Unmarshal(streamRaw, &stream); err != nil { + return "", nil, "", fmt.Errorf("unmarshal metadata stream: %w", err) + } + + decoded, err := base64.StdEncoding.DecodeString(stream.Data) + if err != nil { + return "", nil, "", fmt.Errorf("decode metadata stream data: %w", err) + } + + dict := stream.Dict + if dict == nil { + dict = make(map[string]any) + } + + return objKey, dict, string(decoded), nil +} + +// injectFacturXIntoXMP splices the fx rdf:Description and the PDF/A +// extension-schema declaration into an XMP packet. It returns the new packet and +// whether a change was made (false when the fx namespace is already present). +func injectFacturXIntoXMP(xmp string, facturX gotenberg.FacturX) (string, bool) { + if strings.Contains(xmp, facturXNamespaceURI) { + return xmp, false + } + + anchor := strings.LastIndex(xmp, "</rdf:RDF>") + if anchor == -1 { + return xmp, false + } + + insert := facturXDescription(facturX) + + if strings.Contains(xmp, "pdfaExtension:schemas") { + // An extension-schema bag already exists (e.g. emitted by another tool): + // splice the fx Description, then append the fx schema entry into the bag. + spliced := xmp[:anchor] + insert + xmp[anchor:] + return injectSchemaIntoExistingBag(spliced), true + } + + // No extension-schema bag yet (the LibreOffice PDF/A case): create the whole + // container alongside the fx Description. + insert += facturXExtensionSchema() + return xmp[:anchor] + insert + xmp[anchor:], true +} + +// injectSchemaIntoExistingBag appends the fx schema entry into an existing +// pdfaExtension:schemas bag. +func injectSchemaIntoExistingBag(xmp string) string { + mi := strings.Index(xmp, "pdfaExtension:schemas") + if mi == -1 { + return xmp + } + + bag := strings.Index(xmp[mi:], "<rdf:Bag") + if bag == -1 { + return xmp + } + + gt := strings.Index(xmp[mi+bag:], ">") + if gt == -1 { + return xmp + } + + pos := mi + bag + gt + 1 + return xmp[:pos] + "\n" + facturXSchemaLi() + xmp[pos:] +} + +// facturXDescription builds the fx rdf:Description carrying the runtime values. +func facturXDescription(facturX gotenberg.FacturX) string { + return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:fx="%s"> + <fx:DocumentType>%s</fx:DocumentType> + <fx:DocumentFileName>%s</fx:DocumentFileName> + <fx:Version>%s</fx:Version> + <fx:ConformanceLevel>%s</fx:ConformanceLevel> + </rdf:Description> +`, + facturXNamespaceURI, + xmlEscape(facturX.DocumentType), + xmlEscape(facturX.DocumentFileName), + xmlEscape(facturX.Version), + xmlEscape(facturX.ConformanceLevel), + ) +} + +// facturXExtensionSchema builds the rdf:Description that declares the PDF/A +// extension schema for the fx namespace, including the namespace declarations. +func facturXExtensionSchema() string { + return fmt.Sprintf(` <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> +%s + </rdf:Bag> + </pdfaExtension:schemas> + </rdf:Description> +`, facturXSchemaLi()) +} + +// facturXSchemaLi builds the rdf:li describing the fx schema and its four +// properties. These are fixed schema definitions, not runtime invoice values. +func facturXSchemaLi() string { + return fmt.Sprintf(` <rdf:li rdf:parseType="Resource"> + <pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema> + <pdfaSchema:namespaceURI>%s</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 of the embedded XML invoice file</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>The actual version of the Factur-X XML schema</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>The conformance level of the embedded Factur-X data</pdfaProperty:description> + </rdf:li> + </rdf:Seq> + </pdfaSchema:property> + </rdf:li>`, facturXNamespaceURI) +} + +// xmlEscape escapes a string for safe inclusion in XML character data. +func xmlEscape(s string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(s)) + return buf.String() +} + // Watermark is not available in this implementation. func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { _, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark", diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index 48c818e8..a3f52962 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -695,6 +695,43 @@ Feature: /forms/libreoffice/convert Then the response PDF(s) should have the "embed_1.xml" file embedded Then the response PDF(s) should have the "embed_2.xml" file embedded + @convert + @embed + @factur-x + Scenario: POST /forms/libreoffice/convert (Factur-X / ZUGFeRD) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | pdfa | PDF/A-3b | field | + | embeds | testdata/embed_1.xml | file | + | embedsMetadata | {"embed_1.xml":{"mimeType":"text/xml","relationship":"Alternative"}} | field | + | facturx | {"conformanceLevel":"EN 16931"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s) + Then the response PDF(s) should have the "embed_1.xml" file embedded with relationship "Alternative" + Then the response PDF(s) should declare Factur-X XMP with conformance level "EN 16931" + + @factur-x + Scenario: POST /forms/pdfengines/factur-x (Standalone) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/page_1.docx | file | + | pdfa | PDF/A-3b | field | + | Gotenberg-Output-Filename | base | header | + Then the response status code should be 200 + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/factur-x" endpoint with the following form data and header(s): + | files | teststore/base.pdf | file | + | facturx | {"conformanceLevel":"BASIC","documentType":"ORDER"} | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s) + Then the response PDF(s) should declare Factur-X XMP with conformance level "BASIC" + # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index d2a2ff10..904fdb51 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -1349,6 +1349,57 @@ func (s *scenario) thePdfsShouldHaveEmbeddedFileWithRelationship(ctx context.Con return nil } +func (s *scenario) thePdfsShouldDeclareFacturXConformanceLevel(ctx context.Context, kind, conformanceLevel string) error { + dirPath := s.teststoreDir + + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + + var paths []string + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", dirPath, err) + } + + for _, path := range paths { + cmd := []string{ + "pdfinfo", + "-meta", + filepath.Base(path), + } + + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + if !strings.Contains(output, "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#") { + return errors.New("missing Factur-X namespace in XMP") + } + + if !strings.Contains(output, "pdfaExtension:schemas") { + return errors.New("missing PDF/A extension schema in XMP") + } + + conformanceTag := fmt.Sprintf("<fx:ConformanceLevel>%s</fx:ConformanceLevel>", conformanceLevel) + if !strings.Contains(output, conformanceTag) { + return fmt.Errorf("missing fx:ConformanceLevel %q in XMP", conformanceLevel) + } + } + + return nil +} + func InitializeScenario(ctx *godog.ScenarioContext) { s := &scenario{} ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) { @@ -1389,6 +1440,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile) ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship) + ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel) ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)