// fileread_exif_test.go -- EXIF 方向校正测试. package builtin import ( "bytes" "encoding/binary" "image" "image/color" "image/jpeg" "testing" ) // ───────────────────────────────────────────────────────────────────── // 辅助:构建最小合法 JPEG with EXIF APP1 // ───────────────────────────────────────────────────────────────────── // buildJPEGWithOrientation 构建一个含有指定 Orientation 的最小 JPEG 字节流. // 图片内容是 4×2 的纯色图(便于验证旋转后的尺寸). func buildJPEGWithOrientation(t *testing.T, width, height int, orient exifOrientation) []byte { t.Helper() // 1. 构建图片内容(4×2 渐变色,便于验证旋转方向正确性) img := image.NewNRGBA(image.Rect(0, 0, width, height)) for y := 0; y < height; y++ { for x := 0; x < width; x++ { img.Set(x, y, color.NRGBA{R: uint8(x * 64), G: uint8(y * 64), B: 128, A: 255}) } } // 2. 编码为 JPEG 字节(不含 EXIF) var rawJPEG bytes.Buffer if err := jpeg.Encode(&rawJPEG, img, &jpeg.Options{Quality: 90}); err != nil { t.Fatalf("jpeg.Encode: %v", err) } rawBytes := rawJPEG.Bytes() // 3. 构建 EXIF APP1 segment(Little-Endian TIFF) // TIFF 头(8字节):II + 0x002A + IFD0 offset(=8) // IFD0:1 条目(Orientation tag) // 条目(12字节):tag(0x0112) + type(SHORT=3) + count(1) + value(orient<<16 LE) app1Data := buildMinimalEXIF(orient) // 4. 构建 APP1 segment:FF E1 + length(2字节,含自身) + data app1Len := uint16(2 + len(app1Data)) // length 字段包含自身的 2 字节 var app1Seg bytes.Buffer app1Seg.WriteByte(0xFF) app1Seg.WriteByte(0xE1) _ = binary.Write(&app1Seg, binary.BigEndian, app1Len) app1Seg.Write(app1Data) // 5. 把 APP1 插入 JPEG SOI(FF D8)之后 if len(rawBytes) < 2 { t.Fatal("rawBytes too short") } var out bytes.Buffer out.Write(rawBytes[:2]) // FF D8 out.Write(app1Seg.Bytes()) // APP1 out.Write(rawBytes[2:]) // 原始 JPEG 剩余部分 return out.Bytes() } // buildMinimalEXIF 构建只含 Orientation IFD 条目的最小 EXIF 数据(Little-Endian). func buildMinimalEXIF(orient exifOrientation) []byte { var buf bytes.Buffer // "Exif\0\0" buf.WriteString("Exif\x00\x00") // TIFF 头:II(小端)+ 0x002A + IFD0 offset(从 TIFF 头开始,= 8) buf.WriteString("II") _ = binary.Write(&buf, binary.LittleEndian, uint16(0x002A)) _ = binary.Write(&buf, binary.LittleEndian, uint32(8)) // IFD0 at offset 8 // IFD0 entry count = 1 _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) // 条目:tag(0x0112) + type SHORT(3) + count(1) + value(orient) _ = binary.Write(&buf, binary.LittleEndian, uint16(0x0112)) _ = binary.Write(&buf, binary.LittleEndian, uint16(3)) // SHORT _ = binary.Write(&buf, binary.LittleEndian, uint32(1)) // count _ = binary.Write(&buf, binary.LittleEndian, uint16(orient)) _ = binary.Write(&buf, binary.LittleEndian, uint16(0)) // padding // Next IFD offset = 0(无下一 IFD) _ = binary.Write(&buf, binary.LittleEndian, uint32(0)) return buf.Bytes() } // ───────────────────────────────────────────────────────────────────── // parseJPEGOrientation 测试 // ───────────────────────────────────────────────────────────────────── func TestParseJPEGOrientation_Normal(t *testing.T) { data := buildJPEGWithOrientation(t, 4, 2, exifOrientNormal) got := parseJPEGOrientation(data) if got != exifOrientNormal { t.Errorf("got %d, want %d", got, exifOrientNormal) } } func TestParseJPEGOrientation_Rotate90CW(t *testing.T) { data := buildJPEGWithOrientation(t, 4, 2, exifOrientRotate90CW) got := parseJPEGOrientation(data) if got != exifOrientRotate90CW { t.Errorf("got %d, want %d (Rotate90CW)", got, exifOrientRotate90CW) } } func TestParseJPEGOrientation_AllValues(t *testing.T) { for _, orient := range []exifOrientation{1, 2, 3, 4, 5, 6, 7, 8} { data := buildJPEGWithOrientation(t, 4, 2, orient) got := parseJPEGOrientation(data) if got != orient { t.Errorf("orient=%d: got %d", orient, got) } } } func TestParseJPEGOrientation_NoEXIF(t *testing.T) { // 普通 JPEG 无 APP1,应返回 1 img := image.NewNRGBA(image.Rect(0, 0, 4, 2)) var buf bytes.Buffer _ = jpeg.Encode(&buf, img, nil) got := parseJPEGOrientation(buf.Bytes()) if got != 1 { t.Errorf("无 EXIF 的 JPEG 应返回 1,got %d", got) } } func TestParseJPEGOrientation_NonJPEG(t *testing.T) { got := parseJPEGOrientation([]byte("not a jpeg")) if got != 1 { t.Errorf("非 JPEG 应返回 1,got %d", got) } } func TestParseJPEGOrientation_TooShort(t *testing.T) { got := parseJPEGOrientation([]byte{0xFF}) if got != 1 { t.Errorf("太短应返回 1,got %d", got) } } // ───────────────────────────────────────────────────────────────────── // correctJPEGOrientation 测试 // ───────────────────────────────────────────────────────────────────── func TestCorrectJPEGOrientation_Normal_NoChange(t *testing.T) { // Orientation=1,不做任何变换,输出应仍为有效 JPEG 且尺寸不变 data := buildJPEGWithOrientation(t, 4, 2, exifOrientNormal) result := correctJPEGOrientation(data) // Orientation=1 时原样返回 if !bytes.Equal(result, data) { t.Error("Orientation=1 应原样返回") } } func TestCorrectJPEGOrientation_Rotate90CW_DimensionSwapped(t *testing.T) { // 4×2 图片,Orientation=6(顺时针90°),校正后应为 2×4 data := buildJPEGWithOrientation(t, 4, 2, exifOrientRotate90CW) result := correctJPEGOrientation(data) img, _, err := image.Decode(bytes.NewReader(result)) if err != nil { t.Fatalf("校正后 JPEG 解码失败: %v", err) } bounds := img.Bounds() gotW := bounds.Max.X - bounds.Min.X gotH := bounds.Max.Y - bounds.Min.Y if gotW != 2 || gotH != 4 { t.Errorf("Rotate90CW 后尺寸应为 2×4,got %d×%d", gotW, gotH) } } func TestCorrectJPEGOrientation_Rotate180_SameDimension(t *testing.T) { // 旋转 180° 后尺寸不变 data := buildJPEGWithOrientation(t, 4, 2, exifOrientRotate180) result := correctJPEGOrientation(data) img, _, err := image.Decode(bytes.NewReader(result)) if err != nil { t.Fatalf("校正后 JPEG 解码失败: %v", err) } bounds := img.Bounds() gotW := bounds.Max.X - bounds.Min.X gotH := bounds.Max.Y - bounds.Min.Y if gotW != 4 || gotH != 2 { t.Errorf("Rotate180 后尺寸应为 4×2,got %d×%d", gotW, gotH) } } func TestCorrectJPEGOrientation_Rotate90CCW_DimensionSwapped(t *testing.T) { data := buildJPEGWithOrientation(t, 4, 2, exifOrientRotate90CCW) result := correctJPEGOrientation(data) img, _, err := image.Decode(bytes.NewReader(result)) if err != nil { t.Fatalf("校正后 JPEG 解码失败: %v", err) } bounds := img.Bounds() gotW := bounds.Max.X - bounds.Min.X gotH := bounds.Max.Y - bounds.Min.Y if gotW != 2 || gotH != 4 { t.Errorf("Rotate90CCW 后尺寸应为 2×4,got %d×%d", gotW, gotH) } } func TestCorrectJPEGOrientation_InvalidData_ReturnOriginal(t *testing.T) { // 垃圾数据,不是有效 JPEG,应原样返回 garbage := []byte{0x00, 0x01, 0x02, 0x03} result := correctJPEGOrientation(garbage) if !bytes.Equal(result, garbage) { t.Error("无效数据应原样返回") } } // ───────────────────────────────────────────────────────────────────── // applyOrientation 测试(像素级验证) // ───────────────────────────────────────────────────────────────────── // makeTestImage 创建一个 2×1 的测试图片:左像素红色,右像素蓝色. func makeTestImage() image.Image { img := image.NewNRGBA(image.Rect(0, 0, 2, 1)) img.SetNRGBA(0, 0, color.NRGBA{R: 255, G: 0, B: 0, A: 255}) // 红 img.SetNRGBA(1, 0, color.NRGBA{R: 0, G: 0, B: 255, A: 255}) // 蓝 return img } func TestApplyOrientation_FlipH(t *testing.T) { // 水平翻转:左红右蓝 → 左蓝右红 img := makeTestImage() result := applyOrientation(img, exifOrientFlipH) r, _, b, _ := result.At(0, 0).RGBA() if r > 0 { t.Error("水平翻转后 (0,0) 应为蓝色") } _ = b } func TestApplyOrientation_Rotate180(t *testing.T) { // 旋转 180°:尺寸不变,(0,0) 从红变蓝 img := makeTestImage() result := applyOrientation(img, exifOrientRotate180) bounds := result.Bounds() if bounds.Max.X != 2 || bounds.Max.Y != 1 { t.Errorf("Rotate180 尺寸应不变 2×1,got %dx%d", bounds.Max.X, bounds.Max.Y) } } func TestApplyOrientation_Rotate90CW_Dimensions(t *testing.T) { // 顺时针90°:2×1 → 1×2 img := makeTestImage() result := applyOrientation(img, exifOrientRotate90CW) bounds := result.Bounds() if bounds.Max.X != 1 || bounds.Max.Y != 2 { t.Errorf("Rotate90CW 2×1 应得 1×2,got %dx%d", bounds.Max.X, bounds.Max.Y) } } func TestApplyOrientation_Unknown_ReturnSrc(t *testing.T) { // 未知 orientation 值,原样返回 img := makeTestImage() result := applyOrientation(img, exifOrientation(99)) if result != img { t.Error("未知 orientation 应原样返回 src") } }