v0.32.0 0617278

converter.go RawHistoryFolder

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 nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss)
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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close