package exif import ( "bytes" "encoding/binary" "math" "testing" "time" ) // buildTIFF 拼出小端 TIFF(IFD0 + ExifIFD + GPS IFD),JPEG 与 HEIC 共用。 func buildTIFF(lat, lon float64, shot string) []byte { tiff := &bytes.Buffer{} tiff.WriteString("II") binary.Write(tiff, binary.LittleEndian, uint16(0x002A)) binary.Write(tiff, binary.LittleEndian, uint32(8)) // IFD0 偏移 const ( ifd0Off = 8 exifOff = ifd0Off + 30 // IFD0: 2 条目 = 2 + 24 + 4 shotOff = exifOff + 18 // ExifIFD: 1 条目 = 2 + 12 + 4 shotLen = len("2006:01:02 15:04:05") + 1 gpsOff = shotOff + shotLen ratOff = gpsOff + 54 // GPS IFD: 4 条目 = 2 + 48 + 4 ) // IFD0 binary.Write(tiff, binary.LittleEndian, uint16(2)) tiff.Write(entryBytes(0x8769, 4, 1, uint32(exifOff))) tiff.Write(entryBytes(0x8825, 4, 1, uint32(gpsOff))) binary.Write(tiff, binary.LittleEndian, uint32(0)) // ExifIFD:DateTimeOriginal binary.Write(tiff, binary.LittleEndian, uint16(1)) tiff.Write(entryBytes(0x9003, 2, uint32(shotLen), uint32(shotOff))) binary.Write(tiff, binary.LittleEndian, uint32(0)) tiff.WriteString(shot + "\x00") // GPS IFD binary.Write(tiff, binary.LittleEndian, uint16(4)) latRef, lonRef := byte('N'), byte('E') alat, alon := math.Abs(lat), math.Abs(lon) if lat < 0 { latRef = 'S' } if lon < 0 { lonRef = 'W' } tiff.Write(entryBytes(1, 2, 2, uint32(latRef))) tiff.Write(entryBytes(2, 5, 3, uint32(ratOff))) tiff.Write(entryBytes(3, 2, 2, uint32(lonRef))) tiff.Write(entryBytes(4, 5, 3, uint32(ratOff+24))) binary.Write(tiff, binary.LittleEndian, uint32(0)) writeDMS(tiff, alat) writeDMS(tiff, alon) return tiff.Bytes() } // buildJPEG 拼一张最小 JPEG:SOI + APP1(EXIF) + EOI。 // 只有 EXIF 段是真的,后面不需要有效图像数据——解析器读到 APP1 就返回。 func buildJPEG(t *testing.T, lat, lon float64, shot string) []byte { t.Helper() body := &bytes.Buffer{} body.Write([]byte("Exif\x00\x00")) body.Write(buildTIFF(lat, lon, shot)) out := &bytes.Buffer{} out.Write([]byte{0xFF, 0xD8, 0xFF, 0xE1}) binary.Write(out, binary.BigEndian, uint16(body.Len()+2)) out.Write(body.Bytes()) out.Write([]byte{0xFF, 0xD9}) return out.Bytes() } // mkBox 拼一个 ISO BMFF 盒子(32 位长度)。 func mkBox(typ string, payload []byte) []byte { b := make([]byte, 8+len(payload)) binary.BigEndian.PutUint32(b[0:4], uint32(8+len(payload))) copy(b[4:8], typ) copy(b[8:], payload) return b } // buildHEIC 拼一个最小 HEIC:ftyp + meta(iinf + iloc) + Exif item。 // Exif item 放在文件末尾,iloc 里填它的绝对偏移——真实文件也是这样定位的。 func buildHEIC(t *testing.T, lat, lon float64, shot string) []byte { t.Helper() tiff := buildTIFF(lat, lon, shot) // HEIF 的 Exif item = 4 字节 TIFF 偏移 + TIFF 本体 item := append([]byte{0, 0, 0, 4}, tiff...) ftyp := mkBox("ftyp", append([]byte("heic"), []byte{0, 0, 0, 0, 'h', 'e', 'i', 'c'}...)) // infe v2:version+flags + item_ID + protection + item_type + 空名字 infe := mkBox("infe", append([]byte{2, 0, 0, 0, 0, 1, 0, 0}, append([]byte("Exif"), 0)...)) iinf := mkBox("iinf", append([]byte{0, 0, 0, 0, 0, 1}, infe...)) // iloc v0:offsetSize=4 lengthSize=4 baseSize=0,一条数据段 ilocPayload := []byte{0, 0, 0, 0, 0x44, 0x00, 0, 1, 0, 1, 0, 0, 0, 1} offPos := len(ilocPayload) ilocPayload = append(ilocPayload, 0, 0, 0, 0) // extent_offset 待回填 ilocPayload = append(ilocPayload, byte(len(item)>>24), byte(len(item)>>16), byte(len(item)>>8), byte(len(item))) iloc := mkBox("iloc", ilocPayload) meta := mkBox("meta", append([]byte{0, 0, 0, 0}, append(iinf, iloc...)...)) // extent_offset 的绝对位置:meta 头(8) + fullbox(4) + iinf + iloc 头(8) + 段内偏移 pos := 8 + 4 + len(iinf) + 8 + offPos binary.BigEndian.PutUint32(meta[pos:pos+4], uint32(len(ftyp)+len(meta))) out := &bytes.Buffer{} out.Write(ftyp) out.Write(meta) out.Write(item) return out.Bytes() } func entryBytes(tag, typ uint16, count uint32, val uint32) []byte { b := make([]byte, 12) binary.LittleEndian.PutUint16(b[0:2], tag) binary.LittleEndian.PutUint16(b[2:4], typ) binary.LittleEndian.PutUint32(b[4:8], count) binary.LittleEndian.PutUint32(b[8:12], val) return b } // writeDMS 把十进制度拆成 度/分/秒 三个 rational。 func writeDMS(w *bytes.Buffer, v float64) { d := math.Floor(v) m := math.Floor((v - d) * 60) s := ((v-d)*60 - m) * 60 for _, p := range [][2]uint32{{uint32(d), 1}, {uint32(m), 1}, {uint32(math.Round(s * 10000)), 10000}} { binary.Write(w, binary.LittleEndian, p[0]) binary.Write(w, binary.LittleEndian, p[1]) } } func TestParseGPSAndTime(t *testing.T) { // 大理古城附近,EXIF 存的是 WGS-84 jpg := buildJPEG(t, 25.6890, 100.1560, "2026:08:14 12:40:33") info, err := Parse(bytes.NewReader(jpg)) if err != nil { t.Fatalf("解析失败: %v", err) } if !info.HasGPS { t.Fatal("没读到 GPS") } if math.Abs(info.Lat-25.6890) > 1e-4 || math.Abs(info.Lon-100.1560) > 1e-4 { t.Fatalf("坐标不对: %v, %v", info.Lat, info.Lon) } if !info.HasTime { t.Fatal("没读到拍摄时间") } want := time.Date(2026, 8, 14, 12, 40, 33, 0, time.Local) if !info.ShotAt.Equal(want) { t.Fatalf("时间不对: %v 期望 %v", info.ShotAt, want) } } func TestParseSouthWest(t *testing.T) { jpg := buildJPEG(t, -33.8688, -70.6693, "2025:01:02 03:04:05") info, err := Parse(bytes.NewReader(jpg)) if err != nil { t.Fatalf("解析失败: %v", err) } if info.Lat > 0 || info.Lon > 0 { t.Fatalf("南纬/西经应该是负数: %v, %v", info.Lat, info.Lon) } } func TestParseRejectsNonImage(t *testing.T) { if _, err := Parse(bytes.NewReader([]byte("PNG\x89 not a jpeg"))); err == nil { t.Fatal("非图片应该报错") } bare := []byte{0xFF, 0xD8, 0xFF, 0xD9} // 合法 JPEG 但没有 EXIF 段 if _, err := Parse(bytes.NewReader(bare)); err == nil { t.Fatal("没有 EXIF 应该报错") } } func TestParseHEIC(t *testing.T) { // 喜洲古镇附近,EXIF 存 WGS-84 heic := buildHEIC(t, 25.8560, 100.1310, "2026:08:15 15:43:00") info, err := Parse(bytes.NewReader(heic)) if err != nil { t.Fatalf("HEIC 解析失败: %v", err) } if !info.HasGPS || math.Abs(info.Lat-25.8560) > 1e-4 || math.Abs(info.Lon-100.1310) > 1e-4 { t.Fatalf("坐标不对: %v, %v", info.Lat, info.Lon) } if !info.HasTime || info.ShotAt.Format("2006-01-02 15:04:05") != "2026-08-15 15:43:00" { t.Fatalf("时间不对: %v", info.ShotAt) } } func TestParseHEICNoExifItem(t *testing.T) { // 有 meta/iinf/iloc 但没有 Exif item:应报「没有 EXIF」而不是崩 infe := mkBox("infe", append([]byte{2, 0, 0, 0, 0, 1, 0, 0}, append([]byte("hvc1"), 0)...)) iinf := mkBox("iinf", append([]byte{0, 0, 0, 0, 0, 1}, infe...)) meta := mkBox("meta", append([]byte{0, 0, 0, 0}, iinf...)) ftyp := mkBox("ftyp", append([]byte("heic"), []byte{0, 0, 0, 0, 'h', 'e', 'i', 'c'}...)) buf := append(ftyp, meta...) if _, err := Parse(bytes.NewReader(buf)); err == nil { t.Fatal("没有 Exif item 应该报错") } } func TestParseTruncatedDoesNotPanic(t *testing.T) { // 截断到各种长度都不该 panic(上传中断、文件损坏在真实世界里都会出现) full := buildHEIC(t, 25.8560, 100.1310, "2026:08:15 15:43:00") for _, n := range []int{1, 5, 12, 20, len(full) / 2, len(full) - 1} { if n <= len(full) { _, _ = Parse(bytes.NewReader(full[:n])) } } jpg := buildJPEG(t, 25.6890, 100.1560, "2026:08:14 12:40:33") for _, n := range []int{1, 4, 10, len(jpg) / 2, len(jpg) - 1} { _, _ = Parse(bytes.NewReader(jpg[:n])) } }