From e4ee0f29d648f8d5a0a766dd797d108361905deb Mon Sep 17 00:00:00 2001 From: Johannes Klein Date: Tue, 6 Oct 2026 10:37:27 +0200 Subject: [PATCH] feat(libreoffice): add generateTaggedPdf form field Tags the PDF without declaring PDF/UA conformance. Today tagging is only available through pdfua, which also writes pdfuaid:part=1 into the XMP metadata. Mirrors the Chromium routes, where generateTaggedPdf and pdfua are separate fields. Defaults to false, so existing requests are unchanged. Closes #1676 --- .bruno/LibreOffice/Convert to PDF.bru | 1 + pkg/modules/libreoffice/api/api.go | 5 ++ pkg/modules/libreoffice/api/libreoffice.go | 2 +- pkg/modules/libreoffice/routes.go | 3 + test/integration/README.md | 1 + .../features/libreoffice_convert.feature | 20 ++++++ test/integration/scenario/scenario.go | 1 + test/integration/scenario/tagged.go | 44 ++++++++++++ test/integration/scenario/tagged_test.go | 67 +++++++++++++++++++ 9 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 test/integration/scenario/tagged.go create mode 100644 test/integration/scenario/tagged_test.go diff --git a/.bruno/LibreOffice/Convert to PDF.bru b/.bruno/LibreOffice/Convert to PDF.bru index c65e1dc0..c867a25f 100644 --- a/.bruno/LibreOffice/Convert to PDF.bru +++ b/.bruno/LibreOffice/Convert to PDF.bru @@ -31,6 +31,7 @@ body:multipart-form { ~skipEmptyPages: false ~addOriginalDocumentAsStream: false ~singlePageSheets: false + ~generateTaggedPdf: false ~initialView: 0 ~initialPage: 1 ~magnification: 0 diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 71e41937..32a660e0 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -266,6 +266,10 @@ type Options struct { // NativeTiledWatermarkText specifies the tiled watermark text. NativeTiledWatermarkText string + // GenerateTaggedPdf specifies whether to generate a tagged (accessible) + // PDF without declaring PDF/UA conformance. PdfFormats.PdfUa implies it. + GenerateTaggedPdf bool + // PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b, // PDF/A-3b and PDF/UA. PdfFormats gotenberg.PdfFormats @@ -318,6 +322,7 @@ func DefaultOptions() Options { NativeWatermarkRotateAngle: 0, NativeWatermarkFontName: "Helvetica", NativeTiledWatermarkText: "", + GenerateTaggedPdf: false, PdfFormats: gotenberg.PdfFormats{ PdfA: "", PdfUa: false, diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index 36cba032..538fe4c5 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -437,7 +437,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input args = append( args, "--export", "PDFUACompliance=false", - "--export", "UseTaggedPDF=false", + "--export", fmt.Sprintf("UseTaggedPDF=%t", options.GenerateTaggedPdf), "--export", "EnableTextAccessForAccessibilityTools=false", ) } diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 51c0b895..0841d236 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -75,6 +75,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap skipEmptyPages bool addOriginalDocumentAsStream bool singlePageSheets bool + generateTaggedPdf bool losslessImageCompression bool quality int reduceImageResolution bool @@ -126,6 +127,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages). Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream). Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets). + Bool("generateTaggedPdf", &generateTaggedPdf, defaultOptions.GenerateTaggedPdf). Custom("initialView", func(value string) error { if value == "" { initialView = defaultOptions.InitialView @@ -396,6 +398,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap NativeWatermarkRotateAngle: nativeWatermarkRotateAngle, NativeWatermarkFontName: nativeWatermarkFontName, NativeTiledWatermarkText: nativeTiledWatermarkText, + GenerateTaggedPdf: generateTaggedPdf, } if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing { diff --git a/test/integration/README.md b/test/integration/README.md index 66d5733a..42691638 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -67,6 +67,7 @@ Available tags: - `there should be the following file(s) in the (response|webhook request):` (table of filenames) - `the "" PDF should have page(s)` - `the "" PDF (should|should NOT) be set to landscape orientation` +- `the "" PDF (should|should NOT) be tagged` - `the "" PDF (should|should NOT) have the following content at page :` (docstring) - `the "" PDF (should|should NOT) have content matching "" at page ` - `the (response|webhook request) PDF(s) should be valid "" with a tolerance of failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`) diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index cc2ef507..92bb4db6 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -142,6 +142,24 @@ Feature: /forms/libreoffice/convert | foo.pdf | Then the "foo.pdf" PDF should be set to landscape orientation + Scenario: POST /forms/libreoffice/convert (Tagged PDF) + 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 | + | Gotenberg-Output-Filename | foo | header | + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should NOT be tagged + 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 | + | generateTaggedPdf | true | field | + | Gotenberg-Output-Filename | foo | header | + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should be tagged + Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Single Document) 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): @@ -215,6 +233,7 @@ Feature: /forms/libreoffice/convert | skipEmptyPages | foo | field | | addOriginalDocumentAsStream | foo | field | | singlePageSheets | foo | field | + | generateTaggedPdf | foo | field | | initialView | 5 | field | | initialPage | -1 | field | | magnification | 9 | field | @@ -255,6 +274,7 @@ Feature: /forms/libreoffice/convert form field 'skipEmptyPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) form field 'addOriginalDocumentAsStream' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) form field 'singlePageSheets' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) + form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) form field 'initialView' is invalid (got '5', resulting to value is not 0, 1 or 2) form field 'initialPage' is invalid (got '-1', resulting to value is inferior to 1) form field 'magnification' is invalid (got '9', resulting to value is not 0, 1, 2, 3 or 4) diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index 5af46f04..fa494c3d 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -1795,6 +1795,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { 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) be tagged$`, s.thePdfShouldBeTagged) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have a document outline$`, s.thePdfShouldHaveADocumentOutline) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage) diff --git a/test/integration/scenario/tagged.go b/test/integration/scenario/tagged.go new file mode 100644 index 00000000..4b253243 --- /dev/null +++ b/test/integration/scenario/tagged.go @@ -0,0 +1,44 @@ +package scenario + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +func (s *scenario) thePdfShouldBeTagged(ctx context.Context, name, kind string) error { + path := filepath.Join(s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name) + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("stat PDF %q: %w", path, err) + } + + cmd := []string{"pdfinfo", filepath.Base(path)} + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + return checkTagged(output, kind != "should NOT") +} + +// checkTagged reads the "Tagged" line of pdfinfo, which reflects the Marked +// entry of the document catalog's MarkInfo dictionary. +func checkTagged(output string, want bool) error { + for line := range strings.SplitSeq(output, "\n") { + value, found := strings.CutPrefix(strings.TrimSpace(line), "Tagged:") + if !found { + continue + } + + tagged := strings.TrimSpace(value) == "yes" + if tagged != want { + return fmt.Errorf("expected tagged PDF to be %t, but actual is %t", want, tagged) + } + + return nil + } + + return fmt.Errorf("invalid pdfinfo output, no Tagged line: %q", output) +} diff --git a/test/integration/scenario/tagged_test.go b/test/integration/scenario/tagged_test.go new file mode 100644 index 00000000..26be5e97 --- /dev/null +++ b/test/integration/scenario/tagged_test.go @@ -0,0 +1,67 @@ +package scenario + +import ( + "strings" + "testing" +) + +func TestCheckTagged(t *testing.T) { + t.Parallel() + + pdfinfo := func(tagged string) string { + return "Producer: LibreOffice 26.8\nTagged: " + tagged + "\nPages: 1\n" + } + + tests := []struct { + name string + output string + want bool + wantErr string + }{ + { + name: "tagged PDF expected and found", + output: pdfinfo("yes"), + want: true, + }, + { + name: "untagged PDF expected and found", + output: pdfinfo("no"), + want: false, + }, + { + name: "tagged PDF expected but untagged", + output: pdfinfo("no"), + want: true, + wantErr: "expected tagged PDF to be true, but actual is false", + }, + { + name: "untagged PDF expected but tagged", + output: pdfinfo("yes"), + want: false, + wantErr: "expected tagged PDF to be false, but actual is true", + }, + { + name: "no Tagged line", + output: "Pages: 1\n", + want: true, + wantErr: "invalid pdfinfo output, no Tagged line:", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := checkTagged(tc.output, tc.want) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err) + } + }) + } +}