• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cshum / imagor / 21470007299

29 Jan 2026 07:41AM UTC coverage: 91.734% (-0.1%) from 91.871%
21470007299

Pull #713

github

mevinbabuc
fix: use string key for ristretto cache

Ristretto requires string/uint64/[]byte keys, not custom structs.
Added String() method to watermarkCacheKey.
Pull Request #713: feat: add watermark caching for improved performance

79 of 100 new or added lines in 5 files covered. (79.0%)

1 existing line in 1 file now uncovered.

5638 of 6146 relevant lines covered (91.73%)

1.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.45
/processor/vipsprocessor/processor.go
1
package vipsprocessor
2

3
import (
4
        "context"
5
        "math"
6
        "runtime"
7
        "strings"
8
        "sync"
9

10
        "github.com/cshum/imagor"
11
        "github.com/cshum/vipsgen/vips"
12
        "github.com/dgraph-io/ristretto"
13
        "go.uber.org/zap"
14
)
15

16
// FilterFunc filter handler function
17
type FilterFunc func(ctx context.Context, img *vips.Image, load imagor.LoadFunc, args ...string) (err error)
18

19
// FilterMap filter handler map
20
type FilterMap map[string]FilterFunc
21

22
var processorLock sync.RWMutex
23
var processorCount int
24

25
// Processor implements imagor.Processor interface
26
type Processor struct {
27
        Filters            FilterMap
28
        FallbackFunc       FallbackFunc
29
        DisableBlur        bool
30
        DisableFilters     []string
31
        MaxFilterOps       int
32
        Logger             *zap.Logger
33
        Concurrency        int
34
        MaxCacheFiles      int
35
        MaxCacheMem        int
36
        MaxCacheSize       int
37
        MaxWidth           int
38
        MaxHeight          int
39
        MaxResolution      int
40
        MaxAnimationFrames int
41
        MozJPEG            bool
42
        StripMetadata      bool
43
        AvifSpeed          int
44
        Unlimited          bool
45
        Debug              bool
46

47
        WatermarkCacheSize int64
48

49
        disableFilters map[string]bool
50
        watermarkCache *ristretto.Cache
51
}
52

53
// NewProcessor create Processor
54
func NewProcessor(options ...Option) *Processor {
1✔
55
        v := &Processor{
1✔
56
                MaxWidth:           9999,
1✔
57
                MaxHeight:          9999,
1✔
58
                MaxResolution:      81000000,
1✔
59
                Concurrency:        1,
1✔
60
                MaxFilterOps:       -1,
1✔
61
                MaxAnimationFrames: -1,
1✔
62
                Logger:             zap.NewNop(),
1✔
63
                disableFilters:     map[string]bool{},
1✔
64
        }
1✔
65
        v.Filters = FilterMap{
1✔
66
                "watermark":        v.watermark,
1✔
67
                "round_corner":     roundCorner,
1✔
68
                "rotate":           rotate,
1✔
69
                "label":            label,
1✔
70
                "grayscale":        grayscale,
1✔
71
                "brightness":       brightness,
1✔
72
                "background_color": backgroundColor,
1✔
73
                "contrast":         contrast,
1✔
74
                "modulate":         modulate,
1✔
75
                "hue":              hue,
1✔
76
                "saturation":       saturation,
1✔
77
                "rgb":              rgb,
1✔
78
                "blur":             blur,
1✔
79
                "sharpen":          sharpen,
1✔
80
                "strip_icc":        stripIcc,
1✔
81
                "strip_exif":       stripExif,
1✔
82
                "to_colorspace":    toColorspace,
1✔
83
                "trim":             trim,
1✔
84
                "padding":          v.padding,
1✔
85
                "proportion":       proportion,
1✔
86
                "crop":             crop,
1✔
87
        }
1✔
88
        for _, option := range options {
2✔
89
                option(v)
1✔
90
        }
1✔
91
        if v.DisableBlur {
2✔
92
                v.DisableFilters = append(v.DisableFilters, "blur", "sharpen")
1✔
93
        }
1✔
94
        for _, name := range v.DisableFilters {
2✔
95
                v.disableFilters[name] = true
1✔
96
        }
1✔
97
        if v.Concurrency == -1 {
2✔
98
                v.Concurrency = runtime.NumCPU()
1✔
99
        }
1✔
100
        return v
1✔
101
}
102

