diff --git a/.bruno/Chromium/Convert/HTML to PDF.bru b/.bruno/Chromium/Convert/HTML to PDF.bru index c0cb4a05..40a59dc3 100644 --- a/.bruno/Chromium/Convert/HTML to PDF.bru +++ b/.bruno/Chromium/Convert/HTML to PDF.bru @@ -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: diff --git a/.bruno/Chromium/Convert/Markdown to PDF.bru b/.bruno/Chromium/Convert/Markdown to PDF.bru index 65e0ff66..007b55cb 100644 --- a/.bruno/Chromium/Convert/Markdown to PDF.bru +++ b/.bruno/Chromium/Convert/Markdown to PDF.bru @@ -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: diff --git a/.bruno/Chromium/Convert/URL to PDF.bru b/.bruno/Chromium/Convert/URL to PDF.bru index 8283c248..2b22d1f4 100644 --- a/.bruno/Chromium/Convert/URL to PDF.bru +++ b/.bruno/Chromium/Convert/URL to PDF.bru @@ -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: diff --git a/.bruno/LibreOffice/Convert to PDF.bru b/.bruno/LibreOffice/Convert to PDF.bru index 1e823647..c65e1dc0 100644 --- a/.bruno/LibreOffice/Convert to PDF.bru +++ b/.bruno/LibreOffice/Convert to PDF.bru @@ -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: diff --git a/.bruno/PDF Engines/Merge/Merge PDFs.bru b/.bruno/PDF Engines/Merge/Merge PDFs.bru index 3923e15b..ee29e25e 100644 --- a/.bruno/PDF Engines/Merge/Merge PDFs.bru +++ b/.bruno/PDF Engines/Merge/Merge PDFs.bru @@ -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: diff --git a/.bruno/PDF Engines/Optimize/Optimize PDF.bru b/.bruno/PDF Engines/Optimize/Optimize PDF.bru new file mode 100644 index 00000000..96f0f0a4 --- /dev/null +++ b/.bruno/PDF Engines/Optimize/Optimize PDF.bru @@ -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"} +} diff --git a/.bruno/PDF Engines/Split/Split PDF.bru b/.bruno/PDF Engines/Split/Split PDF.bru index 6806b45d..270b94f7 100644 --- a/.bruno/PDF Engines/Split/Split PDF.bru +++ b/.bruno/PDF Engines/Split/Split PDF.bru @@ -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: diff --git a/Makefile b/Makefile index c479a7c2..b4f2fed5 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/compose.yaml b/compose.yaml index 0f864817..9d2ead6c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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}" diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index c84780f1..7a6d7241 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -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) } diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index ef9a1424..69ecebef 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -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) diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 17ef7991..d6cc88f9 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -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) diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index b4b04707..5e82350d 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -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) { diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index eeffb636..84ad58ab 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -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] diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 38562f33..c3386f9d 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -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) diff --git a/pkg/modules/pdfcpu/optimize.go b/pkg/modules/pdfcpu/optimize.go new file mode 100644 index 00000000..4f193ebd --- /dev/null +++ b/pkg/modules/pdfcpu/optimize.go @@ -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 +} diff --git a/pkg/modules/pdfcpu/optimize_test.go b/pkg/modules/pdfcpu/optimize_test.go new file mode 100644 index 00000000..5d4f7324 --- /dev/null +++ b/pkg/modules/pdfcpu/optimize_test.go @@ -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) + } + } +} diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index 7962619c..9b013e7f 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -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 { diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index b2e97e47..af455ad5 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -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), diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 165d0b04..693a8e07 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -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, diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 694f4980..bc8d1b31 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -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", diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 67f5501d..398f4e82 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -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", diff --git a/test/integration/README.md b/test/integration/README.md index d6d99aa2..fc728268 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -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 diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index af256113..2513845e 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -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 diff --git a/test/integration/features/pdfengines_optimize.feature b/test/integration/features/pdfengines_optimize.feature new file mode 100644 index 00000000..7867b111 --- /dev/null +++ b/test/integration/features/pdfengines_optimize.feature @@ -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 diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index 0478078e..69e667c1 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -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)) diff --git a/test/integration/testdata/image-heavy.pdf b/test/integration/testdata/image-heavy.pdf new file mode 100644 index 00000000..24c551ca Binary files /dev/null and b/test/integration/testdata/image-heavy.pdf differ diff --git a/test/integration/testdata/optimize-image-html/image.png b/test/integration/testdata/optimize-image-html/image.png new file mode 100644 index 00000000..897396c8 Binary files /dev/null and b/test/integration/testdata/optimize-image-html/image.png differ diff --git a/test/integration/testdata/optimize-image-html/index.html b/test/integration/testdata/optimize-image-html/index.html new file mode 100644 index 00000000..354e884d --- /dev/null +++ b/test/integration/testdata/optimize-image-html/index.html @@ -0,0 +1,15 @@ + + +
+ +
+
+