v0.30.0 eb3068f

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
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}

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