first release
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user