mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-07-02 00:17:40 +08: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_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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}]
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
~rotateAngle: 90
|
||||
~rotatePages:
|
||||
~facturx: {"conformanceLevel":"EN 16931","documentType":"INVOICE","documentFileName":"factur-x.xml","version":"1.0"}
|
||||
}
|
||||
|
||||
headers {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+324
-3
@@ -3,7 +3,9 @@ package qpdf
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -527,8 +529,10 @@ func patchCatalogAF(catalogRef string, catalogValue map[string]any, filespecRefs
|
||||
}
|
||||
|
||||
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
|
||||
// them to a temp file, and applies the update via --update-from-json.
|
||||
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error {
|
||||
// them to a temp file, and applies the update via --update-from-json. extraArgs
|
||||
// are appended to the QPDF command (e.g., --json-stream-data=inline when the
|
||||
// update replaces stream data).
|
||||
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any, extraArgs ...string) error {
|
||||
updateJSON := map[string]any{
|
||||
"qpdf": []any{
|
||||
map[string]any{
|
||||
@@ -560,9 +564,10 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
|
||||
updateArgs := make([]string, 0, 5+len(engine.globalArgs))
|
||||
updateArgs := make([]string, 0, 5+len(engine.globalArgs)+len(extraArgs))
|
||||
updateArgs = append(updateArgs, inputPath)
|
||||
updateArgs = append(updateArgs, engine.globalArgs...)
|
||||
updateArgs = append(updateArgs, extraArgs...)
|
||||
updateArgs = append(updateArgs, "--newline-before-endstream")
|
||||
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
|
||||
updateArgs = append(updateArgs, "--replace-input")
|
||||
@@ -635,6 +640,322 @@ func stripQpdfStringPrefix(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// facturXNamespaceURI is the Factur-X/ZUGFeRD XMP namespace required by strict
|
||||
// validators.
|
||||
const facturXNamespaceURI = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
|
||||
|
||||
// InjectFacturXXMP injects Factur-X/ZUGFeRD XMP metadata into the document-level
|
||||
// XMP packet (Catalog /Metadata stream) of a PDF/A-3 using QPDF's JSON
|
||||
// manipulation. It reads the existing XMP packet, splices in the fx
|
||||
// rdf:Description plus the PDF/A extension-schema declaration, and writes the
|
||||
// stream back uncompressed so the document stays PDF/A-valid.
|
||||
//
|
||||
// It assumes the input already carries a Catalog /Metadata stream (always true
|
||||
// for a LibreOffice PDF/A export). The injection is idempotent: a packet that
|
||||
// already declares the fx namespace is left untouched.
|
||||
func (engine *QPdf) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.InjectFacturXXMP",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
err := validateFacturX(facturX)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("injecting Factur-X XMP into %s with QPDF", inputPath))
|
||||
|
||||
args := append([]string{inputPath}, engine.globalArgs...)
|
||||
args = append(args, "--newline-before-endstream", "--json-output", "--json-stream-data=inline")
|
||||
|
||||
output, err := engine.execCaptureOutput(ctx, args...)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
objects, err := parsePdfObjects(output)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
metaKey, metaDict, xmp, err := findMetadataStream(objects)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("locate XMP metadata stream: %w", err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
newXmp, changed := injectFacturXIntoXMP(xmp, facturX)
|
||||
if !changed {
|
||||
logger.DebugContext(ctx, "Factur-X XMP already present, skipping injection")
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// PDF/A requires the metadata stream to be uncompressed and unfiltered. We
|
||||
// provide the decoded XMP as the new stream data, so any existing filter
|
||||
// must be dropped and the length left for QPDF to recompute.
|
||||
delete(metaDict, "/Filter")
|
||||
delete(metaDict, "/DecodeParms")
|
||||
delete(metaDict, "/Length")
|
||||
|
||||
updateObjects := map[string]any{
|
||||
metaKey: map[string]any{
|
||||
"stream": map[string]any{
|
||||
"dict": metaDict,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte(newXmp)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = engine.writeAndApplyUpdate(ctx, logger, inputPath, updateObjects, "--json-stream-data=inline")
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFacturX checks the Factur-X fields against the supported values.
|
||||
func validateFacturX(facturX gotenberg.FacturX) error {
|
||||
switch facturX.ConformanceLevel {
|
||||
case gotenberg.FacturXConformanceMinimum,
|
||||
gotenberg.FacturXConformanceBasicWL,
|
||||
gotenberg.FacturXConformanceBasic,
|
||||
gotenberg.FacturXConformanceEN16931,
|
||||
gotenberg.FacturXConformanceExtended,
|
||||
gotenberg.FacturXConformanceXRechnung:
|
||||
default:
|
||||
return fmt.Errorf("conformance level '%s': %w", facturX.ConformanceLevel, gotenberg.ErrPdfFacturXValueNotSupported)
|
||||
}
|
||||
|
||||
switch facturX.DocumentType {
|
||||
case gotenberg.FacturXDocumentTypeInvoice,
|
||||
gotenberg.FacturXDocumentTypeOrder,
|
||||
gotenberg.FacturXDocumentTypeOrderResponse,
|
||||
gotenberg.FacturXDocumentTypeOrderChange:
|
||||
default:
|
||||
return fmt.Errorf("document type '%s': %w", facturX.DocumentType, gotenberg.ErrPdfFacturXValueNotSupported)
|
||||
}
|
||||
|
||||
if facturX.DocumentFileName == "" {
|
||||
return fmt.Errorf("document file name is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported)
|
||||
}
|
||||
|
||||
if facturX.Version == "" {
|
||||
return fmt.Errorf("version is empty: %w", gotenberg.ErrPdfFacturXValueNotSupported)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findMetadataStream locates the document-level XMP metadata stream referenced
|
||||
// by the Catalog /Metadata entry. It returns the object key (e.g. "obj:4 0 R"),
|
||||
// the stream dict, and the decoded XMP packet.
|
||||
func findMetadataStream(objects map[string]json.RawMessage) (string, map[string]any, string, error) {
|
||||
var metadataRef string
|
||||
for _, raw := range objects {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
valueRaw, ok := obj["value"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(valueRaw, &value); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if typeVal, _ := value["/Type"].(string); typeVal == "/Catalog" {
|
||||
metadataRef, _ = value["/Metadata"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if metadataRef == "" {
|
||||
return "", nil, "", errors.New("no /Metadata reference in the catalog")
|
||||
}
|
||||
|
||||
// References in values use the "4 0 R" form; object keys use "obj:4 0 R".
|
||||
objKey := "obj:" + metadataRef
|
||||
raw, ok := objects[objKey]
|
||||
if !ok {
|
||||
return "", nil, "", fmt.Errorf("metadata object '%s' not found", objKey)
|
||||
}
|
||||
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return "", nil, "", fmt.Errorf("unmarshal metadata object: %w", err)
|
||||
}
|
||||
|
||||
streamRaw, ok := obj["stream"]
|
||||
if !ok {
|
||||
return "", nil, "", errors.New("metadata object is not a stream")
|
||||
}
|
||||
|
||||
var stream struct {
|
||||
Dict map[string]any `json:"dict"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
||||
return "", nil, "", fmt.Errorf("unmarshal metadata stream: %w", err)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(stream.Data)
|
||||
if err != nil {
|
||||
return "", nil, "", fmt.Errorf("decode metadata stream data: %w", err)
|
||||
}
|
||||
|
||||
dict := stream.Dict
|
||||
if dict == nil {
|
||||
dict = make(map[string]any)
|
||||
}
|
||||
|
||||
return objKey, dict, string(decoded), nil
|
||||
}
|
||||
|
||||
// injectFacturXIntoXMP splices the fx rdf:Description and the PDF/A
|
||||
// extension-schema declaration into an XMP packet. It returns the new packet and
|
||||
// whether a change was made (false when the fx namespace is already present).
|
||||
func injectFacturXIntoXMP(xmp string, facturX gotenberg.FacturX) (string, bool) {
|
||||
if strings.Contains(xmp, facturXNamespaceURI) {
|
||||
return xmp, false
|
||||
}
|
||||
|
||||
anchor := strings.LastIndex(xmp, "</rdf:RDF>")
|
||||
if anchor == -1 {
|
||||
return xmp, false
|
||||
}
|
||||
|
||||
insert := facturXDescription(facturX)
|
||||
|
||||
if strings.Contains(xmp, "pdfaExtension:schemas") {
|
||||
// An extension-schema bag already exists (e.g. emitted by another tool):
|
||||
// splice the fx Description, then append the fx schema entry into the bag.
|
||||
spliced := xmp[:anchor] + insert + xmp[anchor:]
|
||||
return injectSchemaIntoExistingBag(spliced), true
|
||||
}
|
||||
|
||||
// No extension-schema bag yet (the LibreOffice PDF/A case): create the whole
|
||||
// container alongside the fx Description.
|
||||
insert += facturXExtensionSchema()
|
||||
return xmp[:anchor] + insert + xmp[anchor:], true
|
||||
}
|
||||
|
||||
// injectSchemaIntoExistingBag appends the fx schema entry into an existing
|
||||
// pdfaExtension:schemas bag.
|
||||
func injectSchemaIntoExistingBag(xmp string) string {
|
||||
mi := strings.Index(xmp, "pdfaExtension:schemas")
|
||||
if mi == -1 {
|
||||
return xmp
|
||||
}
|
||||
|
||||
bag := strings.Index(xmp[mi:], "<rdf:Bag")
|
||||
if bag == -1 {
|
||||
return xmp
|
||||
}
|
||||
|
||||
gt := strings.Index(xmp[mi+bag:], ">")
|
||||
if gt == -1 {
|
||||
return xmp
|
||||
}
|
||||
|
||||
pos := mi + bag + gt + 1
|
||||
return xmp[:pos] + "\n" + facturXSchemaLi() + xmp[pos:]
|
||||
}
|
||||
|
||||
// facturXDescription builds the fx rdf:Description carrying the runtime values.
|
||||
func facturXDescription(facturX gotenberg.FacturX) string {
|
||||
return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:fx="%s">
|
||||
<fx:DocumentType>%s</fx:DocumentType>
|
||||
<fx:DocumentFileName>%s</fx:DocumentFileName>
|
||||
<fx:Version>%s</fx:Version>
|
||||
<fx:ConformanceLevel>%s</fx:ConformanceLevel>
|
||||
</rdf:Description>
|
||||
`,
|
||||
facturXNamespaceURI,
|
||||
xmlEscape(facturX.DocumentType),
|
||||
xmlEscape(facturX.DocumentFileName),
|
||||
xmlEscape(facturX.Version),
|
||||
xmlEscape(facturX.ConformanceLevel),
|
||||
)
|
||||
}
|
||||
|
||||
// facturXExtensionSchema builds the rdf:Description that declares the PDF/A
|
||||
// extension schema for the fx namespace, including the namespace declarations.
|
||||
func facturXExtensionSchema() string {
|
||||
return fmt.Sprintf(` <rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
||||
<pdfaExtension:schemas>
|
||||
<rdf:Bag>
|
||||
%s
|
||||
</rdf:Bag>
|
||||
</pdfaExtension:schemas>
|
||||
</rdf:Description>
|
||||
`, facturXSchemaLi())
|
||||
}
|
||||
|
||||
// facturXSchemaLi builds the rdf:li describing the fx schema and its four
|
||||
// properties. These are fixed schema definitions, not runtime invoice values.
|
||||
func facturXSchemaLi() string {
|
||||
return fmt.Sprintf(` <rdf:li rdf:parseType="Resource">
|
||||
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
||||
<pdfaSchema:namespaceURI>%s</pdfaSchema:namespaceURI>
|
||||
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
|
||||
<pdfaSchema:property>
|
||||
<rdf:Seq>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
|
||||
</rdf:li>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>DocumentType</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>INVOICE</pdfaProperty:description>
|
||||
</rdf:li>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>Version</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description>
|
||||
</rdf:li>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description>
|
||||
</rdf:li>
|
||||
</rdf:Seq>
|
||||
</pdfaSchema:property>
|
||||
</rdf:li>`, facturXNamespaceURI)
|
||||
}
|
||||
|
||||
// xmlEscape escapes a string for safe inclusion in XML character data.
|
||||
func xmlEscape(s string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(s))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Watermark is not available in this implementation.
|
||||
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
|
||||
|
||||
@@ -695,6 +695,43 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response PDF(s) should have the "embed_1.xml" file embedded
|
||||
Then the response PDF(s) should have the "embed_2.xml" file embedded
|
||||
|
||||
@convert
|
||||
@embed
|
||||
@factur-x
|
||||
Scenario: POST /forms/libreoffice/convert (Factur-X / ZUGFeRD)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
| pdfa | PDF/A-3b | field |
|
||||
| embeds | testdata/embed_1.xml | file |
|
||||
| embedsMetadata | {"embed_1.xml":{"mimeType":"text/xml","relationship":"Alternative"}} | field |
|
||||
| facturx | {"conformanceLevel":"EN 16931"} | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s)
|
||||
Then the response PDF(s) should have the "embed_1.xml" file embedded with relationship "Alternative"
|
||||
Then the response PDF(s) should declare Factur-X XMP with conformance level "EN 16931"
|
||||
|
||||
@factur-x
|
||||
Scenario: POST /forms/pdfengines/factur-x (Standalone)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
| pdfa | PDF/A-3b | field |
|
||||
| Gotenberg-Output-Filename | base | header |
|
||||
Then the response status code should be 200
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/factur-x" endpoint with the following form data and header(s):
|
||||
| files | teststore/base.pdf | file |
|
||||
| facturx | {"conformanceLevel":"BASIC","documentType":"ORDER"} | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 0 failed rule(s)
|
||||
Then the response PDF(s) should declare Factur-X XMP with conformance level "BASIC"
|
||||
|
||||
# FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
|
||||
@convert
|
||||
@metadata
|
||||
|
||||
@@ -1349,6 +1349,57 @@ func (s *scenario) thePdfsShouldHaveEmbeddedFileWithRelationship(ctx context.Con
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scenario) thePdfsShouldDeclareFacturXConformanceLevel(ctx context.Context, kind, conformanceLevel string) error {
|
||||
dirPath := s.teststoreDir
|
||||
|
||||
_, err := os.Stat(dirPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("directory %q does not exist", dirPath)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
||||
if pathErr != nil {
|
||||
return pathErr
|
||||
}
|
||||
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("walk %q: %w", dirPath, err)
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
cmd := []string{
|
||||
"pdfinfo",
|
||||
"-meta",
|
||||
filepath.Base(path),
|
||||
}
|
||||
|
||||
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#") {
|
||||
return errors.New("missing Factur-X namespace in XMP")
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "pdfaExtension:schemas") {
|
||||
return errors.New("missing PDF/A extension schema in XMP")
|
||||
}
|
||||
|
||||
conformanceTag := fmt.Sprintf("<fx:ConformanceLevel>%s</fx:ConformanceLevel>", conformanceLevel)
|
||||
if !strings.Contains(output, conformanceTag) {
|
||||
return fmt.Errorf("missing fx:ConformanceLevel %q in XMP", conformanceLevel)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||
s := &scenario{}
|
||||
ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
|
||||
@@ -1389,6 +1440,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted)
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile)
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship)
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)
|
||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||
|
||||
Reference in New Issue
Block a user