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