mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-16 17:23:17 +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
|
~skipEmptyPages: false
|
||||||
~addOriginalDocumentAsStream: false
|
~addOriginalDocumentAsStream: false
|
||||||
~singlePageSheets: false
|
~singlePageSheets: false
|
||||||
|
~generateTaggedPdf: false
|
||||||
~initialView: 0
|
~initialView: 0
|
||||||
~initialPage: 1
|
~initialPage: 1
|
||||||
~magnification: 0
|
~magnification: 0
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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