All checks were successful
Build and Test / build-and-test (push) Successful in 37s
Bake the EXIF Orientation rotation into uploaded JPEG pixels (and strip the tag) so receipts are upright in every consumer, not just EXIF-aware viewers. Acts only when orientation is known (tag 2..8); images with no tag, tag 1, non-JPEG, or PDFs pass through byte-for-byte. Wired into the receipt, attachments, and AI-classify paths. Documented as spec item 13. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
3.9 KiB
Go
115 lines
3.9 KiB
Go
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/binary"
|
||
"image"
|
||
"image/color"
|
||
"image/jpeg"
|
||
"testing"
|
||
|
||
"github.com/disintegration/imaging"
|
||
)
|
||
|
||
// jpegBytes encodes a w×h test image as JPEG (no EXIF).
|
||
func jpegBytes(t *testing.T, w, h int) []byte {
|
||
t.Helper()
|
||
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{uint8(x * 30), uint8(y * 30), 100, 255})
|
||
}
|
||
}
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||
t.Fatalf("encode jpeg: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
// withOrientation splices a minimal big-endian EXIF APP1 segment carrying the
|
||
// given Orientation value into a JPEG (right after the SOI marker).
|
||
func withOrientation(t *testing.T, jpg []byte, orientation uint16) []byte {
|
||
t.Helper()
|
||
tiff := new(bytes.Buffer)
|
||
tiff.WriteString("MM") // big-endian
|
||
binary.Write(tiff, binary.BigEndian, uint16(0x002A)) // magic
|
||
binary.Write(tiff, binary.BigEndian, uint32(8)) // IFD0 at offset 8
|
||
binary.Write(tiff, binary.BigEndian, uint16(1)) // one entry
|
||
binary.Write(tiff, binary.BigEndian, uint16(0x0112)) // tag: Orientation
|
||
binary.Write(tiff, binary.BigEndian, uint16(3)) // type: SHORT
|
||
binary.Write(tiff, binary.BigEndian, uint32(1)) // count
|
||
binary.Write(tiff, binary.BigEndian, orientation) // value (in high 2 bytes)
|
||
binary.Write(tiff, binary.BigEndian, uint16(0)) // value padding
|
||
binary.Write(tiff, binary.BigEndian, uint32(0)) // next IFD: none
|
||
|
||
payload := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
|
||
app1 := new(bytes.Buffer)
|
||
app1.Write([]byte{0xFF, 0xE1})
|
||
binary.Write(app1, binary.BigEndian, uint16(len(payload)+2)) // segment length
|
||
app1.Write(payload)
|
||
|
||
out := make([]byte, 0, len(jpg)+app1.Len())
|
||
out = append(out, jpg[:2]...) // SOI
|
||
out = append(out, app1.Bytes()...)
|
||
out = append(out, jpg[2:]...)
|
||
return out
|
||
}
|
||
|
||
func TestExifOrientation(t *testing.T) {
|
||
plain := jpegBytes(t, 4, 2)
|
||
if got := exifOrientation(plain); got != 0 {
|
||
t.Errorf("plain JPEG: orientation = %d, want 0 (no tag)", got)
|
||
}
|
||
for _, o := range []uint16{1, 3, 6, 8} {
|
||
tagged := withOrientation(t, plain, o)
|
||
if got := exifOrientation(tagged); got != int(o) {
|
||
t.Errorf("tagged orientation %d: got %d", o, got)
|
||
}
|
||
}
|
||
if got := exifOrientation([]byte("not a jpeg")); got != 0 {
|
||
t.Errorf("non-JPEG: orientation = %d, want 0", got)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeOrientationPassThrough(t *testing.T) {
|
||
plain := jpegBytes(t, 4, 2)
|
||
|
||
// Non-JPEG mime → untouched.
|
||
png := []byte("fake png bytes")
|
||
if got := normalizeOrientation(png, "image/png"); !bytes.Equal(got, png) {
|
||
t.Error("png should pass through unchanged")
|
||
}
|
||
// JPEG without EXIF → untouched (can't know, don't guess or recompress).
|
||
if got := normalizeOrientation(plain, "image/jpeg"); !bytes.Equal(got, plain) {
|
||
t.Error("EXIF-less JPEG should pass through byte-for-byte")
|
||
}
|
||
// JPEG already upright (orientation 1) → untouched.
|
||
up := withOrientation(t, plain, 1)
|
||
if got := normalizeOrientation(up, "image/jpeg"); !bytes.Equal(got, up) {
|
||
t.Error("orientation-1 JPEG should pass through byte-for-byte")
|
||
}
|
||
}
|
||
|
||
func TestNormalizeOrientationRotates(t *testing.T) {
|
||
// Stored 4×2 (wide) image tagged orientation 6 ("rotate 90° CW to view") must
|
||
// come out upright — i.e. tall (2×4) — with the EXIF tag stripped.
|
||
wide := jpegBytes(t, 4, 2)
|
||
tagged := withOrientation(t, wide, 6)
|
||
|
||
out := normalizeOrientation(tagged, "image/jpeg")
|
||
if bytes.Equal(out, tagged) {
|
||
t.Fatal("orientation-6 JPEG should have been re-encoded upright")
|
||
}
|
||
if got := exifOrientation(out); got != 0 {
|
||
t.Errorf("output still carries orientation tag %d; want stripped (0)", got)
|
||
}
|
||
img, err := imaging.Decode(bytes.NewReader(out))
|
||
if err != nil {
|
||
t.Fatalf("decode output: %v", err)
|
||
}
|
||
b := img.Bounds()
|
||
if b.Dx() != 2 || b.Dy() != 4 {
|
||
t.Errorf("output dims = %dx%d, want 2x4 (rotated upright)", b.Dx(), b.Dy())
|
||
}
|
||
}
|