feat(pdfengines): redesign Factur-X API with dedicated form fields

This commit is contained in:
Julien Neuhart
2026-06-06 14:03:58 +02:00
parent 9ab39b6fca
commit 287ee5be72
23 changed files with 599 additions and 253 deletions

View File

@@ -247,3 +247,53 @@ func assertContains(t *testing.T, haystack, needle string) {
t.Errorf("expected output to contain %q", needle)
}
}
func TestParsePdfAId(t *testing.T) {
for _, tc := range []struct {
scenario string
xmp string
expectPart string
expectConform string
}{
{
scenario: "element form",
xmp: `<rdf:Description><pdfaid:part>3</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance></rdf:Description>`,
expectPart: "3",
expectConform: "B",
},
{
scenario: "attribute form",
xmp: `<rdf:Description pdfaid:part="3" pdfaid:conformance="U"></rdf:Description>`,
expectPart: "3",
expectConform: "U",
},
{
scenario: "part 2",
xmp: `<pdfaid:part>2</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>`,
expectPart: "2",
expectConform: "B",
},
{
scenario: "no pdfa identification",
xmp: `<rdf:Description><dc:title>foo</dc:title></rdf:Description>`,
expectPart: "",
expectConform: "",
},
{
scenario: "empty packet",
xmp: "",
expectPart: "",
expectConform: "",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
part, conformance := parsePdfAId(tc.xmp)
if part != tc.expectPart {
t.Errorf("expected part %q but got %q", tc.expectPart, part)
}
if conformance != tc.expectConform {
t.Errorf("expected conformance %q but got %q", tc.expectConform, conformance)
}
})
}
}

View File

@@ -12,6 +12,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
@@ -729,6 +730,68 @@ func (engine *QPdf) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, f
return nil
}
// ReadPdfAConformance reads the PDF/A part and conformance from the
// document-level XMP packet (pdfaid:part and pdfaid:conformance) using QPDF's
// JSON output. It returns empty strings when the document carries no XMP
// metadata stream or no PDF/A identification.
func (engine *QPdf) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadPdfAConformance",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
logger.DebugContext(ctx, fmt.Sprintf("reading PDF/A conformance from %s with QPDF", inputPath))
args := append([]string{inputPath}, engine.globalArgs...)
args = append(args, "--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
}
_, _, xmp, err := findMetadataStream(objects)
if err != nil {
// No XMP metadata stream means no PDF/A identification.
logger.DebugContext(ctx, fmt.Sprintf("no XMP metadata stream in %s: %s", inputPath, err))
span.SetStatus(codes.Ok, "")
return "", "", nil
}
part, conformance := parsePdfAId(xmp)
span.SetStatus(codes.Ok, "")
return part, conformance, nil
}
var (
pdfaIdPartRe = regexp.MustCompile(`pdfaid:part[\s>="']*([0-9]+)`)
pdfaIdConformanceRe = regexp.MustCompile(`pdfaid:conformance[\s>="']*([A-Za-z]+)`)
)
// parsePdfAId extracts the PDF/A part and conformance from an XMP packet. It
// handles both the element (<pdfaid:part>3</pdfaid:part>) and attribute
// (pdfaid:part="3") serializations.
func parsePdfAId(xmp string) (part string, conformance string) {
if m := pdfaIdPartRe.FindStringSubmatch(xmp); m != nil {
part = m[1]
}
if m := pdfaIdConformanceRe.FindStringSubmatch(xmp); m != nil {
conformance = m[1]
}
return part, conformance
}
// validateFacturX checks the Factur-X fields against the supported values.
func validateFacturX(facturX gotenberg.FacturX) error {
switch facturX.ConformanceLevel {
@@ -930,7 +993,7 @@ func facturXSchemaLi() string {
<pdfaProperty:name>DocumentType</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>INVOICE</pdfaProperty:description>
<pdfaProperty:description>The type of the embedded Factur-X document</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>Version</pdfaProperty:name>