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

cshum / imagor / 21857996397

10 Feb 2026 08:50AM UTC coverage: 91.942% (+0.02%) from 91.924%
21857996397

Pull #737

github

cshum
fix(vipsprocessor): nested fill resize for >= 1000px
Pull Request #737: fix(vipsprocessor): nested fill resize for >= 1000px

19 of 19 new or added lines in 3 files covered. (100.0%)

1 existing line in 1 file now uncovered.

5796 of 6304 relevant lines covered (91.94%)

1.1 hits per line

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

84.52
/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
        "go.uber.org/zap"
13
)
14

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

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

21
var processorLock sync.RWMutex
22
var processorCount int
23

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

46
        disableFilters map[string]bool
47
}
48

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

100
// Startup implements imagor.Processor interface
101
func (v *Processor) Startup(_ context.Context) error {
1✔
102
        processorLock.Lock()
1✔
103
        defer processorLock.Unlock()
1✔
104
        processorCount++
1✔
105
        if processorCount <= 1 {
2✔
106
                if v.Debug {
2✔
107
                        vips.SetLogging(func(domain string, level vips.LogLevel, msg string) {
2✔
108
                                switch level {
1✔
109
                                case vips.LogLevelDebug:
1✔
110
                                        v.Logger.Debug(domain, zap.String("log", msg))
1✔
111
                                case vips.LogLevelMessage, vips.LogLevelInfo:
1✔
112
                                        v.Logger.Info(domain, zap.String("log", msg))
1✔
113
                                case vips.LogLevelWarning, vips.LogLevelCritical, vips.LogLevelError:
1✔
114
                                        v.Logger.Warn(domain, zap.String("log", msg))
1✔
115
                                }
116
                        }, vips.LogLevelDebug)
117
                } else {
×
118
                        vips.SetLogging(func(domain string, level vips.LogLevel, msg string) {
×
119
                                v.Logger.Warn(domain, zap.String("log", msg))
×
120
                        }, vips.LogLevelError)
×
121
                }
122
                vips.Startup(&vips.Config{
1✔
123
                        MaxCacheFiles:    v.MaxCacheFiles,
1✔
124
                        MaxCacheMem:      v.MaxCacheMem,
1✔
125
                        MaxCacheSize:     v.MaxCacheSize,
1✔
126
                        ConcurrencyLevel: v.Concurrency,
1✔
127
                })
1✔
128
        }
129
        if v.FallbackFunc == nil {
2✔
130
                if vips.HasOperation("magickload_buffer") {
1✔
131
                        v.FallbackFunc = bufferFallbackFunc
×
132
                        v.Logger.Debug("source fallback", zap.String("fallback", "magickload_buffer"))
×
133
                } else {
1✔
134
                        v.FallbackFunc = v.bmpFallbackFunc
1✔
135
                        v.Logger.Debug("source fallback", zap.String("fallback", "bmp"))
1✔
136
                }
1✔
137
        }
138
        return nil
1✔
139
}
140

141
// Shutdown implements imagor.Processor interface
142
func (v *Processor) Shutdown(_ context.Context) error {
1✔
143
        processorLock.Lock()
1✔
144
        defer processorLock.Unlock()
1✔
145
        if processorCount <= 0 {
1✔
146
                return nil
×
147
        }
×
148
        processorCount--
1✔
149
        if processorCount == 0 {
2✔
150
                vips.Shutdown()
1✔
151
        }
1✔
152
        return nil
1✔
153
}
154

155
func (v *Processor) newImageFromBlob(
156
        ctx context.Context, blob *imagor.Blob, options *vips.LoadOptions,
157
) (*vips.Image, error) {
1✔
158
        if blob == nil || blob.IsEmpty() {
1✔
159
                return nil, imagor.ErrNotFound
×
160
        }
×
161
        if blob.BlobType() == imagor.BlobTypeMemory {
2✔
162
                buf, width, height, bands, _ := blob.Memory()
1✔
163
                return vips.NewImageFromMemory(buf, width, height, bands)
1✔
164
        }
1✔
165
        reader, _, err := blob.NewReader()
1✔
166
        if err != nil {
1✔
167
                return nil, err
×
168
        }
×
169
        src := vips.NewSource(reader)
1✔
170
        contextDefer(ctx, src.Close)
1✔
171
        img, err := vips.NewImageFromSource(src, options)
1✔
172
        if err != nil && v.FallbackFunc != nil {
2✔
173
                src.Close()
1✔
174
                return v.FallbackFunc(blob, options)
1✔
175
        }
1✔
176
        return img, err
1✔
177
}
178

