package admin import ( "bytes" "fmt" "image" "image/color" _ "image/gif" _ "image/jpeg" "image/png" "io" "golang.org/x/image/draw" _ "golang.org/x/image/webp" // nur der Dekodierer – x/image ist schon da ) // Aus einem einzigen hochgeladenen Bild entstehen alle Größen, die Browser und // Android brauchen. Zwei Bilder zu pflegen (Tab-Symbol und App-Symbol) war der // Grund, es zusammenzulegen. const ( // Chrome verweigert die Installation, wenn im Manifest kein Symbol von // mindestens dieser Kantenlänge steht. minIconSide = 192 // Der maskable-Bereich, den Android garantiert nicht wegschneidet: 80 % in // der Mitte. Der Rest verschwindet je nach Gerät im Kreis oder Squircle. maskableSafeZone = 0.8 ) // iconVariant beschreibt eine der erzeugten Dateien. type iconVariant struct { // name ist der Dateiname im Symbol-Verzeichnis, ohne Endung. name string side int // maskable legt das Motiv verkleinert auf einen farbigen Grund. maskable bool } // renderAppIcon erzeugt aus der Vorlage die PNG-Dateien des Symbol-Satzes. // Der Schlüssel der Rückgabe ist der Dateiname inklusive Endung. // // Hochskaliert wird nie: Aus einer 256er-Vorlage ein 512er zu rechnen ergibt // ein matschiges Symbol, das schlechter aussieht als gar keins. Fehlt eine // Größe, nennt das Manifest sie einfach nicht. func renderAppIcon(src io.Reader, bg color.Color) (map[string][]byte, error) { img, _, err := image.Decode(src) if err != nil { return nil, fmt.Errorf("decode image: %w", err) } // Nicht quadratische Vorlagen werden gerahmt, nicht beschnitten – ein // Zuschnitt würde ein Logo anschneiden, und das fällt erst auf, wenn das // Symbol schon überall klebt. square := squareCanvas(img) side := square.Bounds().Dx() if side < minIconSide { return nil, fmt.Errorf("image must be at least %dx%d pixels, got %dx%d", minIconSide, minIconSide, img.Bounds().Dx(), img.Bounds().Dy()) } variants := []iconVariant{ // 64 statt 32 fürs Tab: Auf feinen Bildschirmen wird das Symbol mit // doppelter Pixeldichte gezeichnet. {name: "64", side: 64}, {name: "192", side: 192}, } if side >= 512 { variants = append(variants, iconVariant{name: "512", side: 512}) } maskSide := 192 if side >= 512 { maskSide = 512 } variants = append(variants, iconVariant{name: "maskable", side: maskSide, maskable: true}) out := make(map[string][]byte, len(variants)) for _, v := range variants { buf, err := encodeVariant(square, v, bg) if err != nil { return nil, fmt.Errorf("render %s: %w", v.name, err) } out[v.name+".png"] = buf } return out, nil } func encodeVariant(square image.Image, v iconVariant, bg color.Color) ([]byte, error) { dst := image.NewRGBA(image.Rect(0, 0, v.side, v.side)) target := dst.Bounds() if v.maskable { // Durchsichtig geht hier nicht: Android legt sonst eine weiße Kachel // unter das Symbol, die im dunklen App-Menü leuchtet. draw.Draw(dst, dst.Bounds(), image.NewUniform(bg), image.Point{}, draw.Src) inset := int(float64(v.side) * (1 - maskableSafeZone) / 2) target = image.Rect(inset, inset, v.side-inset, v.side-inset) } // CatmullRom statt des BiLinear aus internal/thumbnails: Vorschaubilder // dürfen weich sein, ein Symbol soll scharfe Kanten behalten. draw.CatmullRom.Scale(dst, target, square, square.Bounds(), draw.Over, nil) var buf bytes.Buffer // PNG, damit Transparenz erhalten bleibt – ein Symbol mit weißem Kasten // drumherum sieht auf jedem farbigen Hintergrund falsch aus. if err := png.Encode(&buf, dst); err != nil { return nil, err } return buf.Bytes(), nil } // squareCanvas legt das Bild mittig auf eine quadratische, durchsichtige // Fläche. Ist es schon quadratisch, bleibt es unverändert. func squareCanvas(img image.Image) image.Image { b := img.Bounds() if b.Dx() == b.Dy() { return img } side := b.Dx() if b.Dy() > side { side = b.Dy() } dst := image.NewRGBA(image.Rect(0, 0, side, side)) draw.Draw(dst, image.Rect( (side-b.Dx())/2, (side-b.Dy())/2, (side-b.Dx())/2+b.Dx(), (side-b.Dy())/2+b.Dy(), ), img, b.Min, draw.Src) return dst } // parseHexColor liest "#rrggbb". Bei allem anderen kommt die Ersatzfarbe // zurück – eine kaputte Einstellung soll keinen Upload scheitern lassen. func parseHexColor(s string, fallback color.RGBA) color.RGBA { if len(s) != 7 || s[0] != '#' { return fallback } var r, g, b uint8 if _, err := fmt.Sscanf(s[1:], "%02x%02x%02x", &r, &g, &b); err != nil { return fallback } return color.RGBA{R: r, G: g, B: b, A: 255} }