Commit 54d8129f92

54d8129f927ed073a9df97d66d4f4a7880e3227b

parent: 93c1933342

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-25 23:20 UTC

Add ImageMagick-backed JPEG and PNG formats

Layout: unified · split

internal/format/magick.go added +68
@@ -0,0 +1,68 @@
1package format
2
3import (
4 "bytes"
5 "fmt"
6 "io"
7 "os/exec"
8 "strconv"
9 "strings"
10)
11
12// Magick reads metadata with exiftool and resizes with ImageMagick.
13type Magick struct {
14 name string
15 exts []string
16}
17
18func NewMagick(name string, exts ...string) *Magick {
19 return &Magick{name: name, exts: exts}
20}
21
22func (m *Magick) Name() string { return m.name }
23func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
24func (m *Magick) Kind() Kind { return KindImage }
25
26func (m *Magick) Metadata(path string) (Meta, error) {
27 out, err := exec.Command("exiftool", "-json", "-n",
28 "-ImageWidth", "-ImageHeight", "-Orientation",
29 "-DateTimeOriginal", "-CreateDate",
30 "-Make", "-Model", "-LensModel",
31 "-FNumber", "-ExposureTime", "-ISO",
32 path).Output()
33 if err != nil {
34 return Meta{}, fmt.Errorf("exiftool %s: %w", path, err)
35 }
36 return parseExif(out)
37}
38
39func (m *Magick) Resize(src string, width int, dst io.Writer) error {
40 cmd := exec.Command("magick", src,
41 "-auto-orient", "-resize", strconv.Itoa(width)+"x",
42 "-strip", "-quality", "82", "jpg:-")
43 var stderr bytes.Buffer
44 cmd.Stdout = dst
45 cmd.Stderr = &stderr
46 if err := cmd.Run(); err != nil {
47 return fmt.Errorf("magick %s: %w: %s", src, err, strings.TrimSpace(stderr.String()))
48 }
49 return nil
50}
51
52// Default returns the formats supported in v1.
53func Default() *Registry {
54 r := &Registry{}
55 r.Register(NewMagick("jpeg", ".jpg", ".jpeg"))
56 r.Register(NewMagick("png", ".png"))
57 return r
58}
59
60// CheckTools reports the first required external tool missing from PATH.
61func CheckTools() error {
62 for _, tool := range []string{"magick", "exiftool"} {
63 if _, err := exec.LookPath(tool); err != nil {
64 return fmt.Errorf("%s not found on PATH", tool)
65 }
66 }
67 return nil
68}
internal/format/magick_test.go added +120
@@ -0,0 +1,120 @@
1package format
2
3import (
4 "bytes"
5 "fmt"
6 "image/jpeg"
7 "os/exec"
8 "path/filepath"
9 "testing"
10 "time"
11)
12
13func requireTools(t *testing.T) {
14 t.Helper()
15 if err := CheckTools(); err != nil {
16 t.Skip(err)
17 }
18}
19
20func makeImage(t *testing.T, name string, w, h int) string {
21 t.Helper()
22 p := filepath.Join(t.TempDir(), name)
23 run(t, "magick", "-size", fmt.Sprintf("%dx%d", w, h), "xc:gray", p)
24 return p
25}
26
27func run(t *testing.T, name string, args ...string) {
28 t.Helper()
29 if out, err := exec.Command(name, args...).CombinedOutput(); err != nil {
30 t.Fatalf("%s %v: %v\n%s", name, args, err, out)
31 }
32}
33
34func resizedSize(t *testing.T, f Format, src string, w int) (int, int) {
35 t.Helper()
36 var buf bytes.Buffer
37 if err := f.Resize(src, w, &buf); err != nil {
38 t.Fatal(err)
39 }
40 cfg, err := jpeg.DecodeConfig(&buf)
41 if err != nil {
42 t.Fatalf("output is not JPEG: %v", err)
43 }
44 return cfg.Width, cfg.Height
45}
46
47func TestMagickJPEG(t *testing.T) {
48 requireTools(t)
49 p := makeImage(t, "a.jpg", 300, 200)
50 run(t, "exiftool", "-overwrite_original", "-DateTimeOriginal=2025:03:14 23:10:05", "-Model=TestCam", p)
51
52 f := NewMagick("jpeg", ".jpg", ".jpeg")
53 m, err := f.Metadata(p)
54 if err != nil {
55 t.Fatal(err)
56 }
57 if m.Width != 300 || m.Height != 200 || m.Camera != "TestCam" {
58 t.Fatalf("got %+v", m)
59 }
60 if want := time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC); !m.Taken.Equal(want) {
61 t.Fatalf("Taken = %v", m.Taken)
62 }
63 if w, h := resizedSize(t, f, p, 150); w != 150 || h != 100 {
64 t.Fatalf("resized to %dx%d, want 150x100", w, h)
65 }
66}
67
68func TestMagickOrientation(t *testing.T) {
69 requireTools(t)
70 p := makeImage(t, "r.jpg", 300, 200)
71 run(t, "exiftool", "-overwrite_original", "-n", "-Orientation=6", p)
72
73 f := NewMagick("jpeg", ".jpg")
74 m, err := f.Metadata(p)
75 if err != nil {
76 t.Fatal(err)
77 }
78 if m.Width != 200 || m.Height != 300 {
79 t.Fatalf("metadata %dx%d, want 200x300", m.Width, m.Height)
80 }
81 if w, h := resizedSize(t, f, p, 100); w != 100 || h != 150 {
82 t.Fatalf("resized to %dx%d, want 100x150", w, h)
83 }
84}
85
86func TestMagickPNG(t *testing.T) {
87 requireTools(t)
88 p := makeImage(t, "a.png", 120, 80)
89 f := NewMagick("png", ".png")
90 m, err := f.Metadata(p)
91 if err != nil {
92 t.Fatal(err)
93 }
94 if m.Width != 120 || m.Height != 80 {
95 t.Fatalf("got %+v", m)
96 }
97 if w, h := resizedSize(t, f, p, 60); w != 60 || h != 40 {
98 t.Fatalf("resized to %dx%d, want 60x40", w, h)
99 }
100}
101
102func TestMagickResizeError(t *testing.T) {
103 requireTools(t)
104 var buf bytes.Buffer
105 if err := NewMagick("jpeg", ".jpg").Resize(filepath.Join(t.TempDir(), "missing.jpg"), 100, &buf); err == nil {
106 t.Fatal("want error")
107 }
108}
109
110func TestDefaultRegistry(t *testing.T) {
111 r := Default()
112 for path, want := range map[string]string{"a.JPG": "jpeg", "a.jpeg": "jpeg", "a.png": "png"} {
113 if f := r.Lookup(path); f == nil || f.Name() != want {
114 t.Errorf("%s: got %v, want %s", path, f, want)
115 }
116 }
117 if r.Lookup("a.heic") != nil {
118 t.Error("heic should not match in v1")
119 }
120}