feat(pdfengines): optimize PDF images to reduce file size (#359)

This commit is contained in:
Julien Neuhart
2026-08-13 19:39:09 +02:00
parent 7a730cfdc2
commit 1ac1d9887e
29 changed files with 776 additions and 5 deletions

View File

@@ -47,6 +47,8 @@ body:multipart-form {
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -48,6 +48,8 @@ body:multipart-form {
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -47,6 +47,8 @@ body:multipart-form {
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -64,6 +64,8 @@ body:multipart-form {
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -17,6 +17,8 @@ body:multipart-form {
~autoIndexBookmarks: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
~userPassword:

View File

@@ -0,0 +1,26 @@
meta {
name: Optimize PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/optimize
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
~imageQuality: 80
}
headers {
~Gotenberg-Output-Filename: optimized
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -18,6 +18,8 @@ body:multipart-form {
~flatten: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -82,6 +82,7 @@ PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_OPTIMIZE_IMAGES_ENGINES=pdfcpu
PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
@@ -162,6 +163,8 @@ NO_CONCURRENCY=false
# encrypt
# pdfengines-flatten
# flatten
# pdfengines-optimize
# optimize
# pdfengines-merge
# merge
# pdfengines-metadata

View File

@@ -82,6 +82,7 @@ services:
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
- "--pdfengines-optimize-images-engines=${PDFENGINES_OPTIMIZE_IMAGES_ENGINES}"
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"

View File

@@ -49,6 +49,7 @@ type PdfEngineMock struct {
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
OptimizeImagesMock func(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
@@ -80,6 +81,10 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, f
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
}
func (engine *PdfEngineMock) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
return engine.OptimizeImagesMock(ctx, logger, imageQuality, inputPath)
}
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return engine.ReadMetadataMock(ctx, logger, inputPath)
}

View File

@@ -281,6 +281,12 @@ type PdfEngine interface {
// PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
// OptimizeImages re-encodes the raster images of a PDF in place to shrink
// the file, leaving text, vectors, fonts and structure untouched.
// imageQuality is the JPEG quality (1 to 100) applied to each re-encoded
// image.
OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
// ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)

View File

@@ -479,6 +479,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
@@ -504,7 +505,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -568,6 +569,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
@@ -590,7 +592,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -651,6 +653,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
@@ -682,7 +685,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -807,7 +810,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string, optimizeImages bool, imageQuality int) error {
outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath)
@@ -904,6 +907,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("rotate PDFs: %w", err)
}
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)

View File

@@ -296,6 +296,20 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
return err
}
// OptimizeImages is not available in this implementation.
func (engine *ExifTool) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.OptimizeImages",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
err := fmt.Errorf("optimize PDF images with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata extracts the metadata of a given PDF file by invoking
// the exiftool binary with "-j" (JSON output) and parsing the result.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {

View File

@@ -60,6 +60,11 @@ func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *slog.Lo
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// OptimizeImages is not available in this implementation.
func (engine *LibreOfficePdfEngine) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
return fmt.Errorf("optimize PDF images with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]

View File

@@ -44,6 +44,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -515,6 +516,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}
}
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
needsConvertStub := !nativePdfFormats ||
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
(nativePdfFormats && hasPostProcessing)

View File

@@ -0,0 +1,321 @@
package pdfcpu
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
_ "image/png" // Register the PNG decoder: pdfcpu extracts FlateDecode images as PNG.
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// minOptimizeImageSize is the smallest encoded image worth re-encoding. Smaller
// images (thumbnails, icons, and line art that FlateDecode already keeps tiny)
// are left untouched: a JPEG pass would add artifacts for little or no gain.
const minOptimizeImageSize = 30 << 10 // 30 KiB
// pdfcpuListRowID matches the image Id (e.g. "X6") in a `pdfcpu images extract`
// filename such as "input_1_X6.png".
var pdfcpuListRowID = regexp.MustCompile(`_(X\d+)\.`)
// pdfcpuImage is one raster image XObject as reported by `pdfcpu images list`.
type pdfcpuImage struct {
obj int
id string
masked bool
comp int
bytes int64
filter string
}
// OptimizeImages re-encodes the raster images of inputPath to JPEG in place,
// shrinking image-heavy PDFs (a common case for Chromium output, which embeds
// non-JPEG images losslessly) while leaving text, vectors, fonts and structure
// untouched. See https://github.com/gotenberg/gotenberg/issues/359.
//
// Only lossless (FlateDecode), non-CMYK, non-masked images at or above
// [minOptimizeImageSize] are touched. Already-compressed, transparent, CMYK and
// small images are skipped so the pass never enlarges a file or corrupts
// transparency. It never fails the conversion for a single unreadable image; it
// logs and moves on, and returns the input unchanged when nothing qualifies.
func (engine *PdfCpu) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.OptimizeImages",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
fail := func(err error) error {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
images, err := engine.listImages(ctx, inputPath)
if err != nil {
return fail(fmt.Errorf("optimize PDF images with pdfcpu: %w", err))
}
var targets []pdfcpuImage
for _, img := range images {
if optimizableImage(img) {
targets = append(targets, img)
}
}
if len(targets) == 0 {
logger.DebugContext(ctx, "no images to optimize")
span.SetStatus(codes.Ok, "")
return nil
}
workDir, err := os.MkdirTemp(filepath.Dir(inputPath), "optimize-images-*")
if err != nil {
return fail(fmt.Errorf("optimize PDF images with pdfcpu: create work directory: %w", err))
}
defer func() {
if err := os.RemoveAll(workDir); err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("remove image optimization work directory: %v", err))
}
}()
extracted, err := engine.extractImages(ctx, logger, inputPath, workDir)
if err != nil {
return fail(fmt.Errorf("optimize PDF images with pdfcpu: %w", err))
}
// Chain one update per image. Each update writes a fresh file; current holds
// the latest successful output, so a single failed image is skipped without
// discarding the ones already done. The input is only replaced on success.
current := inputPath
optimized := 0
for _, img := range targets {
src, ok := extracted[img.id]
if !ok {
logger.WarnContext(ctx, fmt.Sprintf("optimize images: image %s was not extracted, leaving it as is", img.id))
continue
}
reencoded := filepath.Join(workDir, img.id+".jpg")
err = reencodeToJpeg(src, reencoded, imageQuality)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("optimize images: re-encode %s: %v; leaving it as is", img.id, err))
continue
}
next := filepath.Join(workDir, fmt.Sprintf("optimized-%d.pdf", optimized))
err = engine.updateImage(ctx, logger, current, reencoded, next, img.obj)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("optimize images: update %s: %v; leaving it as is", img.id, err))
continue
}
current = next
optimized++
}
if optimized == 0 {
span.SetStatus(codes.Ok, "")
return nil
}
err = os.Rename(current, inputPath)
if err != nil {
return fail(fmt.Errorf("optimize PDF images with pdfcpu: replace input: %w", err))
}
logger.DebugContext(ctx, fmt.Sprintf("optimized %d image(s) at quality %d", optimized, imageQuality))
span.SetStatus(codes.Ok, "")
return nil
}
// optimizableImage reports whether an image is a safe, worthwhile target: a
// lossless (FlateDecode), non-CMYK, non-masked image at or above the size
// threshold. Everything else is left untouched.
func optimizableImage(img pdfcpuImage) bool {
switch {
case img.filter != "FlateDecode":
return false // Already compressed (JPEG/JPX); re-encoding would only add loss.
case img.comp == 4:
return false // CMYK; a JPEG round-trip is unsafe.
case img.masked:
return false // Soft mask, image mask or alpha; JPEG has no transparency.
case img.bytes < minOptimizeImageSize:
return false
default:
return true
}
}
// listImages runs `pdfcpu images list` and parses its table. The command writes
// to stdout, so it is run directly to capture the output.
func (engine *PdfCpu) listImages(ctx context.Context, inputPath string) ([]pdfcpuImage, error) {
cmd := exec.CommandContext(ctx, engine.binPath, "images", "list", inputPath) //nolint:gosec // binPath is validated at Provision; inputPath is a Gotenberg working file.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("run pdfcpu images list: %w", err)
}
return parseImagesList(stdout.String()), nil
}
// parseImagesList parses the fixed-column table of `pdfcpu images list`. Columns
// are separated by U+2502; the header and separator rows are skipped because
// their second column is not a numeric object number.
func parseImagesList(output string) []pdfcpuImage {
var images []pdfcpuImage
for _, line := range strings.Split(output, "\n") {
cols := strings.Split(line, "│")
if len(cols) < 9 {
continue
}
obj, err := strconv.Atoi(strings.TrimSpace(cols[1]))
if err != nil {
continue
}
comp := 0
if fields := strings.Fields(cols[6]); len(fields) >= 2 {
comp, _ = strconv.Atoi(fields[1])
}
images = append(images, pdfcpuImage{
obj: obj,
id: strings.TrimSpace(cols[2]),
masked: strings.TrimSpace(cols[3]) != "image",
comp: comp,
bytes: parseHumanSize(cols[7]),
filter: strings.TrimSpace(cols[8]),
})
}
return images
}
// parseHumanSize converts a pdfcpu size cell such as "4.4 MB" or "194 KB" into
// a byte count.
func parseHumanSize(cell string) int64 {
fields := strings.Fields(cell)
if len(fields) == 0 {
return 0
}
value, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0
}
multiplier := float64(1)
if len(fields) > 1 {
switch strings.ToUpper(fields[1]) {
case "KB":
multiplier = 1 << 10
case "MB":
multiplier = 1 << 20
case "GB":
multiplier = 1 << 30
}
}
return int64(value * multiplier)
}
// extractImages extracts every image of inputPath into dir and returns a map of
// image Id (e.g. "X6") to the extracted file path.
func (engine *PdfCpu) extractImages(ctx context.Context, logger *slog.Logger, inputPath, dir string) (map[string]string, error) {
args := []string{"images", "extract", inputPath, dir}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("extract images: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read extracted images: %w", err)
}
extracted := make(map[string]string, len(entries))
for _, entry := range entries {
if match := pdfcpuListRowID.FindStringSubmatch(entry.Name()); match != nil {
extracted[match[1]] = filepath.Join(dir, entry.Name())
}
}
return extracted, nil
}
// updateImage replaces the image object objNr of inFile with the image at
// imagePath, writing the result to outFile. The replacement must share the
// original image dimensions, which reencodeToJpeg preserves.
func (engine *PdfCpu) updateImage(ctx context.Context, logger *slog.Logger, inFile, imagePath, outFile string, objNr int) error {
args := []string{"images", "update", inFile, imagePath, outFile, strconv.Itoa(objNr)}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("update image: %w", err)
}
return nil
}
// reencodeToJpeg decodes the image at src and writes it to dst as JPEG at the
// given quality, keeping the original pixel dimensions (pdfcpu requires the
// replacement to match). quality is clamped to the valid 1 to 100 range.
func reencodeToJpeg(src, dst string, quality int) error {
if quality < 1 {
quality = 1
}
if quality > 100 {
quality = 100
}
in, err := os.Open(src) //nolint:gosec // src is a file this package extracted into its own temp dir.
if err != nil {
return fmt.Errorf("open image: %w", err)
}
defer in.Close()
img, _, err := image.Decode(in)
if err != nil {
return fmt.Errorf("decode image: %w", err)
}
out, err := os.Create(dst) //nolint:gosec // dst is a file in this package's own temp dir.
if err != nil {
return fmt.Errorf("create re-encoded image: %w", err)
}
defer out.Close()
err = jpeg.Encode(out, img, &jpeg.Options{Quality: quality})
if err != nil {
return fmt.Errorf("encode JPEG: %w", err)
}
return nil
}