103
// Startup implements imagor.Processor interface
104
func (v *Processor) Startup(_ context.Context) error {
1✔
105
        processorLock.Lock()
1✔
106
        defer processorLock.Unlock()
1✔
107
        processorCount++
1✔
108
        if processorCount <= 1 {
2✔
109
                if v.Debug {
2✔
110
                        vips.SetLogging(func(domain string, level vips.LogLevel, msg string) {
2✔
111
                                switch level {
1✔
112
                                case vips.LogLevelDebug:
1✔
113
                                        v.Logger.Debug(domain, zap.String("log", msg))
1✔
114
                                case vips.LogLevelMessage, vips.LogLevelInfo:
1✔
115
                                        v.Logger.Info(domain, zap.String("log", msg))
1✔
116
                                case vips.LogLevelWarning, vips.LogLevelCritical, vips.LogLevelError:
1✔
117
                                        v.Logger.Warn(domain, zap.String("log", msg))
1✔
118
                                }
119
                        }, vips.LogLevelDebug)
120
                } else {
×
121
                        vips.SetLogging(func(domain string, level vips.LogLevel, msg string) {
×
122
                                v.Logger.Warn(domain, zap.String("log", msg))
×
123
                        }, vips.LogLevelError)
×
124
                }
125
                vips.Startup(&vips.Config{
1✔
126
                        MaxCacheFiles:    v.MaxCacheFiles,
1✔
127
                        MaxCacheMem:      v.MaxCacheMem,
1✔
128
                        MaxCacheSize:     v.MaxCacheSize,
1✔
129
                        ConcurrencyLevel: v.Concurrency,
1✔
130
                })
1✔
131
        }
132
        if v.FallbackFunc == nil {
2✔
133
                if vips.HasOperation("magickload_buffer") {
1✔
134
                        v.FallbackFunc = bufferFallbackFunc
×
135
                        v.Logger.Debug("source fallback", zap.String("fallback", "magickload_buffer"))
×
136
                } else {
1✔
137
                        v.FallbackFunc = v.bmpFallbackFunc
1✔
138
                        v.Logger.Debug("source fallback", zap.String("fallback", "bmp"))
1✔
139
                }
1✔
140
        }
141
        if v.WatermarkCacheSize > 0 {
2✔
142
                cache, err := ristretto.NewCache(&ristretto.Config{
1✔
143
                        NumCounters: 1e4,
1✔
144
                        MaxCost:     v.WatermarkCacheSize,
1✔
145
                        BufferItems: 64,
1✔
146
                })
1✔
147
                if err != nil {
1✔
NEW
148
                        return err
×
NEW
149
                }
×
150
                v.watermarkCache = cache
1✔
151
                v.Logger.Info("watermark cache enabled", zap.Int64("max_size", v.WatermarkCacheSize))
1✔
152
        }
153
        return nil
1✔
154
}
155

156
// Shutdown implements imagor.Processor interface
157
func (v *Processor) Shutdown(_ context.Context) error {
1✔
158
        processorLock.Lock()
1✔
159
        defer processorLock.Unlock()
1✔
160
        if processorCount <= 0 {
1✔
161
                return nil
×
162
        }
×
163
        processorCount--
1✔
164
        if processorCount == 0 {
2✔
165
                vips.Shutdown()
1✔
166
        }
1✔
167
        return nil
1✔
168
}
169

170
func (v *Processor) newImageFromBlob(
171
        ctx context.Context, blob *imagor.Blob, options *vips.LoadOptions,
172
) (*vips.Image, error) {
1✔
173
        if blob == nil || blob.IsEmpty() {
1✔
174
                return nil, imagor.ErrNotFound
×
175
        }
×
176
        if blob.BlobType() == imagor.BlobTypeMemory {
2✔
177
                buf, width, height, bands, _ := blob.Memory()
1✔
178
                return vips.NewImageFromMemory(buf, width, height, bands)
1✔
179
        }
1✔
180
        reader, _, err := blob.NewReader()
1✔
181
        if err != nil {
1✔
182
                return nil, err
×
183
        }
×
184
        src := vips.NewSource(reader)
1✔
185
        contextDefer(ctx, src.Close)
1✔
186
        img, err := vips.NewImageFromSource(src, options)
1✔
187
        if err != nil && v.FallbackFunc != nil {
2✔
188
                src.Close()
1✔
189
                return v.FallbackFunc(blob, options)
1✔
190
        }
1✔
191
        return img, err
1✔
192
}
193

