1// Package favicon provides favicon fetching and caching infrastructure.
2package favicon
3
4import (
5 "fmt"
6 "image"
7 "image/png"
8 "os"
9
10 "golang.org/x/image/draw"
11)
12
13const (
14 // NormalizedIconSize is the standard size for dmenu/fuzzel icons.
15 NormalizedIconSize = 32
16)
17
18// ResizePNG loads a PNG from srcPath, resizes it to a square of the given size,
19// and saves the result to dstPath. Uses golang.org/x/image/draw for high-quality
20// resizing with CatmullRom interpolation.
21func ResizePNG(srcPath, dstPath string, size int) error {
22 // Open source file
23 srcFile, err := os.Open(srcPath)
24 if err != nil {
25 return fmt.Errorf("open source: %w", err)
26 }
27 defer func() { _ = srcFile.Close() }()
28
29 // Decode PNG
30 srcImg, err := png.Decode(srcFile)
31 if err != nil {
32 return fmt.Errorf("decode png: %w", err)
33 }
34
35 // Calculate crop region to maintain aspect ratio (center crop to square)
36 srcBounds := srcImg.Bounds()
37 srcW := srcBounds.Dx()
38 srcH := srcBounds.Dy()
39
40 var cropRect image.Rectangle
41 if srcW > srcH {
42 // Wider than tall - crop sides
43 offset := (srcW - srcH) / 2
44 cropRect = image.Rect(srcBounds.Min.X+offset, srcBounds.Min.Y, srcBounds.Min.X+offset+srcH, srcBounds.Max.Y)
45 } else if srcH > srcW {
46 // Taller than wide - crop top/bottom
47 offset := (srcH - srcW) / 2
48 cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+offset, srcBounds.Max.X, srcBounds.Min.Y+offset+srcW)
49 } else {
50 // Already square
51 cropRect = srcBounds
52 }
53
54 // Create cropped subimage
55 croppedImg := cropImage(srcImg, cropRect)
56
57 // Create destination image
58 dstImg := image.NewRGBA(image.Rect(0, 0, size, size))
59
60 // Scale using CatmullRom for high-quality interpolation
61 draw.CatmullRom.Scale(dstImg, dstImg.Bounds(), croppedImg, croppedImg.Bounds(), draw.Over, nil)
62
63 // Create destination file
64 dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, diskCacheFilePerm)
65 if err != nil {
66 return fmt.Errorf("create destination: %w", err)
67 }
68 defer func() { _ = dstFile.Close() }()
69
70 // Encode as PNG
71 if err := png.Encode(dstFile, dstImg); err != nil {
72 return fmt.Errorf("encode png: %w", err)
73 }
74
75 return nil
76}
77
78// cropImage returns a cropped portion of the source image.
79func cropImage(src image.Image, rect image.Rectangle) image.Image {
80 // If the source supports SubImage, use it for efficiency
81 if subImager, ok := src.(interface {
82 SubImage(r image.Rectangle) image.Image
83 }); ok {
84 return subImager.SubImage(rect)
85 }
86
87 // Otherwise, copy pixels manually
88 dst := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
89 for y := 0; y < rect.Dy(); y++ {
90 for x := 0; x < rect.Dx(); x++ {
91 dst.Set(x, y, src.At(rect.Min.X+x, rect.Min.Y+y))
92 }
93 }
94 return dst
95}