package storage import ( "bytes" "encoding/binary" "image" "image/color" "image/jpeg" "strconv" "testing" ) // itoa 避免为一个断言引入 strconv 到主逻辑。 func itoa(n int) string { return strconv.Itoa(n) } // webpDimensions 直接解析 WebP 容器头(VP8/VP8L/VP8X 三种 chunk), // 避免依赖 libwebp 桩。 func webpDimensions(t *testing.T, data []byte) (int, int) { t.Helper() if len(data) < 30 { t.Fatalf("webp too short: %d", len(data)) } switch string(data[12:16]) { case "VP8 ": // 有损:帧头内含 0x9d012a 起始码,宽高各 14 位紧跟其后。 b := data[23:26] if b[0] != 0x9d || b[1] != 0x01 || b[2] != 0x2a { t.Fatalf("unexpected VP8 keyframe signature %x", b) } return int(binary.LittleEndian.Uint16(data[26:28]) & 0x3FFF), int(binary.LittleEndian.Uint16(data[28:30]) & 0x3FFF) case "VP8L": // 无损:1 字节签名 + 14 位宽 - 1 + 14 位高 - 1 if data[20] != 0x2f { t.Fatalf("unexpected VP8L signature %x", data[20]) } bits := binary.LittleEndian.Uint32(data[21:25]) return int(bits&0x3FFF) + 1, int((bits>>14)&0x3FFF) + 1 case "VP8X": w := int(data[24]) | int(data[25])<<8 | int(data[26])<<16 h := int(data[27]) | int(data[28])<<8 | int(data[29])<<16 return w + 1, h + 1 default: t.Fatalf("unknown webp chunk %q", data[12:16]) return 0, 0 } } // buildJPEGWithOrientation 生成一张 JPEG,并在 SOI 之后插入带指定 // Orientation 的 APP1/Exif 段。byteOrder 为 true 时用小端(II),false 用大端(MM)。 func buildJPEGWithOrientation(t *testing.T, orientation int, littleEndian bool, w, h int) []byte { t.Helper() var raw bytes.Buffer if err := jpeg.Encode(&raw, image.NewGray(image.Rect(0, 0, w, h)), nil); err != nil { t.Fatalf("encode base jpeg: %v", err) } body := raw.Bytes() // TIFF: header(8) + IFD0(2 + 12 + 4) tiff := make([]byte, 0, 26) bo := binary.ByteOrder(binary.BigEndian) if littleEndian { tiff = append(tiff, 'I', 'I') bo = binary.LittleEndian } else { tiff = append(tiff, 'M', 'M') } magic := make([]byte, 2) bo.PutUint16(magic, 42) tiff = append(tiff, magic...) off := make([]byte, 4) bo.PutUint32(off, 8) // IFD0 紧跟 TIFF header tiff = append(tiff, off...) tiff = append(tiff, 0x01, 0x00) // IFD0 条目数 = 1 entry := make([]byte, 12) bo.PutUint16(entry[0:2], 0x0112) // Orientation bo.PutUint16(entry[2:4], 3) // SHORT bo.PutUint32(entry[4:8], 1) // count = 1 -> 值内联在前 2 字节 bo.PutUint16(entry[8:10], uint16(orientation)) // entry[10:12] 是 next IFD 偏移,保持为 0 tiff = append(tiff, entry...) payload := append([]byte("Exif\x00\x00"), tiff...) seg := []byte{0xFF, 0xE1, 0, 0} binary.BigEndian.PutUint16(seg[2:4], uint16(len(payload)+2)) seg = append(seg, payload...) out := make([]byte, 0, len(body)+len(seg)) out = append(out, body[:2]...) // SOI out = append(out, seg...) out = append(out, body[2:]...) return out } func TestJPEGOrientation(t *testing.T) { cases := []struct { name string data []byte want int }{ {"无 EXIF 的裸 JPEG 回落到 1", mustJPEG(t, 4, 4), 1}, {"小端 Orientation=6", buildJPEGWithOrientation(t, 6, true, 4, 4), 6}, {"大端 Orientation=8", buildJPEGWithOrientation(t, 8, false, 4, 4), 8}, {"Orientation=1 视为无需变换", buildJPEGWithOrientation(t, 1, true, 4, 4), 1}, {"越界值不采信", buildJPEGWithOrientation(t, 99, true, 4, 4), 1}, {"非 JPEG 数据", []byte("not an image at all"), 1}, {"空数据", nil, 1}, {"截断的 JPEG", []byte{0xFF, 0xD8, 0xFF}, 1}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := jpegOrientation(c.data); got != c.want { t.Fatalf("jpegOrientation() = %d, want %d", got, c.want) } }) } } func mustJPEG(t *testing.T, w, h int) []byte { t.Helper() var b bytes.Buffer if err := jpeg.Encode(&b, image.NewGray(image.Rect(0, 0, w, h)), nil); err != nil { t.Fatalf("encode: %v", err) } return b.Bytes() } // TestApplyJPEGOrientation 用 2x3 的彩色图逐像素校验 8 种方向的映射关系, // 并确认 5/6/7/8 会交换宽高。 func TestApplyJPEGOrientation(t *testing.T) { // 源图 2 宽 3 高,标记为 (x,y) 的颜色值 = y*10+x,便于追踪。 const sw, sh = 2, 3 src := image.NewRGBA(image.Rect(0, 0, sw, sh)) for y := 0; y < sh; y++ { for x := 0; x < sw; x++ { src.Set(x, y, color.RGBA{R: uint8(y*10 + x), A: 255}) } } r := func(x, y int) uint8 { return uint8(y*10 + x) } // 期望:目标 (dx,dy) 处的像素值 cases := []struct { ori int dstW, dstH int expect func(dx, dy int) uint8 }{ {exifOrientTopLeft, sw, sh, func(dx, dy int) uint8 { return r(dx, dy) }}, {exifOrientTopRight, sw, sh, func(dx, dy int) uint8 { return r(sw-1-dx, dy) }}, {exifOrientBottomRight, sw, sh, func(dx, dy int) uint8 { return r(sw-1-dx, sh-1-dy) }}, {exifOrientBottomLeft, sw, sh, func(dx, dy int) uint8 { return r(dx, sh-1-dy) }}, {exifOrientLeftTop, sh, sw, func(dx, dy int) uint8 { return r(dy, dx) }}, {exifOrientRightTop, sh, sw, func(dx, dy int) uint8 { return r(dy, sh-1-dx) }}, {exifOrientRightBottom, sh, sw, func(dx, dy int) uint8 { return r(sw-1-dy, sh-1-dx) }}, {exifOrientLeftBottom, sh, sw, func(dx, dy int) uint8 { return r(sw-1-dy, dx) }}, } for _, c := range cases { t.Run(itoa(c.ori), func(t *testing.T) { got := applyJPEGOrientation(src, c.ori) if b := got.Bounds(); b.Dx() != c.dstW || b.Dy() != c.dstH { t.Fatalf("bounds = %dx%d, want %dx%d", b.Dx(), b.Dy(), c.dstW, c.dstH) } for dy := 0; dy < c.dstH; dy++ { for dx := 0; dx < c.dstW; dx++ { want := color.RGBA{R: c.expect(dx, dy), A: 255} gotR, gotG, gotB, gotA := got.At(dx, dy).RGBA() wantR, wantG, wantB, wantA := want.RGBA() if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA { t.Fatalf("ori=%d pixel(%d,%d) = RGBA(%d,%d,%d,%d), want R=%d", c.ori, dx, dy, gotR>>8, gotG>>8, gotB>>8, gotA>>8, c.expect(dx, dy)) } } } }) } } // Orientation=1 / 越界值必须原样返回同一对象,不做无谓的整图重绘。 func TestApplyJPEGOrientationNoop(t *testing.T) { src := image.NewRGBA(image.Rect(0, 0, 4, 4)) for _, ori := range []int{0, 1, 9, -1} { if got := applyJPEGOrientation(src, ori); got != src { t.Fatalf("orientation %d: expected same instance back", ori) } } } // 端到端:带 Orientation=6 的 JPEG 走完整转换后,输出必须是竖图。 // // 依赖真实 libwebp(cgo)。在无 libwebp 的环境(webp 被替换为编译桩)下 // Encode 不产出字节,此时跳过——像素级映射由 TestApplyJPEGOrientation 覆盖。 func TestConvertStaticImageToWebPAppliesEXIFOrientation(t *testing.T) { // 存成 40x30 像素但 EXIF 声明 Orientation=6,实际应显示为 30x40。 data := buildJPEGWithOrientation(t, exifOrientRightTop, true, 40, 30) cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { t.Fatalf("DecodeConfig: %v", err) } if format != "jpeg" { t.Fatalf("format = %s, want jpeg", format) } // 先确认前提:标准库读到的就是横躺的像素,不应用 EXIF。 if cfg.Width != 40 || cfg.Height != 30 { t.Fatalf("raw jpeg dims = %dx%d, want 40x30", cfg.Width, cfg.Height) } out, err := ConvertStaticImageToWebP(bytes.NewReader(data), EncodeSpec{MaxSide: 1600, Quality: 80}) if err != nil { t.Fatalf("ConvertStaticImageToWebP: %v", err) } if len(out) == 0 || !bytes.HasPrefix(out, []byte("RIFF")) { t.Skip("webp encoder is stubbed (no libwebp); pixel mapping is covered by TestApplyJPEGOrientation") } w, h := webpDimensions(t, out) if w != 30 || h != 40 { t.Fatalf("webp dims = %dx%d, want 30x40 (orientation 6 applied)", w, h) } }