hsa-app/internal/web/orientation.go

114 lines
3.4 KiB
Go
Raw Normal View History

package web
import (
"bytes"
"encoding/binary"
"github.com/disintegration/imaging"
)
// normalizeOrientation returns image bytes that are physically upright.
//
// Phone cameras store a photo in the sensor's native orientation plus an EXIF
// "Orientation" tag telling viewers to rotate it. We bake that rotation into the
// pixels (and drop the tag) so the stored receipt is upright in every consumer —
// the browser, downloads, the AI classifier, a future PDF export — not just ones
// that happen to honor EXIF.
//
// It only acts when the orientation is actually KNOWN: a JPEG carrying an EXIF
// Orientation tag of 2..8. With no tag (the common case — there's no way to know
// which way is up) or an already-upright tag of 1, the original bytes are returned
// verbatim, so we neither guess nor needlessly recompress. PDFs and non-JPEG
// images pass through untouched, as does anything that fails to decode.
func normalizeOrientation(data []byte, mime string) []byte {
if mime != "image/jpeg" {
return data
}
switch exifOrientation(data) {
case 0, 1:
return data // no metadata to act on, or already upright
}
// imaging.AutoOrientation reads the EXIF tag and rotates/flips to upright.
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return data // undecodable — keep the original rather than lose it
}
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(90)); err != nil {
return data
}
return buf.Bytes()
}
// exifOrientation returns the EXIF Orientation tag (1..8) of a JPEG, or 0 when the
// image has no such tag. It is a minimal, allocation-free scan: it walks the JPEG
// segment markers to the APP1 (Exif) block and reads tag 0x0112 from IFD0.
func exifOrientation(data []byte) int {
if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 { // SOI
return 0
}
i := 2
for i+4 <= len(data) {
if data[i] != 0xFF {
return 0 // not at a marker boundary — give up
}
marker := data[i+1]
if marker == 0xDA || marker == 0xD9 { // SOS (image data begins) or EOI
return 0
}
segLen := int(data[i+2])<<8 | int(data[i+3])
if segLen < 2 {
return 0
}
segStart, segEnd := i+4, i+2+segLen
if segEnd > len(data) {
return 0
}
if marker == 0xE1 { // APP1
if o, ok := orientationFromAPP1(data[segStart:segEnd]); ok {
return o
}
}
i = segEnd
}
return 0
}
// orientationFromAPP1 reads the Orientation tag out of an APP1 segment's payload
// (the "Exif\0\0" header followed by a TIFF block). Returns (0, false) for any
// malformed or non-Exif segment.
func orientationFromAPP1(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
}
ifdOff := int(bo.Uint32(tiff[4:8]))
if ifdOff < 8 || ifdOff+2 > len(tiff) {
return 0, false
}
count := int(bo.Uint16(tiff[ifdOff : ifdOff+2]))
for k, entry := 0, ifdOff+2; k < count; k, entry = k+1, entry+12 {
if entry+12 > len(tiff) {
return 0, false
}
if bo.Uint16(tiff[entry:entry+2]) == 0x0112 { // Orientation
val := int(bo.Uint16(tiff[entry+8 : entry+10]))
if val >= 1 && val <= 8 {
return val, true
}
return 0, false
}
}
return 0, false
}