Files
2026-10-16 09:39:30 +08:00

270 lines
9.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(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)
}
}
// 回归:比例校验必须与转码时的 EXIF 归一用同一套方向。
//
// 手机竖拍照片存成 40x30 像素但 EXIF 声明 Orientation=6,肉眼看到的是 30x40 的竖图。
// 若比例校验只看原始像素宽高(30/40 的横躺比例),一张合规的方形头像会被判为
// 「比例不符」而拒——这正是线上验证曾发现的缺口。
func TestAspectCheckAppliesEXIFOrientation(t *testing.T) {
// 像素 40x30 + Orientation=6 → 归一后 30x40(竖图 3:4,不是 1:1)
if err := (&aspect{W: 1, H: 1}).check(image.Config{Width: 40, Height: 30}, exifOrientRightTop); err == nil {
t.Fatal("40x30 + ori6 归一后是 3:4 竖图,头像应判为比例不符")
}
// 像素 40x30 + Orientation=1 → 仍是 3:4 竖图,同样应拒
if err := (&aspect{W: 1, H: 1}).check(image.Config{Width: 40, Height: 30}, exifOrientTopLeft); err == nil {
t.Fatal("40x30 ori1 是 3:4 竖图,头像应判为比例不符")
}
// 像素 40x40 + Orientation=6 → 归一后仍 40x40,头像应放行
if err := (&aspect{W: 1, H: 1}).check(image.Config{Width: 40, Height: 40}, exifOrientRightTop); err != nil {
t.Fatalf("40x40 + ori6 应放行: %v", err)
}
// 背景 16:9:像素 160x90 + Orientation=6 → 归一后 90x160(9:16)应拒
if err := (&aspect{W: 16, H: 9}).check(image.Config{Width: 160, Height: 90}, exifOrientRightTop); err == nil {
t.Fatal("160x90 + ori6 归一后是 9:16 竖图,背景应判为比例不符")
}
// nil aspect 表示该用途不校验比例(如通用图)
if err := (*aspect)(nil).check(image.Config{Width: 4000, Height: 3}, exifOrientRightTop); err != nil {
t.Fatalf("nil aspect 不应校验: %v", err)
}
}
// 回归:EXIF 归一会交换宽高的四种方向,与不交换的三种。
func TestOrientationSwapsDimensions(t *testing.T) {
swaps := map[int]bool{
exifOrientTopLeft: false, exifOrientTopRight: false,
exifOrientBottomRight: false, exifOrientBottomLeft: false,
exifOrientLeftTop: true, exifOrientRightTop: true,
exifOrientRightBottom: true, exifOrientLeftBottom: true,
}
for ori, want := range swaps {
if got := orientationSwapsDimensions(ori); got != want {
t.Errorf("orientationSwapsDimensions(%d) = %v, want %v", ori, got, want)
}
}
}