mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-14 19:32: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
|
||||
}
|
||||
Reference in New Issue
Block a user