shithub: hugo

Download patch

ref: 823f53c861bb49aecc6104e0add39fc3b0729025
parent: f9978ed16476ca6d233a89669c62c798cdf9db9d
author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
date: Mon Aug 26 15:12:41 EDT 2019

Add a set of image filters

With this you can do variants of this:

```
{{ $img := resources.Get "images/misc/3-jenny.jpg" }}
{{ $img := $img.Resize "300x" }}
{{ $g1 := $img.Filter images.Grayscale }}
{{ $g2 := $img | images.Filter (images.Saturate 30) (images.GaussianBlur 3) }}
```

Fixes #6255

diff: cannot open b/resources/testdata/golden//null: file does not exist: 'b/resources/testdata/golden//null'
--- a/go.mod
+++ b/go.mod
@@ -12,7 +12,7 @@
 	github.com/bep/debounce v1.2.0
 	github.com/bep/gitmap v1.1.0
 	github.com/bep/go-tocss v0.6.0
-	github.com/disintegration/imaging v1.6.0
+	github.com/disintegration/gift v1.2.1
 	github.com/dustin/go-humanize v1.0.0
 	github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385
 	github.com/fortytw2/leaktest v1.3.0
--- a/go.sum
+++ b/go.sum
@@ -88,8 +88,8 @@
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
-github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA=
-github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ=
+github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
+github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
 github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg=
 github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
@@ -338,8 +338,6 @@
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190422183909-d864b10871cd/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81 h1:00VmoueYNlNz/aHIilyyQz/MHSqGoWJzpFv/HW8xpzI=
-golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
 golang.org/x/image v0.0.0-20190523035834-f03afa92d3ff h1:+2zgJKVDVAz/BWSsuniCmU1kLCjL88Z8/kv39xCI9NQ=
 golang.org/x/image v0.0.0-20190523035834-f03afa92d3ff/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
--- a/resources/image.go
+++ b/resources/image.go
@@ -16,7 +16,6 @@
 import (
 	"fmt"
 	"image"
-	"image/color"
 	"image/draw"
 	_ "image/gif"
 	_ "image/png"
@@ -23,11 +22,13 @@
 	"os"
 	"strings"
 
+	"github.com/gohugoio/hugo/resources/internal"
+
 	"github.com/gohugoio/hugo/resources/resource"
 
 	_errors "github.com/pkg/errors"
 
-	"github.com/disintegration/imaging"
+	"github.com/disintegration/gift"
 	"github.com/gohugoio/hugo/helpers"
 	"github.com/gohugoio/hugo/resources/images"
 
@@ -82,8 +83,13 @@
 // filter and returns the transformed image. If one of width or height is 0, the image aspect
 // ratio is preserved.
 func (i *imageResource) Resize(spec string) (resource.Image, error) {
-	return i.doWithImageConfig("resize", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) {
-		return i.Proc.Resize(src, conf)
+	conf, err := i.decodeImageConfig("resize", spec)
+	if err != nil {
+		return nil, err
+	}
+
+	return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+		return i.Proc.ApplyFiltersFromConfig(src, conf)
 	})
 }
 
@@ -90,8 +96,13 @@
 // Fit scales down the image using the specified resample filter to fit the specified
 // maximum width and height.
 func (i *imageResource) Fit(spec string) (resource.Image, error) {
-	return i.doWithImageConfig("fit", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) {
-		return i.Proc.Fit(src, conf)
+	conf, err := i.decodeImageConfig("fit", spec)
+	if err != nil {
+		return nil, err
+	}
+
+	return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+		return i.Proc.ApplyFiltersFromConfig(src, conf)
 	})
 }
 
@@ -99,11 +110,25 @@
 // crops the resized image to the specified dimensions using the given anchor point.
 // Space delimited config: 200x300 TopLeft
 func (i *imageResource) Fill(spec string) (resource.Image, error) {
-	return i.doWithImageConfig("fill", spec, func(src image.Image, conf images.ImageConfig) (image.Image, error) {
-		return i.Proc.Fill(src, conf)
+	conf, err := i.decodeImageConfig("fill", spec)
+	if err != nil {
+		return nil, err
+	}
+
+	return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+		return i.Proc.ApplyFiltersFromConfig(src, conf)
 	})
 }
 
