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 "