View File

@@ -0,0 +1,83 @@
package pdfcpu
import "testing"
const sampleImagesList = `pages: all
/tmp/multi.pdf:
4 images available (8.9 MB)
Page │ Obj# │ Id │ Type SoftMask ImgMask │ Width │ Height │ ColorSpace Comp bpc Interp │ Size │ Filters
━━━━━┿━━━━━━┿━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━┿━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━┿━━━━━━━━━━━━
1 │ 6 │ X6 │ image │ 2400 │ 1800 │ ICCBased 3 8 │ 5.5 MB │ FlateDecode
│ 8 │ X8 │ image * │ 1400 │ 1000 │ ICCBased 3 8 │ 63 KB │ FlateDecode
│ 9 │ X9 │ image │ 2400 │ 1800 │ ICCBased 3 8 │ 194 KB │ DCTDecode
│ 10 │ X10 │ image │ 120 │ 90 │ DeviceCMYK 4 8 │ 14 KB │ FlateDecode
`
func TestParseImagesList(t *testing.T) {
images := parseImagesList(sampleImagesList)
if len(images) != 4 {
t.Fatalf("expected 4 images, got %d", len(images))
}
for _, tc := range []struct {
index int
obj int
id string
masked bool
comp int
filter string
}{
{0, 6, "X6", false, 3, "FlateDecode"},
{1, 8, "X8", true, 3, "FlateDecode"},
{2, 9, "X9", false, 3, "DCTDecode"},
{3, 10, "X10", false, 4, "FlateDecode"},
} {
img := images[tc.index]
if img.obj != tc.obj || img.id != tc.id || img.masked != tc.masked || img.comp != tc.comp || img.filter != tc.filter {
t.Errorf("image %d = %+v, want obj=%d id=%s masked=%v comp=%d filter=%s",
tc.index, img, tc.obj, tc.id, tc.masked, tc.comp, tc.filter)
}
}
}
func TestParseHumanSize(t *testing.T) {
for _, tc := range []struct {
cell string
want int64
}{
{"5.5 MB", int64(5.5 * (1 << 20))},
{"194 KB", 194 << 10},
{" 14 KB ", 14 << 10},
{"512 B", 512},
{"2 GB", 2 << 30},
{"", 0},
{"garbage", 0},
} {
if got := parseHumanSize(tc.cell); got != tc.want {
t.Errorf("parseHumanSize(%q) = %d, want %d", tc.cell, got, tc.want)
}
}
}
func TestOptimizableImage(t *testing.T) {
base := pdfcpuImage{obj: 1, id: "X1", masked: false, comp: 3, bytes: 1 << 20, filter: "FlateDecode"}
for _, tc := range []struct {
scenario string
mutate func(pdfcpuImage) pdfcpuImage
want bool
}{
{"lossless RGB above threshold", func(i pdfcpuImage) pdfcpuImage { return i }, true},
{"already compressed", func(i pdfcpuImage) pdfcpuImage { i.filter = "DCTDecode"; return i }, false},
{"CMYK", func(i pdfcpuImage) pdfcpuImage { i.comp = 4; return i }, false},
{"masked", func(i pdfcpuImage) pdfcpuImage { i.masked = true; return i }, false},
{"below threshold", func(i pdfcpuImage) pdfcpuImage { i.bytes = minOptimizeImageSize - 1; return i }, false},
{"grayscale above threshold", func(i pdfcpuImage) pdfcpuImage { i.comp = 1; return i }, true},
} {
if got := optimizableImage(tc.mutate(base)); got != tc.want {
t.Errorf("%s: optimizableImage = %v, want %v", tc.scenario, got, tc.want)
}
}
}

