Pad inline thumbnails without stretching
This commit is contained in:
@@ -39,7 +39,7 @@ const (
|
|||||||
|
|
||||||
// Included in content hashes so renderer/template changes never reuse
|
// Included in content hashes so renderer/template changes never reuse
|
||||||
// stale Telegram file_ids or S3 objects from an older deployment.
|
// 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.
|
// outputFormat is one of the three generation formats.
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
_ "image/jpeg"
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"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
|
// renderThumbnail creates the dedicated small JPEG used only by Telegram's
|
||||||
// inline-result picker. Reusing the full-resolution photo URL as its thumbnail
|
// inline-result picker. It centers the aspect-correct preview on a square
|
||||||
// can leave clients displaying a 320-pixel partial decode over the full photo
|
// canvas because some clients force inline thumbnails into a square slot.
|
||||||
// canvas even though the downloaded media itself is intact.
|
// 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 {
|
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{
|
args := []string{
|
||||||
"-jpeg", "-jpegopt", "quality=82,progressive=n,optimize=y",
|
"-jpeg", "-jpegopt", "quality=82,progressive=n,optimize=y",
|
||||||
"-r", strconv.Itoa(dpi), "-singlefile", "-f", "1", "-l", "1",
|
"-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 {
|
if err := r.run(ctx, workdir, r.Pdftoppm, args...); err != nil {
|
||||||
return fmt.Errorf("render inline thumbnail: %w", err)
|
return fmt.Errorf("render inline thumbnail: %w", err)
|
||||||
}
|
}
|
||||||
width, height, err := imageSize(outPath)
|
width, height, err := imageSize(sourcePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("inspect inline thumbnail: %w", err)
|
return fmt.Errorf("inspect inline thumbnail: %w", err)
|
||||||
}
|
}
|
||||||
if width > inlineThumbnailSide || height > inlineThumbnailSide {
|
if width > inlineThumbnailSide || height > inlineThumbnailSide {
|
||||||
return fmt.Errorf("inline thumbnail is unexpectedly large: %dx%d", width, height)
|
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
|
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) {
|
func imageSize(path string) (int, int, error) {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -121,8 +123,8 @@ func TestRenderIntegration(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("decode thumbnail size: %v", err)
|
t.Fatalf("decode thumbnail size: %v", err)
|
||||||
}
|
}
|
||||||
if thumbWidth > inlineThumbnailSide || thumbHeight > inlineThumbnailSide {
|
if thumbWidth != inlineThumbnailSide || thumbHeight != inlineThumbnailSide {
|
||||||
t.Errorf("thumbnail is %dx%d, want each side <= %d", thumbWidth, 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)
|
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) {
|
func pngSizeBytes(data []byte) (int, int, error) {
|
||||||
return imageSizeBytes(data)
|
return imageSizeBytes(data)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user