From 5c2813279d60b033e8234ffcb47fb6036b1d86bc Mon Sep 17 00:00:00 2001 From: Fernando Paes Date: Mon, 15 Jun 2026 00:19:27 +0000 Subject: [PATCH 1/3] fix(send): generate JPEGThumbnail for images in /send/media Images sent through SendMediaFile and SendMediaUrl were delivered without a JPEGThumbnail, so clients (notably iOS) showed no inline preview until the full image finished downloading. This adds a reusable makeJPEGThumbnail helper that decodes the source bytes, resizes to a 72px-wide preview preserving aspect ratio (never upscaling), and JPEG-encodes it at quality 50. The helper is wired into the image branch of both SendMediaFile and SendMediaUrl (newsletter and normal paths). The existing inline thumbnail logic in SendCarousel is refactored to reuse the same helper. Thumbnail generation is best-effort: if decoding or encoding fails the helper returns nil and the message is sent without a preview rather than failing the request. Co-Authored-By: Claude Sonnet 4.6 --- pkg/sendMessage/service/send_service.go | 107 +++++++++++++++------- pkg/sendMessage/service/thumbnail_test.go | 95 +++++++++++++++++++ 2 files changed, 167 insertions(+), 35 deletions(-) create mode 100644 pkg/sendMessage/service/thumbnail_test.go diff --git a/pkg/sendMessage/service/send_service.go b/pkg/sendMessage/service/send_service.go index c6ecdcdd..51bd4d80 100644 --- a/pkg/sendMessage/service/send_service.go +++ b/pkg/sendMessage/service/send_service.go @@ -1028,15 +1028,20 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, switch data.Type { case "image": + // Generate a JPEG preview thumbnail for better client UX (iOS in + // particular). On failure jpegThumb is nil and the message is sent + // without a preview rather than failing the request. + jpegThumb := makeJPEGThumbnail(fileData, 72) if isNewsletter { // Newsletter: SEM MediaKey e FileEncSHA256 media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ - Caption: proto.String(data.Caption), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, }} } else { // Normal: COM MediaKey e FileEncSHA256 @@ -1049,6 +1054,7 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, }} } mediaType = "ImageMessage" @@ -1312,15 +1318,20 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc switch data.Type { case "image": + // Generate a JPEG preview thumbnail for better client UX (iOS in + // particular). On failure jpegThumb is nil and the message is sent + // without a preview rather than failing the request. + jpegThumb := makeJPEGThumbnail(fileData, 72) if isNewsletter { // Newsletter: sem criptografia (sem MediaKey e FileEncSHA256) media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ - Caption: proto.String(data.Caption), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, }} } else { // Normal: com criptografia @@ -1333,6 +1344,7 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, }} } mediaType = "ImageMessage" @@ -1875,6 +1887,53 @@ func stringPointer(s string) *string { return &s } +// makeJPEGThumbnail decodes raw image bytes and produces a small JPEG +// thumbnail suitable for the JPEGThumbnail field of WhatsApp media messages. +// The thumbnail keeps the original aspect ratio and is capped at maxWidth +// pixels wide. It returns nil if the image cannot be decoded so callers can +// fall back to sending the message without a preview thumbnail. +func makeJPEGThumbnail(fileData []byte, maxWidth int) []byte { + if maxWidth < 1 { + maxWidth = 72 + } + + img, _, err := image.Decode(bytes.NewReader(fileData)) + if err != nil { + return nil + } + + bounds := img.Bounds() + srcWidth := bounds.Dx() + srcHeight := bounds.Dy() + if srcWidth < 1 || srcHeight < 1 { + return nil + } + + thumbWidth := maxWidth + if srcWidth < thumbWidth { + thumbWidth = srcWidth + } + thumbHeight := int(float64(srcHeight) * float64(thumbWidth) / float64(srcWidth)) + if thumbHeight < 1 { + thumbHeight = 1 + } + + thumbImg := image.NewRGBA(image.Rect(0, 0, thumbWidth, thumbHeight)) + for y := 0; y < thumbHeight; y++ { + for x := 0; x < thumbWidth; x++ { + srcX := x * srcWidth / thumbWidth + srcY := y * srcHeight / thumbHeight + thumbImg.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) + } + } + + var thumbBuf bytes.Buffer + if err := jpeg.Encode(&thumbBuf, thumbImg, &jpeg.Options{Quality: 50}); err != nil { + return nil + } + return thumbBuf.Bytes() +} + func sectionsToString(data *ListStruct) (string, error) { type row struct { Header string `json:"header"` @@ -2515,29 +2574,7 @@ func (s *sendService) SendCarousel(data *CarouselStruct, instance *instance_mode uploaded, err := client.Upload(context.Background(), fileData, whatsmeow.MediaImage) if err == nil { // Generate JPEG thumbnail for iOS compatibility - var jpegThumb []byte - img, _, decErr := image.Decode(bytes.NewReader(fileData)) - if decErr == nil { - // Resize to 72px thumbnail - bounds := img.Bounds() - thumbWidth := 72 - thumbHeight := int(float64(bounds.Dy()) * float64(thumbWidth) / float64(bounds.Dx())) - if thumbHeight < 1 { - thumbHeight = 1 - } - thumbImg := image.NewRGBA(image.Rect(0, 0, thumbWidth, thumbHeight)) - for y := 0; y < thumbHeight; y++ { - for x := 0; x < thumbWidth; x++ { - srcX := x * bounds.Dx() / thumbWidth - srcY := y * bounds.Dy() / thumbHeight - thumbImg.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) - } - } - var thumbBuf bytes.Buffer - if jpeg.Encode(&thumbBuf, thumbImg, &jpeg.Options{Quality: 50}) == nil { - jpegThumb = thumbBuf.Bytes() - } - } + jpegThumb := makeJPEGThumbnail(fileData, 72) header.HasMediaAttachment = proto.Bool(true) header.Media = &waE2E.InteractiveMessage_Header_ImageMessage{ diff --git a/pkg/sendMessage/service/thumbnail_test.go b/pkg/sendMessage/service/thumbnail_test.go new file mode 100644 index 00000000..088cc754 --- /dev/null +++ b/pkg/sendMessage/service/thumbnail_test.go @@ -0,0 +1,95 @@ +package send_service + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +// encodePNG builds a simple solid-color PNG of the given size for testing. +func encodePNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("failed to encode test PNG: %v", err) + } + return buf.Bytes() +} + +func TestMakeJPEGThumbnail_ResizesLandscape(t *testing.T) { + src := encodePNG(t, 800, 400) + + thumb := makeJPEGThumbnail(src, 72) + if thumb == nil { + t.Fatal("expected a thumbnail, got nil") + } + + cfg, format, err := image.DecodeConfig(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("thumbnail is not a decodable image: %v", err) + } + if format != "jpeg" { + t.Fatalf("expected jpeg thumbnail, got %q", format) + } + if cfg.Width != 72 { + t.Fatalf("expected width 72, got %d", cfg.Width) + } + // Aspect ratio (2:1) must be preserved: 72 wide -> 36 tall. + if cfg.Height != 36 { + t.Fatalf("expected height 36 to preserve aspect ratio, got %d", cfg.Height) + } +} + +func TestMakeJPEGThumbnail_DoesNotUpscaleSmallImages(t *testing.T) { + src := encodePNG(t, 40, 40) + + thumb := makeJPEGThumbnail(src, 72) + if thumb == nil { + t.Fatal("expected a thumbnail, got nil") + } + + cfg, _, err := image.DecodeConfig(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("thumbnail is not a decodable image: %v", err) + } + if cfg.Width != 40 || cfg.Height != 40 { + t.Fatalf("expected the thumbnail to keep the original 40x40 size, got %dx%d", cfg.Width, cfg.Height) + } +} + +func TestMakeJPEGThumbnail_InvalidInputReturnsNil(t *testing.T) { + if thumb := makeJPEGThumbnail([]byte("not an image"), 72); thumb != nil { + t.Fatalf("expected nil for non-image input, got %d bytes", len(thumb)) + } + if thumb := makeJPEGThumbnail(nil, 72); thumb != nil { + t.Fatalf("expected nil for nil input, got %d bytes", len(thumb)) + } +} + +func TestMakeJPEGThumbnail_DefaultsInvalidMaxWidth(t *testing.T) { + src := encodePNG(t, 800, 800) + + thumb := makeJPEGThumbnail(src, 0) + if thumb == nil { + t.Fatal("expected a thumbnail with defaulted maxWidth, got nil") + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("thumbnail is not a decodable image: %v", err) + } + if cfg.Width != 72 { + t.Fatalf("expected defaulted width 72, got %d", cfg.Width) + } +} + +// ensure the jpeg encoder import stays referenced even if the helper changes. +var _ = jpeg.DefaultQuality From 9814d3337378c8b264756248fc1a6293fad6b619 Mon Sep 17 00:00:00 2001 From: Fernando Paes Date: Mon, 15 Jun 2026 00:21:34 +0000 Subject: [PATCH 2/3] fix(send): generate JPEGThumbnail for PDF documents in /send/media PDF documents sent through SendMediaFile and SendMediaUrl had no inline preview thumbnail, so clients showed a generic document icon instead of a preview of the first page. This adds a makePDFThumbnail helper that rasterizes page 1 of the PDF with the external pdftoppm tool (poppler-utils) and re-encodes the result through makeJPEGThumbnail for a consistent preview. The thumbnail is wired into the document branch of both SendMediaFile and SendMediaUrl when the detected mime type is application/pdf. The feature degrades gracefully: if pdftoppm is not installed or rasterization fails, the helper returns nil and the document is sent without a preview rather than panicking or returning an error. Note: poppler-utils must be available in the runtime image for this to take effect (see Dockerfile). Co-Authored-By: Claude Sonnet 4.6 --- pkg/sendMessage/service/send_service.go | 85 +++++++++++++++++++---- pkg/sendMessage/service/thumbnail_test.go | 47 +++++++++++++ 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/pkg/sendMessage/service/send_service.go b/pkg/sendMessage/service/send_service.go index 51bd4d80..d625cd3d 100644 --- a/pkg/sendMessage/service/send_service.go +++ b/pkg/sendMessage/service/send_service.go @@ -1128,15 +1128,23 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, } mediaType = "AudioMessage" case "document": + // For PDF documents, rasterize page 1 into a JPEG preview thumbnail. + // A missing pdftoppm or a failure yields nil and the document is + // sent without a preview instead of failing the request. + var jpegThumb []byte + if mimeType == "application/pdf" { + jpegThumb = makePDFThumbnail(fileData, 200) + } if isNewsletter { media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ - FileName: &data.Filename, - Caption: proto.String(data.Caption), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + FileName: &data.Filename, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, }} } else { media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ @@ -1149,6 +1157,7 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, }} } @@ -1422,15 +1431,23 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc } mediaType = "AudioMessage" case "document": + // For PDF documents, rasterize page 1 into a JPEG preview thumbnail. + // A missing pdftoppm or a failure yields nil and the document is + // sent without a preview instead of failing the request. + var jpegThumb []byte + if mimeType == "application/pdf" { + jpegThumb = makePDFThumbnail(fileData, 200) + } if isNewsletter { media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ - URL: &uploaded.URL, - FileName: &data.Filename, - Caption: proto.String(data.Caption), - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + URL: &uploaded.URL, + FileName: &data.Filename, + Caption: proto.String(data.Caption), + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, }} } else { media = &waE2E.Message{DocumentMessage: &waE2E.DocumentMessage{ @@ -1443,6 +1460,7 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, }} } @@ -1934,6 +1952,45 @@ func makeJPEGThumbnail(fileData []byte, maxWidth int) []byte { return thumbBuf.Bytes() } +// makePDFThumbnail rasterizes the first page of a PDF into a JPEG thumbnail +// using the external "pdftoppm" tool (poppler-utils). It returns nil when +// pdftoppm is not installed or rasterization fails, so callers can gracefully +// send the document without a preview instead of failing the request. +func makePDFThumbnail(fileData []byte, maxWidth int) []byte { + if _, err := exec.LookPath("pdftoppm"); err != nil { + return nil + } + + scaleWidth := maxWidth + if scaleWidth < 1 { + scaleWidth = 72 + } + + // Render only the first page to a PNG on stdout, scaled to scaleWidth. + // "-scale-to-y -1" keeps the original aspect ratio. + cmd := exec.Command("pdftoppm", + "-png", + "-f", "1", + "-l", "1", + "-singlefile", + "-scale-to-x", strconv.Itoa(scaleWidth), + "-scale-to-y", "-1", + ) + cmd.Stdin = bytes.NewReader(fileData) + + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return nil + } + if out.Len() == 0 { + return nil + } + + // Re-encode the rendered PNG as a JPEG thumbnail for consistency with images. + return makeJPEGThumbnail(out.Bytes(), maxWidth) +} + func sectionsToString(data *ListStruct) (string, error) { type row struct { Header string `json:"header"` diff --git a/pkg/sendMessage/service/thumbnail_test.go b/pkg/sendMessage/service/thumbnail_test.go index 088cc754..87460383 100644 --- a/pkg/sendMessage/service/thumbnail_test.go +++ b/pkg/sendMessage/service/thumbnail_test.go @@ -6,6 +6,7 @@ import ( "image/color" "image/jpeg" "image/png" + "os/exec" "testing" ) @@ -91,5 +92,51 @@ func TestMakeJPEGThumbnail_DefaultsInvalidMaxWidth(t *testing.T) { } } +func TestMakePDFThumbnail_InvalidInputReturnsNil(t *testing.T) { + // With or without pdftoppm installed, garbage input must never panic and + // must yield nil so the caller falls back to sending without a preview. + if thumb := makePDFThumbnail([]byte("%PDF-not-really"), 200); thumb != nil { + t.Fatalf("expected nil for invalid PDF input, got %d bytes", len(thumb)) + } + if thumb := makePDFThumbnail(nil, 200); thumb != nil { + t.Fatalf("expected nil for nil input, got %d bytes", len(thumb)) + } +} + +func TestMakePDFThumbnail_RendersFirstPageWhenPopplerAvailable(t *testing.T) { + if _, err := exec.LookPath("pdftoppm"); err != nil { + t.Skip("pdftoppm not installed; skipping PDF rasterization test") + } + + pdf := minimalPDF(t) + thumb := makePDFThumbnail(pdf, 200) + if thumb == nil { + t.Fatal("expected a thumbnail from a valid PDF, got nil") + } + + cfg, format, err := image.DecodeConfig(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("PDF thumbnail is not a decodable image: %v", err) + } + if format != "jpeg" { + t.Fatalf("expected jpeg thumbnail, got %q", format) + } + if cfg.Width < 1 || cfg.Width > 200 { + t.Fatalf("expected width within (0, 200], got %d", cfg.Width) + } +} + +// minimalPDF returns the bytes of a tiny, valid single-page PDF. +func minimalPDF(t *testing.T) []byte { + t.Helper() + const doc = "%PDF-1.1\n" + + "1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n" + + "2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n" + + "3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>endobj\n" + + "trailer<< /Root 1 0 R >>\n" + + "%%EOF\n" + return []byte(doc) +} + // ensure the jpeg encoder import stays referenced even if the helper changes. var _ = jpeg.DefaultQuality From d1a96af8bbe39e6349e5ac3117bce2bc8aba4944 Mon Sep 17 00:00:00 2001 From: Fernando Paes Date: Mon, 15 Jun 2026 00:28:04 +0000 Subject: [PATCH 3/3] build: add Dockerfile.custom with poppler-utils for PDF thumbnails Adds a custom Dockerfile that mirrors the upstream build and installs poppler-utils (pdftoppm) in the runtime image, which is required for the PDF document thumbnail feature. Kept separate from the upstream Dockerfile. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile.custom | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Dockerfile.custom diff --git a/Dockerfile.custom b/Dockerfile.custom new file mode 100644 index 00000000..4fd0c43c --- /dev/null +++ b/Dockerfile.custom @@ -0,0 +1,40 @@ +# Dockerfile.custom +# +# Same build as the upstream Dockerfile, with poppler-utils added to the +# runtime image so PDF document thumbnails (pdftoppm) work in production. +# Kept separate from the upstream Dockerfile to keep the contribution diff clean. + +FROM golang:1.25.0-alpine AS build + +RUN apk update && apk add --no-cache git build-base libjpeg-turbo-dev libwebp-dev + +WORKDIR /build + +# Copy dependency files first to cache the module download layer. +COPY go.mod go.sum ./ + +# whatsmeow-lib is a local replace dependency (see go.mod). +COPY whatsmeow-lib/ ./whatsmeow-lib/ + +RUN go mod download + +# Copy the rest of the source. +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=1 go build -ldflags "-X main.version=${VERSION}" -o server ./cmd/evolution-go + +FROM alpine:3.19.1 AS final + +# poppler-utils provides pdftoppm, used to rasterize PDF page 1 for thumbnails. +RUN apk update && apk add --no-cache tzdata ffmpeg libjpeg-turbo libwebp poppler-utils + +WORKDIR /app + +COPY --from=build /build/server . +COPY --from=build /build/manager/dist ./manager/dist +COPY --from=build /build/VERSION ./VERSION + +ENV TZ=America/Sao_Paulo + +ENTRYPOINT ["/app/server"]