diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..60bf574 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project are documented here. Versions are git tags; +release tags `X.Y.Z` are built and deployed automatically (pre-release tags such +as `0.0.0a1` are built and staged only). + +## [0.0.1] - 2026-06-19 + +### Added +- Upright receipt images (EXIF orientation normalization). Phone cameras often + store a photo sideways with an EXIF "Orientation" tag that only some viewers + honor, so receipts could appear rotated. On upload the indicated rotation is now + baked into the pixels (and the tag dropped) so the stored image is upright in + every consumer — browser, download, the AI classifier, and any future export. + - Applies only when orientation is actually known (a JPEG with an EXIF tag of + 2..8). Images with no tag, an already-upright tag, non-JPEG images, and PDFs + are left byte-for-byte untouched — no guessing from image content. + - Runs on the primary receipt, every additional attachment, and the image sent + to the AI classifier. New uploads only; existing stored receipts are unchanged. + - See spec.md item 13. diff --git a/go.mod b/go.mod index 70e6bb4..76cb18f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.26.4 require ( github.com/coreos/go-oidc/v3 v3.18.0 + github.com/disintegration/imaging v1.6.2 golang.org/x/oauth2 v0.36.0 + modernc.org/sqlite v1.52.0 ) require ( @@ -14,9 +16,9 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.52.0 // indirect ) diff --git a/go.sum b/go.sum index ad19f6d..1be3945 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,62 @@ github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/web/orientation.go b/internal/web/orientation.go new file mode 100644 index 0000000..9f82a29 --- /dev/null +++ b/internal/web/orientation.go @@ -0,0 +1,113 @@ +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 +} diff --git a/internal/web/orientation_test.go b/internal/web/orientation_test.go new file mode 100644 index 0000000..7bca86d --- /dev/null +++ b/internal/web/orientation_test.go @@ -0,0 +1,115 @@ +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()) + } +} diff --git a/internal/web/scan.go b/internal/web/scan.go index b48cb8f..33be612 100644 --- a/internal/web/scan.go +++ b/internal/web/scan.go @@ -62,6 +62,7 @@ func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) { http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType) return } + data = normalizeOrientation(data, mimeType) // upright image reads better for the AI sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType) if err != nil { diff --git a/internal/web/upload.go b/internal/web/upload.go index 70f2b7b..e19b17f 100644 --- a/internal/web/upload.go +++ b/internal/web/upload.go @@ -139,6 +139,8 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { mimeType = detectMime(data) if !allowedMime(mimeType) { errs = append(errs, "Only images and PDFs are allowed.") + } else { + data = normalizeOrientation(data, mimeType) // bake in EXIF rotation } } @@ -264,6 +266,7 @@ func readAttachments(r *http.Request) (out []pendingAttachment, errs []string) { errs = append(errs, "Attachments must be images or PDFs.") continue } + adata = normalizeOrientation(adata, amime) // bake in EXIF rotation out = append(out, pendingAttachment{ data: adata, mimeType: amime, diff --git a/spec.md b/spec.md index 7dcdd6d..9546edd 100644 --- a/spec.md +++ b/spec.md @@ -353,4 +353,33 @@ half-written row, and recover automatically on the next boot — no manual repai is not auto-cleaned. The DB/blob is the source of truth for serving. - Recovery from true corruption (beyond crash-consistency) is restore-from-backup: the metadata-only daily backup (item 11) recovers the records; the full .db - export (/export/db, blobs included) recovers records + images. \ No newline at end of file + export (/export/db, blobs included) recovers records + images. + +13. Upright receipt images (EXIF orientation normalization) + +Phone cameras often save a photo in the sensor's native orientation plus an EXIF +"Orientation" tag that says "rotate me when displaying". Some viewers honor the +tag and some don't, so a receipt that looks fine in one place shows up sideways in +another (and the bytes are stored verbatim, so the problem follows the file). On +upload, bake the indicated rotation into the pixels so the stored image is upright +for EVERY consumer — browser, download, the AI classifier, a future PDF export — +not just EXIF-aware ones. + + - Act only when orientation is actually KNOWN. A JPEG carrying an EXIF + Orientation tag of 2..8 is decoded, rotated/flipped upright, re-encoded + (JPEG, q≈90), and the tag dropped. A tag of 1 (already upright) or NO tag at + all — the common case, where there is genuinely no way to know which way is up + — leaves the original bytes byte-for-byte untouched. We never infer orientation + from image content or guess; an unrotatable image is left as-is, never wrong. + - Scope: JPEG only (where camera orientation tags live in practice). PDFs and + other image types pass through unchanged, as does any file that fails to decode + (kept rather than lost). + - Applies to the primary receipt, every additional attachment (item 10), and the + image sent to the AI classifier (an upright image reads more reliably). It runs + after the MIME allowlist check and before the dual-write, so file_size_bytes and + both stored copies (disk + DB blob) reflect the normalized bytes. + - Client side cannot help: there is no browser/camera API to disable capture + rotation, so this is necessarily a server-side fix. + + Out of scope: content-based auto-rotation (detecting "up" without metadata), + rotating PDFs, and backfilling already-stored receipts. \ No newline at end of file