精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
package exif
import (
"fmt"
"sync"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner/externaltools/exiftool"
)
var globalExifParser *exiftool.Exiftool
var globalInit sync.Once
func Initialize() (func(), error) {
var err error
globalInit.Do(func() {
globalExifParser, err = exiftool.New()
})
if err != nil {
return nil, err
}
log.Info(nil, "Found exiftool.", "binary_path", globalExifParser.BinaryPath(), "version", globalExifParser.Version())
return func() {
globalMu.Lock()
defer globalMu.Unlock()
if globalExifParser == nil {
return
}
if err := globalExifParser.Close(); err != nil {
log.Error(nil, "Cleanup exiftool error", "error", err)
return
}
globalExifParser = nil
}, nil
}
var globalMu sync.Mutex
func Parse(filepath string) (*models.MediaEXIF, error) {
globalMu.Lock()
defer globalMu.Unlock()
if globalExifParser == nil {
return nil, fmt.Errorf("no exif parser initialized")
}
var values struct {
exiftool.PhotoMeta
exiftool.TimeAll
exiftool.GPS
}
if err := globalExifParser.QueryJSONTagsByNumber(filepath, &values); err != nil {
return nil, err
}
values.PhotoMeta.SanitizeFloats()
ret := models.MediaEXIF{
Camera: values.Model,
Maker: values.Make,
Lens: values.LensModel,
Iso: values.ISO,
Flash: values.Flash,
Orientation: values.Orientation,
ExposureProgram: values.ExposureProgram,
Exposure: values.ExposureTime,
Aperture: values.Aperture,
FocalLength: values.FocalLength,
Description: values.ImageDescription,
}
dateShot := values.TimeAll.TimeInLocal()
if !dateShot.IsZero() {
ret.DateShot = new(dateShot)
}
offsetSec, ok := values.TimeAll.OffsetSecs(dateShot)
if ok {
ret.OffsetSecShot = &offsetSec
}
if values.GPS.IsValid() {
ret.GPSLatitude = values.GPS.GPSLatitude
ret.GPSLongitude = values.GPS.GPSLongitude
}
return &ret, nil
}
func MIMEType(filepath string) (string, error) {
globalMu.Lock()
defer globalMu.Unlock()
if globalExifParser == nil {
return "", fmt.Errorf("no exif parser initialized")
}
var mime exiftool.MIMEType
if err := globalExifParser.QueryJSONTagsByNumber(filepath, &mime); err != nil {
return "", err
}
if mime.MIMEType == nil {
return "", nil
}
return *mime.MIMEType, nil
}
+141
View File
@@ -0,0 +1,141 @@
package exif
import (
"os"
"sync"
"testing"
"time"
_ "github.com/photoview/photoview/api/test_utils/flags"
)
func resetForTest() {
globalMu.Lock()
defer globalMu.Unlock()
if globalExifParser != nil {
_ = globalExifParser.Close()
}
globalExifParser = nil
// Allow Initialize() to run again in subsequent tests
globalInit = sync.Once{}
}
func TestParseWithoutInit(t *testing.T) {
resetForTest()
if _, err := Parse("./test_data/bird.jpg"); err == nil {
t.Fatalf("Parse() without Init() doesn't return an error")
}
}
func TestParse(t *testing.T) {
resetForTest()
cleanup, err := Initialize()
if err != nil {
t.Fatalf("Initialize() error: %v", err)
}
defer cleanup()
filename := "./test_data/bird.jpg"
metadata, err := Parse(filename)
if err != nil {
t.Fatalf("Parse() returns an error: %v", err)
}
if metadata == nil {
t.Errorf("Parse(%q) should not return nil", filename)
}
}
func TestMIMEType(t *testing.T) {
resetForTest()
cleanup, err := Initialize()
if err != nil {
t.Fatalf("Initialize() error: %v", err)
}
defer cleanup()
filename := "./test_data/bird.jpg"
mime, err := MIMEType(filename)
if err != nil {
t.Fatalf("MIMEType() returns an error: %v", err)
}
if mime == "" {
t.Errorf("MIMEType(%q) should not return an empty string", filename)
}
}
func fileModifyDateLiteralInUTC(t *testing.T, file string) time.Time {
fstat, err := os.Stat(file)
if err != nil {
t.Fatalf("os.Stat(%q) error: %v", file, err)
}
ret := fstat.ModTime().Truncate(time.Second)
_, offset := ret.Zone()
ret = ret.Add(time.Duration(offset) * time.Second).UTC()
return ret
}
func mustParseNoTimeZone(t *testing.T, timeStr string) time.Time {
layout := "2006:01:02 15:04:05.999"
ret, err := time.ParseInLocation(layout, timeStr, time.UTC)
if err != nil {
t.Fatalf("time.Parse(%q) error: %v", timeStr, err)
}
return ret
}
func TestSamplesTime(t *testing.T) {
resetForTest()
cleanup, err := Initialize()
if err != nil {
t.Fatalf("Initialize() error: %v", err)
}
defer cleanup()
tests := []struct {
file string
wantTime time.Time
wantOffsetSec *int
}{
{"./test_data/sample1.heif", fileModifyDateLiteralInUTC(t, "./test_data/sample1.heif"), nil},
{"./test_data/sample1_nef.jpg", mustParseNoTimeZone(t, "2008:03:15 07:44:21.49"), new(-7 * 60 * 60)},
}
for _, tc := range tests {
t.Run(tc.file, func(t *testing.T) {
metadata, err := Parse(tc.file)
if err != nil {
t.Fatalf("Parse(%q) returns an error: %v", tc.file, err)
}
if metadata == nil {
t.Fatalf("metadata == nil")
}
if metadata.DateShot == nil {
t.Fatalf("metadata.DateShot == nil")
}
if got := *metadata.DateShot; !got.Equal(tc.wantTime) {
t.Errorf("metadata.DateShot = %v, want: %v", got, tc.wantTime)
}
if tc.wantOffsetSec == nil {
if got := metadata.OffsetSecShot; got != nil {
t.Errorf("metadata.OffsetSecShot = %+v, want: nil", got)
}
} else {
if got, want := *metadata.OffsetSecShot, *tc.wantOffsetSec; got != want {
t.Errorf("metadata.OffsetSecShot = &(%v), want: &(%v)", got, want)
}
}
})
}
}
@@ -0,0 +1,260 @@
package exiftool
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"sync"
)
// Exiftool launches an external `exiftool` process to query photos' exif info.
// It doesn't support concurrency usage.
type Exiftool struct {
path string
version string
marker string
cmd *exec.Cmd
stdin io.WriteCloser
stdinBuf *bufio.Writer
stdout *MarkReader
stderr *MarkReader
closeOnce sync.Once
}
const marker = "{ready}\n"
const bufferSize = 10240
// New returns a new instance of Exiftool.
func New() (*Exiftool, error) {
path, err := exec.LookPath("exiftool")
if err != nil {
return nil, err
}
voutput, err := exec.Command(path, "-ver").Output()
if err != nil {
return nil, fmt.Errorf("run `exiftool -ver` error: %w", err)
}
version := string(bytes.Trim(voutput, " \t\n\r"))
if version == "" {
return nil, fmt.Errorf("run `exiftool -ver` error: no output")
}
cmd := exec.Command(path, "-stay_open", "True", "-@", "-")
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stdin error: %w", err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
defer stdin.Close()
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stdout error: %w", err)
}
stdout, err := NewMarkReader(stdoutPipe, bufferSize, marker)
if err != nil {
defer stdin.Close()
return nil, err
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
defer stdin.Close()
return nil, fmt.Errorf("get `exiftool -stay_open True -@ -` stderr error: %w", err)
}
stderr, err := NewMarkReader(stderrPipe, bufferSize, marker)
if err != nil {
defer stdin.Close()
return nil, err
}
if err := cmd.Start(); err != nil {
defer stdin.Close()
return nil, fmt.Errorf("run `exiftool -stay_open True -@ -` error: %w", err)
}
return &Exiftool{
path: path,
version: version,
marker: marker,
cmd: cmd,
stdin: stdin,
stdinBuf: bufio.NewWriterSize(stdin, bufferSize),
stdout: stdout,
stderr: stderr,
}, nil
}
// BinaryPath returns the path of `exiftool` binary.
func (e *Exiftool) BinaryPath() string {
return e.path
}
// Version returns the version of `exiftool` binary.
func (e *Exiftool) Version() string {
return e.version
}
// Close stops the external process of `exiftool`.
func (e *Exiftool) Close() (err error) {
e.closeOnce.Do(func() {
defer e.cmd.Wait()
defer e.stdin.Close()
if err = e.rawSendCommand("-stay_open", "False"); err != nil {
_ = e.cmd.Process.Kill()
}
})
return
}
func (e *Exiftool) rawSendCommand(args ...string) error {
e.stdout.Reset()
e.stderr.Reset()
for _, arg := range args {
if _, err := e.stdinBuf.WriteString(arg); err != nil {
return err
}
if err := e.stdinBuf.WriteByte('\n'); err != nil {
return err
}
}
for _, arg := range []string{"-echo4\n", "{ready}\n", "-execute\n"} {
if _, err := e.stdinBuf.WriteString(arg); err != nil {
return err
}
}
return e.stdinBuf.Flush()
}
func (e *Exiftool) rawReadStderr() error {
stderrOutput, err := io.ReadAll(e.stderr)
if err != nil {
return err
}
outStr := string(bytes.Trim(stderrOutput, " \n\t\r"))
if outStr == "" {
return nil
}
return errors.New(outStr)
}
func (e *Exiftool) rawGetTags(v any, args ...string) (err error) {
if err = e.rawSendCommand(append(args, "-json")...); err != nil {
return
}
defer func() {
_, _ = io.Copy(io.Discard, e.stdout)
err = e.rawReadStderr()
}()
if err = json.NewDecoder(e.stdout).Decode(v); err != nil {
return
}
return nil
}
func (e *Exiftool) rawSaveEmbedFile(outputPath string, args ...string) (hasEmbededFile bool, err error) {
if err = e.rawSendCommand(append(args, "-b", "-W", outputPath)...); err != nil {
return
}
defer func() {
_, _ = io.Copy(io.Discard, e.stdout)
err = e.rawReadStderr()
}()
var output []byte
if output, err = io.ReadAll(e.stdout); err != nil {
return
}
outStr := string(bytes.Trim(output, " \n\r\t"))
switch outStr {
case "0 output files created":
return
case "1 output files created":
default:
err = fmt.Errorf("invalid output: %s", outStr)
return
}
if err = e.rawReadStderr(); err != nil {
return
}
hasEmbededFile = true
return
}
func (e *Exiftool) rawUpdateFile(args ...string) (err error) {
if err = e.rawSendCommand(args...); err != nil {
return
}
defer func() {
_, _ = io.Copy(io.Discard, e.stdout)
err = e.rawReadStderr()
}()
var output []byte
if output, err = io.ReadAll(e.stdout); err != nil {
return
}
outStr := string(bytes.Trim(output, " \n\r\t"))
if outStr != "1 image files updated" {
err = fmt.Errorf("invalid output: %s", outStr)
return
}
return
}
// QueryJSONTagsByNumber queries the exif info of `file` with a given struct `value`. Tags are fields of the `value`. The values are a number if possible.
// See values.go for example structs.
func (e *Exiftool) QueryJSONTagsByNumber(file string, value any) error {
rows := []any{value}
if err := e.rawGetTags(&rows, "-n", file); err != nil {
return fmt.Errorf("query %q tags error: %w", file, err)
}
if len(rows) != 1 {
return fmt.Errorf("query %q tags error: return %d responses, should be only 1", file, len(rows))
}
return nil
}
// SaveJPEGPreview saves a preview jpeg from `src` to `previewOutput`.
func (e *Exiftool) SaveJPEGPreview(src string, previewOutput string) (bool, error) {
saved, err := e.rawSaveEmbedFile(previewOutput, "-JpgFromRaw", src)
if err != nil {
return false, fmt.Errorf("save jpeg preview for %q error: %w", src, err)
}
if !saved {
return false, nil
}
if err = e.rawUpdateFile("-TagsFromFile", src, "-overwrite_original", previewOutput); err != nil {
return false, fmt.Errorf("save tags to jpeg preview for %q error: %w", src, err)
}
return true, nil
}
@@ -0,0 +1,409 @@
package exiftool
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
_ "github.com/photoview/photoview/api/test_utils/flags"
)
func TestExiftool(t *testing.T) {
instance, err := New()
if err != nil {
t.Fatalf("create instance error: %v", err)
}
t.Log("bin:", instance.BinaryPath())
t.Log("version:", instance.Version())
if instance.BinaryPath() == "" {
t.Errorf("want exiftool binary, but got an empty string")
}
if instance.Version() == "" {
t.Errorf("want exiftool version, but got an emtpy string")
}
if err := instance.Close(); err != nil {
t.Errorf("close instance error: %v", err)
}
}
func TestExiftoolQueryJSONTagsWithEmbed(t *testing.T) {
instance, err := New()
if err != nil {
t.Fatalf("new error: %v", err)
}
defer instance.Close()
file := "./test_data/correct_gps.jpg"
var value struct {
TimeAll
MIMEType
PhotoMeta
}
if err := instance.QueryJSONTagsByNumber(file, &value); err != nil {
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", file, err)
return
}
if time := value.TimeAll.TimeInLocal(); time.IsZero() {
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid TimeAll", file)
}
if value.MIMEType.MIMEType == nil {
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid MIMEType", file)
}
if value.PhotoMeta.Model == nil {
t.Errorf("QueryJSONTagsByNumber(%q) error: no valid PhotoMeta", file)
}
}
func TestExiftoolQueryMIMEType(t *testing.T) {
tests := []struct {
file string
want string
}{
{"./test_data/no_timezone.jpg", "image/jpeg"},
{"./test_data/subsec_timezone.heic", "image/heic"},
{"./test_data/raw_with_preview_jpg.cr3", "image/x-canon-cr3"},
{"./test_data/no_exif.jpg", "image/jpeg"},
}
instance, err := New()
if err != nil {
t.Fatalf("new error: %v", err)
}
defer instance.Close()
for _, tc := range tests {
t.Run(tc.file, func(t *testing.T) {
var value struct{ MIMEType }
err := instance.QueryJSONTagsByNumber(tc.file, &value)
if err != nil {
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
return
}
if got := value.MIMEType.MIMEType; got == nil || *got != tc.want {
t.Errorf("QueryJSONTagsByNumber(%q) = %v, want: %q", tc.file, got, tc.want)
}
})
}
}
func checkTimeallFieldsHasValue(t *testing.T, time TimeAll, fields []string) {
t.Helper()
tv := reflect.ValueOf(time)
for _, field := range fields {
fv := tv.FieldByName(field)
if !fv.IsValid() {
t.Errorf("can't find field %q in TimeAll", field)
continue
}
if fv.IsNil() || fv.Elem().IsZero() {
t.Errorf("field %q is type %T with value %v", field, fv.Type(), fv.Interface())
continue
}
}
}
func TestExiftoolQueryTimeAllHasOffset(t *testing.T) {
tests := []struct {
file string
wantKeys []string
wantTime time.Time
wantOffsetSec int
}{
{"./test_data/raw_with_preview_jpg.cr3", []string{
"CreateDate",
"DateTimeOriginal",
"FileModifyDate",
"MediaCreateDate",
"OffsetTime",
"OffsetTimeOriginal",
"SubSecCreateDate",
"SubSecDateTimeOriginal",
"TimeZone",
"TrackCreateDate",
}, mustParseInUTC(t, "2019:09:13 14:36:48.87"), 7200},
{"./test_data/subsec_timezone.heic", []string{
"CreateDate",
"DateTimeOriginal",
"FileModifyDate",
"OffsetTime",
"OffsetTimeOriginal",
"SubSecCreateDate",
"SubSecDateTimeOriginal",
}, mustParseInUTC(t, "2025:10:28 14:20:22.164"), 3600},
{"./test_data/createdate_timezone_separate.jpg", []string{
"CreateDate",
"DateTimeOriginal",
"TimeZone",
"FileModifyDate",
"SubSecCreateDate",
"SubSecDateTimeOriginal",
}, mustParseInUTC(t, "2008:03:15 07:44:21.49"), -7 * 60 * 60},
}
instance, err := New()
if err != nil {
t.Fatalf("new error: %v", err)
}
defer instance.Close()
for _, tc := range tests {
t.Run(tc.file, func(t *testing.T) {
var value struct{ TimeAll }
err := instance.QueryJSONTagsByNumber(tc.file, &value)
if err != nil {
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
return
}
checkTimeallFieldsHasValue(t, value.TimeAll, tc.wantKeys)
gotTime := value.TimeAll.TimeInLocal()
if !gotTime.Equal(tc.wantTime) {
t.Errorf("value.TimeAll.TimeInLocal() = %v, want: %v", gotTime, tc.wantTime)
}
if got, ok := value.TimeAll.OffsetSecs(gotTime); !ok || got != tc.wantOffsetSec {
t.Errorf("value.TimeAll.OffsetSecs() = (%v, %v), want: (%v, true)", got, ok, tc.wantOffsetSec)
}
})
}
}
func fileModifyDateLiteralUTC(t *testing.T, file string) time.Time {
t.Helper()
dir := filepath.Dir(file)
file = filepath.Base(file)
fsys := os.DirFS(dir)
info, err := fs.Stat(fsys, file)
if err != nil {
t.Fatalf("read file stat error: %v", err)
}
ret := info.ModTime().Truncate(time.Second)
_, offsetSec := ret.Zone()
ret = ret.Add(time.Duration(offsetSec) * time.Second).UTC()
return ret
}
func TestExiftoolQueryTimeAllNoOffset(t *testing.T) {
tests := []struct {
file string
wantKeys []string
wantTime time.Time
wantHasOffset bool
wantOffset int
}{
{"./test_data/no_timezone.jpg", []string{
"DateTimeOriginal",
"FileModifyDate",
}, mustParseInUTC(t, "2012:05:06 15:39:44"), false, 0},
{"./test_data/subsec_no_timezone.heic", []string{
"CreateDate",
"DateTimeOriginal",
"FileModifyDate",
"OffsetTime",
"SubSecCreateDate",
"SubSecDateTimeOriginal",
}, mustParseInUTC(t, "2025:10:28 14:20:22.164"), true, 3600},
{"./test_data/no_exif.jpg", []string{
"FileModifyDate",
}, fileModifyDateLiteralUTC(t, "./test_data/no_exif.jpg"), false, 0},
}
instance, err := New()
if err != nil {
t.Fatalf("new error: %v", err)
}
defer instance.Close()
for _, tc := range tests {
t.Run(tc.file, func(t *testing.T) {
var value struct{ TimeAll }
err := instance.QueryJSONTagsByNumber(tc.file, &value)
if err != nil {
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
return
}
checkTimeallFieldsHasValue(t, value.TimeAll, tc.wantKeys)
gotTime := value.TimeAll.TimeInLocal()
if !gotTime.Equal(tc.wantTime) {
t.Errorf("value.TimeAll.TimeInLocal() = %v, want: %v", gotTime, tc.wantTime)
}
gotOffset, gotHasOffset := value.TimeAll.OffsetSecs(gotTime)
if gotHasOffset != tc.wantHasOffset {
t.Errorf("value.TimeAll.OffsetSecs() = (_, %v), want: (_, %v)", gotHasOffset, tc.wantHasOffset)
}
if !gotHasOffset {
return
}
if gotOffset != tc.wantOffset {
t.Errorf("value.TimeAll.OffsetSecs() = (%v, _), want: (%v, _)", gotOffset, tc.wantHasOffset)
}
})
}
}
func TestExiftoolQueryGPS(t *testing.T) {
tests := []struct {
file string
hasGPS bool
wantLat, wantLong float64
}{
{"./test_data/correct_gps.jpg", true, 44.4789972, 11.2979222},
{"./test_data/incorrect_gps.jpg", false, 0, 0},
{"./test_data/no_exif.jpg", false, 0, 0},
}
instance, err := New()
if err != nil {
t.Fatalf("new error: %v", err)
}
defer instance.Close()
for _, tc := range tests {
t.Run(tc.file, func(t *testing.T) {
var value struct{ GPS }
if err := instance.QueryJSONTagsByNumber(tc.file, &value); err != nil {
t.Errorf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
return
}
hasGPS := value.GPS.IsValid()
if hasGPS != tc.hasGPS {
t.Errorf("QueryJSONTagsByNumber(%q) has GPS: %v, want GPS: %v", tc.file, hasGPS, tc.hasGPS)
return
}
if !tc.hasGPS {
return
}
gpsToString := func(latitude, longitude float64) string {
return fmt.Sprintf("(%.7f, %.7f)", latitude, longitude)
}
lat := *value.GPS.GPSLatitude
long := *value.GPS.GPSLongitude
if got, want := gpsToString(lat, long), gpsToString(tc.wantLat, tc.wantLong); got != want {
t.Errorf("QueryJSONTagsByNumber(%q) = %s, want: %s", tc.file, got, want)
}
})
}
}
func TestExiftoolSaveJPEGPreview(t *testing.T) {
tests := []struct {
file string
wantOK bool
}{
{"./test_data/raw_with_preview_jpg.cr3", true},
{"./test_data/no_timezone.jpg", false},
}
instance, err := New()
if err != nil {
t.Fatalf("new error: %v", err)
}
defer instance.Close()
outputDir := t.TempDir()
for _, tc := range tests {
t.Run(tc.file, func(t *testing.T) {
output := filepath.Join(outputDir, "preview.jpg")
ok, err := instance.SaveJPEGPreview(tc.file, output)
if err != nil {
t.Errorf("SaveJPEGPreview(%q, %q) error: %v", tc.file, output, err)
return
}
if ok != tc.wantOK {
t.Errorf("SaveJPEGPreview(%q, %q) = %v, want: %v", tc.file, output, ok, tc.wantOK)
}
if !ok {
return
}
var jpg struct {
MIMEType
TimeAll
}
if err := instance.QueryJSONTagsByNumber(output, &jpg); err != nil {
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", output, err)
return
}
if got, want := jpg.MIMEType.MIMEType, "image/jpeg"; got == nil || *got != want {
t.Errorf("MIMEType(%q) = %v, want: %q", output, got, want)
return
}
var raw struct{ TimeAll }
if err := instance.QueryJSONTagsByNumber(tc.file, &raw); err != nil {
t.Fatalf("QueryJSONTagsByNumber(%q) error: %v", tc.file, err)
}
if got, want := jpg.TimeAll.TimeInLocal(), raw.TimeAll.TimeInLocal(); !got.Equal(want) {
t.Errorf("jpg.TimeAll.TimeInLocal() = %q, want: %q", got, want)
}
})
}
}
func TestExiftoolError(t *testing.T) {
instance, err := New()
if err != nil {
t.Fatalf("new error: %v", err)
}
defer instance.Close()
tests := []struct {
file string
errStr string
}{
{"./test_data/non_exist.jpg", "File not found"},
}
for _, tc := range tests {
t.Run(tc.file, func(t *testing.T) {
checkErr := func(err error, fmtStr string, args ...any) {
t.Helper()
if want := tc.errStr; err == nil || !strings.Contains(err.Error(), want) {
t.Errorf(fmtStr+" %v, want %v", append(args, err, want)...)
}
}
var value MIMEType
err = instance.QueryJSONTagsByNumber(tc.file, &value)
checkErr(err, "QueryJSONTagsByNumber(%q)", tc.file)
output := filepath.Join(t.TempDir(), "output.jpg")
_, err = instance.SaveJPEGPreview(tc.file, output)
checkErr(err, "SaveJPEGPreview(%q, %q)", tc.file, output)
})
}
}
@@ -0,0 +1,116 @@
package exiftool
import (
"bytes"
"errors"
"io"
)
// MarkReader reads from upstream and stops at marker boundaries.
type MarkReader struct {
upstream io.Reader
buf []byte
mark []byte
// valid data: buf[start:pending]
// pending, need to check if contains mark: buf[pending:end]
start int
pending int
end int
// if true, mark begins from buf[pending]
hasMark bool
// if true, upstream is EOF
upstreamEOF bool
// if true, this reader returns EOF
paused bool
}
// NewMarkReader creates a reader that stops at each marker and requires Reset to continue.
func NewMarkReader(upstream io.Reader, bufferSize int, mark string) (*MarkReader, error) {
if bufferSize < 2*len(mark) {
return nil, errors.New("buffer too small")
}
return &MarkReader{
upstream: upstream,
buf: make([]byte, bufferSize),
mark: []byte(mark),
}, nil
}
// Reset resumes reading after a previously encountered marker boundary.
func (r *MarkReader) Reset() {
r.paused = false
}
// Read returns bytes up to (but excluding) the next marker, then reports EOF until Reset.
func (r *MarkReader) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if r.paused {
return 0, io.EOF
}
if r.start == r.pending {
// compact buffer
copy(r.buf[0:], r.buf[r.start:r.end])
r.end -= r.start
r.pending -= r.start
r.start = 0
// fill buffer
n, err := io.ReadAtLeast(r.upstream, r.buf[r.end:], len(r.mark))
r.end += n
if err == io.EOF || err == io.ErrUnexpectedEOF {
r.upstreamEOF = true
err = nil
}
if err != nil {
return 0, err
}
r.checkMarkInPending()
}
// read from valid data
n := copy(p, r.buf[r.start:r.pending])
r.start += n
if r.start == r.pending {
switch {
case r.hasMark:
r.pending += len(r.mark)
r.start += len(r.mark)
r.hasMark = false
r.paused = true
r.checkMarkInPending()
case r.upstreamEOF:
r.paused = r.start == r.end
}
}
if r.paused {
return n, io.EOF
}
return n, nil
}
func (r *MarkReader) checkMarkInPending() {
if markAt := bytes.Index(r.buf[r.pending:r.end], r.mark); markAt >= 0 {
r.hasMark = true
r.pending = r.pending + markAt
return
}
if r.upstreamEOF {
r.pending = r.end
return
}
r.pending = max(r.pending, r.end-len(r.mark)+1)
}
@@ -0,0 +1,192 @@
package exiftool
import (
"io"
"runtime"
"strings"
"testing"
"testing/iotest"
)
func readAllString(t *testing.T, r io.Reader) string {
t.Helper()
var data []byte
for {
buf := make([]byte, 1024)
var n int
var err error
var memstats runtime.MemStats
runtime.ReadMemStats(&memstats)
mallocs := 0 - memstats.Mallocs
n, err = r.Read(buf)
runtime.ReadMemStats(&memstats)
mallocs += memstats.Mallocs
if mallocs > 0 {
t.Errorf("r.Read() allocs %d bytes", mallocs)
}
data = append(data, buf[:n]...)
if err != nil {
if err == io.EOF {
break
}
t.Fatalf("read failed: %v", err)
}
if n == 0 {
t.Error("read error, return 0 byte without errors")
}
}
return string(data)
}
func TestNewMarkReader(t *testing.T) {
tests := []struct {
name string
bufferSize int
mark string
wantErr string
}{
{
name: "BufferTooSmall",
bufferSize: 5,
mark: "abcd",
wantErr: "buffer too small",
},
{
name: "TwiceMarkLen",
bufferSize: 8,
mark: "abcd",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := NewMarkReader(strings.NewReader("x"), tc.bufferSize, tc.mark)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("NewMarkReader() returns error: %v", err)
}
return
}
if err == nil || err.Error() != tc.wantErr {
t.Errorf("err = %v, want: %q", err, tc.wantErr)
}
})
}
}
func TestMarkReader(t *testing.T) {
tests := []struct {
name string
mark string
input string
bufferSize int
wantSegments []string
}{
{
name: "CrossRead",
mark: "<<MARK>>",
input: "hello<<MARK>>world",
bufferSize: 2 * 8, /* len(mark) = 8 */
wantSegments: []string{"hello", "world"},
},
{
name: "EndWithMark",
mark: "<<MARK>>",
input: "foo<<MARK>>bar<<MARK>>",
bufferSize: 64,
wantSegments: []string{"foo", "bar"},
},
{
name: "TrailingPrefixMark",
mark: "<<MARK>>",
input: "foo<<MARK>>bar<<MAR",
bufferSize: 64,
wantSegments: []string{"foo", "bar<<MAR"},
},
{
name: "Empty",
mark: "<<MARK>>",
input: "<<MARK>><<MARK>>abc<<MARK>>",
bufferSize: 64,
wantSegments: []string{"", "", "abc"},
},
{
name: "LongMark",
mark: "01234567890123456789",
input: "a01234567890123456789b",
bufferSize: 64,
wantSegments: []string{"a", "b"},
},
{
name: "RepeatMark",
mark: "aaaaa", /* a * 5 */
input: "0aaaaa1aaa2aaaa3aaaaa4aaaaaa5",
bufferSize: 64,
wantSegments: []string{"0", "1aaa2aaaa3", "4", "a5"},
},
{
name: "OneByteMark",
mark: "|",
input: "0|1|234|5|6",
bufferSize: 64,
wantSegments: []string{"0", "1", "234", "5", "6"},
},
}
readWraper := []struct {
name string
wrap func(r io.Reader) io.Reader
}{
{"Original", func(r io.Reader) io.Reader { return r }},
{"Half", iotest.HalfReader},
{"OneByte", iotest.OneByteReader},
}
for _, wrapper := range readWraper {
t.Run(wrapper.name, func(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
up := wrapper.wrap(strings.NewReader(tc.input))
r, err := NewMarkReader(up, tc.bufferSize, tc.mark)
if err != nil {
t.Fatal("NewMarkReader() returns error:", err)
}
for i, want := range tc.wantSegments {
if got := readAllString(t, r); got != want {
t.Errorf("segment[%d] = %q, want: %q", i, got, want)
}
var buf [1024]byte
if _, err := r.Read(buf[:]); err != io.EOF {
t.Error("r.Read() after readAllString() returns non-EOF error:", err)
}
r.Reset()
}
var buf [1024]byte
if _, err := r.Read(buf[:]); err != io.EOF {
t.Error("r.Read() after all segments returns non-EOF error:", err)
}
})
}
})
}
}
@@ -0,0 +1,171 @@
package exiftool
import (
"fmt"
"math"
"strings"
"time"
)
// GPS stores gps-related tags.
type GPS struct {
GPSLatitude *float64
GPSLongitude *float64
}
// IsValid returns true when GPS data is valid.
func (gps GPS) IsValid() bool {
if gps.GPSLongitude == nil || gps.GPSLatitude == nil {
return false
}
if math.IsNaN(*gps.GPSLatitude) {
return false
}
if math.IsNaN(*gps.GPSLongitude) {
return false
}
if math.Abs(*gps.GPSLatitude) > 90 || math.Abs(*gps.GPSLongitude) > 180 {
return false
}
return true
}
func (gps GPS) String() string {
if !gps.IsValid() {
return "GPS(invalid)"
}
return fmt.Sprintf("GPS(%.9f, %.9f)", *gps.GPSLatitude, *gps.GPSLongitude)
}
// TimeAll stores tags returned by -time:all.
type TimeAll struct {
SubSecDateTimeOriginal *string
SubSecCreateDate *string
DateTimeOriginal *string
CreateDate *string
TrackCreateDate *string
MediaCreateDate *string
FileModifyDate *string
OffsetTimeOriginal *string
OffsetTime *string
TimeZone *int
GPSDateTime *string
}
const layout = "2006:01:02 15:04:05.999"
const layoutWithTimezone = "2006:01:02 15:04:05.999Z07:00"
// TimeInLocal returns most likely time. The date and time are in local. The timezone is meaningless and always be in UTC. Use `OffsetSecs()` to determine the timezone.
func (t TimeAll) TimeInLocal() time.Time {
for _, dateP := range []*string{
// Keep the order for the priority to generate DateShot
t.SubSecDateTimeOriginal,
t.SubSecCreateDate,
t.DateTimeOriginal,
t.CreateDate,
t.TrackCreateDate,
t.MediaCreateDate,
t.FileModifyDate,
} {
if dateP == nil {
continue
}
date := *dateP
// Ignore timezone
if zoneIndex := strings.IndexAny(date, "+-Z"); zoneIndex >= 0 {
date = date[:zoneIndex]
}
if date, err := time.ParseInLocation(layout, date, time.UTC); err == nil {
return date
}
}
return time.Time{}
}
// OffsetSecs returns seconds offset by UTC.
func (t TimeAll) OffsetSecs(local time.Time) (int, bool) {
for _, offsetP := range []*string{
t.OffsetTimeOriginal,
t.OffsetTime,
} {
if offsetP == nil {
continue
}
if t, err := time.Parse("-07:00", *offsetP); err == nil {
_, offsetSecs := t.Zone()
return offsetSecs, true
}
}
// TimeZone is in minutes
if t.TimeZone != nil {
return *t.TimeZone * 60, true
}
// Calculate offset sec with GPS time and local time.
if local.IsZero() {
return 0, false
}
if t.GPSDateTime == nil {
return 0, false
}
gpsDate, err := time.Parse(layoutWithTimezone, *t.GPSDateTime)
if err != nil {
return 0, false
}
gpsDate = gpsDate.UTC()
// GPS time is always UTC per EXIF spec
// offset = local time (in UTC) - GPS UTC time
offset := int(local.Sub(gpsDate).Seconds())
return offset, true
}
type PhotoMeta struct {
ImageDescription *string
Model *string
Make *string
LensModel *string
ISO *int64
Flash *int64
Orientation *int64
ExposureProgram *int64
ExposureTime *float64
Aperture *float64
FocalLength *float64
}
func (m *PhotoMeta) SanitizeFloats() {
for _, value := range []**float64{
&m.ExposureTime,
&m.Aperture,
&m.FocalLength,
} {
if *value == nil {
continue
}
if math.IsNaN(**value) || math.IsInf(**value, 0) {
*value = nil
}
}
}
type MIMEType struct {
MIMEType *string
}
@@ -0,0 +1,197 @@
package exiftool
import (
"math"
"testing"
"time"
)
func TestGPSIsValid(t *testing.T) {
tests := []struct {
name string
gps GPS
wantValid bool
}{
{"LatNormalLongNormal", GPS{new(10.0), new(10.0)}, true},
{"ZeroValue", GPS{new(0.0), new(0.0)}, true},
{"LatNilLongNormal", GPS{new(math.NaN()), new(10.0)}, false},
{"LatNormalLongNil", GPS{new(10.0), new(math.NaN())}, false},
{"Lat>90LongNormal", GPS{new(100.0), new(10.0)}, false},
{"Lat<-90LongNormal", GPS{new(-100.0), new(10.0)}, false},
{"LatNormalLong>180", GPS{new(10.0), new(190.0)}, false},
{"LatNormalLong<-180", GPS{new(10.0), new(-190.0)}, false},
{"Empty", GPS{}, false},
{"EmptyLat", GPS{nil, new(0.0)}, false},
{"EmptyLong", GPS{new(0.0), nil}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.gps.IsValid()
if got != tc.wantValid {
t.Fatalf("gps.IsValid(%v) = %v, want: %v", tc.gps, got, tc.wantValid)
}
})
}
}
func mustParseInUTC(t *testing.T, timeStr string) time.Time {
t.Helper()
ret, err := time.ParseInLocation(layout, timeStr, time.UTC)
if err != nil {
t.Fatalf("time.ParseInLocation(%q) returns error: %v", timeStr, err)
}
return ret
}
func TestTimeAllTimeLocal(t *testing.T) {
wantStr := "2025:10:28 14:20:22.164"
otherStr := "2024:10:28 14:20:22.164"
tests := []struct {
name string
timeAll TimeAll
want time.Time
}{
{"DateTimeOriginal", TimeAll{
DateTimeOriginal: &wantStr,
CreateDate: &otherStr,
TrackCreateDate: &otherStr,
MediaCreateDate: &otherStr,
FileModifyDate: &otherStr,
}, mustParseInUTC(t, wantStr)},
{"CreateDate", TimeAll{
CreateDate: &wantStr,
TrackCreateDate: &otherStr,
MediaCreateDate: &otherStr,
FileModifyDate: &otherStr,
}, mustParseInUTC(t, wantStr)},
{"TrackCreateDate", TimeAll{
TrackCreateDate: &wantStr,
MediaCreateDate: &otherStr,
FileModifyDate: &otherStr,
}, mustParseInUTC(t, wantStr)},
{"MediaCreateDate", TimeAll{
MediaCreateDate: &wantStr,
FileModifyDate: &otherStr,
}, mustParseInUTC(t, wantStr)},
{"FileModifyDate", TimeAll{
FileModifyDate: &wantStr,
}, mustParseInUTC(t, wantStr)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.timeAll.TimeInLocal(); !got.Equal(tc.want) {
t.Errorf("timeAll.Time() = %v, want: %v", got, tc.want)
}
})
}
}
func TestTimeAllTimeWithTimezone(t *testing.T) {
wantStr := "2025:10:28 14:20:22.164"
otherStr := "2024:10:28 14:20:22.164"
tests := []struct {
name string
timeAll TimeAll
want time.Time
}{
{"SubSecDateTimeOriginal", TimeAll{
SubSecDateTimeOriginal: &wantStr,
SubSecCreateDate: &otherStr,
DateTimeOriginal: &otherStr,
CreateDate: &otherStr,
TrackCreateDate: &otherStr,
MediaCreateDate: &otherStr,
FileModifyDate: &otherStr,
}, mustParseInUTC(t, wantStr)},
{"SubSecCreateDate", TimeAll{
SubSecCreateDate: &wantStr,
DateTimeOriginal: &otherStr,
CreateDate: &otherStr,
TrackCreateDate: &otherStr,
MediaCreateDate: &otherStr,
FileModifyDate: &otherStr,
}, mustParseInUTC(t, wantStr)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.timeAll.TimeInLocal(); !got.Equal(tc.want) {
t.Errorf("timeAll.Time() = %v, want: %v", got, tc.want)
}
})
}
}
func TestTimeAllTimeEmpty(t *testing.T) {
var timeAll TimeAll
if got := timeAll.TimeInLocal(); !got.IsZero() {
t.Errorf("timeAll.Time() is a valid time, which it should not be")
}
}
func TestTimeAllOffsetSecs(t *testing.T) {
wantStr := "+01:00"
otherStr := "-01:00"
gpsDateTime := "2025:10:28 14:20:22Z"
otherTimezone := -120
localTime := mustParseInUTC(t, "2025:10:28 13:20:22")
tests := []struct {
name string
timeAll TimeAll
want int
}{
{"OffsetTimeOriginal", TimeAll{
OffsetTimeOriginal: &wantStr,
OffsetTime: &otherStr,
TimeZone: &otherTimezone,
GPSDateTime: &gpsDateTime,
}, 60 * 60},
{"OffsetTime", TimeAll{
OffsetTime: &wantStr,
TimeZone: &otherTimezone,
GPSDateTime: &gpsDateTime,
}, 60 * 60},
{"TimeZone", TimeAll{
TimeZone: &otherTimezone,
GPSDateTime: &gpsDateTime,
}, -120 * 60},
{"GPS", TimeAll{
GPSDateTime: &gpsDateTime,
}, -60 * 60},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := tc.timeAll.OffsetSecs(localTime)
if !ok {
t.Fatalf("timeAll.OffsetSecs() is not valid, which it should be")
}
if got != tc.want {
t.Errorf("timeAll.OffsetSecs() = %v, want: %v", got, tc.want)
}
})
}
}
func TestTimeAllOffsetSecsEmptyLocal(t *testing.T) {
timeAll := TimeAll{
GPSDateTime: new("14:20:22 2025:10:28Z"),
}
got, ok := timeAll.OffsetSecs(time.Time{})
if ok || got != 0 {
t.Errorf("timeAll.OffsetSecs() = (%d, %v), want: (%d, %v)", got, ok, 0, false)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package externaltools provides wrappers for tools outside the Go runtime.
// These tools are provided by the runtime environment. Some require initialization
// and must be cleaned up properly when finished.
// Packages under externaltools should have as few dependencies as possible to avoid cycles.
package externaltools