first release
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
// Package exif 从 JPEG 里读出拍摄时间与 GPS 坐标(照片导入用)。
|
||||
//
|
||||
// 只做两件事:EXIF 的 DateTimeOriginal 和 GPS IFD,其余一概不碰。
|
||||
// 之所以自己解析而不引第三方库:站点是内网/离线构建,少一个依赖少一份麻烦;
|
||||
// JPEG 的 EXIF 就在紧跟在 SOI 之后的 APP1 段里,格式固定,值得手写。
|
||||
//
|
||||
// 注意 EXIF 里的 GPS 是 WGS-84,而 rond / 站点内部是 GCJ-02,
|
||||
// 调用方拿到坐标后要自己转(见 internal/geo.WGS84ToGCJ02)。
|
||||
package exif
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Info 是从一张照片里读到的有用信息。
|
||||
type Info struct {
|
||||
Lat, Lon float64
|
||||
HasGPS bool
|
||||
ShotAt time.Time
|
||||
HasTime bool
|
||||
}
|
||||
|
||||
var (
|
||||
errNotImage = errors.New("不认识的图片格式(只支持 JPEG 与 HEIC/HEIF)")
|
||||
errNoEXIF = errors.New("这张图片没有 EXIF 信息")
|
||||
errNeedField = errors.New("EXIF 里没有拍摄时间或 GPS")
|
||||
// exif 时间没有时区,按本地理解(手机拍的就是当地时间)
|
||||
timeLayout = "2006:01:02 15:04:05"
|
||||
)
|
||||
|
||||
// Parse 读出照片的拍摄时间与 GPS,自动识别 JPEG 与 HEIC/HEIF。
|
||||
// 缺 GPS 或时间都能容忍,用 Has* 判断。
|
||||
func Parse(r io.Reader) (Info, error) {
|
||||
// HEIC 的 Exif item 位置由 iloc 指到绝对偏移,可能在文件靠后,所以要读全;
|
||||
// 上限 32MB 兜底(调用方另有更严的大小限制)。
|
||||
buf, err := io.ReadAll(io.LimitReader(r, 32<<20))
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
if len(buf) >= 2 && buf[0] == 0xFF && buf[1] == 0xD8 {
|
||||
return parseJPEG(buf)
|
||||
}
|
||||
// ISO BMFF:第 5~8 字节是 ftyp
|
||||
if len(buf) >= 12 && string(buf[4:8]) == "ftyp" {
|
||||
return parseHEIF(buf)
|
||||
}
|
||||
return Info{}, errNotImage
|
||||
}
|
||||
|
||||
// parseJPEG 扫 JPEG 的段,找到 APP1 里的 EXIF。
|
||||
func parseJPEG(buf []byte) (Info, error) {
|
||||
i := 2
|
||||
for i+4 <= len(buf) {
|
||||
if buf[i] != 0xFF { // 段间的填充字节
|
||||
i++
|
||||
continue
|
||||
}
|
||||
marker := buf[i+1]
|
||||
switch {
|
||||
case marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7):
|
||||
i += 2 // 这些标记后面没有长度字段
|
||||
continue
|
||||
case marker == 0xDA || marker == 0xD9: // 图像数据开始 / 文件结束
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
size := int(buf[i+2])<<8 | int(buf[i+3])
|
||||
if size < 2 || i+2+size > len(buf) {
|
||||
break
|
||||
}
|
||||
seg := buf[i+4 : i+2+size]
|
||||
if marker == 0xE1 && len(seg) > 6 && string(seg[:6]) == "Exif\x00\x00" {
|
||||
return parseTIFF(seg[6:])
|
||||
}
|
||||
i += 2 + size
|
||||
}
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
|
||||
// ParseAll 是 Parse 的便利版:把「有照片但没 GPS」也算成软失败,
|
||||
// 返回尽量多的信息,同时给出缺什么。
|
||||
func ParseAll(r io.Reader) (Info, error) {
|
||||
info, err := Parse(r)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
if !info.HasGPS && !info.HasTime {
|
||||
return info, errNeedField
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
typ uint16
|
||||
count uint32
|
||||
valOff [4]byte
|
||||
}
|
||||
|
||||
// readIFD 读出某个 IFD 的所有条目(tag -> entry)。
|
||||
func readIFD(t []byte, off int, bo binary.ByteOrder) map[uint16]entry {
|
||||
out := map[uint16]entry{}
|
||||
if off < 0 || off+2 > len(t) {
|
||||
return out
|
||||
}
|
||||
n := int(bo.Uint16(t[off : off+2]))
|
||||
base := off + 2
|
||||
if base+n*12 > len(t) {
|
||||
n = (len(t) - base) / 12
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
p := base + i*12
|
||||
e := entry{
|
||||
typ: bo.Uint16(t[p+2 : p+4]),
|
||||
count: bo.Uint32(t[p+4 : p+8]),
|
||||
}
|
||||
copy(e.valOff[:], t[p+8:p+12])
|
||||
out[bo.Uint16(t[p:p+2])] = e
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// typeSize 是 EXIF 里每种类型的字节数。
|
||||
func typeSize(typ uint16) int {
|
||||
switch typ {
|
||||
case 1, 2, 6, 7: // BYTE / ASCII / SBYTE / UNDEFINED
|
||||
return 1
|
||||
case 3, 8: // SHORT / SSHORT
|
||||
return 2
|
||||
case 4, 9, 11: // LONG / SLONG / FLOAT
|
||||
return 4
|
||||
case 5, 10, 12: // RATIONAL / SRATIONAL / DOUBLE
|
||||
return 8
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// valueOffset 返回值所在位置的偏移。总长 > 4 字节时 valOff 存的是偏移,
|
||||
// 否则值直接内联在 valOff 里(左对齐)。
|
||||
func (e entry) valueOffset(t []byte, bo binary.ByteOrder) int {
|
||||
if int(e.count)*typeSize(e.typ) > 4 {
|
||||
return int(bo.Uint32(e.valOff[:]))
|
||||
}
|
||||
// 内联值:算出它在整个 t 里的位置
|
||||
return -1
|
||||
}
|
||||
|
||||
// inline 返回内联值(4 字节以内)的字节。
|
||||
func (e entry) inline() []byte { return e.valOff[:] }
|
||||
|
||||
// ascii 读 ASCII 类型的值(EXIF 里以 NUL 结尾)。
|
||||
func (e entry) ascii(t []byte, bo binary.ByteOrder) string {
|
||||
total := int(e.count)
|
||||
if total <= 0 {
|
||||
return ""
|
||||
}
|
||||
var raw []byte
|
||||
if total <= 4 {
|
||||
raw = e.inline()[:total]
|
||||
} else {
|
||||
off := int(bo.Uint32(e.valOff[:]))
|
||||
if off < 0 || off+total > len(t) {
|
||||
return ""
|
||||
}
|
||||
raw = t[off : off+total]
|
||||
}
|
||||
return strings.TrimRight(string(raw), "\x00")
|
||||
}
|
||||
|
||||
// rationals 读一组 RATIONAL(用 uint32 对表示),n 是想要的个数。
|
||||
func (e entry) rationals(t []byte, bo binary.ByteOrder, n int) ([]float64, bool) {
|
||||
off := int(bo.Uint32(e.valOff[:]))
|
||||
if off < 0 || off+n*8 > len(t) {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num := bo.Uint32(t[off+i*8 : off+i*8+4])
|
||||
den := bo.Uint32(t[off+i*8+4 : off+i*8+8])
|
||||
if den == 0 {
|
||||
return nil, false
|
||||
}
|
||||
out[i] = float64(num) / float64(den)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func parseTIFF(t []byte) (Info, error) {
|
||||
var info Info
|
||||
if len(t) < 8 {
|
||||
return info, errNoEXIF
|
||||
}
|
||||
var bo binary.ByteOrder
|
||||
switch string(t[:2]) {
|
||||
case "II":
|
||||
bo = binary.LittleEndian
|
||||
case "MM":
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return info, errNoEXIF
|
||||
}
|
||||
if bo.Uint16(t[2:4]) != 0x002A {
|
||||
return info, errNoEXIF
|
||||
}
|
||||
ifd0 := readIFD(t, int(bo.Uint32(t[4:8])), bo)
|
||||
|
||||
// 拍摄时间在 Exif 子 IFD(IFD0 里的 0x8769 指向它)
|
||||
if e, ok := ifd0[0x8769]; ok {
|
||||
sub := readIFD(t, int(bo.Uint32(e.valOff[:])), bo)
|
||||
if d, ok := sub[0x9003]; ok {
|
||||
if s := strings.TrimSpace(d.ascii(t, bo)); s != "" {
|
||||
if tm, err := time.ParseInLocation(timeLayout, s, time.Local); err == nil {
|
||||
info.ShotAt, info.HasTime = tm, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GPS 在另一个子 IFD(IFD0 里的 0x8825)
|
||||
if e, ok := ifd0[0x8825]; ok {
|
||||
gps := readIFD(t, int(bo.Uint32(e.valOff[:])), bo)
|
||||
lat, okLat := gpsCoord(gps, t, bo, 1, 2)
|
||||
lon, okLon := gpsCoord(gps, t, bo, 3, 4)
|
||||
if okLat && okLon {
|
||||
info.Lat, info.Lon, info.HasGPS = lat, lon, true
|
||||
}
|
||||
}
|
||||
if !info.HasGPS && !info.HasTime {
|
||||
return info, fmt.Errorf("%w", errNeedField)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// gpsCoord 把「度分秒 + 方位」拼成十进制度。refTag 是 1/3(N-S、E-W),valTag 是 2/4。
|
||||
func gpsCoord(gps map[uint16]entry, t []byte, bo binary.ByteOrder, refTag, valTag uint16) (float64, bool) {
|
||||
refE, ok1 := gps[refTag]
|
||||
valE, ok2 := gps[valTag]
|
||||
if !ok1 || !ok2 {
|
||||
return 0, false
|
||||
}
|
||||
parts, ok := valE.rationals(t, bo, 3)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
v := parts[0] + parts[1]/60 + parts[2]/3600
|
||||
switch strings.ToUpper(strings.TrimSpace(refE.ascii(t, bo))) {
|
||||
case "S", "W":
|
||||
v = -v
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// HEIF/HEIC 容器里的 EXIF 提取。
|
||||
//
|
||||
// HEIC 是 ISO BMFF(和 MP4 同一套盒子结构):文件头是 ftyp,后面跟一个 meta box,
|
||||
// 里面 iinf 描述每个 item(其中有一个 item_type == "Exif"),iloc 给出它的位置。
|
||||
// 取出来的 Exif item 是「4 字节 TIFF 偏移 + 标准 TIFF」,后半段与 JPEG 的 APP1 完全一样,
|
||||
// 所以直接交给 parseTIFF 复用。
|
||||
//
|
||||
// 纯 Go 实现,不引 libheif:内网/离线构建环境下 CGO 依赖太麻烦,而我们只要 EXIF 这一小块。
|
||||
package exif
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var errNotHEIF = errors.New("不是 HEIF/HEIC 容器")
|
||||
|
||||
// boxHeader 读一个 box 的头,返回类型、负载起点与下一个 box 的起点。
|
||||
func boxHeader(b []byte, off int) (typ string, payload, next int, ok bool) {
|
||||
if off < 0 || off+8 > len(b) {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
size := int(binary.BigEndian.Uint32(b[off : off+4]))
|
||||
hdr := 8
|
||||
switch size {
|
||||
case 1: // largesize
|
||||
if off+16 > len(b) {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
size = int(binary.BigEndian.Uint64(b[off+8 : off+16]))
|
||||
hdr = 16
|
||||
case 0: // 一直到文件末尾
|
||||
size = len(b) - off
|
||||
}
|
||||
if size < hdr || off+size > len(b) {
|
||||
return "", 0, 0, false
|
||||
}
|
||||
return string(b[off+4 : off+8]), off + hdr, off + size, true
|
||||
}
|
||||
|
||||
// parseHEIF 从 HEIC/HEIF 里取 EXIF。
|
||||
func parseHEIF(b []byte) (Info, error) {
|
||||
for off := 0; off+8 <= len(b); {
|
||||
typ, payload, next, ok := boxHeader(b, off)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if typ == "meta" {
|
||||
// meta 是 FullBox:负载前面还有 4 字节 version+flags
|
||||
if payload+4 > next {
|
||||
return Info{}, errNotHEIF
|
||||
}
|
||||
return parseMeta(b, payload+4, next)
|
||||
}
|
||||
if next <= off {
|
||||
break
|
||||
}
|
||||
off = next
|
||||
}
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
|
||||
func parseMeta(b []byte, start, end int) (Info, error) {
|
||||
iinfStart, iinfEnd := -1, -1
|
||||
ilocStart, ilocEnd := -1, -1
|
||||
for off := start; off+8 <= end; {
|
||||
typ, payload, next, ok := boxHeader(b, off)
|
||||
if !ok || next > end {
|
||||
break
|
||||
}
|
||||
switch typ {
|
||||
case "iinf":
|
||||
iinfStart, iinfEnd = payload, next
|
||||
case "iloc":
|
||||
ilocStart, ilocEnd = payload, next
|
||||
}
|
||||
off = next
|
||||
}
|
||||
if iinfStart < 0 || ilocStart < 0 {
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
itemID, ok := findExifItem(b, iinfStart, iinfEnd)
|
||||
if !ok {
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
off, length, ok := findItemExtent(b, ilocStart, ilocEnd, itemID)
|
||||
if !ok || off < 0 || length <= 0 || off+length > len(b) {
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
item := b[off : off+length]
|
||||
if len(item) < 4 {
|
||||
return Info{}, errNoEXIF
|
||||
}
|
||||
// Exif item 开头 4 字节是 TIFF 头相对本 item 起点的偏移
|
||||
tiff := int(binary.BigEndian.Uint32(item[:4]))
|
||||
if tiff < 0 || tiff+8 > len(item) {
|
||||
tiff = 4 // 少数写入方这里填得不准,退化成「紧跟这 4 字节」
|
||||
}
|
||||
return parseTIFF(item[tiff:])
|
||||
}
|
||||
|
||||
// findExifItem 在 iinf 里找 item_type 为 "Exif" 的 item。
|
||||
func findExifItem(b []byte, start, end int) (uint32, bool) {
|
||||
if start+4 > end {
|
||||
return 0, false
|
||||
}
|
||||
ver := b[start]
|
||||
p := start + 4
|
||||
var count int
|
||||
if ver == 0 {
|
||||
if p+2 > end {
|
||||
return 0, false
|
||||
}
|
||||
count = int(binary.BigEndian.Uint16(b[p : p+2]))
|
||||
p += 2
|
||||
} else {
|
||||
if p+4 > end {
|
||||
return 0, false
|
||||
}
|
||||
count = int(binary.BigEndian.Uint32(b[p : p+4]))
|
||||
p += 4
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
typ, payload, next, ok := boxHeader(b, p)
|
||||
if !ok || next > end {
|
||||
break
|
||||
}
|
||||
// infe v2 起:version+flags(4) + item_ID(2) + protection(2) + item_type(4)
|
||||
if typ == "infe" && payload < next && b[payload] >= 2 && payload+12 <= next {
|
||||
if string(b[payload+8:payload+12]) == "Exif" {
|
||||
return uint32(binary.BigEndian.Uint16(b[payload+4 : payload+6])), true
|
||||
}
|
||||
}
|
||||
p = next
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// findItemExtent 在 iloc 里找指定 item 的第一段数据(偏移 + 长度)。
|
||||
func findItemExtent(b []byte, start, end int, wantID uint32) (int, int, bool) {
|
||||
if start+8 > end {
|
||||
return 0, 0, false
|
||||
}
|
||||
ver := b[start]
|
||||
p := start + 4
|
||||
offsetSize := int(b[p] >> 4)
|
||||
lengthSize := int(b[p] & 0x0F)
|
||||
p++
|
||||
baseSize := int(b[p] >> 4)
|
||||
indexSize := 0
|
||||
if ver >= 1 {
|
||||
indexSize = int(b[p] & 0x0F)
|
||||
}
|
||||
p++
|
||||
if offsetSize == 0 || lengthSize == 0 || p+2 > end {
|
||||
return 0, 0, false
|
||||
}
|
||||
count := int(binary.BigEndian.Uint16(b[p : p+2]))
|
||||
p += 2
|
||||
|
||||
// 越过越界就读不动了,返回 false 让上层当作没找到
|
||||
readN := func(n int) (uint64, bool) {
|
||||
if n < 0 || p+n > end {
|
||||
return 0, false
|
||||
}
|
||||
var v uint64
|
||||
for i := 0; i < n; i++ {
|
||||
v = v<<8 | uint64(b[p+i])
|
||||
}
|
||||
p += n
|
||||
return v, true
|
||||
}
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
var id uint32
|
||||
if ver == 0 {
|
||||
v, ok := readN(2)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
id = uint32(v)
|
||||
} else {
|
||||
v, ok := readN(4)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
id = uint32(v)
|
||||
}
|
||||
if ver >= 1 {
|
||||
if _, ok := readN(2); !ok { // construction_method + reserved
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
if _, ok := readN(2); !ok { // data_reference_index
|
||||
return 0, 0, false
|
||||
}
|
||||
base, ok := readN(baseSize)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
extentCountV, ok := readN(2)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
for e := 0; e < int(extentCountV); e++ {
|
||||
if ver >= 1 && indexSize > 0 {
|
||||
if _, ok := readN(indexSize); !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
off, ok1 := readN(offsetSize)
|
||||
length, ok2 := readN(lengthSize)
|
||||
if !ok1 || !ok2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if id == wantID {
|
||||
return int(base + off), int(length), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
Reference in New Issue
Block a user