Files
Aether/backend/internal/admin/appicon.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

143 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
}