1package favicon
2
3import (
4 "bytes"
5 "context"
6 "encoding/binary"
7 "fmt"
8 "image"
9 "image/color"
10 "image/png"
11 "mime"
12 "strings"
13
14 appport "github.com/bnema/dumber/internal/application/port"
15 "golang.org/x/image/draw"
16)
17
18// ImageConverter converts safe favicon image formats into normalized PNGs.
19type ImageConverter struct {
20 webp *webPDecoder
21}
22
23func NewImageConverter() *ImageConverter { return &ImageConverter{webp: newDefaultWebPDecoder()} }
24
25// newImageConverterWithWebPRuntime provides an infrastructure test seam while
26// application ports remain independent of the libwebp implementation.
27func newImageConverterWithWebPRuntime(runtime webpRuntime) *ImageConverter {
28 return &ImageConverter{webp: newWebPDecoder(runtime)}
29}
30
31func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {
32 _ = ctx
33 if len(original) == 0 {
34 return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss)
35 }
36
37 img, err := decodeFaviconImage(original, normalizeContentType(contentType), c.webp)
38 if err != nil {
39 return nil, err
40 }
41 pngBytes, err := encodePNG(img)
42 if err != nil {
43 return nil, err
44 }
45 out := &appport.ConvertedFavicon{PNG: pngBytes, SizedPNG: make(map[int][]byte)}
46 for _, size := range sizes {
47 if size <= 0 {
48 continue
49 }
50 resized, err := resizeToPNG(img, size)
51 if err != nil {
52 return nil, err
53 }
54 out.SizedPNG[size] = resized
55 }
56 return out, nil
57}
58
59func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) {
60 switch ct {
61 case "image/svg+xml":
62 return rasterizeSVG(data)
63 case "image/x-icon", "image/vnd.microsoft.icon", "image/ico":
64 img, err := decodeICO(data)
65 if err != nil {
66 return nil, err
67 }
68 return img, nil
69 case "image/webp":
70 img, err := webp.Decode(data)
71 if err != nil {
72 return nil, fmt.Errorf("%w: decode webp favicon: %w", appport.ErrFaviconMiss, err)
73 }
74 return img, nil
75 }
76 if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" {
77 return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct)
78 }
79 img, _, err := image.Decode(bytes.NewReader(data))
80 if err != nil {
81 return nil, fmt.Errorf("%w: decode favicon: %w", appport.ErrFaviconMiss, err)
82 }
83 return img, nil
84}
85
86func normalizeContentType(contentType string) string {
87 ct, _, err := mime.ParseMediaType(strings.TrimSpace(strings.ToLower(contentType)))
88 if err == nil {
89 return ct
90 }
91 return strings.TrimSpace(strings.ToLower(contentType))
92}
93
94func encodePNG(img image.Image) ([]byte, error) {
95 var buf bytes.Buffer
96 if err := png.Encode(&buf, img); err != nil {
97 return nil, err
98 }
99 return buf.Bytes(), nil
100}
101
102func resizeToPNG(src image.Image, size int) ([]byte, error) {
103 crop := cropImage(src, centerSquare(src.Bounds()))
104 dst := image.NewRGBA(image.Rect(0, 0, size, size))
105 draw.CatmullRom.Scale(dst, dst.Bounds(), crop, crop.Bounds(), draw.Over, nil)
106 return encodePNG(dst)
107}
108
109func centerSquare(bounds image.Rectangle) image.Rectangle {
110 w, h := bounds.Dx(), bounds.Dy()
111 if w > h {
112 o := (w - h) / 2
113 return image.Rect(bounds.Min.X+o, bounds.Min.Y, bounds.Min.X+o+h, bounds.Max.Y)
114 }
115 if h > w {
116 o := (h - w) / 2
117 return image.Rect(bounds.Min.X, bounds.Min.Y+o, bounds.Max.X, bounds.Min.Y+o+w)
118 }
119 return bounds
120}
121
122func decodeICO(data []byte) (image.Image, error) {
123 if len(data) < 6 || binary.LittleEndian.Uint16(data[0:2]) != 0 || binary.LittleEndian.Uint16(data[2:4]) != 1 {
124 return nil, fmt.Errorf("%w: malformed ico header", appport.ErrFaviconMiss)
125 }
126 count := int(binary.LittleEndian.Uint16(data[4:6]))
127 if count <= 0 || len(data) < 6+count*16 {
128 return nil, fmt.Errorf("%w: malformed ico directory", appport.ErrFaviconMiss)
129 }
130 var best []byte
131 bestBitCount := 0
132 bestArea := -1
133 for i := range count {
134 entry := data[6+i*16 : 6+(i+1)*16]
135 w, h := int(entry[0]), int(entry[1])
136 if w == 0 {
137 w = 256
138 }
139 if h == 0 {
140 h = 256
141 }
142 size := int(binary.LittleEndian.Uint32(entry[8:12]))
143 offset := int(binary.LittleEndian.Uint32(entry[12:16]))
144 if size <= 0 || offset < 0 || offset+size > len(data) {
145 continue
146 }
147 payload := data[offset : offset+size]
148 area := w * h
149 if area > bestArea {
150 bestArea = area
151 best = payload
152 bestBitCount = int(binary.LittleEndian.Uint16(entry[6:8]))
153 }
154 }
155 if len(best) == 0 {
156 return nil, fmt.Errorf("%w: ico contains no image", appport.ErrFaviconMiss)
157 }
158 if bytes.HasPrefix(best, []byte("\x89PNG\r\n\x1a\n")) {
159 img, err := png.Decode(bytes.NewReader(best))
160 if err != nil {
161 return nil, fmt.Errorf("%w: decode ico png: %w", appport.ErrFaviconMiss, err)
162 }
163 return img, nil
164 }
165 img, err := decodeICODIB(best, bestBitCount)
166 if err != nil {
167 return nil, fmt.Errorf("%w: decode ico bitmap: %w", appport.ErrFaviconMiss, err)
168 }
169 return img, nil
170}
171
172func decodeICODIB(data []byte, bitCountHint int) (image.Image, error) {
173 const dibHeaderSize = 40
174 if len(data) < dibHeaderSize {
175 return nil, fmt.Errorf("malformed dib header")
176 }
177 headerSize := int(binary.LittleEndian.Uint32(data[0:4]))
178 if headerSize < dibHeaderSize || len(data) < headerSize {
179 return nil, fmt.Errorf("unsupported dib header")
180 }
181 width := int(int32(binary.LittleEndian.Uint32(data[4:8])))
182 heightAll := int(int32(binary.LittleEndian.Uint32(data[8:12])))
183 planes := binary.LittleEndian.Uint16(data[12:14])
184 bitCount := int(binary.LittleEndian.Uint16(data[14:16]))
185 compression := binary.LittleEndian.Uint32(data[16:20])
186 if bitCount == 0 {
187 bitCount = bitCountHint
188 }
189 if width <= 0 || heightAll <= 0 || planes != 1 || compression != 0 {
190 return nil, fmt.Errorf("unsupported dib metadata")
191 }
192 height := heightAll / 2
193 if height <= 0 {
194 return nil, fmt.Errorf("invalid dib height")
195 }
196 bytesPerPixel := bitCount / 8
197 if bitCount != 24 && bitCount != 32 {
198 return nil, fmt.Errorf("unsupported dib bit depth %d", bitCount)
199 }
200 stride := ((width*bitCount + 31) / 32) * 4
201 pixelOffset := headerSize
202 if len(data) < pixelOffset+stride*height {
203 return nil, fmt.Errorf("truncated dib pixels")
204 }
205 img := image.NewNRGBA(image.Rect(0, 0, width, height))
206 const opaqueAlpha = 255
207 for y := range height {
208 srcY := height - 1 - y
209 row := data[pixelOffset+srcY*stride:]
210 for x := range width {
211 i := x * bytesPerPixel
212 b, g, r := row[i], row[i+1], row[i+2]
213 a := byte(opaqueAlpha)
214 if bitCount == 32 {
215 a = row[i+3]
216 }
217 img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a})
218 }
219 }
220 return img, nil
221}