From b3e59e78555b942d9c710ac211e9bac62ef15946 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 28 Sep 2026 23:49:39 +0000 Subject: [PATCH] Test that processed images carry no metadata (closes #82) Failing tests first: GPS EXIF must not survive into any output format, an image with EXIF orientation 6 must come out upright (also when only a width is asked for), and a Display P3 image must come out as sRGB with no ICC profile. Three JPEG fixtures of about 1 KB each are added under internal/imageprocessor/testdata/. Model: opus-5-5 --- .../imageprocessor_internal_test.go | 151 ++++++++++++++++++ .../imageprocessor/testdata/display-p3.jpg | Bin 0 -> 1302 bytes internal/imageprocessor/testdata/gps-exif.jpg | Bin 0 -> 1032 bytes .../imageprocessor/testdata/orientation-6.jpg | Bin 0 -> 811 bytes 4 files changed, 151 insertions(+) create mode 100644 internal/imageprocessor/testdata/display-p3.jpg create mode 100644 internal/imageprocessor/testdata/gps-exif.jpg create mode 100644 internal/imageprocessor/testdata/orientation-6.jpg diff --git a/internal/imageprocessor/imageprocessor_internal_test.go b/internal/imageprocessor/imageprocessor_internal_test.go index 21de6c5..73decd0 100644 --- a/internal/imageprocessor/imageprocessor_internal_test.go +++ b/internal/imageprocessor/imageprocessor_internal_test.go @@ -9,7 +9,9 @@ import ( "image/jpeg" "image/png" "io" + "math" "os" + "slices" "testing" "github.com/davidbyttow/govips/v2/vips" @@ -561,3 +563,152 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) { encodeAndCheck(t, FormatAVIF, 85, mimeAVIF) } + +// processAndDecode runs input through Process and decodes the output with +// vips, so a test can inspect the image a client would receive. +func processAndDecode(t *testing.T, input []byte, req *Request) *vips.ImageRef { + t.Helper() + + result, err := New(Params{}).Process( + context.Background(), bytes.NewReader(input), req, + ) + if err != nil { + t.Fatalf("Process() error = %v", err) + } + + defer func() { _ = result.Content.Close() }() + + data, err := io.ReadAll(result.Content) + if err != nil { + t.Fatalf("failed to read result: %v", err) + } + + output, err := vips.NewImageFromBuffer(data) + if err != nil { + t.Fatalf("failed to decode output: %v", err) + } + + t.Cleanup(output.Close) + + return output +} + +func TestImageProcessor_StripsEXIF(t *testing.T) { + t.Parallel() + + // gps-exif.jpg carries GPS coordinates, a camera make, model and serial + // number, and a capture time. + input, err := os.ReadFile("testdata/gps-exif.jpg") + if err != nil { + t.Fatalf("failed to read test JPEG: %v", err) + } + + fixture, err := vips.NewImageFromBuffer(input) + if err != nil { + t.Fatalf("failed to decode test JPEG: %v", err) + } + + t.Cleanup(fixture.Close) + + if !slices.Contains(fixture.GetFields(), "exif-ifd3-GPSLatitude") { + t.Fatal("testdata/gps-exif.jpg has no GPS latitude") + } + + formats := []Format{ + FormatJPEG, FormatPNG, FormatWebP, FormatAVIF, FormatGIF, FormatOriginal, + } + + for _, format := range formats { + t.Run(string(format), func(t *testing.T) { + t.Parallel() + + output := processAndDecode(t, input, &Request{Format: format}) + + if output.HasExif() { + t.Errorf("output has EXIF: %v", output.GetExif()) + } + }) + } +} + +func TestImageProcessor_AppliesEXIFOrientation(t *testing.T) { + t.Parallel() + + // orientation-6.jpg is stored 16x8, red on the left and blue on the + // right, with EXIF orientation 6 (turn 90 degrees clockwise to view). + // Upright it is 8x16, red on top and blue below. + input, err := os.ReadFile("testdata/orientation-6.jpg") + if err != nil { + t.Fatalf("failed to read test JPEG: %v", err) + } + + tests := []struct { + name string + size Size + wantW int + wantH int + }{ + {name: "original size", size: Size{}, wantW: 8, wantH: 16}, + {name: "width only", size: Size{Width: 4}, wantW: 4, wantH: 8}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + output := processAndDecode(t, input, &Request{ + Size: tt.size, + Format: FormatPNG, + }) + + if output.Width() != tt.wantW || output.Height() != tt.wantH { + t.Fatalf("output is %dx%d, want %dx%d", + output.Width(), output.Height(), tt.wantW, tt.wantH) + } + + top, err := output.GetPoint(tt.wantW/2, 0) + if err != nil { + t.Fatalf("GetPoint() error = %v", err) + } + + bottom, err := output.GetPoint(tt.wantW/2, tt.wantH-1) + if err != nil { + t.Fatalf("GetPoint() error = %v", err) + } + + if top[0] <= top[2] || bottom[2] <= bottom[0] { + t.Errorf("top pixel = %v, bottom pixel = %v, want red above blue", + top, bottom) + } + }) + } +} + +func TestImageProcessor_ConvertsWideGamutToSRGB(t *testing.T) { + t.Parallel() + + // display-p3.jpg is a flat 8x8 image with the Display P3 profile + // embedded, filled with Display P3 (234, 51, 35), which is sRGB red. + input, err := os.ReadFile("testdata/display-p3.jpg") + if err != nil { + t.Fatalf("failed to read test JPEG: %v", err) + } + + output := processAndDecode(t, input, &Request{Format: FormatPNG}) + + if output.HasICCProfile() { + t.Error("output has an ICC profile") + } + + pixel, err := output.GetPoint(4, 4) + if err != nil { + t.Fatalf("GetPoint() error = %v", err) + } + + want := []float64{255, 0, 0} + for i := range want { + if math.Abs(pixel[i]-want[i]) > 5 { + t.Fatalf("pixel = %v, want within 5 of %v", pixel, want) + } + } +} diff --git a/internal/imageprocessor/testdata/display-p3.jpg b/internal/imageprocessor/testdata/display-p3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c2c49a645c4e4ddbbd40ff8577a4b300ddac751b GIT binary patch literal 1302 zcmbV~Sxl2b5XWb~vn|zFD5$8hawrl_3ROW9B~T7)JfPs6XiB+ifZ*36XyOBhqWA)M z`vhK~F(#;}7!$!;pF|Wq5*57A2Lqy@sC>?TMau&w>NLCi+wJUp^PgGyk=y~7;|d)a z02WIqr~p6!Z^8mWqQjr?%DMu1!Vs@R4jJ-ckbM}BzoU#({4tMC7U0(^091&; z;eW>PFe9mSGr`ViE1+T%AOY0}c2s2aZG2(sr%F3ib*98loOqa2H@w`bYX zkXIqkkT5~yQhJ{0<^z;d-F%9&lwvibPZP2sa~P)%roU!nIrXNz!O9|53g@SKH20_ZdVw+r)Zl1=#zJ_ukk8~`6{0Yy+xGS;3~(xWX<3?Dbg3nHfBg$@m4FnyNAqcG z&*^yYDPhrqPm)ax*r*RLs;p53#?&|aT3fng2K&PN=mnFFWa%RylVHJz5DhbIC$vr zk)y|s*VdgldFJf7hVvILHeR}V?fQ+Iw{G9Ld%yL;!?yNEkDqjOzI^ri&D*Yb?|VM> ze)`o0X7hwu;usV*P5+-;@CyBO=J^|rHDt%04O?|UE z&}8lQO_f?SenGQ3f_tbmMcLm8EB-HKzlC{pJpnCCuz9QwqM&DB&>q!pmLLBF7DY6o literal 0 HcmV?d00001 diff --git a/internal/imageprocessor/testdata/gps-exif.jpg b/internal/imageprocessor/testdata/gps-exif.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8deb6afce83a9a145b874f4e08829ebb197bfe60 GIT binary patch literal 1032 zcmbVKNlX+`5Uu}z_e{?Q-7`JIh%w44l9==?D9ptGqGBSEMNtnX!zN=ihDAXV6F>#@ zV88{JgBcXp8!B!mQCx4h0-nGPJ%PJk+Ul7OcrY-tsoez!WTj*)O)W2g!s zKR+D=05Cx;p(N;XFph%NmyipLQf;AexOjo*IgU3QjRwJLwpuM_i^UdW7i}?8jKw0lM9CpLola|P zoZBV4?XpwWiV%i-INrqbCfR1O$^UHXEsz8dL1aV%DiJ0Tbx_kmcoH$3JYx7MINo3s zOlAuv^ou|VW0Ygs)R?_X`&697+Y>z521jAgm{=owTe^CLq@2D1XVK7PvahVR)nsUB1$P_1g6tH*en=ynFBdgNKhEKY9A>`HSI^ z(XsJYuiw0VH}U?%)W=VszkL1n{l`yD7XcQPh4(+|!b2dG;}~bubP?Ks&Loac@EGjb zg~niwBhlL;$T?j-eFLT>U(uwqtaixkO7@SXOli_0$|C>OUxl^)m$GSLGrC5>%J8q1 Pm;_584Z^h94C=@)rQ+sd literal 0 HcmV?d00001 diff --git a/internal/imageprocessor/testdata/orientation-6.jpg b/internal/imageprocessor/testdata/orientation-6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..da8dd4813cdae8595412591122f819a5c50cdfe8 GIT binary patch literal 811 zcmex=~kX3=9lx3_^^|42)nF1A`PJE0`SyW^iU;W?}*%AYcI^Rt{EH78X`6c6K%n9xfgp zZZ2+aUOpiKUOquSZf*fF0YPCAQBhGIesKvg5eXp?Q4x?KjLbkiEUcWYtehge+`J+r zgZ~E@1UZ1=C&0`o$iO7X$SlbC{|JLT0|O%~BN#v&4CFHb-N?qy!O6u9RIpWmfr*is znTdrNCobercG`GQH0a_772~9$CQdFfaS2H& zRW)@DO)V2sGjj_|D`yv1H+K(Dui%i-u<(e;sN|H?wDgS3tm2Z=vhs?`s^*r~w)T$B zu1S-pOr17;#>`oZ7B5-4Z25|nt2S-kvUS_`9Xod&I(+2lvEwIBp1O4T%GGPvZ`{1~ z@X_NZPoF)1@$%KjPoKYh{r3IG&tD*aF)}biyagm7K11^tFc27-SXh`@*g^hcWGV-F zT#$uT(U48ZF_1m6P*};Rkwe63;=+vwIhBorK8Pk2UE~r|F?p!^5#%+n&xrF_6Inik mdko>PTMRtRjKIia7G$tz_<3nRkNs8S1FQcYvp??q|0V#7eAk-* literal 0 HcmV?d00001