package media import ( "bytes" "encoding/binary" "hash/crc32" "image" "image/color" "image/gif" "image/jpeg" "image/png" "strings" "testing" xwebp "golang.org/x/image/webp" ) // photo makes a JPEG w×h whose left half is red and right half blue, so its // orientation can be read back from the pixels. func photo(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++ { c := color.RGBA{220, 20, 20, 255} if x >= w/2 { c = color.RGBA{20, 20, 220, 255} } img.Set(x, y, c) } } var b bytes.Buffer if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 95}); err != nil { t.Fatal(err) } return b.Bytes() } // withEXIF puts an EXIF block (orientation, and a GPS block with a latitude) // and a comment right after a JPEG's start marker, the way a phone does. func withEXIF(jpg []byte, orientation uint16, gps bool) []byte { bo := binary.LittleEndian var t bytes.Buffer t.WriteString("II") binary.Write(&t, bo, uint16(42)) binary.Write(&t, bo, uint32(8)) n := uint16(1) if gps { n = 2 } binary.Write(&t, bo, n) // Orientation: tag, type SHORT, count 1, value. binary.Write(&t, bo, []uint16{0x0112, 3}) binary.Write(&t, bo, uint32(1)) binary.Write(&t, bo, []uint16{orientation, 0}) if gps { gpsOff := uint32(8 + 2 + 2*12 + 4) binary.Write(&t, bo, []uint16{0x8825, 4}) binary.Write(&t, bo, uint32(1)) binary.Write(&t, bo, gpsOff) } binary.Write(&t, bo, uint32(0)) // no next IFD if gps { binary.Write(&t, bo, uint16(1)) binary.Write(&t, bo, []uint16{0x0001, 2}) // GPSLatitudeRef "N" binary.Write(&t, bo, uint32(2)) t.Write([]byte{'N', 0, 0, 0}) binary.Write(&t, bo, uint32(0)) } seg := append([]byte("Exif\x00\x00"), t.Bytes()...) var out bytes.Buffer out.Write(jpg[:2]) out.Write([]byte{0xFF, 0xE1}) binary.Write(&out, binary.BigEndian, uint16(len(seg)+2)) out.Write(seg) com := []byte("secret comment") out.Write([]byte{0xFF, 0xFE}) binary.Write(&out, binary.BigEndian, uint16(len(com)+2)) out.Write(com) out.Write(jpg[2:]) return out.Bytes() } func none(string) bool { return false } // sizes are the JPEG or PNG files, without their WebP copies. func sizes(img *Image) []File { var out []File for _, f := range img.Files { if !f.WebP { out = append(out, f) } } return out } func decodeAny(t *testing.T, f File) image.Image { t.Helper() var ( got image.Image err error ) switch { case f.WebP: got, err = xwebp.Decode(bytes.NewReader(f.Data)) case strings.HasSuffix(f.Path, ".png"): got, err = png.Decode(bytes.NewReader(f.Data)) default: got, err = jpeg.Decode(bytes.NewReader(f.Data)) } if err != nil { t.Fatalf("%s: %v", f.Path, err) } return got } func TestReadJPEGMeta(t *testing.T) { m := ReadJPEGMeta(withEXIF(photo(t, 40, 20), 6, true)) if m.Orientation != 6 || !m.GPS || !m.EXIF { t.Fatalf("got %+v", m) } m = ReadJPEGMeta(photo(t, 40, 20)) if m.Orientation != 1 || m.GPS || m.EXIF { t.Fatalf("plain JPEG: got %+v", m) } if m := ReadJPEGMeta([]byte{0xFF, 0xD8, 0xFF, 0xE1, 0xFF}); m.Orientation != 1 { t.Fatalf("truncated: got %+v", m) } } func TestStripsAndOrients(t *testing.T) { in := withEXIF(photo(t, 2400, 1200), 6, true) // stored on its side img, err := Process(in, "Dock at Dawn.JPG", "2026/10", none) if err != nil { t.Fatal(err) } if img.URL != "/media/2026/10/dock-at-dawn.jpg" || img.Type != "image/jpeg" { t.Fatalf("url %s type %s", img.URL, img.Type) } // Shown the right way up it's 1200 wide and 2400 tall: 1200 is under the // largest width, so that's the main size, with 960 and 480 beside it. if img.Width != 1200 || img.Height != 2400 || len(sizes(img)) != 3 { t.Fatalf("main %dx%d, %d sizes", img.Width, img.Height, len(sizes(img))) } paths := map[string]bool{} for _, f := range img.Files { paths[f.Path] = true } for _, want := range []string{"dock-at-dawn.jpg", "dock-at-dawn-480w.jpg", "dock-at-dawn-960w.jpg", "dock-at-dawn.webp", "dock-at-dawn-480w.webp"} { if !paths["media/2026/10/"+want] { t.Fatalf("no %s in %v", want, paths) } } for _, f := range img.Files { m := ReadJPEGMeta(f.Data) if m.EXIF || m.GPS || bytes.Contains(f.Data, []byte("secret comment")) || bytes.Contains(f.Data, []byte("EXIF")) { t.Fatalf("%s still carries metadata", f.Path) } got := decodeAny(t, f) if got.Bounds().Dx() != f.Width || got.Bounds().Dy() != f.Height { t.Fatalf("%s is %v, says %dx%d", f.Path, got.Bounds(), f.Width, f.Height) } // Red was on the left of the stored picture; a quarter turn clockwise // puts it on top. r, _, b, _ := got.At(f.Width/2, f.Height/8).RGBA() if r < b { t.Fatalf("%s: top isn't red, so it wasn't turned", f.Path) } } notes := strings.Join(img.Notes, " ") for _, want := range []string{"location", "EXIF", "right way up"} { if !strings.Contains(notes, want) { t.Errorf("notes %q don't mention %q", notes, want) } } } func TestResizesLargePhoto(t *testing.T) { img, err := Process(photo(t, 4000, 3000), "big.jpg", "2026/10", none) if err != nil { t.Fatal(err) } var widths []int for _, f := range sizes(img) { widths = append(widths, f.Width) } if len(widths) != 3 || widths[0] != 1600 || img.Height != 1200 { t.Fatalf("widths %v, main height %d", widths, img.Height) } } func TestNeverOverwrites(t *testing.T) { taken := func(p string) bool { return p == "static/media/2026/10/a.jpg" || p == "static/media/2026/10/a-2.jpg" } img, err := Process(photo(t, 100, 50), "a.jpg", "2026/10", taken) if err != nil { t.Fatal(err) } if img.URL != "/media/2026/10/a-3.jpg" || len(sizes(img)) != 1 { t.Fatalf("got %s with %d files", img.URL, len(img.Files)) } } func TestPNGStaysPNGWithTransparency(t *testing.T) { src := image.NewNRGBA(image.Rect(0, 0, 64, 32)) src.Set(1, 1, color.NRGBA{0, 0, 0, 255}) var b bytes.Buffer png.Encode(&b, src) b.WriteString("trailing payload after IEND") img, err := Process(b.Bytes(), "logo.png", "2026/10", none) if err != nil { t.Fatal(err) } if img.Type != "image/png" || !strings.HasSuffix(img.URL, ".png") { t.Fatalf("got %s %s", img.Type, img.URL) } if bytes.Contains(img.Files[0].Data, []byte("trailing payload")) { t.Fatal("data after the image survived") } for _, f := range img.Files { if _, _, _, a := decodeAny(t, f).At(10, 10).RGBA(); a != 0 { t.Fatalf("%s: transparency lost", f.Path) } } } func TestAnimatedGIFNoted(t *testing.T) { pal := color.Palette{color.Black, color.White} a := &gif.GIF{} for i := 0; i < 3; i++ { a.Image = append(a.Image, image.NewPaletted(image.Rect(0, 0, 10, 10), pal)) a.Delay = append(a.Delay, 10) } var b bytes.Buffer gif.EncodeAll(&b, a) if n := gifFrames(b.Bytes()); n != 3 { t.Fatalf("counted %d frames", n) } img, err := Process(b.Bytes(), "spin.gif", "2026/10", none) if err != nil { t.Fatal(err) } if !strings.Contains(strings.Join(img.Notes, " "), "first frame") { t.Fatalf("notes: %v", img.Notes) } } func TestRefuses(t *testing.T) { svg := []byte(``) if _, err := Process(svg, "x.svg", "2026/10", none); err != ErrNotPicture { t.Fatalf("svg: %v", err) } if _, err := Process([]byte("