194
func newThumbnailFromBlob(
195
        ctx context.Context, blob *imagor.Blob,
196
        width, height int, crop vips.Interesting, size vips.Size, options *vips.LoadOptions,
197
) (*vips.Image, error) {
1✔
198
        if blob == nil || blob.IsEmpty() {
1✔
199
                return nil, imagor.ErrNotFound
×
200
        }
×
201
        reader, _, err := blob.NewReader()
1✔
202
        if err != nil {
1✔
203
                return nil, err
×
204
        }
×
205
        src := vips.NewSource(reader)
1✔
206
        contextDefer(ctx, src.Close)
1✔
207
        var optionString string
1✔
208
        if options != nil {
2✔
209
                optionString = options.OptionString()
1✔
210
        }
1✔
211
        return vips.NewThumbnailSource(src, width, &vips.ThumbnailSourceOptions{
1✔
212
                Height:       height,
1✔
213
                Crop:         crop,
1✔
214
                Size:         size,
1✔
215
                OptionString: optionString,
1✔
216
        })
1✔
217
}
218

219
// NewThumbnail creates new thumbnail with resize and crop from imagor.Blob
220
func (v *Processor) NewThumbnail(
221
        ctx context.Context, blob *imagor.Blob, width, height int, crop vips.Interesting,
222
        size vips.Size, n, page int, dpi int,
223
) (*vips.Image, error) {
1✔
224
        var options = &vips.LoadOptions{}
1✔
225
        if dpi > 0 {
1✔
226
                options.Dpi = dpi
×
227
        }
×
228
        options.Unlimited = v.Unlimited
1✔
229
        var err error
1✔
230
        var img *vips.Image
1✔
231
        if isMultiPage(blob, n, page) {
2✔
232
                applyMultiPageOptions(options, n, page)
1✔
233
                if crop == vips.InterestingNone || size == vips.SizeForce {
2✔
234
                        if img, err = v.newImageFromBlob(ctx, blob, options); err != nil {
1✔
235
                                return nil, WrapErr(err)
×
236
                        }
×
237
                        if n > 1 || page > 1 {
2✔
238
                                // reload image to restrict frames loaded
1✔
239
                                n, page = recalculateImage(img, n, page)
1✔
240
                                return v.NewThumbnail(ctx, blob, width, height, crop, size, -n, -page, dpi)
1✔
241
                        }
1✔
242
                        if _, err = v.CheckResolution(img, nil); err != nil {
2✔
243
                                return nil, err
1✔
244
                        }
1✔
245
                        if err = img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
246
                                Height: height, Size: size, Crop: crop,
1✔
247
                        }); err != nil {
1✔
248
                                img.Close()
×
249
                                return nil, WrapErr(err)
×
250
                        }
×
251
                } else {
1✔
252
                        if img, err = v.CheckResolution(v.newImageFromBlob(ctx, blob, options)); err != nil {
1✔
253
                                return nil, WrapErr(err)
×
254
                        }
×
255
                        if n > 1 || page > 1 {
2✔
256
                                // reload image to restrict frames loaded
1✔
257
                                n, page = recalculateImage(img, n, page)
1✔
258
                                return v.NewThumbnail(ctx, blob, width, height, crop, size, -n, -page, dpi)
1✔
259
                        }
1✔
260
                        if err = v.animatedThumbnailWithCrop(img, width, height, crop, size); err != nil {
1✔
261
                                img.Close()
×
262
                                return nil, WrapErr(err)
×
263
                        }
×
264
                }
265
        } else {
1✔
266
                switch blob.BlobType() {
1✔
267
                case imagor.BlobTypeJPEG, imagor.BlobTypeGIF, imagor.BlobTypeWEBP:
1✔
268
                        // only allow real thumbnail for jpeg gif webp
1✔
269
                        img, err = newThumbnailFromBlob(ctx, blob, width, height, crop, size, options)
1✔
270
                default:
1✔
271
                        img, err = v.newThumbnailFallback(ctx, blob, width, height, crop, size, options)
1✔
272
                }
273
        }
