mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-14 11:22:15 +01:00
feat(pdfengines): optimize PDF images to reduce file size (#359)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user