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
This commit is contained in:
Johannes Klein
2026-10-06 10:37:27 +02:00
committed by Julien Neuhart
parent db150b7733
commit e4ee0f29d6
9 changed files with 143 additions and 1 deletions

View File

@@ -31,6 +31,7 @@ body:multipart-form {
~skipEmptyPages: false ~skipEmptyPages: false
~addOriginalDocumentAsStream: false ~addOriginalDocumentAsStream: false
~singlePageSheets: false ~singlePageSheets: false
~generateTaggedPdf: false
~initialView: 0 ~initialView: 0
~initialPage: 1 ~initialPage: 1
~magnification: 0 ~magnification: 0

View File

@@ -266,6 +266,10 @@ type Options struct {
// NativeTiledWatermarkText specifies the tiled watermark text. // NativeTiledWatermarkText specifies the tiled watermark text.
NativeTiledWatermarkText string 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, // PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA. // PDF/A-3b and PDF/UA.
PdfFormats gotenberg.PdfFormats PdfFormats gotenberg.PdfFormats
@@ -318,6 +322,7 @@ func DefaultOptions() Options {
NativeWatermarkRotateAngle: 0, NativeWatermarkRotateAngle: 0,
NativeWatermarkFontName: "Helvetica", NativeWatermarkFontName: "Helvetica",
NativeTiledWatermarkText: "", NativeTiledWatermarkText: "",
GenerateTaggedPdf: false,
PdfFormats: gotenberg.PdfFormats{ PdfFormats: gotenberg.PdfFormats{
PdfA: "", PdfA: "",
PdfUa: false, PdfUa: false,

View File

@@ -437,7 +437,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
args = append( args = append(
args, args,
"--export", "PDFUACompliance=false", "--export", "PDFUACompliance=false",
"--export", "UseTaggedPDF=false", "--export", fmt.Sprintf("UseTaggedPDF=%t", options.GenerateTaggedPdf),
"--export", "EnableTextAccessForAccessibilityTools=false", "--export", "EnableTextAccessForAccessibilityTools=false",
) )
} }

View File

@@ -75,6 +75,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
skipEmptyPages bool skipEmptyPages bool
addOriginalDocumentAsStream bool addOriginalDocumentAsStream bool
singlePageSheets bool singlePageSheets bool
generateTaggedPdf bool
losslessImageCompression bool losslessImageCompression bool
quality int quality int
reduceImageResolution bool reduceImageResolution bool
@@ -126,6 +127,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages). Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream). Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets). Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
Bool("generateTaggedPdf", &generateTaggedPdf, defaultOptions.GenerateTaggedPdf).
Custom("initialView", func(value string) error { Custom("initialView", func(value string) error {
if value == "" { if value == "" {
initialView = defaultOptions.InitialView initialView = defaultOptions.InitialView
@@ -396,6 +398,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
NativeWatermarkRotateAngle: nativeWatermarkRotateAngle, NativeWatermarkRotateAngle: nativeWatermarkRotateAngle,
NativeWatermarkFontName: nativeWatermarkFontName, NativeWatermarkFontName: nativeWatermarkFontName,
NativeTiledWatermarkText: nativeTiledWatermarkText, NativeTiledWatermarkText: nativeTiledWatermarkText,
GenerateTaggedPdf: generateTaggedPdf,
} }
if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing { if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing {

View File

@@ -67,6 +67,7 @@ Available tags:
- `there should be the following file(s) in the (response|webhook request):` (table of filenames) - `there should be the following file(s) in the (response|webhook request):` (table of filenames)
- `the "<name>" PDF should have <N> page(s)` - `the "<name>" PDF should have <N> page(s)`
- `the "<name>" PDF (should|should NOT) be set to landscape orientation` - `the "<name>" PDF (should|should NOT) be set to landscape orientation`
- `the "<name>" PDF (should|should NOT) be tagged`
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring) - `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
- `the "<name>" PDF (should|should NOT) have content matching "<regexp>" at page <N>` - `the "<name>" PDF (should|should NOT) have content matching "<regexp>" at page <N>`
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`) - `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)

View File

@@ -142,6 +142,24 @@ Feature: /forms/libreoffice/convert
| foo.pdf | | foo.pdf |
Then the "foo.pdf" PDF should be set to landscape orientation 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) Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Single Document)
Given I have a default Gotenberg container 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): 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 | | skipEmptyPages | foo | field |
| addOriginalDocumentAsStream | foo | field | | addOriginalDocumentAsStream | foo | field |
| singlePageSheets | foo | field | | singlePageSheets | foo | field |
| generateTaggedPdf | foo | field |
| initialView | 5 | field | | initialView | 5 | field |
| initialPage | -1 | field | | initialPage | -1 | field |
| magnification | 9 | 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 '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 '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 '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 '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 '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) form field 'magnification' is invalid (got '9', resulting to value is not 0, 1, 2, 3 or 4)

View File

@@ -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 (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 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 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 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 the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)

View File

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

View File

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