274
        return v.CheckResolution(img, WrapErr(err))
1✔
275
}
276

277
func (v *Processor) newThumbnailFallback(
278
        ctx context.Context, blob *imagor.Blob, width, height int, crop vips.Interesting, size vips.Size, options *vips.LoadOptions,
279
) (img *vips.Image, err error) {
1✔
280
        if img, err = v.CheckResolution(v.newImageFromBlob(ctx, blob, options)); err != nil {
2✔
281
                return
1✔
282
        }
1✔
283
        if err = img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
284
                Height: height, Size: size, Crop: crop,
1✔
285
        }); err != nil {
1✔
286
                img.Close()
×
287
                return
×
288
        }
×
289
        return img, WrapErr(err)
1✔
290
}
291

292
// NewImage creates new Image from imagor.Blob
293
func (v *Processor) NewImage(ctx context.Context, blob *imagor.Blob, n, page int, dpi int) (*vips.Image, error) {
1✔
294
        var options = &vips.LoadOptions{}
1✔
295
        if dpi > 0 {
1✔
296
                options.Dpi = dpi
×
297
        }
×
298
        options.Unlimited = v.Unlimited
1✔
299
        if isMultiPage(blob, n, page) {
2✔
300
                applyMultiPageOptions(options, n, page)
1✔
301
                img, err := v.CheckResolution(v.newImageFromBlob(ctx, blob, options))
1✔
302
                if err != nil {
1✔
303
                        return nil, WrapErr(err)
×
304
                }
×
305
                // reload image to restrict frames loaded
306
                if n > 1 || page > 1 {
2✔
307
                        n, page = recalculateImage(img, n, page)
1✔
308
                        return v.NewImage(ctx, blob, -n, -page, dpi)
1✔
309
                }
1✔
310
                return img, nil
1✔
311
        }
312
        img, err := v.CheckResolution(v.newImageFromBlob(ctx, blob, options))
1✔
313
        if err != nil {
1✔
314
                return nil, WrapErr(err)
×
315
        }
×
316
        return img, nil
1✔
317
}
318

319
// Thumbnail handles thumbnail operation
320
func (v *Processor) Thumbnail(
321
        img *vips.Image, width, height int, crop vips.Interesting, size vips.Size,
322
) error {
1✔
323
        if crop == vips.InterestingNone || size == vips.SizeForce || img.Height() == img.PageHeight() {
2✔
324
                return img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
325
                        Height: height, Size: size, Crop: crop,
1✔
326
                })
1✔
327
        }
1✔
328
        return v.animatedThumbnailWithCrop(img, width, height, crop, size)
1✔
329
}
330

331
// FocalThumbnail handles thumbnail with custom focal point
332
func (v *Processor) FocalThumbnail(img *vips.Image, w, h int, fx, fy float64) (err error) {
1✔
333
        var imageWidth, imageHeight float64
1✔
334
        // exif orientation greater 5-8 are 90 or 270 degrees, w and h swapped
1✔
335
        if img.Orientation() > 4 {
2✔
336
                imageWidth = float64(img.PageHeight())
1✔
337
                imageHeight = float64(img.Width())
1✔
338
        } else {
2✔
339
                imageWidth = float64(img.Width())
1✔
340
                imageHeight = float64(img.PageHeight())
1✔
341
        }
1✔
342

343
        if float64(w)/float64(h) > float64(imageWidth)/float64(imageHeight) {
2✔
344
                if err = img.ThumbnailImage(w, &vips.ThumbnailImageOptions{
1✔
345
                        Height: v.MaxHeight, Crop: vips.InterestingNone,
1✔
346
                }); err != nil {
1✔
347
                        return
×
348
                }
×
349
        } else {
1✔
350
                if err = img.ThumbnailImage(v.MaxWidth, &vips.ThumbnailImageOptions{
1✔
351
                        Height: h, Crop: vips.InterestingNone,
1✔
352
                }); err != nil {
1✔
353
                        return
×
354
                }
×
355
        }
356
        var top, left float64
1✔
357
        left = float64(img.Width())*fx - float64(w)/2
1✔
358
        top = float64(img.PageHeight())*fy - float64(h)/2
1✔
359
        left = math.Max(0, math.Min(left, float64(img.Width()-w)))
1✔
360
        top = math.Max(0, math.Min(top, float64(img.PageHeight()-h)))
1✔
361
        return img.ExtractAreaMultiPage(int(left), int(top), w, h)
1✔
362
}
363

