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
~addOriginalDocumentAsStream: false
~singlePageSheets: false
~generateTaggedPdf: false
~initialView: 0
~initialPage: 1
~magnification: 0

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,7 @@ Available tags:
- `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|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 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`)

View File

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

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 "([^"]*)" 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)

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