179
func newThumbnailFromBlob(
180
        ctx context.Context, blob *imagor.Blob,
181
        width, height int, crop vips.Interesting, size vips.Size, options *vips.LoadOptions,
182
) (*vips.Image, error) {
1✔
183
        if blob == nil || blob.IsEmpty() {
1✔
184
                return nil, imagor.ErrNotFound
×
185
        }
×
186
        reader, _, err := blob.NewReader()
1✔
187
        if err != nil {
1✔
188
                return nil, err
×
189
        }
×
190
        src := vips.NewSource(reader)
1✔
191
        contextDefer(ctx, src.Close)
1✔
192
        var optionString string
1✔
193
        if options != nil {
2✔
194
                optionString = options.OptionString()
1✔
195
        }
1✔
196
        return vips.NewThumbnailSource(src, width, &vips.ThumbnailSourceOptions{
1✔
197
                Height:       height,
1✔
198
                Crop:         crop,
1✔
199
                Size:         size,
1✔
200
                OptionString: optionString,
1✔
201
        })
1✔
202
}
203

204
// NewThumbnail creates new thumbnail with resize and crop from imagor.Blob
205
func (v *Processor) NewThumbnail(
206
        ctx context.Context, blob *imagor.Blob, width, height int, crop vips.Interesting,
207
        size vips.Size, n, page int, dpi int,
208
) (*vips.Image, error) {
1✔
209
        var options = &vips.LoadOptions{}
1✔
210
        if dpi > 0 {
1✔
211
                options.Dpi = dpi
×
212
        }
×
213
        options.Unlimited = v.Unlimited
1✔
214
        var err error
1✔
215
        var img *vips.Image
1✔
216
        if isMultiPage(blob, n, page) {
2✔
217
                applyMultiPageOptions(options, n, page)
1✔
218
                if crop == vips.InterestingNone || size == vips.SizeForce {
2✔
219
                        if img, err = v.newImageFromBlob(ctx, blob, options); err != nil {
1✔
220
                                return nil, WrapErr(err)
×
221
                        }
×
222
                        if n > 1 || page > 1 {
2✔
223
                                // reload image to restrict frames loaded
1✔
224
                                n, page = recalculateImage(img, n, page)
1✔
225
                                return v.NewThumbnail(ctx, blob, width, height, crop, size, -n, -page, dpi)
1✔
226
                        }
1✔
227
                        if _, err = v.CheckResolution(img, nil); err != nil {
2✔
228
                                return nil, err
1✔
229
                        }
1✔
230
                        if err = img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
231
                                Height: height, Size: size, Crop: crop,
1✔
232
                        }); err != nil {
1✔
233
                                img.Close()
×
234
                                return nil, WrapErr(err)
×
235
                        }
×
236
                } else {
1✔
237
                        if img, err = v.CheckResolution(v.newImageFromBlob(ctx, blob, options)); err != nil {
1✔
238
                                return nil, WrapErr(err)
×
239
                        }
×
240
                        if n > 1 || page > 1 {
2✔
241
                                // reload image to restrict frames loaded
1✔
242
                                n, page = recalculateImage(img, n, page)
1✔
243
                                return v.NewThumbnail(ctx, blob, width, height, crop, size, -n, -page, dpi)
1✔
244
                        }
1✔
245
                        if err = v.animatedThumbnailWithCrop(img, width, height, crop, size); err != nil {
1✔
246
                                img.Close()
×
247
                                return nil, WrapErr(err)
×
248
                        }
×
249
                }
250
        } else {
1✔
251
                // Use fallback for nested images >= 1000px to avoid VIPS thumbnail bug
1✔
252
                // where the "image source" loader fails to resize properly
1✔
253
                if isNestedImage(ctx) && (width >= 1000 || height >= 1000) {
1✔
UNCOV
254
                        img, err = v.newThumbnailFallback(ctx, blob, width, height, crop, size, options)
×
255
                } else {
1✔
256
                        switch blob.BlobType() {
1✔
257
                        case imagor.BlobTypeJPEG, imagor.BlobTypeGIF, imagor.BlobTypeWEBP:
1✔
258
                                // only allow real thumbnail for jpeg gif webp
1✔
259
                                img, err = newThumbnailFromBlob(ctx, blob, width, height, crop, size, options)
1✔
260
                        default:
1✔
261
                                img, err = v.newThumbnailFallback(ctx, blob, width, height, crop, size, options)
1✔
262
                        }
263
                }
264
        }
265
        return v.CheckResolution(img, WrapErr(err))
1✔
266
}
267

268
func (v *Processor) newThumbnailFallback(
269
        ctx context.Context, blob *imagor.Blob, width, height int, crop vips.Interesting, size vips.Size, options *vips.LoadOptions,
270
) (img *vips.Image, err error) {
1✔
271
        if img, err = v.CheckResolution(v.newImageFromBlob(ctx, blob, options)); err != nil {
2✔
272
                return
1✔
273
        }
1✔
274
        if err = img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
275
                Height: height, Size: size, Crop: crop,
1✔
276
        }); err != nil {
1✔
277
                img.Close()
×
278
                return
×
279
        }
×
280
        return img, WrapErr(err)
1✔
281
}
282

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