View File

@@ -18,6 +18,7 @@ type multiPdfEngines struct {
splitEngines []gotenberg.PdfEngine
flattenEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
optimizeImagesEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine
@@ -36,6 +37,7 @@ func newMultiPdfEngines(
splitEngines,
flattenEngines,
convertEngines,
optimizeImagesEngines,
readMetadataEngines,
writeMetadataEngines,
passwordEngines,
@@ -53,6 +55,7 @@ func newMultiPdfEngines(
splitEngines: splitEngines,
flattenEngines: flattenEngines,
convertEngines: convertEngines,
optimizeImagesEngines: optimizeImagesEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
@@ -189,6 +192,17 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger,
)
}
// OptimizeImages re-encodes the images of a PDF using the first available
// engine that supports image optimization.
func (multi *multiPdfEngines) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.OptimizeImages", multi.optimizeImagesEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.OptimizeImages(ctx, logger, imageQuality, inputPath)
},
func(err error) error { return fmt.Errorf("optimize PDF images with multi PDF engines: %w", err) },
)
}
// Convert transforms the given PDF to a specific PDF format using the first
// available engine that supports PDF conversion.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {

View File

@@ -32,6 +32,7 @@ type PdfEngines struct {
splitNames []string
flattenNames []string
convertNames []string
optimizeImagesNames []string
readMetadataNames []string
writeMetadataNames []string
encryptNames []string
@@ -57,6 +58,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-optimize-images-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the image optimization feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
@@ -91,6 +93,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
splitNames := flags.MustStringSlice("pdfengines-split-engines")
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
optimizeImagesNames := flags.MustStringSlice("pdfengines-optimize-images-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
@@ -148,6 +151,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.convertNames = convertNames
}
mod.optimizeImagesNames = defaultNames
if len(optimizeImagesNames) > 0 {
mod.optimizeImagesNames = optimizeImagesNames
}
mod.readMetadataNames = defaultNames
if len(readMetadataNames) > 0 {
mod.readMetadataNames = readMetadataNames
@@ -247,6 +255,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.splitNames)
findNonExistingEngines(mod.flattenNames)
findNonExistingEngines(mod.optimizeImagesNames)
findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMetadataNames)
@@ -275,6 +284,7 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")),
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames, " ")),
fmt.Sprintf("optimize images engines - %s", strings.Join(mod.optimizeImagesNames, " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames, " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
@@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.splitNames),
engines(mod.flattenNames),
engines(mod.convertNames),
engines(mod.optimizeImagesNames),
engines(mod.readMetadataNames),
engines(mod.writeMetadataNames),
engines(mod.encryptNames),
@@ -341,6 +352,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
mergeRoute(engine),
splitRoute(engine),
flattenRoute(engine),
optimizeRoute(engine),
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),