+func (i *imageResource) Filter(filters ...gift.Filter) (resource.Image, error) {
+	conf := i.Proc.GetDefaultImageConfig("filter")
+	conf.Key = internal.HashString(filters)
+
+	return i.doWithImageConfig(conf, func(src image.Image) (image.Image, error) {
+		return i.Proc.Filter(src, filters...)
+	})
+}
+
 func (i *imageResource) isJPEG() bool {
 	name := strings.ToLower(i.getResourcePaths().relTargetDirFile.file)
 	return strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg")
@@ -118,12 +143,7 @@
 
 var imageProcSem = make(chan bool, imageProcWorkers)
 
-func (i *imageResource) doWithImageConfig(action, spec string, f func(src image.Image, conf images.ImageConfig) (image.Image, error)) (resource.Image, error) {
-	conf, err := i.decodeImageConfig(action, spec)
-	if err != nil {
-		return nil, err
-	}
-
+func (i *imageResource) doWithImageConfig(conf images.ImageConfig, f func(src image.Image) (image.Image, error)) (resource.Image, error) {
 	return i.getSpec().imageCache.getOrCreate(i, conf, func() (*imageResource, image.Image, error) {
 		imageProcSem <- true
 		defer func() {
@@ -130,7 +150,7 @@
 			<-imageProcSem
 		}()
 
-		errOp := action
+		errOp := conf.Action
 		errPath := i.getSourceFilename()
 
 		src, err := i.decodeSource()
@@ -138,17 +158,12 @@
 			return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err}
 		}
 
-		if conf.Rotate != 0 {
-			// Rotate it before any scaling to get the dimensions correct.
-			src = imaging.Rotate(src, float64(conf.Rotate), color.Transparent)
-		}
-
-		converted, err := f(src, conf)
+		converted, err := f(src)
 		if err != nil {
 			return nil, nil, &os.PathError{Op: errOp, Path: errPath, Err: err}
 		}
 
-		if i.Format == imaging.PNG {
+		if i.Format == images.PNG {
 			// Apply the colour palette from the source
 			if paletted, ok := src.(*image.Paletted); ok {
 				tmp := image.NewPaletted(converted.Bounds(), paletted.Palette)
@@ -222,7 +237,7 @@
 	// Do not change for no good reason.
 	const md5Threshold = 100
 
-	key := conf.Key(i.Format)
+	key := conf.GetKey(i.Format)
 
 	// It is useful to have the key in clear text, but when nesting transforms, it
 	// can easily be too long to read, and maybe even too long
--- a/resources/image_test.go
+++ b/resources/image_test.go
@@ -16,14 +16,20 @@
 import (
 	"fmt"
 	"math/rand"
+	"os"
 	"path/filepath"
+	"regexp"
 	"strconv"
 	"sync"
 	"testing"
 
+	"github.com/disintegration/gift"
+
+	"github.com/gohugoio/hugo/helpers"
+
 	"github.com/gohugoio/hugo/media"
+	"github.com/gohugoio/hugo/resources/images"
 	"github.com/gohugoio/hugo/resources/resource"
-
 	"github.com/google/go-cmp/cmp"
 
 	"github.com/gohugoio/hugo/htesting/hqt"
@@ -35,6 +41,9 @@
 	cmp.Comparer(func(p1, p2 *resourceAdapter) bool {
 		return p1.resourceAdapterInner == p2.resourceAdapterInner
 	}),
+	cmp.Comparer(func(p1, p2 os.FileInfo) bool {
+		return p1.Name() == p2.Name() && p1.Size() == p2.Size() && p1.IsDir() == p2.IsDir()
+	}),
 	cmp.Comparer(func(p1, p2 *genericResource) bool { return p1 == p2 }),
 	cmp.Comparer(func(m1, m2 media.Type) bool {
 		return m1.Type() == m2.Type()
@@ -94,7 +103,7 @@
 	fittedAgain, err = fittedAgain.Fit("10x20")
 	c.Assert(err, qt.IsNil)
 	c.Assert(fittedAgain.RelPermalink(), qt.Equals, "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f65ba24dc2b7fba0f56d7f104519157.jpg")
-	assertWidthHeight(fittedAgain, 10, 6)
+	assertWidthHeight(fittedAgain, 10, 7)
 
 	filled, err := image.Fill("200x100 bottomLeft")
 	c.Assert(err, qt.IsNil)
@@ -155,7 +164,10 @@
 
 		t.Run(name, func(t *testing.T) {
 			c := qt.New(t)
-			spec := newTestResourceOsFs(c)
+			spec, workDir := newTestResourceOsFs(c)
+			defer func() {
+				os.Remove(workDir)
+			}()
 
 			check1 := func(img resource.Image) {
 				resizedLink := "/a/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x50_resize_q75_box.jpg"
@@ -192,7 +204,10 @@
 
 	c := qt.New(t)
 
-	spec := newTestResourceOsFs(c)
+	spec, workDir := newTestResourceOsFs(c)
+	defer func() {
+		os.Remove(workDir)
+	}()
 
 	image := fetchImageForSpec(spec, c, "sunset.jpg")
 
@@ -315,6 +330,133 @@
 	c.Assert(err, qt.IsNil)
 	c.Assert(content, hqt.IsSameType, "")
 	c.Assert(content.(string), qt.Contains, `<svg height="100" width="100">`)
+}
+
+func TestImageOperationsGolden(t *testing.T) {
+	c := qt.New(t)
+	c.Parallel()
+
+	devMode := false
+
+	testImages := []string{"sunset.jpg", "gohugoio8.png", "gohugoio24.png"}
+
+	spec, workDir := newTestResourceOsFs(c)
+	defer func() {
+		if !devMode {
+			os.Remove(workDir)
+		}
+	}()
+
+	if devMode {
+		fmt.Println(workDir)
+	}
+
+	for _, img := range testImages {
+
+		orig := fetchImageForSpec(spec, c, img)
+		for _, resizeSpec := range []string{"200x100", "600x", "200x r90 q50 Box"} {
+			resized, err := orig.Resize(resizeSpec)
+			c.Assert(err, qt.IsNil)
+			rel := resized.RelPermalink()
+			c.Log("resize", rel)
+			c.Assert(rel, qt.Not(qt.Equals), "")
+		}
+
+		for _, fillSpec := range []string{"300x200 Gaussian Smart", "100x100 Center", "300x100 TopLeft NearestNeighbor", "400x200 BottomLeft"} {
+			resized, err := orig.Fill(fillSpec)
+			c.Assert(err, qt.IsNil)
+			rel := resized.RelPermalink()
+			c.Log("fill", rel)
+			c.Assert(rel, qt.Not(qt.Equals), "")
+		}
+
+		for _, fitSpec := range []string{"300x200 Linear"} {
+			resized, err := orig.Fit(fitSpec)
+			c.Assert(err, qt.IsNil)
+			rel := resized.RelPermalink()
+			c.Log("fit", rel)
+			c.Assert(rel, qt.Not(qt.Equals), "")
+		}
+
+		f := &images.Filters{}
+
+		filters := []gift.Filter{
+			f.Grayscale(),
+			f.GaussianBlur(6),
+			f.Saturation(50),
+			f.Sepia(100),
+			f.Brightness(30),
+			f.ColorBalance(10, -10, -10),
+			f.Colorize(240, 50, 100),
+			f.Gamma(1.5),
+			f.UnsharpMask(1, 1, 0),
+			f.Sigmoid(0.5, 7),
+			f.Pixelate(5),
+			f.Invert(),
+			f.Hue(22),
+			f.Contrast(32.5),
+		}
+
+		resized, err := orig.Fill("400x200 center")
+
+		for _, filter := range filters {
+			resized, err := resized.Filter(filter)
+			c.Assert(err, qt.IsNil)
+			rel := resized.RelPermalink()
+			c.Logf("filter: %v %s", filter, rel)
+			c.Assert(rel, qt.Not(qt.Equals), "")
+		}
+
+		resized, err = resized.Filter(filters[0:4]...)
+		c.Assert(err, qt.IsNil)
+		rel := resized.RelPermalink()
+		c.Log("filter all", rel)
+		c.Assert(rel, qt.Not(qt.Equals), "")
+	}
+
+	if devMode {
+		return
+	}
+
+	dir1 := filepath.Join(workDir, "resources/_gen/images/a")
+	dir2 := filepath.FromSlash("testdata/golden")
+
+	// The two dirs above should now be the same.
+	d1, err := os.Open(dir1)
+	c.Assert(err, qt.IsNil)
+	d2, err := os.Open(dir2)
+	c.Assert(err, qt.IsNil)
+
+	dirinfos1, err := d1.Readdir(-1)
+	c.Assert(err, qt.IsNil)
+	dirinfos2, err := d2.Readdir(-1)
+
+	c.Assert(err, qt.IsNil)
+	c.Assert(len(dirinfos1), qt.Equals, len(dirinfos2))
+
+	for i, fi1 := range dirinfos1 {
+		if regexp.MustCompile("gauss").MatchString(fi1.Name()) {
+			continue
+		}
+		fi2 := dirinfos2[i]
+		c.Assert(fi1.Name(), qt.Equals, fi2.Name())
+		c.Assert(fi1, eq, fi2)
+		f1, err := os.Open(filepath.Join(dir1, fi1.Name()))
+		c.Assert(err, qt.IsNil)
+		f2, err := os.Open(filepath.Join(dir2, fi2.Name()))
+		c.Assert(err, qt.IsNil)
+
+		hash1, err := helpers.MD5FromReader(f1)
+		c.Assert(err, qt.IsNil)
+		hash2, err := helpers.MD5FromReader(f2)
+		c.Assert(err, qt.IsNil)
+
+		f1.Close()
+		f2.Close()
+
+		c.Assert(hash1, qt.Equals, hash2)
+	}
+
 }
 
 func BenchmarkResizeParallel(b *testing.B) {
--- a/resources/images/config.go
+++ b/resources/images/config.go
@@ -19,7 +19,8 @@
 	"strconv"
 	"strings"
 
-	"github.com/disintegration/imaging"
+	"github.com/disintegration/gift"
+
 	"github.com/mitchellh/mapstructure"
 )
 
@@ -29,61 +30,59 @@
 )
 
 var (
-	imageFormats = map[string]imaging.Format{
-		".jpg":  imaging.JPEG,
-		".jpeg": imaging.JPEG,
-		".png":  imaging.PNG,
-		".tif":  imaging.TIFF,
-		".tiff": imaging.TIFF,
-		".bmp":  imaging.BMP,
-		".gif":  imaging.GIF,
+	imageFormats = map[string]Format{
+		".jpg":  JPEG,
+		".jpeg": JPEG,
+		".png":  PNG,
+		".tif":  TIFF,
+		".tiff": TIFF,
+		".bmp":  BMP,
+		".gif":  GIF,
 	}
 
 	// Add or increment if changes to an image format's processing requires
 	// re-generation.
-	imageFormatsVersions = map[imaging.Format]int{
-		imaging.PNG: 2, // Floyd Steinberg dithering
+	imageFormatsVersions = map[Format]int{
+		PNG: 2, // Floyd Steinberg dithering
 	}
 
 	// Increment to mark all processed images as stale. Only use when absolutely needed.
 	// See the finer grained smartCropVersionNumber and imageFormatsVersions.
 	mainImageVersionNumber = 0
-
-	// Increment to mark all traced SVGs as stale.
-	traceVersionNumber = 0
 )
 
-var anchorPositions = map[string]imaging.Anchor{
-	strings.ToLower("Center"):      imaging.Center,
-	strings.ToLower("TopLeft"):     imaging.TopLeft,
-	strings.ToLower("Top"):         imaging.Top,
-	strings.ToLower("TopRight"):    imaging.TopRight,
-	strings.ToLower("Left"):        imaging.Left,
-	strings.ToLower("Right"):       imaging.Right,
-	strings.ToLower("BottomLeft"):  imaging.BottomLeft,
-	strings.ToLower("Bottom"):      imaging.Bottom,
-	strings.ToLower("BottomRight"): imaging.BottomRight,
+var anchorPositions = map[string]gift.Anchor{
+	strings.ToLower("Center"):      gift.CenterAnchor,
+	strings.ToLower("TopLeft"):     gift.TopLeftAnchor,
+	strings.ToLower("Top"):         gift.TopAnchor,
+	strings.ToLower("TopRight"):    gift.TopRightAnchor,
+	strings.ToLower("Left"):        gift.LeftAnchor,
+	strings.ToLower("Right"):       gift.RightAnchor,
+	strings.ToLower("BottomLeft"):  gift.BottomLeftAnchor,
+	strings.ToLower("Bottom"):      gift.BottomAnchor,
+	strings.ToLower("BottomRight"): gift.BottomRightAnchor,
 }
 
-var imageFilters = map[string]imaging.ResampleFilter{
-	strings.ToLower("NearestNeighbor"):   imaging.NearestNeighbor,
-	strings.ToLower("Box"):               imaging.Box,
-	strings.ToLower("Linear"):            imaging.Linear,
-	strings.ToLower("Hermite"):           imaging.Hermite,
-	strings.ToLower("MitchellNetravali"): imaging.MitchellNetravali,
-	strings.ToLower("CatmullRom"):        imaging.CatmullRom,
-	strings.ToLower("BSpline"):           imaging.BSpline,
-	strings.ToLower("Gaussian"):          imaging.Gaussian,
-	strings.ToLower("Lanczos"):           imaging.Lanczos,
-	strings.ToLower("Hann"):              imaging.Hann,
-	strings.ToLower("Hamming"):           imaging.Hamming,
-	strings.ToLower("Blackman"):          imaging.Blackman,
-	strings.ToLower("Bartlett"):          imaging.Bartlett,
-	strings.ToLower("Welch"):             imaging.Welch,
-	strings.ToLower("Cosine"):            imaging.Cosine,
+var imageFilters = map[string]gift.Resampling{
+
+	strings.ToLower("NearestNeighbor"):   gift.NearestNeighborResampling,
+	strings.ToLower("Box"):               gift.BoxResampling,
+	strings.ToLower("Linear"):            gift.LinearResampling,
+	strings.ToLower("Hermite"):           hermiteResampling,
+	strings.ToLower("MitchellNetravali"): mitchellNetravaliResampling,
+	strings.ToLower("CatmullRom"):        catmullRomResampling,
+	strings.ToLower("BSpline"):           bSplineResampling,
+	strings.ToLower("Gaussian"):          gaussianResampling,
+	strings.ToLower("Lanczos"):           gift.LanczosResampling,
+	strings.ToLower("Hann"):              hannResampling,
+	strings.ToLower("Hamming"):           hammingResampling,
+	strings.ToLower("Blackman"):          blackmanResampling,
+	strings.ToLower("Bartlett"):          bartlettResampling,
+	strings.ToLower("Welch"):             welchResampling,
+	strings.ToLower("Cosine"):            cosineResampling,
 }
 
-func ImageFormatFromExt(ext string) (imaging.Format, bool) {
+func ImageFormatFromExt(ext string) (Format, bool) {
 	f, found := imageFormats[ext]
 	return f, found
 }
@@ -100,8 +99,8 @@
 		return i, errors.New("JPEG quality must be a number between 1 and 100")
 	}
 
-	if i.Anchor == "" || strings.EqualFold(i.Anchor, SmartCropIdentifier) {
-		i.Anchor = SmartCropIdentifier
+	if i.Anchor == "" || strings.EqualFold(i.Anchor, smartCropIdentifier) {
+		i.Anchor = smartCropIdentifier
 	} else {
 		i.Anchor = strings.ToLower(i.Anchor)
 		if _, found := anchorPositions[i.Anchor]; !found {
@@ -139,8 +138,8 @@
 	for _, part := range parts {
 		part = strings.ToLower(part)
 
-		if part == SmartCropIdentifier {
-			c.AnchorStr = SmartCropIdentifier
+		if part == smartCropIdentifier {
+			c.AnchorStr = smartCropIdentifier
 		} else if pos, ok := anchorPositions[part]; ok {
 			c.Anchor = pos
 			c.AnchorStr = part
@@ -198,7 +197,7 @@
 
 	if c.AnchorStr == "" {
 		c.AnchorStr = defaults.Anchor
-		if !strings.EqualFold(c.AnchorStr, SmartCropIdentifier) {
+		if !strings.EqualFold(c.AnchorStr, smartCropIdentifier) {
 			c.Anchor = anchorPositions[c.AnchorStr]
 		}
 	}
@@ -210,6 +209,9 @@
 type ImageConfig struct {
 	Action string
 
+	// If set, this will be used as the key in filenames etc.
+	Key string
+
 	// Quality ranges from 1 to 100 inclusive, higher is better.
 	// This is only relevant for JPEG images.
 	// Default is 75.
@@ -222,14 +224,18 @@
 	Width  int
 	Height int
 
-	Filter    imaging.ResampleFilter
+	Filter    gift.Resampling
 	FilterStr string
 
-	Anchor    imaging.Anchor
+	Anchor    gift.Anchor
 	AnchorStr string
 }
 
-func (i ImageConfig) Key(format imaging.Format) string {
+func (i ImageConfig) GetKey(format Format) string {
+	if i.Key != "" {
+		return i.Action + "_" + i.Key
+	}
+
 	k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height)
 	if i.Action != "" {
 		k += "_" + i.Action
@@ -241,7 +247,7 @@
 		k += "_r" + strconv.Itoa(i.Rotate)
 	}
 	anchor := i.AnchorStr
-	if anchor == SmartCropIdentifier {
+	if anchor == smartCropIdentifier {
 		anchor = anchor + strconv.Itoa(smartCropVersionNumber)
 	}
 
@@ -268,9 +274,9 @@
 	// Default image quality setting (1-100). Only used for JPEG images.
 	Quality int
 
-	// Resample filter used. See https://github.com/disintegration/imaging
+	// Resample filter to use in resize operations..
 	ResampleFilter string
 
-	// The anchor used in Fill. Default is "smart", i.e. Smart Crop.
+	// The anchor to use in Fill. Default is "smart", i.e. Smart Crop.
 	Anchor string
 }
--- /dev/null
+++ b/resources/images/filters.go
@@ -1,0 +1,168 @@
+// Copyright 2019 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package images provides template functions for manipulating images.
+package images
+
+import (
+	"github.com/disintegration/gift"
+	"github.com/spf13/cast"
+)
+
+// Increment for re-generation of images using these filters.
+const filterAPIVersion = 0
+
+type Filters struct {
+}
+
+// Brightness creates a filter that changes the brightness of an image.
+// The percentage parameter must be in range (-100, 100).
+func (*Filters) Brightness(percentage interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(percentage),
+		Filter:  gift.Brightness(cast.ToFloat32(percentage)),
+	}
+}
+
+// ColorBalance creates a filter that changes the color balance of an image.
+// The percentage parameters for each color channel (red, green, blue) must be in range (-100, 500).
+func (*Filters) ColorBalance(percentageRed, percentageGreen, percentageBlue interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(percentageRed, percentageGreen, percentageBlue),
+		Filter:  gift.ColorBalance(cast.ToFloat32(percentageRed), cast.ToFloat32(percentageGreen), cast.ToFloat32(percentageBlue)),
+	}
+}
+
+// Colorize creates a filter that produces a colorized version of an image.
+// The hue parameter is the angle on the color wheel, typically in range (0, 360).
+// The saturation parameter must be in range (0, 100).
+// The percentage parameter specifies the strength of the effect, it must be in range (0, 100).
+func (*Filters) Colorize(hue, saturation, percentage interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(hue, saturation, percentage),
+		Filter:  gift.Colorize(cast.ToFloat32(hue), cast.ToFloat32(saturation), cast.ToFloat32(percentage)),
+	}
+}
+
+// Contrast creates a filter that changes the contrast of an image.
+// The percentage parameter must be in range (-100, 100).
+func (*Filters) Contrast(percentage interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(percentage),
+		Filter:  gift.Contrast(cast.ToFloat32(percentage)),
+	}
+}
+
+// Gamma creates a filter that performs a gamma correction on an image.
+// The gamma parameter must be positive. Gamma = 1 gives the original image.
+// Gamma less than 1 darkens the image and gamma greater than 1 lightens it.
+func (*Filters) Gamma(gamma interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(gamma),
+		Filter:  gift.Gamma(cast.ToFloat32(gamma)),
+	}
+}
+
+// GaussianBlur creates a filter that applies a gaussian blur to an image.
+func (*Filters) GaussianBlur(sigma interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(sigma),
+		Filter:  gift.GaussianBlur(cast.ToFloat32(sigma)),
+	}
+}
+
+// Grayscale creates a filter that produces a grayscale version of an image.
+func (*Filters) Grayscale() gift.Filter {
+	return filter{
+		Filter: gift.Grayscale(),
+	}
+}
+
+// Hue creates a filter that rotates the hue of an image.
+// The hue angle shift is typically in range -180 to 180.
+func (*Filters) Hue(shift interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(shift),
+		Filter:  gift.Hue(cast.ToFloat32(shift)),
+	}
+}
+
+// Invert creates a filter that negates the colors of an image.
+func (*Filters) Invert() gift.Filter {
+	return filter{
+		Filter: gift.Invert(),
+	}
+}
+
+// Pixelate creates a filter that applies a pixelation effect to an image.
+func (*Filters) Pixelate(size interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(size),
+		Filter:  gift.Pixelate(cast.ToInt(size)),
+	}
+}
+
+// Saturation creates a filter that changes the saturation of an image.
+func (*Filters) Saturation(percentage interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(percentage),
+		Filter:  gift.Saturation(cast.ToFloat32(percentage)),
+	}
+}
+
+// Sepia creates a filter that produces a sepia-toned version of an image.
+func (*Filters) Sepia(percentage interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(percentage),
+		Filter:  gift.Sepia(cast.ToFloat32(percentage)),
+	}
+}
+
+// Sigmoid creates a filter that changes the contrast of an image using a sigmoidal function and returns the adjusted image.
+// It's a non-linear contrast change useful for photo adjustments as it preserves highlight and shadow detail.
+func (*Filters) Sigmoid(midpoint, factor interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(midpoint, factor),
+		Filter:  gift.Sigmoid(cast.ToFloat32(midpoint), cast.ToFloat32(factor)),
+	}
+}
+
+// UnsharpMask creates a filter that sharpens an image.
+// The sigma parameter is used in a gaussian function and affects the radius of effect.
+// Sigma must be positive. Sharpen radius roughly equals 3 * sigma.
+// The amount parameter controls how much darker and how much lighter the edge borders become. Typically between 0.5 and 1.5.
+// The threshold parameter controls the minimum brightness change that will be sharpened. Typically between 0 and 0.05.
+func (*Filters) UnsharpMask(sigma, amount, threshold interface{}) gift.Filter {
+	return filter{
+		Options: newFilterOpts(sigma, amount, threshold),
+		Filter:  gift.UnsharpMask(cast.ToFloat32(sigma), cast.ToFloat32(amount), cast.ToFloat32(threshold)),
+	}
+}
+
+type filter struct {
+	Options filterOpts
+	gift.Filter
+}
+
+// For cache-busting.
+type filterOpts struct {
+	Version int
+	Vals    interface{}
+}
+
+func newFilterOpts(vals ...interface{}) filterOpts {
+	return filterOpts{
+		Version: filterAPIVersion,
+		Vals:    vals,
+	}
+}
--- a/resources/images/image.go
+++ b/resources/images/image.go
@@ -15,16 +15,22 @@
 
 import (
 	"image"
+	"image/color"
+	"image/gif"
 	"image/jpeg"
+	"image/png"
 	"io"
 	"sync"
 
-	"github.com/disintegration/imaging"
+	"github.com/disintegration/gift"
+	"golang.org/x/image/bmp"
+	"golang.org/x/image/tiff"
+
 	"github.com/gohugoio/hugo/common/hugio"
 	"github.com/pkg/errors"
 )
 
-func NewImage(f imaging.Format, proc *ImageProcessor, img image.Image, s Spec) *Image {
+func NewImage(f Format, proc *ImageProcessor, img image.Image, s Spec) *Image {
 	if img != nil {
 		return &Image{
 			Format: f,
@@ -40,7 +46,7 @@
 }
 
 type Image struct {
-	Format imaging.Format
+	Format Format
 
 	Proc *ImageProcessor
 
@@ -51,7 +57,7 @@
 
 func (i *Image) EncodeTo(conf ImageConfig, img image.Image, w io.Writer) error {
 	switch i.Format {
-	case imaging.JPEG:
+	case JPEG:
 
 		var rgba *image.RGBA
 		quality := conf.Quality
@@ -69,9 +75,23 @@
 			return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality})
 		}
 		return jpeg.Encode(w, img, &jpeg.Options{Quality: quality})
+	case PNG:
+		encoder := png.Encoder{CompressionLevel: png.DefaultCompression}
+		return encoder.Encode(w, img)
+
+	case GIF:
+		return gif.Encode(w, img, &gif.Options{
+			NumColors: 256,
+		})
+	case TIFF:
+		return tiff.Encode(w, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true})
+
+	case BMP:
+		return bmp.Encode(w, img)
 	default:
-		return imaging.Encode(w, img, i.Format)
+		return errors.New("format not supported")
 	}
+
 }
 
 // Height returns i's height.
@@ -138,19 +158,52 @@
 	Cfg Imaging
 }
 
-func (p *ImageProcessor) Fill(src image.Image, conf ImageConfig) (image.Image, error) {
-	if conf.AnchorStr == SmartCropIdentifier {
-		return smartCrop(src, conf.Width, conf.Height, conf.Anchor, conf.Filter)
+func (p *ImageProcessor) ApplyFiltersFromConfig(src image.Image, conf ImageConfig) (image.Image, error) {
+	var filters []gift.Filter
+
+	if conf.Rotate != 0 {
+		// Apply any rotation before any resize.
+		filters = append(filters, gift.Rotate(float32(conf.Rotate), color.Transparent, gift.NearestNeighborInterpolation))
 	}
-	return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil
+
+	switch conf.Action {
+	case "resize":
+		filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
+	case "fill":
+		if conf.AnchorStr == smartCropIdentifier {
+			bounds, err := p.smartCrop(src, conf.Width, conf.Height, conf.Filter)
+			if err != nil {
+				return nil, err
+			}
+
+			// First crop it, then resize it.
+			filters = append(filters, gift.Crop(bounds))
+			filters = append(filters, gift.Resize(conf.Width, conf.Height, conf.Filter))
+
+		} else {
+			filters = append(filters, gift.ResizeToFill(conf.Width, conf.Height, conf.Filter, conf.Anchor))
+		}
+	case "fit":
+		filters = append(filters, gift.ResizeToFit(conf.Width, conf.Height, conf.Filter))
+	default:
+		return nil, errors.Errorf("unsupported action: %q", conf.Action)
+	}
+
+	return p.Filter(src, filters...)
 }
 
-func (p *ImageProcessor) Fit(src image.Image, conf ImageConfig) (image.Image, error) {
-	return imaging.Fit(src, conf.Width, conf.Height, conf.Filter), nil
+func (p *ImageProcessor) Filter(src image.Image, filters ...gift.Filter) (image.Image, error) {
+	g := gift.New(filters...)
+	dst := image.NewRGBA(g.Bounds(src.Bounds()))
+	g.Draw(dst, src)
+	return dst, nil
 }
 
-func (p *ImageProcessor) Resize(src image.Image, conf ImageConfig) (image.Image, error) {
-	return imaging.Resize(src, conf.Width, conf.Height, conf.Filter), nil
+func (p *ImageProcessor) GetDefaultImageConfig(action string) ImageConfig {
+	return ImageConfig{
+		Action:  action,
+		Quality: p.Cfg.Quality,
+	}
 }
 
 type Spec interface {
@@ -157,6 +210,17 @@
 	// Loads the image source.
 	ReadSeekCloser() (hugio.ReadSeekCloser, error)
 }
+
+// Format is an image file format.
+type Format int
+
+const (
+	JPEG Format = iota + 1
+	PNG
+	GIF
+	TIFF
+	BMP
+)
 
 type imageConfig struct {
 	config       image.Config
--- /dev/null
+++ b/resources/images/resampling.go
@@ -1,0 +1,214 @@
+// Copyright 2019 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package images
+
+import "math"
+
+// We moved from imaging to the gift package for image processing at some point.
+// That package had more, but also less resampling filters. So we add the missing
+// ones here. They are fairly exotic, but someone may use them, so keep them here
+// for now.
+//
+// The filters below are ported from https://github.com/disintegration/imaging/blob/9aab30e6aa535fe3337b489b76759ef97dfaf362/resize.go#L369
+// MIT License.
+
+var (
+	// Hermite cubic spline filter (BC-spline; B=0; C=0).
+	hermiteResampling = resamp{
+		name:    "Hermite",
+		support: 1.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 1.0 {
+				return bcspline(x, 0.0, 0.0)
+			}
+			return 0
+		},
+	}
+
+	// Mitchell-Netravali cubic filter (BC-spline; B=1/3; C=1/3).
+	mitchellNetravaliResampling = resamp{
+		name:    "MitchellNetravali",
+		support: 2.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 2.0 {
+				return bcspline(x, 1.0/3.0, 1.0/3.0)
+			}
+			return 0
+		},
+	}
+
+	// Catmull-Rom - sharp cubic filter (BC-spline; B=0; C=0.5).
+	catmullRomResampling = resamp{
+		name:    "CatmullRomResampling",
+		support: 2.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 2.0 {
+				return bcspline(x, 0.0, 0.5)
+			}
+			return 0
+		},
+	}
+
+	// BSpline is a smooth cubic filter (BC-spline; B=1; C=0).
+	bSplineResampling = resamp{
+		name:    "BSplineResampling",
+		support: 2.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 2.0 {
+				return bcspline(x, 1.0, 0.0)
+			}
+			return 0
+		},
+	}
+
+	// Gaussian blurring filter.
+	gaussianResampling = resamp{
+		name:    "GaussianResampling",
+		support: 2.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 2.0 {
+				return float32(math.Exp(float64(-2 * x * x)))
+			}
+			return 0
+		},
+	}
+
+	//  Hann-windowed sinc filter (3 lobes).
+	hannResampling = resamp{
+		name:    "HannResampling",
+		support: 3.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 3.0 {
+				return sinc(x) * float32(0.5+0.5*math.Cos(math.Pi*float64(x)/3.0))
+			}
+			return 0
+		},
+	}
+
+	hammingResampling = resamp{
+		name:    "HammingResampling",
+		support: 3.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 3.0 {
+				return sinc(x) * float32(0.54+0.46*math.Cos(math.Pi*float64(x)/3.0))
+			}
+			return 0
+		},
+	}
+
+	// Blackman-windowed sinc filter (3 lobes).
+	blackmanResampling = resamp{
+		name:    "BlackmanResampling",
+		support: 3.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 3.0 {
+				return sinc(x) * float32(0.42-0.5*math.Cos(math.Pi*float64(x)/3.0+math.Pi)+0.08*math.Cos(2.0*math.Pi*float64(x)/3.0))
+			}
+			return 0
+		},
+	}
+
+	bartlettResampling = resamp{
+		name:    "BartlettResampling",
+		support: 3.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 3.0 {
+				return sinc(x) * (3.0 - x) / 3.0
+			}
+			return 0
+		},
+	}
+
+	// Welch-windowed sinc filter (parabolic window, 3 lobes).
+	welchResampling = resamp{
+		name:    "WelchResampling",
+		support: 3.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 3.0 {
+				return sinc(x) * (1.0 - (x * x / 9.0))
+			}
+			return 0
+		},
+	}
+
+	// Cosine-windowed sinc filter (3 lobes).
+	cosineResampling = resamp{
+		name:    "CosineResampling",
+		support: 3.0,
+		kernel: func(x float32) float32 {
+			x = absf32(x)
+			if x < 3.0 {
+				return sinc(x) * float32(math.Cos((math.Pi/2.0)*(float64(x)/3.0)))
+			}
+			return 0
+		},
+	}
+)
+
+// The following code is borrowed from https://raw.githubusercontent.com/disintegration/gift/master/resize.go
+// MIT licensed.
+type resamp struct {
+	name    string
+	support float32
+	kernel  func(float32) float32
+}
+
+func (r resamp) String() string {
+	return r.name
+}
+
+func (r resamp) Support() float32 {
+	return r.support
+}
+
+func (r resamp) Kernel(x float32) float32 {
+	return r.kernel(x)
+}
+
+func bcspline(x, b, c float32) float32 {
+	if x < 0 {
+		x = -x
+	}
+	if x < 1 {
+		return ((12-9*b-6*c)*x*x*x + (-18+12*b+6*c)*x*x + (6 - 2*b)) / 6
+	}
+	if x < 2 {
+		return ((-b-6*c)*x*x*x + (6*b+30*c)*x*x + (-12*b-48*c)*x + (8*b + 24*c)) / 6
+	}
+	return 0
+}
+
+func absf32(x float32) float32 {
+	if x < 0 {
+		return -x
+	}
+	return x
+}
+
+func sinc(x float32) float32 {
+	if x == 0 {
+		return 1
+	}
+	return float32(math.Sin(math.Pi*float64(x)) / (math.Pi * float64(x)))
+}
--- a/resources/images/smartcrop.go
+++ b/resources/images/smartcrop.go
@@ -16,14 +16,14 @@
 import (
 	"image"
 
-	"github.com/disintegration/imaging"
+	"github.com/disintegration/gift"
+
 	"github.com/muesli/smartcrop"
 )
 
 const (
 	// Do not change.
-	// TODO(bep) image unexport
-	SmartCropIdentifier = "smart"
+	smartCropIdentifier = "smart"
 
 	// This is just a increment, starting on 1. If Smart Crop improves its cropping, we
 	// need a way to trigger a re-generation of the crops in the wild, so increment this.
@@ -30,22 +30,24 @@
 	smartCropVersionNumber = 1
 )
 
-func newSmartCropAnalyzer(filter imaging.ResampleFilter) smartcrop.Analyzer {
-	return smartcrop.NewAnalyzer(imagingResizer{filter: filter})
+func (p *ImageProcessor) newSmartCropAnalyzer(filter gift.Resampling) smartcrop.Analyzer {
+	return smartcrop.NewAnalyzer(imagingResizer{p: p, filter: filter})
 }
 
 // Needed by smartcrop
 type imagingResizer struct {
-	filter imaging.ResampleFilter
+	p      *ImageProcessor
+	filter gift.Resampling
 }
 
 func (r imagingResizer) Resize(img image.Image, width, height uint) image.Image {
-	return imaging.Resize(img, int(width), int(height), r.filter)
+	result, _ := r.p.Filter(img, gift.Resize(int(width), int(height), r.filter))
+	return result
 }
 
-func smartCrop(img image.Image, width, height int, anchor imaging.Anchor, filter imaging.ResampleFilter) (*image.NRGBA, error) {
+func (p *ImageProcessor) smartCrop(img image.Image, width, height int, filter gift.Resampling) (image.Rectangle, error) {
 	if width <= 0 || height <= 0 {
-		return &image.NRGBA{}, nil
+		return image.Rectangle{}, nil
 	}
 
 	srcBounds := img.Bounds()
@@ -53,23 +55,20 @@
 	srcH := srcBounds.Dy()
 
 	if srcW <= 0 || srcH <= 0 {
-		return &image.NRGBA{}, nil
+		return image.Rectangle{}, nil
 	}
 
 	if srcW == width && srcH == height {
-		return imaging.Clone(img), nil
+		return srcBounds, nil
 	}
 
-	smart := newSmartCropAnalyzer(filter)
+	smart := p.newSmartCropAnalyzer(filter)
 
 	rect, err := smart.FindBestCrop(img, width, height)
 	if err != nil {
-		return nil, err
+		return image.Rectangle{}, err
 	}
 
-	b := img.Bounds().Intersect(rect)
+	return img.Bounds().Intersect(rect), nil
 
-	cropped := imaging.Crop(img, b)
-
-	return imaging.Resize(cropped, width, height, filter), nil
 }
--- a/resources/internal/key.go
+++ b/resources/internal/key.go
@@ -16,8 +16,6 @@
 import (
 	"strconv"
 
-	bp "github.com/gohugoio/hugo/bufferpool"
-
 	"github.com/mitchellh/hashstructure"
 )
 
@@ -44,18 +42,23 @@
 		return k.Name
 	}
 
-	sb := bp.GetBuffer()
-	defer bp.PutBuffer(sb)
+	return k.Name + "_" + HashString(k.elements...)
 
-	sb.WriteString(k.Name)
-	for _, element := range k.elements {
-		hash, err := hashstructure.Hash(element, nil)
-		if err != nil {
-			panic(err)
-		}
-		sb.WriteString("_")
-		sb.WriteString(strconv.FormatUint(hash, 10))
+}
+
+// HashString returns a hash from the given elements.
+// It will panic if the hash cannot be calculated.
+func HashString(elements ...interface{}) string {
+	var o interface{}
+	if len(elements) == 1 {
+		o = elements[0]
+	} else {
+		o = elements
 	}
 
-	return sb.String()
+	hash, err := hashstructure.Hash(o, nil)
+	if err != nil {
+		panic(err)
+	}
+	return strconv.FormatUint(hash, 10)
 }
--- a/resources/internal/key_test.go
+++ b/resources/internal/key_test.go
@@ -32,5 +32,12 @@
 	key := NewResourceTransformationKey("testing",
 		testStruct{Name: "test", V1: int64(10), V2: int32(20), V3: 30, V4: uint64(40)})
 	c := qt.New(t)
-	c.Assert("testing_518996646957295636", qt.Equals, key.Value())
+	c.Assert(key.Value(), qt.Equals, "testing_518996646957295636")
+}
+
+func TestHashString(t *testing.T) {
+	c := qt.New(t)
+
+	c.Assert(HashString("a", "b"), qt.Equals, "2712570657419664240")
+	c.Assert(HashString("ab"), qt.Equals, "590647783936702392")
 }
--- a/resources/resource/resourcetypes.go
+++ b/resources/resource/resourcetypes.go
@@ -14,6 +14,7 @@
 package resource
 
 import (
+	"github.com/disintegration/gift"
 	"github.com/gohugoio/hugo/langs"
 	"github.com/gohugoio/hugo/media"
 
@@ -47,6 +48,7 @@
 	Fill(spec string) (Image, error)
 	Fit(spec string) (Image, error)
 	Resize(spec string) (Image, error)
+	Filter(filters ...gift.Filter) (Image, error)
 }
 
 type ResourceTypesProvider interface {
binary files /dev/null b/resources/testdata/gohugoio24.png differ
binary files /dev/null b/resources/testdata/gohugoio8.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_100x100_fill_box_center_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_14fabac035a010e707ee3733f6590555.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x0_resize_q50_r90_box_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_200x100_resize_box_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x100_fill_nearestneighbor_topleft_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fill_gaussian_smart1_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_300x200_fit_linear_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_bottomleft_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_400x200_fill_box_center_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_55b828db27003cb979bac711748f4789.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_600x0_resize_box_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_621ae6f4010e2eb164521f54f653df1f.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_65ffdad1306cecec4d21bac1edd47c44.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_84b0614b9f84c94c0773ef49ae868d0b.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_874d58b1c4b4b538f7ade152b3e57df8.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_958fee7992cf502355355c021148638b.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_9c5c204a4fc82e861344066bc8d0c7db.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_a0088abf33fdbf6be1651a71e7d4dc33.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_cdb3de8b01145d94ba41047655e42695.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_cfc2eacca4b2748852f953954207d615.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d1ad299f68cb4b3e1eba2ab7633e7857.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_d1f39c78ba8a0ada8233161edeed27ee.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_dd36fa3cc8ae7cf4d686caf1a171284b.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio24_huc57dd738f4724f4b341121e66fd85555_267952_f5d42d1797f90edd6379e0b082fdd53b.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_100x100_fill_box_center_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_1bf2d9610b385893204d0a57ef8d1532.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x0_resize_q50_r90_box_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_200x100_resize_box_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x100_fill_nearestneighbor_topleft_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fill_gaussian_smart1_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_300x200_fit_linear_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_bottomleft_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_400x200_fill_box_center_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_41369feac467f9ecec9ef46911b04fa1.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_4c320010919da2d8b63ed24818b4d8e1.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_600x0_resize_box_2.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_7852bca7fb011b36d030e4d35d8e1d90.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_798ebb7a9e9dc7edd40e2832eb77e457.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_84a8d324276a96584446750f06d04bd4.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_8544b956dc08b714975ae52d4dcfdd78.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_888208ddeeeb3dcfe84697903ddffe30.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9660b4bf59aeb8ac8714d3e466af6197.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9a86fee686dd5973923f5ef5c3b0bc74.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_9d4c2220235b3c2d9fa6506be571560f.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_bac1f274c6786fdb63dd215df2226cd9.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c1ced24877f4b1baf563997e33cadcfa.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_c74bb417b961e09cf1aac2130b7b9b85.png differ
binary files /dev/null b/resources/testdata/golden/gohugoio8_hu7f72c00afdf7634587afaa5eff2a25b2_73538_de67126dc370f606d57f2c229b3accab.png differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_0d1b300da7a815ed567b6dadb6f2ce5e.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_100x100_fill_q75_box_center.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_17fd3c558d78ce249b5f0bcbe1ddbffb.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x0_resize_q50_r90_box.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_200x100_resize_q75_box.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x100_fill_q75_nearestneighbor_topleft.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fill_q75_gaussian_smart1.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_300x200_fit_q75_linear.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_30fc2aab35ca0861bf396d09aebc85a4.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_352eb0101b7c88107520ba719432bbb2.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3efc2d0f29a8e12c5a690fc6c9288854.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_3f1b1455c4a7d13c5aeb7510f9a6a581.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_bottomleft.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_400x200_fill_q75_box_center.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_600x0_resize_q75_box.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_6c5c12ac79d3455ccb1993d51eec3cdf.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_7d9bc4700565266807dc476421066137.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_9f00027c376fe8556cc9996c47f23f78.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_abf356affd7d70d6bec3b3498b572191.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c36da6818db1ab630c3f87f65170003b.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_cb45fcba865177290c89dc9f41d6ff7a.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_d30c10468b33df9010d185a8fe8f0491.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_de1fe6c0f40e7165355507d0f1748083.jpg differ
binary files /dev/null b/resources/testdata/golden/sunset_hu59e56ffff1bc1d8d122b1403d34e039f_90587_f6d8fe32ce3e83abf130e91e33456914.jpg differ
--- a/resources/testhelpers_test.go
+++ b/resources/testhelpers_test.go
@@ -102,11 +102,13 @@
 	}
 }
 
-func newTestResourceOsFs(c *qt.C) *Spec {
+func newTestResourceOsFs(c *qt.C) (*Spec, string) {
 	cfg := createTestCfg()
 	cfg.Set("baseURL", "https://example.com")
 
-	workDir, _ := ioutil.TempDir("", "hugores")
+	workDir, err := ioutil.TempDir("", "hugores")
+	c.Assert(err, qt.IsNil)
+	c.Assert(workDir, qt.Not(qt.Equals), "")
 
 	if runtime.GOOS == "darwin" && !strings.HasPrefix(workDir, "/private") {
 		// To get the entry folder in line with the rest. This its a little bit
@@ -127,8 +129,9 @@
 
 	spec, err := NewSpec(s, filecaches, nil, output.DefaultFormats, media.DefaultTypes)
 	c.Assert(err, qt.IsNil)
-	return spec
 
+	return spec, workDir
+
 }
 
 func fetchSunset(c *qt.C) resource.Image {
@@ -139,6 +142,7 @@
 	spec := newTestResourceSpec(specDescriptor{c: c})
 	return fetchImageForSpec(spec, c, name)
 }
+
 func fetchImageForSpec(spec *Spec, c *qt.C, name string) resource.Image {
 	r := fetchResourceForSpec(spec, c, name)
 
@@ -153,8 +157,9 @@
 func fetchResourceForSpec(spec *Spec, c *qt.C, name string) resource.ContentResource {
 	src, err := os.Open(filepath.FromSlash("testdata/" + name))
 	c.Assert(err, qt.IsNil)
-
-	out, err := helpers.OpenFileForWriting(spec.Fs.Source, name)
+	workDir := spec.WorkingDir
+	targetFilename := filepath.Join(workDir, name)
+	out, err := helpers.OpenFileForWriting(spec.Fs.Source, targetFilename)
 	c.Assert(err, qt.IsNil)
 	_, err = io.Copy(out, src)
 	out.Close()
@@ -163,8 +168,9 @@
 
 	factory := newTargetPaths("/a")
 
-	r, err := spec.New(ResourceSourceDescriptor{Fs: spec.Fs.Source, TargetPaths: factory, LazyPublish: true, SourceFilename: name})
+	r, err := spec.New(ResourceSourceDescriptor{Fs: spec.Fs.Source, TargetPaths: factory, LazyPublish: true, RelTargetFilename: name, SourceFilename: targetFilename})
 	c.Assert(err, qt.IsNil)
+	c.Assert(r, qt.Not(qt.IsNil))
 
 	return r.(resource.ContentResource)
 }
--- a/resources/transform.go
+++ b/resources/transform.go
@@ -21,6 +21,7 @@
 	"strings"
 	"sync"
 
+	"github.com/disintegration/gift"
 	"github.com/spf13/afero"
 
 	bp "github.com/gohugoio/hugo/bufferpool"
@@ -170,6 +171,10 @@
 
 func (r *resourceAdapter) Fit(spec string) (resource.Image, error) {
 	return r.getImageOps().Fit(spec)
+}
+
+func (r *resourceAdapter) Filter(filters ...gift.Filter) (resource.Image, error) {
+	return r.getImageOps().Filter(filters...)
 }
 
 func (r *resourceAdapter) Height() int {
--- a/tpl/images/images.go
+++ b/tpl/images/images.go
@@ -1,4 +1,4 @@
-// Copyright 2017 The Hugo Authors. All rights reserved.
+// Copyright 2019 The Hugo Authors. All rights reserved.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -15,10 +15,16 @@
 package images
 
 import (
-	"errors"
 	"image"
 	"sync"
 
+	"github.com/disintegration/gift"
+
+	"github.com/pkg/errors"
+
+	"github.com/gohugoio/hugo/resources/images"
+	"github.com/gohugoio/hugo/resources/resource"
+
 	// Importing image codecs for image.DecodeConfig
 	_ "image/gif"
 	_ "image/jpeg"
@@ -34,13 +40,15 @@
 // New returns a new instance of the images-namespaced template functions.
 func New(deps *deps.Deps) *Namespace {
 	return &Namespace{
-		cache: map[string]image.Config{},
-		deps:  deps,
+		Filters: &images.Filters{},
+		cache:   map[string]image.Config{},
+		deps:    deps,
 	}
 }
 
 // Namespace provides template functions for the "images" namespace.
 type Namespace struct {
+	*images.Filters
 	cacheMu sync.RWMutex
 	cache   map[string]image.Config
 
@@ -84,4 +92,19 @@
 	ns.cacheMu.Unlock()
 
 	return config, nil
+}
+
+func (ns *Namespace) Filter(args ...interface{}) (resource.Image, error) {
+	if len(args) < 2 {
+		return nil, errors.New("must provide an image and one or more filters")
+	}
+
+	img := args[len(args)-1].(resource.Image)
+	filtersv := args[:len(args)-1]
+	filters := make([]gift.Filter, len(filtersv))
+	for i, f := range filtersv {
+		filters[i] = f.(gift.Filter)
+	}
+
+	return img.Filter(filters...)
 }