310
// Thumbnail handles thumbnail operation
311
func (v *Processor) Thumbnail(
312
        img *vips.Image, width, height int, crop vips.Interesting, size vips.Size,
313
) error {
1✔
314
        if crop == vips.InterestingNone || size == vips.SizeForce || img.Height() == img.PageHeight() {
2✔
315
                return img.ThumbnailImage(width, &vips.ThumbnailImageOptions{
1✔
316
                        Height: height, Size: size, Crop: crop,
1✔
317
                })
1✔
318
        }
1✔
319
        return v.animatedThumbnailWithCrop(img, width, height, crop, size)
1✔
320
}
321

322
// FocalThumbnail handles thumbnail with custom focal point
323
func (v *Processor) FocalThumbnail(img *vips.Image, w, h int, fx, fy float64) (err error) {
1✔
324
        var imageWidth, imageHeight float64
1✔
325
        // exif orientation greater 5-8 are 90 or 270 degrees, w and h swapped
1✔
326
        if img.Orientation() > 4 {
2✔
327
                imageWidth = float64(img.PageHeight())
1✔
328
                imageHeight = float64(img.Width())
1✔
329
        } else {
2✔
330
                imageWidth = float64(img.Width())
1✔
331
                imageHeight = float64(img.PageHeight())
1✔
332
        }
1✔
333

334
        if float64(w)/float64(h) > float64(imageWidth)/float64(imageHeight) {
2✔
335
                if err = img.ThumbnailImage(w, &vips.ThumbnailImageOptions{
1✔
336
                        Height: v.MaxHeight, Crop: vips.InterestingNone,
1✔
337
                }); err != nil {
1✔
338
                        return
×
339
                }
×
340
        } else {
1✔
341
                if err = img.ThumbnailImage(v.MaxWidth, &vips.ThumbnailImageOptions{
1✔
342
                        Height: h, Crop: vips.InterestingNone,
1✔
343
                }); err != nil {
1✔
344
                        return
×
345
                }
×
346
        }
347
        var top, left float64
1✔
348
        left = float64(img.Width())*fx - float64(w)/2
1✔
349
        top = float64(img.PageHeight())*fy - float64(h)/2
1✔
350
        left = math.Max(0, math.Min(left, float64(img.Width()-w)))
1✔
351
        top = math.Max(0, math.Min(top, float64(img.PageHeight()-h)))
1✔
352
        return img.ExtractAreaMultiPage(int(left), int(top), w, h)
1✔
353
}
354

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

385
// CheckResolution check image resolution for image bomb prevention
386
func (v *Processor) CheckResolution(img *vips.Image, err error) (*vips.Image, error) {
1✔
387
        if err != nil || img == nil {
2✔
388
                return img, err
1✔
389
        }
1✔
390
        if !v.Unlimited && (img.Width() > v.MaxWidth || img.PageHeight() > v.MaxHeight ||
1✔
391
                (img.Width()*img.Height()) > v.MaxResolution) {
2✔
392
                img.Close()
1✔
393
                return nil, imagor.ErrMaxResolutionExceeded
1✔
394
        }
1✔
395
        return img, nil
1✔
396
}
397

398
func isMultiPage(blob *imagor.Blob, n, page int) bool {
1✔
399
        return blob != nil && (blob.SupportsAnimation() || blob.BlobType() == imagor.BlobTypePDF) && ((n != 1 && n != 0) || (page != 1 && page != 0))
1✔
400
}
1✔
401

402
func applyMultiPageOptions(params *vips.LoadOptions, n, page int) {
1✔
403
        if page < -1 {
2✔
404
                params.Page = -page - 1
1✔
405
        } else if n < -1 {
3✔
406
                params.N = -n
1✔
407
        } else {
2✔
408
                params.N = -1
1✔
409
        }
1✔
410
}
411

412
func recalculateImage(img *vips.Image, n, page int) (int, int) {
1✔
413
        // reload image to restrict frames loaded
1✔
414
        numPages := img.Pages()
1✔
415
        img.Close()
1✔
416
        if page > 1 && page > numPages {
2✔
417
                page = numPages
1✔
418
        } else if n > 1 && n > numPages {
3✔
419
                n = numPages
1✔
420
        }
1✔
421
        return n, page
1✔
422
}
423

424
// WrapErr wraps error to become imagor.Error
425
func WrapErr(err error) error {
1✔
426
        if err == nil {
2✔
427
                return nil
1✔
428
        }
1✔
429
        if e, ok := err.(imagor.Error); ok {
2✔
430
                return e
1✔
431
        }
1✔
432
        msg := strings.TrimSpace(err.Error())
1✔
433
        if strings.HasPrefix(msg, "VipsForeignLoad:") &&
1✔
434
                strings.HasSuffix(msg, "is not in a known format") {
1✔
435
                return imagor.ErrUnsupportedFormat
×
436
        }
×
437
        return imagor.NewError(msg, 406)
1✔
438
}
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