feat(pdfengines): inject Factur-X/ZUGFeRD XMP metadata

This commit is contained in:
Julien Neuhart
2026-06-05 16:40:40 +02:00
parent 40666529f9
commit 5558e43821
27 changed files with 1099 additions and 8 deletions

View File

@@ -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.

View File

@@ -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)
}
})
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
},
}
}

View File

@@ -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)

View 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&amp;b&lt;c&gt;.xml")
if strings.Contains(got, "<fx:DocumentFileName>a&b<c>.xml") {
t.Error("expected the document file name to be XML-escaped")
}
})
}
func assertContains(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Errorf("expected output to contain %q", needle)
}
}

View File

@@ -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",