mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-15 16:53:19 +01:00
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:
committed by
Julien Neuhart
parent
db150b7733
commit
e4ee0f29d6
@@ -31,6 +31,7 @@ body:multipart-form {
|
||||
~skipEmptyPages: false
|
||||
~addOriginalDocumentAsStream: false
|
||||
~singlePageSheets: false
|
||||
~generateTaggedPdf: false
|
||||
~initialView: 0
|
||||
~initialPage: 1
|
||||
~magnification: 0
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
44
test/integration/scenario/tagged.go
Normal file
44
test/integration/scenario/tagged.go
Normal 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)
|
||||
}
|
||||
67
test/integration/scenario/tagged_test.go
Normal file
67
test/integration/scenario/tagged_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user