mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
feat(pdfengines): inject Factur-X/ZUGFeRD XMP metadata
This commit is contained in:
@@ -53,6 +53,7 @@ body:multipart-form {
|
|||||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.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"}}
|
~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
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ body:multipart-form {
|
|||||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.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"}}
|
~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
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ body:multipart-form {
|
|||||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.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"}}
|
~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
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ body:multipart-form {
|
|||||||
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
~embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
~embeds: @file(../test/integration/testdata/embed_2.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"}}
|
~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
|
~watermarkSource: text
|
||||||
~watermarkExpression: CONFIDENTIAL
|
~watermarkExpression: CONFIDENTIAL
|
||||||
~watermarkPages:
|
~watermarkPages:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
embeds: @file(../test/integration/testdata/embed_1.xml)
|
embeds: @file(../test/integration/testdata/embed_1.xml)
|
||||||
embeds: @file(../test/integration/testdata/embed_2.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"}}
|
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}]
|
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
26
.bruno/PDF Engines/Factur-X/Inject Factur-X XMP.bru
Normal file
@@ -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"}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ body:multipart-form {
|
|||||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||||
~rotateAngle: 90
|
~rotateAngle: 90
|
||||||
~rotatePages:
|
~rotatePages:
|
||||||
|
~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"}
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ body:multipart-form {
|
|||||||
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
~stampOptions: {"scale":"0.5 abs","rot":"45"}
|
||||||
~rotateAngle: 90
|
~rotateAngle: 90
|
||||||
~rotatePages:
|
~rotatePages:
|
||||||
|
~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"}
|
||||||
}
|
}
|
||||||
|
|
||||||
headers {
|
headers {
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -81,6 +81,7 @@ PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
|||||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
||||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||||
|
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
||||||
PROMETHEUS_NAMESPACE=gotenberg
|
PROMETHEUS_NAMESPACE=gotenberg
|
||||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||||
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
|
||||||
@@ -157,6 +158,7 @@ NO_CONCURRENCY=false
|
|||||||
# stamp
|
# stamp
|
||||||
# pdfengines-rotate
|
# pdfengines-rotate
|
||||||
# rotate
|
# rotate
|
||||||
|
# factur-x
|
||||||
# pdfengines-bookmarks
|
# pdfengines-bookmarks
|
||||||
# bookmarks
|
# bookmarks
|
||||||
# prometheus-metrics
|
# prometheus-metrics
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ services:
|
|||||||
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
|
||||||
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
|
||||||
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
|
||||||
|
- "--pdfengines-factur-x-engines=${PDFENGINES_FACTUR_X_ENGINES}"
|
||||||
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
|
||||||
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
|
||||||
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type PdfEngineMock struct {
|
|||||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
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
|
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
|
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 {
|
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)
|
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.
|
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||||
type PdfEngineProviderMock struct {
|
type PdfEngineProviderMock struct {
|
||||||
PdfEngineMock func() (PdfEngine, error)
|
PdfEngineMock func() (PdfEngine, error)
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ var (
|
|||||||
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
|
||||||
// not supported.
|
// not supported.
|
||||||
ErrPdfRotateAngleNotSupported = errors.New("rotation angle 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
|
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
|
||||||
@@ -151,6 +155,55 @@ type Bookmark struct {
|
|||||||
Children []Bookmark `json:"children,omitempty"`
|
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
|
// PdfEngine provides an interface for operations on PDFs. Implementations
|
||||||
// can use various tools like PDFtk, or implement functionality directly in
|
// can use various tools like PDFtk, or implement functionality directly in
|
||||||
// Go.
|
// Go.
|
||||||
@@ -216,6 +269,12 @@ type PdfEngine interface {
|
|||||||
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
||||||
// If pages is empty, all pages are rotated.
|
// If pages is empty, all pages are rotated.
|
||||||
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
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].
|
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||||
|
|||||||
@@ -424,6 +424,89 @@ func (form *FormData) EmbedsMetadata(target *map[string]map[string]string) *Form
|
|||||||
return 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
|
// 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
|
// list of file extensions, to a string slice variable. It populates an error
|
||||||
// if there is no file for given file extensions.
|
// if there is no file for given file extensions.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFormData_Validate(t *testing.T) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX := pdfengines.FormDataPdfFacturX(form, false)
|
||||||
|
|
||||||
var url string
|
var url string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -477,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
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)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX := pdfengines.FormDataPdfFacturX(form, false)
|
||||||
|
|
||||||
var inputPath string
|
var inputPath string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -562,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
url := fmt.Sprintf("file://%s", inputPath)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
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)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX := pdfengines.FormDataPdfFacturX(form, false)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
inputPath string
|
inputPath string
|
||||||
@@ -653,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
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
|
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")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
filename := ctx.OutputFilename(outputPath)
|
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)
|
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)
|
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
|
|||||||
@@ -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)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*ExifTool)(nil)
|
_ gotenberg.Module = (*ExifTool)(nil)
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *slog.Log
|
|||||||
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX := pdfengines.FormDataPdfFacturX(form, false)
|
||||||
|
|
||||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||||
|
|
||||||
@@ -503,6 +504,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
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)
|
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
|
|||||||
@@ -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)
|
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
|
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||||
// without modifying the main PDF content.
|
// without modifying the main PDF content.
|
||||||
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type multiPdfEngines struct {
|
|||||||
watermarkEngines []gotenberg.PdfEngine
|
watermarkEngines []gotenberg.PdfEngine
|
||||||
stampEngines []gotenberg.PdfEngine
|
stampEngines []gotenberg.PdfEngine
|
||||||
rotateEngines []gotenberg.PdfEngine
|
rotateEngines []gotenberg.PdfEngine
|
||||||
|
facturXEngines []gotenberg.PdfEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMultiPdfEngines(
|
func newMultiPdfEngines(
|
||||||
@@ -44,7 +45,8 @@ func newMultiPdfEngines(
|
|||||||
writeBookmarksEngines,
|
writeBookmarksEngines,
|
||||||
watermarkEngines,
|
watermarkEngines,
|
||||||
stampEngines,
|
stampEngines,
|
||||||
rotateEngines []gotenberg.PdfEngine,
|
rotateEngines,
|
||||||
|
facturXEngines []gotenberg.PdfEngine,
|
||||||
) *multiPdfEngines {
|
) *multiPdfEngines {
|
||||||
return &multiPdfEngines{
|
return &multiPdfEngines{
|
||||||
mergeEngines: mergeEngines,
|
mergeEngines: mergeEngines,
|
||||||
@@ -61,6 +63,7 @@ func newMultiPdfEngines(
|
|||||||
watermarkEngines: watermarkEngines,
|
watermarkEngines: watermarkEngines,
|
||||||
stampEngines: stampEngines,
|
stampEngines: stampEngines,
|
||||||
rotateEngines: rotateEngines,
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
|
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type PdfEngines struct {
|
|||||||
watermarkNames []string
|
watermarkNames []string
|
||||||
stampNames []string
|
stampNames []string
|
||||||
rotateNames []string
|
rotateNames []string
|
||||||
|
facturXNames []string
|
||||||
engines []gotenberg.PdfEngine
|
engines []gotenberg.PdfEngine
|
||||||
disableRoutes bool
|
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-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-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-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")
|
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
// Deprecated flags.
|
// Deprecated flags.
|
||||||
@@ -99,6 +101,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
|
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
|
||||||
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
|
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
|
||||||
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
|
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
|
||||||
|
facturXNames := flags.MustStringSlice("pdfengines-factur-x-engines")
|
||||||
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
||||||
|
|
||||||
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
||||||
@@ -195,6 +198,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
mod.rotateNames = rotateNames
|
mod.rotateNames = rotateNames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod.facturXNames = defaultNames
|
||||||
|
if len(facturXNames) > 0 {
|
||||||
|
mod.facturXNames = facturXNames
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +258,7 @@ func (mod *PdfEngines) Validate() error {
|
|||||||
findNonExistingEngines(mod.watermarkNames)
|
findNonExistingEngines(mod.watermarkNames)
|
||||||
findNonExistingEngines(mod.stampNames)
|
findNonExistingEngines(mod.stampNames)
|
||||||
findNonExistingEngines(mod.rotateNames)
|
findNonExistingEngines(mod.rotateNames)
|
||||||
|
findNonExistingEngines(mod.facturXNames)
|
||||||
|
|
||||||
if len(nonExistingEngines) == 0 {
|
if len(nonExistingEngines) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -276,6 +285,7 @@ func (mod *PdfEngines) SystemMessages() []string {
|
|||||||
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
|
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
|
||||||
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
|
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
|
||||||
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")),
|
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.watermarkNames),
|
||||||
engines(mod.stampNames),
|
engines(mod.stampNames),
|
||||||
engines(mod.rotateNames),
|
engines(mod.rotateNames),
|
||||||
|
engines(mod.facturXNames),
|
||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +351,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
|||||||
watermarkRoute(engine),
|
watermarkRoute(engine),
|
||||||
stampRoute(engine),
|
stampRoute(engine),
|
||||||
rotateRoute(engine),
|
rotateRoute(engine),
|
||||||
|
facturXRoute(engine),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -467,6 +467,31 @@ func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metada
|
|||||||
return nil
|
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.
|
// FormDataPdfEncrypt extracts encryption parameters from form data.
|
||||||
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
|
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
|
||||||
form.String("userPassword", &userPassword, "")
|
form.String("userPassword", &userPassword, "")
|
||||||
@@ -707,6 +732,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := FormDataPdfStampFile(form)
|
stampFile := FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX := FormDataPdfFacturX(form, false)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -830,6 +856,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
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)
|
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
@@ -866,6 +897,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
stampFile := FormDataPdfStampFile(form)
|
stampFile := FormDataPdfStampFile(form)
|
||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX := FormDataPdfFacturX(form, false)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -940,6 +972,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
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)
|
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt PDFs: %w", err)
|
return fmt.Errorf("encrypt PDFs: %w", err)
|
||||||
@@ -1265,6 +1302,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
form := ctx.FormData()
|
form := ctx.FormData()
|
||||||
embedPaths := FormDataPdfEmbeds(form)
|
embedPaths := FormDataPdfEmbeds(form)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
|
facturX := FormDataPdfFacturX(form, false)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -1283,6 +1321,11 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("set embeds metadata: %w", err)
|
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...)
|
err = ctx.AddOutputPaths(inputPaths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add output paths: %w", err)
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*PdfTk)(nil)
|
_ gotenberg.Module = (*PdfTk)(nil)
|
||||||
|
|||||||
249
pkg/modules/qpdf/facturx_test.go
Normal file
249
pkg/modules/qpdf/facturx_test.go
Normal file
@@ -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 "<xmp>".
|
||||||
|
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&b<c>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package qpdf
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"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
|
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
|
||||||
// them to a temp file, and applies the update via --update-from-json.
|
// them to a temp file, and applies the update via --update-from-json. extraArgs
|
||||||
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error {
|
// 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{
|
updateJSON := map[string]any{
|
||||||
"qpdf": []any{
|
"qpdf": []any{
|
||||||
map[string]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)
|
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, inputPath)
|
||||||
updateArgs = append(updateArgs, engine.globalArgs...)
|
updateArgs = append(updateArgs, engine.globalArgs...)
|
||||||
|
updateArgs = append(updateArgs, extraArgs...)
|
||||||
updateArgs = append(updateArgs, "--newline-before-endstream")
|
updateArgs = append(updateArgs, "--newline-before-endstream")
|
||||||
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
||||||
updateArgs = append(updateArgs, "--replace-input")
|
updateArgs = append(updateArgs, "--replace-input")
|
||||||
@@ -635,6 +640,322 @@ func stripQpdfStringPrefix(s string) string {
|
|||||||
return s
|
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.
|
// Watermark is not available in this implementation.
|
||||||
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
||||||
|
|||||||
@@ -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_1.xml" file embedded
|
||||||
Then the response PDF(s) should have the "embed_2.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.
|
# FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
|
||||||
@convert
|
@convert
|
||||||
@metadata
|
@metadata
|
||||||
|
|||||||
@@ -1349,6 +1349,57 @@ func (s *scenario) thePdfsShouldHaveEmbeddedFileWithRelationship(ctx context.Con
|
|||||||
return nil
|
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) {
|
func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||||
s := &scenario{}
|
s := &scenario{}
|
||||||
ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
|
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) 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|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 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 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) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
|
||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||||
|
|||||||
Reference in New Issue
Block a user