diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 91beaf9..91675cd 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -39,7 +39,7 @@ const ( // Included in content hashes so renderer/template changes never reuse // stale Telegram file_ids or S3 objects from an older deployment. - renderCacheVersion = "presigned-v2-inline-thumbnail" + renderCacheVersion = "presigned-v3-square-thumbnail" ) // outputFormat is one of the three generation formats. diff --git a/internal/render/render.go b/internal/render/render.go index 3525fb7..754bb3f 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -9,7 +9,9 @@ import ( "errors" "fmt" "image" - _ "image/jpeg" + "image/color" + "image/draw" + "image/jpeg" _ "image/png" "math" "os" @@ -384,11 +386,12 @@ func (r *Renderer) renderRaster(ctx context.Context, workdir string, dpi int, pd } // renderThumbnail creates the dedicated small JPEG used only by Telegram's -// inline-result picker. Reusing the full-resolution photo URL as its thumbnail -// can leave clients displaying a 320-pixel partial decode over the full photo -// canvas even though the downloaded media itself is intact. +// inline-result picker. It centers the aspect-correct preview on a square +// canvas because some clients force inline thumbnails into a square slot. +// Supplying the rectangular preview directly makes those clients stretch it. func (r *Renderer) renderThumbnail(ctx context.Context, workdir string, dpi int, pdfPath, outPath string) error { - base := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + base := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + "-source" + sourcePath := base + ".jpg" args := []string{ "-jpeg", "-jpegopt", "quality=82,progressive=n,optimize=y", "-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1", @@ -397,16 +400,45 @@ func (r *Renderer) renderThumbnail(ctx context.Context, workdir string, dpi int, if err := r.run(ctx, workdir, r.Pdftoppm, args...); err != nil { return fmt.Errorf("render inline thumbnail: %w", err) } - width, height, err := imageSize(outPath) + width, height, err := imageSize(sourcePath) if err != nil { return fmt.Errorf("inspect inline thumbnail: %w", err) } if width > inlineThumbnailSide || height > inlineThumbnailSide { return fmt.Errorf("inline thumbnail is unexpectedly large: %dx%d", width, height) } + + sourceData, err := os.ReadFile(sourcePath) + if err != nil { + return fmt.Errorf("read inline thumbnail: %w", err) + } + source, err := jpeg.Decode(bytes.NewReader(sourceData)) + if err != nil { + return fmt.Errorf("decode inline thumbnail: %w", err) + } + thumbnail := squareThumbnail(source) + var encoded bytes.Buffer + if err := jpeg.Encode(&encoded, thumbnail, &jpeg.Options{Quality: 82}); err != nil { + return fmt.Errorf("encode inline thumbnail: %w", err) + } + if err := os.WriteFile(outPath, encoded.Bytes(), 0o600); err != nil { + return fmt.Errorf("write inline thumbnail: %w", err) + } return nil } +// squareThumbnail pads rather than stretches the already-scaled preview. +func squareThumbnail(source image.Image) *image.RGBA { + canvas := image.NewRGBA(image.Rect(0, 0, inlineThumbnailSide, inlineThumbnailSide)) + draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) + bounds := source.Bounds() + x := (inlineThumbnailSide - bounds.Dx()) / 2 + y := (inlineThumbnailSide - bounds.Dy()) / 2 + target := image.Rect(x, y, x+bounds.Dx(), y+bounds.Dy()) + draw.Draw(canvas, target, source, bounds.Min, draw.Src) + return canvas +} + func imageSize(path string) (int, int, error) { file, err := os.Open(path) if err != nil { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 1d6de7a..aa91572 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "image" + "image/color" + "image/draw" _ "image/png" "os/exec" "strings" @@ -121,8 +123,8 @@ func TestRenderIntegration(t *testing.T) { if err != nil { t.Fatalf("decode thumbnail size: %v", err) } - if thumbWidth > inlineThumbnailSide || thumbHeight > inlineThumbnailSide { - t.Errorf("thumbnail is %dx%d, want each side <= %d", thumbWidth, thumbHeight, inlineThumbnailSide) + if thumbWidth != inlineThumbnailSide || thumbHeight != inlineThumbnailSide { + t.Errorf("thumbnail is %dx%d, want a %dx%d padded square", thumbWidth, thumbHeight, inlineThumbnailSide, inlineThumbnailSide) } linked, err := r.Render(ctx, "", `\hypertarget{details}{Details}\par\hyperlink{details}{Go to details}`, 150) @@ -134,6 +136,29 @@ func TestRenderIntegration(t *testing.T) { } } +func TestSquareThumbnailPadsWithoutStretching(t *testing.T) { + source := image.NewRGBA(image.Rect(0, 0, 160, 80)) + draw.Draw(source, source.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src) + + thumbnail := squareThumbnail(source) + if thumbnail.Bounds() != image.Rect(0, 0, inlineThumbnailSide, inlineThumbnailSide) { + t.Fatalf("thumbnail bounds = %v", thumbnail.Bounds()) + } + for _, sample := range []struct { + point image.Point + want color.RGBA + }{ + {image.Pt(160, 119), color.RGBA{255, 255, 255, 255}}, + {image.Pt(160, 120), color.RGBA{0, 0, 0, 255}}, + {image.Pt(79, 160), color.RGBA{255, 255, 255, 255}}, + {image.Pt(80, 160), color.RGBA{0, 0, 0, 255}}, + } { + if got := thumbnail.RGBAAt(sample.point.X, sample.point.Y); got != sample.want { + t.Errorf("pixel at %v = %v, want %v", sample.point, got, sample.want) + } + } +} + func pngSizeBytes(data []byte) (int, int, error) { return imageSizeBytes(data) }