mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-15 03:42:15 +01:00
feat(pdfengines): optimize PDF images to reduce file size (#359)
This commit is contained in:
321
pkg/modules/pdfcpu/optimize.go
Normal file
321
pkg/modules/pdfcpu/optimize.go
Normal 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
|
||||
}
|
||||
83
pkg/modules/pdfcpu/optimize_test.go
Normal file
83
pkg/modules/pdfcpu/optimize_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user