Files
Aether/backend/internal/imageutil/orientation.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

145 lines
3.9 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 enthält, was mehrere Bildpfade teilen: heute die
// EXIF-Drehung.
package imageutil
import (
"encoding/binary"
"image"
)
// HeadBytes ist die Menge, die zum Finden der Drehung gelesen werden muss.
// Der EXIF-Block steht unmittelbar hinter dem Dateianfang; 64 KB decken auch
// Kameras ab, die ein Vorschaubild mit hineinlegen.
const HeadBytes = 64 << 10
/*
Orientation liest die EXIF-Drehung (1–8) aus dem Anfang einer JPEG-Datei.
Alles Unklare ergibt 1, also „nicht drehen" – ein unlesbarer Block ist kein
Grund, ein Bild zu verweigern.
**Warum von Hand statt mit einer Bibliothek:** Gebraucht wird genau ein Feld.
Der Weg dorthin ist kurz und liegt fest: JPEG-Segmente durchgehen bis APP1,
dort "Exif\0\0", dahinter ein TIFF-Kopf, darin der Verzeichniseintrag mit der
Kennung 0x0112.
**Warum es überhaupt gebraucht wird:** Kameras und Telefone speichern die
Bildpunkte fast immer quer und legen die Drehung als Notiz daneben. Browser
befolgen sie von selbst – Go nicht. Ohne diese Auswertung liegt jedes hochkant
aufgenommene Foto im Vorschaubild auf der Seite, während dasselbe Foto in der
großen Ansicht aufrecht steht.
*/
func Orientation(head []byte) int {
if len(head) < 4 || head[0] != 0xFF || head[1] != 0xD8 { // SOI
return 1
}
i := 2
for i+4 <= len(head) {
if head[i] != 0xFF {
return 1 // kein Segmentanfang mehr – hier ist nichts zu holen
}
marker := head[i+1]
// Bildinhalt erreicht: ab hier kommt kein EXIF mehr.
if marker == 0xDA || marker == 0xD9 {
return 1
}
size := int(binary.BigEndian.Uint16(head[i+2 : i+4]))
if size < 2 || i+2+size > len(head) {
return 1
}
if marker == 0xE1 {
if o, ok := orientationInAPP1(head[i+4 : i+2+size]); ok {
return o
}
}
i += 2 + size
}
return 1
}
func orientationInAPP1(seg []byte) (int, bool) {
const hdr = "Exif\x00\x00"
if len(seg) < len(hdr)+8 || string(seg[:len(hdr)]) != hdr {
return 0, false
}
tiff := seg[len(hdr):]
var bo binary.ByteOrder
switch {
case tiff[0] == 'I' && tiff[1] == 'I':
bo = binary.LittleEndian
case tiff[0] == 'M' && tiff[1] == 'M':
bo = binary.BigEndian
default:
return 0, false
}
if bo.Uint16(tiff[2:4]) != 42 {
return 0, false
}
off := int(bo.Uint32(tiff[4:8]))
if off < 8 || off+2 > len(tiff) {
return 0, false
}
count := int(bo.Uint16(tiff[off : off+2]))
entry := off + 2
for n := 0; n < count; n++ {
if entry+12 > len(tiff) {
return 0, false
}
if bo.Uint16(tiff[entry:entry+2]) == 0x0112 {
// Der Wert ist ein SHORT und steht deshalb im Eintrag selbst,
// nicht anderswo in der Datei.
v := int(bo.Uint16(tiff[entry+8 : entry+10]))
if v >= 1 && v <= 8 {
return v, true
}
return 0, false
}
entry += 12
}
return 0, false
}
// Apply dreht und spiegelt ein Bild so, wie die EXIF-Notiz es vorsieht.
// Bei 1 (und allem Unbekannten) kommt das Bild unverändert zurück – dann
// entsteht auch keine Kopie.
func Apply(img image.Image, orientation int) image.Image {
if orientation <= 1 || orientation > 8 {
return img
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
// Die vier Fälle ab 5 vertauschen Breite und Höhe.
outW, outH := w, h
if orientation >= 5 {
outW, outH = h, w
}
dst := image.NewRGBA(image.Rect(0, 0, outW, outH))
// Für jeden Zielpunkt den Quellpunkt bestimmen. Andersherum (Quelle →
// Ziel) bliebe bei ungeraden Größen gelegentlich eine Lücke.
for y := 0; y < outH; y++ {
for x := 0; x < outW; x++ {
var sx, sy int
switch orientation {
case 2: // waagerecht gespiegelt
sx, sy = w-1-x, y
case 3: // 180 Grad
sx, sy = w-1-x, h-1-y
case 4: // senkrecht gespiegelt
sx, sy = x, h-1-y
case 5: // an der Hauptdiagonalen gespiegelt
sx, sy = y, x
case 6: // 90 Grad im Uhrzeigersinn
sx, sy = y, h-1-x
case 7: // an der Nebendiagonalen gespiegelt
sx, sy = w-1-y, h-1-x
case 8: // 90 Grad gegen den Uhrzeigersinn
sx, sy = w-1-y, x
}
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
}
}
return dst
}