hsa-app/internal/web/orientation_test.go
Jean-Michel Tremblay 7ba0d5abe7
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
Normalize receipt image orientation from EXIF; add changelog (0.0.1)
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>
2026-06-19 20:26:03 -04:00

115 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 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())
}
}