364
func (v *Processor) animatedThumbnailWithCrop(
365
        img *vips.Image, w, h int, crop vips.Interesting, size vips.Size,
366
) (err error) {
1✔
367
        if size == vips.SizeDown && img.Width() < w && img.PageHeight() < h {
1✔
368
                return
×
369
        }
×
370
        var top, left int
1✔
371
        if float64(w)/float64(h) > float64(img.Width())/float64(img.PageHeight()) {
2✔
372
                if err = img.ThumbnailImage(w, &vips.ThumbnailImageOptions{
1✔
373
                        Height: v.MaxHeight, Crop: vips.InterestingNone, Size: size,
1✔
374
                }); err != nil {
1✔
375
                        return
×
376
                }
×
377
        } else {
1✔
378
                if err = img.ThumbnailImage(v.MaxWidth, &vips.ThumbnailImageOptions{
1✔
379
                        Height: h, Crop: vips.InterestingNone, Size: size,
1✔
380
                }); err != nil {
1✔
381
                        return
×
382
                }
×
383
        }
384
        if crop == vips.InterestingHigh {
2✔
385
                left = img.Width() - w
1✔
386
                top = img.PageHeight() - h
1✔
387
        } else if crop == vips.InterestingCentre || crop == vips.InterestingAttention {
3✔
388
                left = (img.Width() - w) / 2
1✔
389
                top = (img.PageHeight() - h) / 2
1✔
390
        }
1✔
391
        return img.ExtractAreaMultiPage(left, top, w, h)
1✔
392
}
393

394
// CheckResolution check image resolution for image bomb prevention
395
func (v *Processor) CheckResolution(img *vips.Image, err error) (*vips.Image, error) {
1✔
396
        if err != nil || img == nil {
2✔
397
                return img, err
1✔
398
        }
1✔
399
        if !v.Unlimited && (img.Width() > v.MaxWidth || img.PageHeight() > v.MaxHeight ||
1✔
400
                (img.Width()*img.Height()) > v.MaxResolution) {
2✔
401
                img.Close()
1✔
402
                return nil, imagor.ErrMaxResolutionExceeded
1✔
403
        }
1✔
404
        return img, nil
1✔
405
}
406

407
func isMultiPage(blob *imagor.Blob, n, page int) bool {
1✔
408
        return blob != nil && (blob.SupportsAnimation() || blob.BlobType() == imagor.BlobTypePDF) && ((n != 1 && n != 0) || (page != 1 && page != 0))
1✔
409
}
1✔
410

411
func applyMultiPageOptions(params *vips.LoadOptions, n, page int) {
1✔
412
        if page < -1 {
2✔
413
                params.Page = -page - 1
1✔
414
        } else if n < -1 {
3✔
415
                params.N = -n
1✔
416
        } else {
2✔
417
                params.N = -1
1✔
418
        }
1✔
419
}
420

421
func recalculateImage(img *vips.Image, n, page int) (int, int) {
1✔
422
        // reload image to restrict frames loaded
1✔
423
        numPages := img.Pages()
1✔
424
        img.Close()
1✔
425
        if page > 1 && page > numPages {
2✔
426
                page = numPages
1✔
427
        } else if n > 1 && n > numPages {
3✔
428
                n = numPages
1✔
429
        }
1✔
430
        return n, page
1✔
431
}
432

433
// WrapErr wraps error to become imagor.Error
434
func WrapErr(err error) error {
1✔
435
        if err == nil {
2✔
436
                return nil
1✔
437
        }
1✔
438
        if e, ok := err.(imagor.Error); ok {
2✔
439
                return e
1✔
440
        }
1✔
441
        msg := strings.TrimSpace(err.Error())
1✔
442
        if strings.HasPrefix(msg, "VipsForeignLoad:") &&
1✔
443
                strings.HasSuffix(msg, "is not in a known format") {
1✔
444
                return imagor.ErrUnsupportedFormat
×
445
        }
×
446
        return imagor.NewError(msg, 406)
1✔
447
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc