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>
160 lines
4.4 KiB
Go
160 lines
4.4 KiB
Go
// 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.
|
||
//
|
||
// 256 KB, nicht 64: Ein APP1-Segment darf selbst schon 65 535 Byte groß sein –
|
||
// Telefone legen ihr eigenes Vorschaubild dort hinein –, und davor können
|
||
// weitere Segmente stehen. Bei 64 KB reichte der Puffer im Grenzfall nicht
|
||
// einmal für das eine Segment.
|
||
const HeadBytes = 256 << 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 {
|
||
return 1
|
||
}
|
||
end := i + 2 + size
|
||
truncated := end > len(head)
|
||
if truncated {
|
||
// Der Puffer endet mitten im Segment. Trotzdem nachsehen: Das
|
||
// Verzeichnis mit der Drehung steht ganz am Anfang des Blocks,
|
||
// das eingebettete Vorschaubild dahinter. Aufzugeben hieße, wegen
|
||
// eines Bildes im Bild die Drehung zu verlieren.
|
||
end = len(head)
|
||
}
|
||
if marker == 0xE1 && i+4 < end {
|
||
if o, ok := orientationInAPP1(head[i+4 : end]); ok {
|
||
return o
|
||
}
|
||
}
|
||
if truncated {
|
||
return 1
|
||
}
|
||
i = end
|
||
}
|
||
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
|
||
}
|