mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-16 04:12:16 +01:00
feat(pdfengines): inject Factur-X/ZUGFeRD XMP metadata
This commit is contained in:
249
pkg/modules/qpdf/facturx_test.go
Normal file
249
pkg/modules/qpdf/facturx_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package qpdf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestValidateFacturX(t *testing.T) {
|
||||
valid := gotenberg.FacturX{
|
||||
ConformanceLevel: gotenberg.FacturXConformanceEN16931,
|
||||
DocumentType: gotenberg.FacturXDocumentTypeInvoice,
|
||||
DocumentFileName: "factur-x.xml",
|
||||
Version: "1.0",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(f *gotenberg.FacturX)
|
||||
wantError bool
|
||||
}{
|
||||
{name: "valid", mutate: func(*gotenberg.FacturX) {}},
|
||||
{
|
||||
name: "valid BASIC WL conformance",
|
||||
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = gotenberg.FacturXConformanceBasicWL },
|
||||
},
|
||||
{
|
||||
name: "valid ORDER document type",
|
||||
mutate: func(f *gotenberg.FacturX) { f.DocumentType = gotenberg.FacturXDocumentTypeOrder },
|
||||
},
|
||||
{
|
||||
name: "unsupported conformance level",
|
||||
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "FOO" },
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty conformance level",
|
||||
mutate: func(f *gotenberg.FacturX) { f.ConformanceLevel = "" },
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported document type",
|
||||
mutate: func(f *gotenberg.FacturX) { f.DocumentType = "RECEIPT" },
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty document file name",
|
||||
mutate: func(f *gotenberg.FacturX) { f.DocumentFileName = "" },
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty version",
|
||||
mutate: func(f *gotenberg.FacturX) { f.Version = "" },
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
facturX := valid
|
||||
tt.mutate(&facturX)
|
||||
|
||||
err := validateFacturX(facturX)
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
if !errors.Is(err, gotenberg.ErrPdfFacturXValueNotSupported) {
|
||||
t.Errorf("expected ErrPdfFacturXValueNotSupported, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMetadataStream(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKey string
|
||||
wantXMP string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "metadata stream found",
|
||||
// "PFhtcD4=" is base64 for "<xmp>".
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"stream":{"dict":{"/Type":"/Metadata","/Subtype":"/XML"},"data":"PHhtcD4="}}}]}`,
|
||||
wantKey: "obj:4 0 R",
|
||||
wantXMP: "<xmp>",
|
||||
},
|
||||
{
|
||||
name: "catalog without metadata reference",
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog"}}}]}`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "metadata object missing",
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}}}]}`,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "metadata object is not a stream",
|
||||
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{"/Type":"/Catalog","/Metadata":"4 0 R"}},"obj:4 0 R":{"value":{"/Type":"/Metadata"}}}]}`,
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
objects, err := parsePdfObjects([]byte(tt.input))
|
||||
if err != nil {
|
||||
t.Fatalf("parse objects: %v", err)
|
||||
}
|
||||
|
||||
key, dict, xmp, err := findMetadataStream(objects)
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if key != tt.wantKey {
|
||||
t.Errorf("key = %q, want %q", key, tt.wantKey)
|
||||
}
|
||||
if xmp != tt.wantXMP {
|
||||
t.Errorf("xmp = %q, want %q", xmp, tt.wantXMP)
|
||||
}
|
||||
if dict == nil {
|
||||
t.Error("expected a non-nil dict")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectFacturXIntoXMP(t *testing.T) {
|
||||
facturX := gotenberg.FacturX{
|
||||
ConformanceLevel: gotenberg.FacturXConformanceEN16931,
|
||||
DocumentType: gotenberg.FacturXDocumentTypeInvoice,
|
||||
DocumentFileName: "factur-x.xml",
|
||||
Version: "1.0",
|
||||
}
|
||||
|
||||
// The packet a LibreOffice PDF/A-3b export produces: no pdfaExtension bag.
|
||||
libreOfficeXMP := `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
|
||||
<pdfaid:part>3</pdfaid:part>
|
||||
<pdfaid:conformance>B</pdfaid:conformance>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>`
|
||||
|
||||
t.Run("creates extension schema when bag absent", func(t *testing.T) {
|
||||
got, changed := injectFacturXIntoXMP(libreOfficeXMP, facturX)
|
||||
if !changed {
|
||||
t.Fatal("expected changed = true")
|
||||
}
|
||||
assertContains(t, got, facturXNamespaceURI)
|
||||
assertContains(t, got, "<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>")
|
||||
assertContains(t, got, "<fx:DocumentType>INVOICE</fx:DocumentType>")
|
||||
assertContains(t, got, "<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>")
|
||||
assertContains(t, got, "<fx:Version>1.0</fx:Version>")
|
||||
assertContains(t, got, "pdfaExtension:schemas")
|
||||
assertContains(t, got, "http://www.aiim.org/pdfa/ns/extension/")
|
||||
// The fx blocks must land inside the RDF container.
|
||||
if strings.Index(got, facturXNamespaceURI) > strings.LastIndex(got, "</rdf:RDF>") {
|
||||
t.Error("fx content injected outside the rdf:RDF container")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("idempotent when fx already present", func(t *testing.T) {
|
||||
once, _ := injectFacturXIntoXMP(libreOfficeXMP, facturX)
|
||||
twice, changed := injectFacturXIntoXMP(once, facturX)
|
||||
if changed {
|
||||
t.Error("expected changed = false on a packet that already declares fx")
|
||||
}
|
||||
if twice != once {
|
||||
t.Error("expected the packet to be left untouched")
|
||||
}
|
||||
if strings.Count(twice, "<pdfaExtension:schemas>") != 1 {
|
||||
t.Error("expected exactly one pdfaExtension:schemas declaration")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends entry to an existing bag", func(t *testing.T) {
|
||||
withBag := `<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
||||
<pdfaExtension:schemas>
|
||||
<rdf:Bag>
|
||||
<rdf:li rdf:parseType="Resource"><pdfaSchema:prefix>other</pdfaSchema:prefix></rdf:li>
|
||||
</rdf:Bag>
|
||||
</pdfaExtension:schemas>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>`
|
||||
got, changed := injectFacturXIntoXMP(withBag, facturX)
|
||||
if !changed {
|
||||
t.Fatal("expected changed = true")
|
||||
}
|
||||
assertContains(t, got, facturXNamespaceURI)
|
||||
if strings.Count(got, "<pdfaExtension:schemas>") != 1 {
|
||||
t.Error("expected the existing pdfaExtension:schemas bag to be reused, not duplicated")
|
||||
}
|
||||
if strings.Count(got, "<pdfaSchema:prefix>") != 2 {
|
||||
t.Error("expected both the existing and the fx schema entries")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no rdf:RDF anchor leaves packet unchanged", func(t *testing.T) {
|
||||
got, changed := injectFacturXIntoXMP("not an xmp packet", facturX)
|
||||
if changed {
|
||||
t.Error("expected changed = false")
|
||||
}
|
||||
if got != "not an xmp packet" {
|
||||
t.Error("expected the packet to be left untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("escapes runtime values", func(t *testing.T) {
|
||||
escaped := facturX
|
||||
escaped.DocumentFileName = "a&b<c>.xml"
|
||||
got, _ := injectFacturXIntoXMP(libreOfficeXMP, escaped)
|
||||
assertContains(t, got, "a&b<c>.xml")
|
||||
if strings.Contains(got, "<fx:DocumentFileName>a&b<c>.xml") {
|
||||
t.Error("expected the document file name to be XML-escaped")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, haystack, needle string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(haystack, needle) {
|
||||
t.Errorf("expected output to contain %q", needle)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package qpdf
|
||||
import (
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user