270 lines
9.8 KiB
Go
270 lines
9.8 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|