From c83dbd7bafacb3734ebadf2e33a4049a17d45ec9 Mon Sep 17 00:00:00 2001 From: Antonio Pauletich Date: Fri, 2 Oct 2026 21:13:46 +0200 Subject: [PATCH] test(chromium): cover header and footer image deduplication --- Makefile | 1 + test/integration/README.md | 3 +- .../chromium_image_deduplication.feature | 57 ++++++++++ test/integration/scenario/images.go | 72 ++++++++++++ test/integration/scenario/images_test.go | 106 ++++++++++++++++++ test/integration/scenario/scenario.go | 1 + .../testdata/header-footer-image/footer.html | 15 +++ .../testdata/header-footer-image/header.html | 15 +++ 8 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 test/integration/features/chromium_image_deduplication.feature create mode 100644 test/integration/scenario/images.go create mode 100644 test/integration/scenario/images_test.go create mode 100644 test/integration/testdata/header-footer-image/footer.html create mode 100644 test/integration/testdata/header-footer-image/header.html diff --git a/Makefile b/Makefile index 60a71b58..a5008821 100644 --- a/Makefile +++ b/Makefile @@ -154,6 +154,7 @@ NO_CONCURRENCY=false # chromium # chromium-concurrent # chromium-convert-html +# chromium-image-deduplication # chromium-convert-markdown # chromium-convert-url # chromium-screenshot-html diff --git a/test/integration/README.md b/test/integration/README.md index b91a714a..66d5733a 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -25,7 +25,7 @@ Available tags: | Group | Tags | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` | +| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-image-deduplication`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` | | LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` | | PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-optimize`, `optimize`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` | | Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` | @@ -75,6 +75,7 @@ Available tags: - `the (response|webhook request) PDF(s) (should|should NOT) allow ""` (actions: `printing`, `copying`, `modifying`, `annotating`) - `the (response|webhook request) PDF(s) (should|should NOT) have the "" file embedded` - `the "" PDF should have image(s)` +- `the "" PDF should reuse image XObject(s) across

