From 776071185c84fc19c2ae2300506f6b685da108a7 Mon Sep 17 00:00:00 2001 From: jvoisin Date: Tue, 10 Dec 2024 00:08:13 +0100 Subject: [PATCH] Resize favicons before storing them Some websites are using images of O(10kB) when not )O(100kB) for their favicons. As miniflux only displays them with a 16x16 resolution, let's do our best to resize them before storing them in the database. This should make miniflux consume less bandwidth when serving pages, for the joy of mobile users on a small data plan. Of course, images that already are 16x16 aren't resized. --- go.mod | 1 + go.sum | 2 + internal/reader/icon/finder.go | 57 +++++++++++++++++++++++++++++ internal/reader/icon/finder_test.go | 54 +++++++++++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/go.mod b/go.mod index 575e4d8444a..ec3e858b7c7 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/prometheus/client_golang v1.20.5 github.com/tdewolff/minify/v2 v2.21.2 golang.org/x/crypto v0.31.0 + golang.org/x/image v0.23.0 golang.org/x/net v0.32.0 golang.org/x/oauth2 v0.24.0 golang.org/x/term v0.27.0 diff --git a/go.sum b/go.sum index 177cda60d83..f580e31203d 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= diff --git a/internal/reader/icon/finder.go b/internal/reader/icon/finder.go index 835a3a14114..e0efc90bd2e 100644 --- a/internal/reader/icon/finder.go +++ b/internal/reader/icon/finder.go @@ -4,12 +4,18 @@ package icon // import "miniflux.app/v2/internal/reader/icon" import ( + "bytes" "encoding/base64" "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" "io" "log/slog" "net/url" "regexp" + "slices" "strings" "miniflux.app/v2/internal/config" @@ -19,6 +25,7 @@ import ( "miniflux.app/v2/internal/urllib" "github.com/PuerkitoBio/goquery" + "golang.org/x/image/draw" "golang.org/x/net/html/charset" ) @@ -180,9 +187,59 @@ func (f *IconFinder) DownloadIcon(iconURL string) (*model.Icon, error) { Content: responseBody, } + icon = resizeIcon(icon) + return icon, nil } +func resizeIcon(icon *model.Icon) *model.Icon { + r := bytes.NewReader(icon.Content) + + if !slices.Contains([]string{"image/jpeg", "image/png", "image/gif"}, icon.MimeType) { + slog.Info("icon isn't a png/gif/jpeg/ico, can't resize", slog.String("mimetype", icon.MimeType)) + return icon + } + + // Don't resize icons that we can't decode, or that already have the right size. + config, _, err := image.DecodeConfig(r) + if err != nil { + slog.Warn("unable to decode the metadata of the icon", slog.Any("error", err)) + return icon + } + if config.Height <= 16 && config.Width <= 16 { + slog.Debug("icon don't need to be rescaled", slog.Int("height", config.Height), slog.Int("width", config.Width)) + return icon + } + + r.Seek(0, io.SeekStart) + + var src image.Image + switch icon.MimeType { + case "image/jpeg": + src, err = jpeg.Decode(r) + case "image/png": + src, err = png.Decode(r) + case "image/gif": + src, err = gif.Decode(r) + } + if err != nil { + slog.Warn("unable to decode the icon", slog.Any("error", err)) + return icon + } + + dst := image.NewRGBA(image.Rect(0, 0, 16, 16)) + draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil) + + var b bytes.Buffer + if err = png.Encode(io.Writer(&b), dst); err != nil { + slog.Warn("unable to encode the new icon", slog.Any("error", err)) + } + + icon.Content = b.Bytes() + icon.MimeType = "image/png" + return icon +} + func findIconURLsFromHTMLDocument(body io.Reader, contentType string) ([]string, error) { queries := []string{ "link[rel='icon' i]", diff --git a/internal/reader/icon/finder_test.go b/internal/reader/icon/finder_test.go index 9bb71126971..1cd632afe7f 100644 --- a/internal/reader/icon/finder_test.go +++ b/internal/reader/icon/finder_test.go @@ -4,8 +4,13 @@ package icon // import "miniflux.app/v2/internal/reader/icon" import ( + "bytes" + "encoding/base64" + "image" "strings" "testing" + + "miniflux.app/v2/internal/model" ) func TestParseImageDataURL(t *testing.T) { @@ -125,3 +130,52 @@ func TestParseDocumentWithWhitespaceIconURL(t *testing.T) { t.Errorf(`Invalid icon URL, got %q`, iconURLs[0]) } } + +func TestResizeIconSmallGif(t *testing.T) { + data, err := base64.StdEncoding.DecodeString("R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==") + if err != nil { + t.Fatal(err) + } + icon := model.Icon{ + Content: data, + MimeType: "image/gif", + } + if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) { + t.Fatalf("Converted gif smaller than 16x16") + } +} + +func TestResizeIconPng(t *testing.T) { + data, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAABEAAAARCAYAAAA7bUf6AAAAHElEQVR42mP8z/C/noFCwDhqyKgho4aMGkIlQwBrHSpf28Yx+gAAAABJRU5ErkJggg==") + if err != nil { + t.Fatal(err) + } + icon := model.Icon{ + Content: data, + MimeType: "image/png", + } + resizedIcon := resizeIcon(&icon) + + if bytes.Equal(data, resizedIcon.Content) { + t.Fatalf("Didn't convert png of 17x17") + } + + config, _, err := image.DecodeConfig(bytes.NewReader(resizedIcon.Content)) + if err != nil { + t.Fatalf("Couln't decode resulting png: %v", err) + } + + if config.Height != 16 || config.Width != 16 { + t.Fatalf("Was expecting an image of 16x16, got %dx%d", config.Width, config.Height) + } +} + +func TestResizeInvalidImage(t *testing.T) { + icon := model.Icon{ + Content: []byte("invalid data"), + MimeType: "image/gif", + } + if !bytes.Equal(icon.Content, resizeIcon(&icon).Content) { + t.Fatalf("Tried to convert an invalid image") + } +}