Files
Aether/backend/internal/imageutil/orientation_test.go
T
marcandClaude Opus 5 4bd9a6ca56 fix(thumbnails): EXIF-Drehung auswerten, hochkant blieb quer
Kameras speichern die Bildpunkte fast immer quer und legen die Drehung
als EXIF-Notiz daneben. Browser befolgen sie von selbst - Go nicht.
Deshalb stand dasselbe Foto in der grossen Ansicht aufrecht und lag im
Vorschaubild auf der Seite.

Neues Paket internal/imageutil liest genau ein Feld (0x0112) aus dem
Anfang der Datei und dreht danach. Von Hand statt mit einer Bibliothek:
Der Weg dorthin ist kurz und liegt fest, und gebraucht wird ein Feld.
Gelesen werden nur die ersten 64 KB, damit ein 40-MB-Foto nicht zweimal
im Speicher liegt.

Gedreht wird vor dem Verkleinern - andersherum bekaeme ein hochkantes
Foto den Zuschnitt eines Querformats.

Bestehende Vorschaubilder aendern sich davon nicht. Dafuer neu:
POST /admin/thumbnails/rebuild samt Knopf im Admin-Bereich.

Auch Profilbilder aus OIDC laufen jetzt durch dieselbe Auswertung.

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

149 lines
4.3 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 imageutil
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/jpeg"
"testing"
)
// jpegMitDrehung baut eine echte JPEG-Datei mit EXIF-Block. Von Hand, damit
// der Test keine Abhängigkeit braucht und genau das prüft, was Kameras
// schreiben: APP1 direkt hinter dem Dateianfang.
func jpegMitDrehung(t *testing.T, o uint16, big bool) []byte {
t.Helper()
var body bytes.Buffer
if err := jpeg.Encode(&body, image.NewGray(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatal(err)
}
raw := body.Bytes() // beginnt mit FFD8
var bo binary.AppendByteOrder = binary.LittleEndian
order := []byte{'I', 'I'}
if big {
bo = binary.BigEndian
order = []byte{'M', 'M'}
}
tif := make([]byte, 0, 26)
tif = append(tif, order...)
tif = bo.AppendUint16(tif, 42)
tif = bo.AppendUint32(tif, 8) // Verzeichnis beginnt direkt dahinter
tif = bo.AppendUint16(tif, 1) // ein Eintrag
tif = bo.AppendUint16(tif, 0x0112)
tif = bo.AppendUint16(tif, 3) // SHORT
tif = bo.AppendUint32(tif, 1) // ein Wert
tif = bo.AppendUint16(tif, o)
tif = append(tif, 0, 0) // Auffüllung auf vier Byte
tif = bo.AppendUint32(tif, 0) // kein weiteres Verzeichnis
payload := append([]byte("Exif\x00\x00"), tif...)
seg := []byte{0xFF, 0xE1}
seg = binary.BigEndian.AppendUint16(seg, uint16(len(payload)+2))
seg = append(seg, payload...)
out := append([]byte{}, raw[:2]...) // SOI
out = append(out, seg...)
return append(out, raw[2:]...)
}
func TestOrientationLiestAlleFaelle(t *testing.T) {
for o := uint16(1); o <= 8; o++ {
for _, big := range []bool{false, true} {
got := Orientation(jpegMitDrehung(t, o, big))
if got != int(o) {
name := "II"
if big {
name = "MM"
}
t.Errorf("%s Drehung %d gelesen als %d", name, o, got)
}
}
}
}
// Was keine Drehung trägt oder kaputt ist, muss 1 ergeben – niemals einen
// Fehler und niemals einen Zufallswert.
func TestOrientationBleibtBeiEinsWennUnklar(t *testing.T) {
var ohne bytes.Buffer
if err := jpeg.Encode(&ohne, image.NewGray(image.Rect(0, 0, 4, 4)), nil); err != nil {
t.Fatal(err)
}
mitDrehung := jpegMitDrehung(t, 6, false)
faelle := map[string][]byte{
"leer": {},
"kein JPEG": []byte("das hier ist ein Text"),
"nur SOI": {0xFF, 0xD8},
"ohne EXIF": ohne.Bytes(),
"abgeschnitten": mitDrehung[:12],
"Laenge zu gross": append([]byte{0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF}, []byte("Exif\x00\x00")...),
"Drehung 0": jpegMitDrehung(t, 0, false),
"Drehung 99": jpegMitDrehung(t, 99, false),
}
for name, data := range faelle {
if got := Orientation(data); got != 1 {
t.Errorf("%s ergab %d statt 1", name, got)
}
}
}
// Ein Bild mit einer eindeutigen Ecke: Nur so lässt sich prüfen, dass nicht
// bloß Breite und Höhe getauscht wurden, sondern richtig herum gedreht.
func markiert(w, h int) image.Image {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{0, 0, 0, 255})
}
}
img.Set(0, 0, color.RGBA{255, 0, 0, 255}) // oben links rot
return img
}
func TestApplyDrehtRichtigHerum(t *testing.T) {
const w, h = 4, 2
src := markiert(w, h)
// Wo landet der rote Punkt von oben links?
erwartet := map[int][2]int{
1: {0, 0},
2: {w - 1, 0}, // waagerecht gespiegelt
3: {w - 1, h - 1}, // 180 Grad
4: {0, h - 1}, // senkrecht gespiegelt
5: {0, 0}, // Hauptdiagonale
6: {h - 1, 0}, // 90 Grad im Uhrzeigersinn: links oben → rechts oben
7: {h - 1, w - 1},
8: {0, w - 1}, // 90 Grad dagegen: links oben → links unten
}
for o, want := range erwartet {
out := Apply(src, o)
b := out.Bounds()
wantW, wantH := w, h
if o >= 5 {
wantW, wantH = h, w
}
if b.Dx() != wantW || b.Dy() != wantH {
t.Errorf("Drehung %d: %dx%d, erwartet %dx%d", o, b.Dx(), b.Dy(), wantW, wantH)
continue
}
r, _, _, _ := out.At(want[0], want[1]).RGBA()
if r < 0x8000 {
t.Errorf("Drehung %d: an %v steht kein roter Punkt", o, want)
}
}
}
// Bei 1 darf kein neues Bild entstehen – das spart bei jedem Vorschaubild
// eine vollständige Kopie.
func TestApplyLaesstEinsUnberuehrt(t *testing.T) {
src := markiert(3, 3)
if Apply(src, 1) != src {
t.Error("Drehung 1 hat kopiert")
}
if Apply(src, 0) != src || Apply(src, 9) != src {
t.Error("unbekannte Drehung hat kopiert")
}
}