page(s)` (checks image appearances on every page and distinct PDF object IDs) - `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings) - `all concurrent response status codes should be ` - `all concurrent responses should have PDF(s)` diff --git a/test/integration/features/chromium_image_deduplication.feature b/test/integration/features/chromium_image_deduplication.feature new file mode 100644 index 00000000..1dcdc053 --- /dev/null +++ b/test/integration/features/chromium_image_deduplication.feature @@ -0,0 +1,57 @@ +@chromium +@chromium-convert-html +@chromium-image-deduplication +Feature: Chromium header and footer image deduplication + + # See: https://github.com/gotenberg/gotenberg/issues/1077. + # See: https://github.com/gotenberg/gotenberg/issues/1669. + # The PNG must appear on every page, but its image XObject must be shared. + Scenario: POST /forms/chromium/convert/html (Header image reused across pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | files | testdata/header-footer-image/header.html | file | + | marginTop | 1 | field | + | marginBottom | 1 | field | + | Gotenberg-Output-Filename | header | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the "header.pdf" PDF should have 1 page(s) + Then the "header.pdf" PDF should reuse 1 image XObject(s) across 1 page(s) + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-3-html/index.html | file | + | files | testdata/header-footer-image/header.html | file | + | marginTop | 1 | field | + | marginBottom | 1 | field | + | Gotenberg-Output-Filename | header | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the "header.pdf" PDF should have 3 page(s) + Then the "header.pdf" PDF should reuse 1 image XObject(s) across 3 page(s) + + Scenario: POST /forms/chromium/convert/html (Footer image reused across pages) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/page-1-html/index.html | file | + | files | testdata/header-footer-image/footer.html | file | + | marginTop | 1 | field | + | marginBottom | 1 | field | + | Gotenberg-Output-Filename | footer | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the "footer.pdf" PDF should have 1 page(s) + Then the "footer.pdf" PDF should reuse 1 image XObject(s) across 1 page(s) + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/pages-3-html/index.html | file | + | files | testdata/header-footer-image/footer.html | file | + | marginTop | 1 | field | + | marginBottom | 1 | field | + | Gotenberg-Output-Filename | footer | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the "footer.pdf" PDF should have 3 page(s) + Then the "footer.pdf" PDF should reuse 1 image XObject(s) across 3 page(s) diff --git a/test/integration/scenario/images.go b/test/integration/scenario/images.go new file mode 100644 index 00000000..a918e426 --- /dev/null +++ b/test/integration/scenario/images.go @@ -0,0 +1,72 @@ +package scenario + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +func (s *scenario) thePdfShouldReuseImageXObjects(ctx context.Context, name string, images, pages int) 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{"pdfimages", "-list", filepath.Base(path)} + output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path) + if err != nil { + return fmt.Errorf("exec %q: %w", cmd, err) + } + + return checkImageXObjectReuse(output, images, pages) +} + +func checkImageXObjectReuse(output string, images, pages int) error { + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 2 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "page") { + return fmt.Errorf("invalid pdfimages -list output: %q", output) + } + + objects := make(map[[2]int]struct{}) + imagesPerPage := make(map[int]int) + for _, line := range lines[2:] { + fields := strings.Fields(line) + if len(fields) < 12 { + return fmt.Errorf("invalid pdfimages -list row: %q", line) + } + + page, err := strconv.Atoi(fields[0]) + if err != nil { + return fmt.Errorf("parse image page %q: %w", fields[0], err) + } + if page < 1 || page > pages { + return fmt.Errorf("image on page %d outside expected range 1-%d", page, pages) + } + + // pdfimages lists appearances, including repeated references to the + // same image. Columns 11 and 12 identify the underlying PDF object. + var ref [2]int + for i := range ref { + ref[i], err = strconv.Atoi(fields[10+i]) + if err != nil { + return fmt.Errorf("parse image object ID in %q: %w", line, err) + } + } + objects[ref] = struct{}{} + imagesPerPage[page]++ + } + + for page := 1; page <= pages; page++ { + if imagesPerPage[page] != images { + return fmt.Errorf("expected %d image appearance(s) on page %d, but actual is %d", images, page, imagesPerPage[page]) + } + } + if len(objects) != images { + return fmt.Errorf("expected %d unique image XObject(s) shared across %d page(s), but actual is %d", images, pages, len(objects)) + } + + return nil +} diff --git a/test/integration/scenario/images_test.go b/test/integration/scenario/images_test.go new file mode 100644 index 00000000..e3638b8f --- /dev/null +++ b/test/integration/scenario/images_test.go @@ -0,0 +1,106 @@ +package scenario + +import ( + "fmt" + "strings" + "testing" +) + +func TestCheckImageXObjectReuse(t *testing.T) { + t.Parallel() + + const header = `page num type width height color comp bpc enc interp object ID x-ppi y-ppi size ratio +-------------------------------------------------------------------------------------------- +` + imageRow := func(page, object, generation int) string { + return fmt.Sprintf("%d %d image 32 32 rgb 3 8 image no %d %d 96 96 3104B 101%%\n", page, page-1, object, generation) + } + + tests := []struct { + name string + output string + pages int + wantErr string + }{ + { + name: "one page baseline", + output: header + imageRow(1, 7, 0), + pages: 1, + }, + { + name: "three pages share one image object", + output: header + imageRow(1, 7, 0) + imageRow(2, 7, 0) + imageRow(3, 7, 0), + pages: 3, + }, + { + name: "regression embeds a different object per page", + output: header + imageRow(1, 7, 0) + imageRow(2, 8, 0) + imageRow(3, 9, 0), + pages: 3, + wantErr: "expected 1 unique image XObject(s) shared across 3 page(s), but actual is 3", + }, + { + name: "object generation is part of its identity", + output: header + imageRow(1, 7, 0) + imageRow(2, 7, 1) + imageRow(3, 7, 0), + pages: 3, + wantErr: "expected 1 unique image XObject(s) shared across 3 page(s), but actual is 2", + }, + { + name: "image missing from a page", + output: header + imageRow(1, 7, 0) + imageRow(3, 7, 0), + pages: 3, + wantErr: "expected 1 image appearance(s) on page 2, but actual is 0", + }, + { + name: "no image cannot pass", + output: header, + pages: 1, + wantErr: "expected 1 image appearance(s) on page 1, but actual is 0", + }, + { + name: "repeated image only on first page cannot pass", + output: header + imageRow(1, 7, 0) + imageRow(1, 7, 0) + imageRow(1, 7, 0), + pages: 3, + wantErr: "expected 1 image appearance(s) on page 1, but actual is 3", + }, + { + name: "image on unexpected page", + output: header + imageRow(1, 7, 0) + imageRow(2, 7, 0), + pages: 1, + wantErr: "image on page 2 outside expected range 1-1", + }, + { + name: "tool error cannot pass", + output: "Syntax Error: Couldn't find trailer dictionary", + pages: 1, + wantErr: "invalid pdfimages -list output:", + }, + { + name: "malformed image row", + output: header + "1 0 image\n", + pages: 1, + wantErr: "invalid pdfimages -list row:", + }, + { + name: "inline image has no reusable object ID", + output: header + "1 0 image 32 32 rgb 3 8 image no [inline] - 96 96 3104B 101%\n", + pages: 1, + wantErr: "parse image object ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := checkImageXObjectReuse(tt.output, 1, tt.pages) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index d2905b49..5af46f04 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -1799,6 +1799,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { 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 have (\d+) image\(s\)$`, s.thePdfShouldHaveImages) + ctx.Then(`^the "([^"]*)" PDF should reuse (\d+) image XObject\(s\) across (\d+) page\(s\)$`, s.thePdfShouldReuseImageXObjects) ctx.Then(`^the "([^"]*)" image should be (\d+)x(\d+) pixels$`, s.theImageShouldBePixels) ctx.Then(`^the "([^"]*)" image pixel at (\d+),(\d+) should be "([^"]*)"$`, s.theImagePixelShouldBe) ctx.Then(`^the "([^"]*)" file size should be (less|greater) than (\d+) KB$`, s.theFileSizeShouldBe) diff --git a/test/integration/testdata/header-footer-image/footer.html b/test/integration/testdata/header-footer-image/footer.html new file mode 100644 index 00000000..91bca733 --- /dev/null +++ b/test/integration/testdata/header-footer-image/footer.html @@ -0,0 +1,15 @@ + + + + + + + + Regression test logo + + diff --git a/test/integration/testdata/header-footer-image/header.html b/test/integration/testdata/header-footer-image/header.html new file mode 100644 index 00000000..91bca733 --- /dev/null +++ b/test/integration/testdata/header-footer-image/header.html @@ -0,0 +1,15 @@ + + + + + + + + Regression test logo + +