View File

@@ -344,6 +344,65 @@ func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []stri
return nil
}
// defaultImageQuality is the JPEG quality applied by the image optimization
// feature when the imageQuality form field is not set.
const defaultImageQuality = 80
// FormDataPdfOptimize extracts the image-optimization options from the form
// data: whether to optimize the images, and the JPEG quality (1 to 100) to
// apply to each re-encoded image.
func FormDataPdfOptimize(form *api.FormData) (bool, int) {
var (
optimizeImages bool
imageQuality int
)
form.
Bool("optimizeImages", &optimizeImages, false).
Custom("imageQuality", func(value string) error {
if value == "" {
imageQuality = defaultImageQuality
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
if intValue > 100 {
return errors.New("value is superior to 100")
}
imageQuality = intValue
return nil
})
return optimizeImages, imageQuality
}
// OptimizeStub re-encodes the images of each given PDF to shrink the file when
// optimizeImages is set, leaving text, vectors and structure untouched. It does
// nothing when optimizeImages is false.
func OptimizeStub(ctx *api.Context, engine gotenberg.PdfEngine, optimizeImages bool, imageQuality int, inputPaths []string) error {
if !optimizeImages {
return nil
}
for _, inputPath := range inputPaths {
err := engine.OptimizeImages(ctx, ctx.Log(), imageQuality, inputPath)
if err != nil {
return fmt.Errorf("optimize images of '%s': %w", inputPath, err)
}
}
return nil
}
// ConvertStub transforms a given PDF to the specified formats defined in
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
// paths.
@@ -931,6 +990,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
optimizeImages, imageQuality := FormDataPdfOptimize(form)
var inputPaths []string
var flatten bool
@@ -998,6 +1058,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
}
}
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
@@ -1108,6 +1173,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
optimizeImages, imageQuality := FormDataPdfOptimize(form)
var inputPaths []string
var flatten bool
@@ -1170,6 +1236,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
}
}
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
@@ -1262,6 +1333,42 @@ func flattenRoute(engine gotenberg.PdfEngine) api.Route {
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
// format.
func optimizeRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/optimize",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
// This route optimizes unconditionally, so the optimizeImages toggle
// is ignored; only the image quality is read.
_, imageQuality := FormDataPdfOptimize(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = OptimizeStub(ctx, engine, true, imageQuality, inputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
func convertRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,

View File

@@ -219,6 +219,20 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g
return err
}
// OptimizeImages is not available in this implementation.
func (engine *PdfTk) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.OptimizeImages",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
err := fmt.Errorf("optimize PDF images with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata is not available in this implementation.
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",

View File

@@ -255,6 +255,20 @@ func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats go
return err
}
// OptimizeImages is not available in this implementation.
func (engine *QPdf) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.OptimizeImages",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
err := fmt.Errorf("optimize PDF images with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata is not available in this implementation.
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",

View File

@@ -27,7 +27,7 @@ Available 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` |
| LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` |
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
| 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` |
## Writing a new test

View File

@@ -1187,6 +1187,26 @@ Feature: /forms/chromium/convert/html
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be flatten
# Post-processing image optimization re-encodes the embedded lossless image to
# JPEG. The same page is ~700 KB without it and well under 300 KB with it.
# See https://github.com/gotenberg/gotenberg/issues/359.
Scenario: POST /forms/chromium/convert/html (Optimize Images)
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/optimize-image-html/index.html | file |
| files | testdata/optimize-image-html/image.png | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the "foo.pdf" file size should be greater than 300 KB
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/optimize-image-html/index.html | file |
| files | testdata/optimize-image-html/image.png | file |
| optimizeImages | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the "foo.pdf" file size should be less than 300 KB
@encrypt
Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
Given I have a default Gotenberg container

View File

@@ -0,0 +1,61 @@
@pdfengines
@pdfengines-optimize
@optimize
Feature: /forms/pdfengines/optimize
# image-heavy.pdf is a ~710 KB PDF whose single image Chromium embedded
# losslessly (FlateDecode). Re-encoding it to JPEG shrinks the file well
# below this threshold while leaving the structure intact.
# See https://github.com/gotenberg/gotenberg/issues/359.
Scenario: POST /forms/pdfengines/optimize (Image-heavy PDF)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/image-heavy.pdf | file |
| Gotenberg-Output-Filename | foo | 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 "foo.pdf" file size should be less than 300 KB
Scenario: POST /forms/pdfengines/optimize (Custom Image Quality)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/image-heavy.pdf | file |
| imageQuality | 40 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the "foo.pdf" file size should be less than 300 KB
Scenario: POST /forms/pdfengines/optimize (PDF Without Images)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
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
Scenario: POST /forms/pdfengines/optimize (Invalid Image Quality)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/image-heavy.pdf | file |
| imageQuality | 200 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Scenario: POST /forms/pdfengines/optimize (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: no form file found for extensions: [.pdf]
"""
Scenario: POST /forms/pdfengines/optimize (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/image-heavy.pdf | file |
Then the response status code should be 404

View File

@@ -1057,6 +1057,29 @@ func (s *scenario) theImagePixelShouldBe(_ context.Context, name string, x, y in
return nil
}
func (s *scenario) theFileSizeShouldBe(_ context.Context, name, comparator string, sizeKB int) error {
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat %q: %w", path, err)
}
limit := int64(sizeKB) * 1024
switch comparator {
case "less":
if info.Size() >= limit {
return fmt.Errorf("expected %s (%d bytes) to be smaller than %d KB", name, info.Size(), sizeKB)
}
case "greater":
if info.Size() <= limit {
return fmt.Errorf("expected %s (%d bytes) to be larger than %d KB", name, info.Size(), sizeKB)
}
}
return nil
}
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
var path string
if !strings.HasPrefix(name, "*_") {
@@ -1650,6 +1673,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
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)
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
if s.gotenbergContainer != nil {
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Image-heavy page</title>
<style>
@page { margin: 0; }
body { margin: 0; }
img { width: 100%; display: block; }
</style>
</head>
<body>
<img src="image.png" />
</body>
</html>