Gleicher Dateiname erzeugt keine neue Datei, sondern eine neue Fassung - gleiche ID, gleiche Vorschaubild-Adresse. Die darf einen Tag im Browser liegen, also zeigte die Kachel nach dem erneuten Hochladen bis zu 24 Stunden das alte Bild. Das erklaert, warum eine Drehung erst nach mehreren Versuchen "ankam". Die Adresse traegt jetzt ?v=<Fassung>. Vorschaubilder entstehen im Hintergrund; die Kachel fragt jetzt bis zu fuenfmal nach, statt bis zum naechsten Neuladen ihr Ersatzsymbol zu zeigen. Nur bei Bildern juenger als zwei Minuten - sonst fragte jede dauerhaft fehlgeschlagene Datei bei jedem Oeffnen erneut. Gemessen: 608 ms bis zur Anzeige, ohne Neuladen. Das Anheben der Kachel beim Ueberfahren ist raus: Steht der Zeiger auf der Unterkante, rutscht sie unter ihm weg, mouseleave, sie faellt zurueck, mouseenter - im Kreis, sichtbar als Flackern. Dazu liest die EXIF-Auswertung jetzt 256 statt 64 KB und gibt bei einem abgeschnittenen Block nicht mehr auf: Die Drehung steht vorn, das eingebettete Vorschaubild dahinter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
4.9 KiB
Go
165 lines
4.9 KiB
Go
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")
|
||
}
|
||
}
|
||
|
||
// Telefone legen ihr eigenes Vorschaubild in den EXIF-Block; der wird dadurch
|
||
// zehntausende Byte groß. Reicht der gelesene Anfang nicht bis zum Ende des
|
||
// Blocks, muss die Drehung trotzdem gefunden werden – sie steht vorn.
|
||
func TestOrientationBeiAbgeschnittenemBlock(t *testing.T) {
|
||
voll := jpegMitDrehung(t, 6, false)
|
||
// Absichtlich hinter dem Verzeichnis abschneiden, aber vor dem Dateiende.
|
||
for _, laenge := range []int{40, 60, 80} {
|
||
if laenge > len(voll) {
|
||
continue
|
||
}
|
||
if got := Orientation(voll[:laenge]); got != 6 {
|
||
t.Errorf("bei %d Byte Kopf: Drehung %d statt 6", laenge, got)
|
||
}
|
||
}
|
||
}
|