summaryrefslogtreecommitdiffstats
path: root/resource
diff options
context:
space:
mode:
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>2017-07-24 09:00:23 +0200
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>2017-12-27 18:44:47 +0100
commit3cdf19e9b7e46c57a9bb43ff02199177feb55768 (patch)
treed05e3dc15824c8eeef3e5455193d2d6328621f47 /resource
parent02f2735f68e1bb2e2c412698755d52c4d396f237 (diff)
:sparkles: Implement Page bundling and image handling
This commit is not the smallest in Hugo's history. Some hightlights include: * Page bundles (for complete articles, keeping images and content together etc.). * Bundled images can be processed in as many versions/sizes as you need with the three methods `Resize`, `Fill` and `Fit`. * Processed images are cached inside `resources/_gen/images` (default) in your project. * Symbolic links (both files and dirs) are now allowed anywhere inside /content * A new table based build summary * The "Total in nn ms" now reports the total including the handling of the files inside /static. So if it now reports more than you're used to, it is just **more real** and probably faster than before (see below). A site building benchmark run compared to `v0.31.1` shows that this should be slightly faster and use less memory: ```bash ▶ ./benchSite.sh "TOML,num_langs=.*,num_root_sections=5,num_pages=(500|1000),tags_per_page=5,shortcodes,render" benchmark old ns/op new ns/op delta BenchmarkSiteBuilding/TOML,num_langs=1,num_root_sections=5,num_pages=500,tags_per_page=5,shortcodes,render-4 101785785 78067944 -23.30% BenchmarkSiteBuilding/TOML,num_langs=1,num_root_sections=5,num_pages=1000,tags_per_page=5,shortcodes,render-4 185481057 149159919 -19.58% BenchmarkSiteBuilding/TOML,num_langs=3,num_root_sections=5,num_pages=500,tags_per_page=5,shortcodes,render-4 103149918 85679409 -16.94% BenchmarkSiteBuilding/TOML,num_langs=3,num_root_sections=5,num_pages=1000,tags_per_page=5,shortcodes,render-4 203515478 169208775 -16.86% benchmark old allocs new allocs delta BenchmarkSiteBuilding/TOML,num_langs=1,num_root_sections=5,num_pages=500,tags_per_page=5,shortcodes,render-4 532464 391539 -26.47% BenchmarkSiteBuilding/TOML,num_langs=1,num_root_sections=5,num_pages=1000,tags_per_page=5,shortcodes,render-4 1056549 772702 -26.87% BenchmarkSiteBuilding/TOML,num_langs=3,num_root_sections=5,num_pages=500,tags_per_page=5,shortcodes,render-4 555974 406630 -26.86% BenchmarkSiteBuilding/TOML,num_langs=3,num_root_sections=5,num_pages=1000,tags_per_page=5,shortcodes,render-4 1086545 789922 -27.30% benchmark old bytes new bytes delta BenchmarkSiteBuilding/TOML,num_langs=1,num_root_sections=5,num_pages=500,tags_per_page=5,shortcodes,render-4 53243246 43598155 -18.12% BenchmarkSiteBuilding/TOML,num_langs=1,num_root_sections=5,num_pages=1000,tags_per_page=5,shortcodes,render-4 105811617 86087116 -18.64% BenchmarkSiteBuilding/TOML,num_langs=3,num_root_sections=5,num_pages=500,tags_per_page=5,shortcodes,render-4 54558852 44545097 -18.35% BenchmarkSiteBuilding/TOML,num_langs=3,num_root_sections=5,num_pages=1000,tags_per_page=5,shortcodes,render-4 106903858 86978413 -18.64% ``` Fixes #3651 Closes #3158 Fixes #1014 Closes #2021 Fixes #1240 Updates #3757
Diffstat (limited to 'resource')
-rw-r--r--resource/image.go551
-rw-r--r--resource/image_cache.go112
-rw-r--r--resource/image_test.go134
-rw-r--r--resource/resource.go275
-rw-r--r--resource/resource_test.go108
-rw-r--r--resource/testdata/sunset.jpgbin0 -> 90587 bytes
-rw-r--r--resource/testhelpers_test.go78
7 files changed, 1258 insertions, 0 deletions
diff --git a/resource/image.go b/resource/image.go
new file mode 100644
index 000000000..c039f68b6
--- /dev/null
+++ b/resource/image.go
@@ -0,0 +1,551 @@
+// Copyright 2017-present 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 resource
+
+import (
+ "errors"
+ "fmt"
+ "image/color"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/mitchellh/mapstructure"
+
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/spf13/afero"
+
+ // Importing image codecs for image.DecodeConfig
+ "image"
+ _ "image/gif"
+ "image/jpeg"
+ _ "image/png"
+
+ "github.com/disintegration/imaging"
+
+ // Import webp codec
+ "sync"
+
+ _ "golang.org/x/image/webp"
+)
+
+var (
+ _ Resource = (*Image)(nil)
+ _ Source = (*Image)(nil)
+ _ Cloner = (*Image)(nil)
+)
+
+// Imaging contains default image processing configuration. This will be fetched
+// from site (or language) config.
+type Imaging struct {
+ // Default image quality setting (1-100). Only used for JPEG images.
+ Quality int
+
+ // Resample filter used. See https://github.com/disintegration/imaging
+ ResampleFilter string
+}
+
+const (
+ defaultJPEGQuality = 75
+ defaultResampleFilter = "box"
+)
+
+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,
+}
+
+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 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,
+}
+
+type Image struct {
+ config image.Config
+ configInit sync.Once
+ configLoaded bool
+
+ copiedToDestinationInit sync.Once
+
+ imaging *Imaging
+
+ *genericResource
+}
+
+func (i *Image) Width() int {
+ i.initConfig()
+ return i.config.Width
+}
+
+func (i *Image) Height() int {
+ i.initConfig()
+ return i.config.Height
+}
+
+// Implement the Cloner interface.
+func (i *Image) WithNewBase(base string) Resource {
+ return &Image{
+ imaging: i.imaging,
+ genericResource: i.genericResource.WithNewBase(base).(*genericResource)}
+}
+
+// Resize resizes the image to the specified width and height using the specified resampling
+// filter and returns the transformed image. If one of width or height is 0, the image aspect
+// ratio is preserved.
+func (i *Image) Resize(spec string) (*Image, error) {
+ return i.doWithImageConfig("resize", spec, func(src image.Image, conf imageConfig) (image.Image, error) {
+ return imaging.Resize(src, conf.Width, conf.Height, conf.Filter), nil
+ })
+}
+
+// Fit scales down the image using the specified resample filter to fit the specified
+// maximum width and height.
+func (i *Image) Fit(spec string) (*Image, error) {
+ return i.doWithImageConfig("fit", spec, func(src image.Image, conf imageConfig) (image.Image, error) {
+ return imaging.Fit(src, conf.Width, conf.Height, conf.Filter), nil
+ })
+}
+
+// Fill scales the image to the smallest possible size that will cover the specified dimensions,
+// crops the resized image to the specified dimensions using the given anchor point.
+// Space delimited config: 200x300 TopLeft
+func (i *Image) Fill(spec string) (*Image, error) {
+ return i.doWithImageConfig("fill", spec, func(src image.Image, conf imageConfig) (image.Image, error) {
+ return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil
+ })
+}
+
+// Holds configuration to create a new image from an existing one, resize etc.
+type imageConfig struct {
+ Action string
+
+ // Quality ranges from 1 to 100 inclusive, higher is better.
+ // This is only relevant for JPEG images.
+ // Default is 75.
+ Quality int
+
+ // Rotate rotates an image by the given angle counter-clockwise.
+ // The rotation will be performed first.
+ Rotate int
+
+ Width int
+ Height int
+
+ Filter imaging.ResampleFilter
+ FilterStr string
+
+ Anchor imaging.Anchor
+ AnchorStr string
+}
+
+func (i *Image) isJPEG() bool {
+ name := strings.ToLower(i.rel)
+ return strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg")
+}
+
+func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, conf imageConfig) (image.Image, error)) (*Image, error) {
+ conf, err := parseImageConfig(spec)
+ if err != nil {
+ return nil, err
+ }
+ conf.Action = action
+
+ if conf.Quality <= 0 && i.isJPEG() {
+ // We need a quality setting for all JPEGs
+ conf.Quality = i.imaging.Quality
+ }
+
+ if conf.FilterStr == "" {
+ conf.FilterStr = i.imaging.ResampleFilter
+ conf.Filter = imageFilters[conf.FilterStr]
+ }
+
+ key := i.relPermalinkForRel(i.filenameFromConfig(conf))
+
+ return i.spec.imageCache.getOrCreate(i.spec, key, func(resourceCacheFilename string) (*Image, error) {
+ ci := i.clone()
+
+ ci.setBasePath(conf)
+
+ src, err := i.decodeSource()
+ if err != nil {
+ return nil, err
+ }
+
+ if conf.Rotate != 0 {
+ // Rotate it befor any scaling to get the dimensions correct.
+ src = imaging.Rotate(src, float64(conf.Rotate), color.Transparent)
+ }
+
+ converted, err := f(src, conf)
+ if err != nil {
+ return ci, err
+ }
+
+ b := converted.Bounds()
+ ci.config = image.Config{Width: b.Max.X, Height: b.Max.Y}
+ ci.configLoaded = true
+
+ return ci, i.encodeToDestinations(converted, conf, resourceCacheFilename, ci.RelPermalink())
+ })
+
+}
+
+func (i imageConfig) key() string {
+ k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height)
+ if i.Action != "" {
+ k += "_" + i.Action
+ }
+ if i.Quality > 0 {
+ k += "_q" + strconv.Itoa(i.Quality)
+ }
+ if i.Rotate != 0 {
+ k += "_r" + strconv.Itoa(i.Rotate)
+ }
+ k += "_" + i.FilterStr + "_" + i.AnchorStr
+ return k
+}
+
+var defaultImageConfig = imageConfig{
+ Action: "",
+ Anchor: imaging.Center,
+ AnchorStr: strings.ToLower("Center"),
+}
+
+func newImageConfig(width, height, quality, rotate int, filter, anchor string) imageConfig {
+ c := defaultImageConfig
+
+ c.Width = width
+ c.Height = height
+ c.Quality = quality
+ c.Rotate = rotate
+
+ if filter != "" {
+ filter = strings.ToLower(filter)
+ if v, ok := imageFilters[filter]; ok {
+ c.Filter = v
+ c.FilterStr = filter
+ }
+ }
+
+ if anchor != "" {
+ anchor = strings.ToLower(anchor)
+ if v, ok := anchorPositions[anchor]; ok {
+ c.Anchor = v
+ c.AnchorStr = anchor
+ }
+ }
+
+ return c
+}
+
+func parseImageConfig(config string) (imageConfig, error) {
+ var (
+ c = defaultImageConfig
+ err error
+ )
+
+ if config == "" {
+ return c, errors.New("image config cannot be empty")
+ }
+
+ parts := strings.Fields(config)
+ for _, part := range parts {
+ part = strings.ToLower(part)
+
+ if pos, ok := anchorPositions[part]; ok {
+ c.Anchor = pos
+ c.AnchorStr = part
+ } else if filter, ok := imageFilters[part]; ok {
+ c.Filter = filter
+ c.FilterStr = part
+ } else if part[0] == 'q' {
+ c.Quality, err = strconv.Atoi(part[1:])
+ if err != nil {
+ return c, err
+ }
+ if c.Quality < 1 && c.Quality > 100 {
+ return c, errors.New("quality ranges from 1 to 100 inclusive")
+ }
+ } else if part[0] == 'r' {
+ c.Rotate, err = strconv.Atoi(part[1:])
+ if err != nil {
+ return c, err
+ }
+ } else if strings.Contains(part, "x") {
+ widthHeight := strings.Split(part, "x")
+ if len(widthHeight) <= 2 {
+ first := widthHeight[0]
+ if first != "" {
+ c.Width, err = strconv.Atoi(first)
+ if err != nil {
+ return c, err
+ }
+ }
+
+ if len(widthHeight) == 2 {
+ second := widthHeight[1]
+ if second != "" {
+ c.Height, err = strconv.Atoi(second)
+ if err != nil {
+ return c, err
+ }
+ }
+ }
+ } else {
+ return c, errors.New("invalid image dimensions")
+ }
+
+ }
+ }
+
+ if c.Width == 0 && c.Height == 0 {
+ return c, errors.New("must provide Width or Height")
+ }
+
+ return c, nil
+}
+
+func (i *Image) initConfig() error {
+ var err error
+ i.configInit.Do(func() {
+ if i.configLoaded {
+ return
+ }
+
+ var (
+ f afero.File
+ config image.Config
+ )
+
+ f, err = i.spec.Fs.Source.Open(i.AbsSourceFilename())
+ if err != nil {
+ return
+ }
+ defer f.Close()
+
+ config, _, err = image.DecodeConfig(f)
+ if err != nil {
+ return
+ }
+ i.config = config
+ })
+
+ return err
+}
+
+func (i *Image) decodeSource() (image.Image, error) {
+ file, err := i.spec.Fs.Source.Open(i.AbsSourceFilename())
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ return imaging.Decode(file)
+}
+
+func (i *Image) copyToDestination(src string) error {
+ var res error
+
+ i.copiedToDestinationInit.Do(func() {
+ target := filepath.Join(i.absPublishDir, i.RelPermalink())
+
+ // Fast path:
+ // This is a processed version of the original.
+ // If it exists on destination with the same filename and file size, it is
+ // the same file, so no need to transfer it again.
+ if fi, err := i.spec.Fs.Destination.Stat(target); err == nil && fi.Size() == i.osFileInfo.Size() {
+ return
+ }
+
+ in, err := i.spec.Fs.Source.Open(src)
+ if err != nil {
+ res = err
+ return
+ }
+ defer in.Close()
+
+ out, err := i.spec.Fs.Destination.Create(target)
+ if err != nil {
+ res = err
+ return
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, in)
+ if err != nil {
+ res = err
+ return
+ }
+ })
+
+ return res
+}
+
+func (i *Image) encodeToDestinations(img image.Image, conf imageConfig, resourceCacheFilename, filename string) error {
+ ext := strings.ToLower(helpers.Ext(filename))
+
+ imgFormat, ok := imageFormats[ext]
+ if !ok {
+ return imaging.ErrUnsupportedFormat
+ }
+
+ target := filepath.Join(i.absPublishDir, filename)
+
+ file1, err := i.spec.Fs.Destination.Create(target)
+ if err != nil {
+ return err
+ }
+ defer file1.Close()
+
+ var w io.Writer
+
+ if resourceCacheFilename != "" {
+ // Also save it to the image resource cache for later reuse.
+ if err = i.spec.Fs.Source.MkdirAll(filepath.Dir(resourceCacheFilename), os.FileMode(0755)); err != nil {
+ return err
+ }
+
+ file2, err := i.spec.Fs.Source.Create(resourceCacheFilename)
+ if err != nil {
+ return err
+ }
+
+ w = io.MultiWriter(file1, file2)
+ defer file2.Close()
+ } else {
+ w = file1
+ }
+
+ switch imgFormat {
+ case imaging.JPEG:
+
+ var rgba *image.RGBA
+ quality := conf.Quality
+
+ if nrgba, ok := img.(*image.NRGBA); ok {
+ if nrgba.Opaque() {
+ rgba = &image.RGBA{
+ Pix: nrgba.Pix,
+ Stride: nrgba.Stride,
+ Rect: nrgba.Rect,
+ }
+ }
+ }
+ if rgba != nil {
+ return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality})
+ } else {
+ return jpeg.Encode(w, img, &jpeg.Options{Quality: quality})
+ }
+ default:
+ return imaging.Encode(w, img, imgFormat)
+ }
+
+}
+
+func (i *Image) clone() *Image {
+ g := *i.genericResource
+
+ return &Image{
+ imaging: i.imaging,
+ genericResource: &g}
+}
+
+func (i *Image) setBasePath(conf imageConfig) {
+ i.rel = i.filenameFromConfig(conf)
+}
+
+// We need to set this to something static during tests.
+var fiModTimeFunc = func(fi os.FileInfo) int64 {
+ return fi.ModTime().Unix()
+}
+
+func (i *Image) filenameFromConfig(conf imageConfig) string {
+ p1, p2 := helpers.FileAndExt(i.rel)
+ sizeModeStr := fmt.Sprintf("_S%d_T%d", i.osFileInfo.Size(), fiModTimeFunc(i.osFileInfo))
+ // On scaling an already scaled image, we get the file info from the original.
+ // Repeating the same info in the filename makes it stuttery for no good reason.
+ if strings.Contains(p1, sizeModeStr) {
+ sizeModeStr = ""
+ }
+
+ const md5Threshold = 100
+
+ key := conf.key()
+
+ // 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
+ // for the different OSes to handle.
+ if len(p1)+len(sizeModeStr)+len(p2) > md5Threshold {
+ key = helpers.MD5String(p1 + key + p2)
+ p1 = p1[:strings.Index(p1, "_S")]
+ }
+
+ return fmt.Sprintf("%s%s_%s%s", p1, sizeModeStr, key, p2)
+}
+
+func decodeImaging(m map[string]interface{}) (Imaging, error) {
+ var i Imaging
+ if err := mapstructure.WeakDecode(m, &i); err != nil {
+ return i, err
+ }
+
+ if i.Quality <= 0 || i.Quality > 100 {
+ i.Quality = defaultJPEGQuality
+ }
+
+ if i.ResampleFilter == "" {
+ i.ResampleFilter = defaultResampleFilter
+ } else {
+ filter := strings.ToLower(i.ResampleFilter)
+ _, found := imageFilters[filter]
+ if !found {
+ return i, fmt.Errorf("%q is not a valid resample filter", filter)
+ }
+ i.ResampleFilter = filter
+ }
+
+ return i, nil
+}
diff --git a/resource/image_cache.go b/resource/image_cache.go
new file mode 100644
index 000000000..14350986e
--- /dev/null
+++ b/resource/image_cache.go
@@ -0,0 +1,112 @@
+// Copyright 2017-present 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 resource
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gohugoio/hugo/helpers"
+)
+
+type imageCache struct {
+ absPublishDir string
+ absCacheDir string
+ pathSpec *helpers.PathSpec
+ mu sync.RWMutex
+ store map[string]*Image
+}
+
+func (c *imageCache) isInCache(key string) bool {
+ c.mu.RLock()
+ _, found := c.store[key]
+ c.mu.RUnlock()
+ return found
+}
+
+func (c *imageCache) deleteByPrefix(prefix string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ for k, _ := range c.store {
+ if strings.HasPrefix(k, prefix) {
+ delete(c.store, k)
+ }
+ }
+}
+
+func (c *imageCache) getOrCreate(
+ spec *Spec, key string, create func(resourceCacheFilename string) (*Image, error)) (*Image, error) {
+ // First check the in-memory store, then the disk.
+ c.mu.RLock()
+ img, found := c.store[key]
+ c.mu.RUnlock()
+
+ if found {
+ return img, nil
+ }
+
+ // Now look in the file cache.
+ cacheFilename := filepath.Join(c.absCacheDir, key)
+
+ // The definition of this counter is not that we have processed that amount
+ // (e.g. resized etc.), it can be fetched from file cache,
+ // but the count of processed image variations for this site.
+ c.pathSpec.ProcessingStats.Incr(&c.pathSpec.ProcessingStats.ProcessedImages)
+
+ r, err := spec.NewResourceFromFilename(nil, c.absPublishDir, cacheFilename, key)
+ notFound := err != nil && os.IsNotExist(err)
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ if notFound {
+ img, err = create(cacheFilename)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ img = r.(*Image)
+ }
+
+ c.mu.Lock()
+ if img2, found := c.store[key]; found {
+ c.mu.Unlock()
+ return img2, nil
+ }
+
+ c.store[key] = img
+
+ c.mu.Unlock()
+
+ if notFound {
+ // File already written to destination
+ return img, nil
+ }
+
+ return img, img.copyToDestination(cacheFilename)
+
+}
+
+func newImageCache(ps *helpers.PathSpec, absCacheDir, absPublishDir string) *imageCache {
+ return &imageCache{pathSpec: ps, store: make(map[string]*Image), absCacheDir: absCacheDir, absPublishDir: absPublishDir}
+}
+
+func timeTrack(start time.Time, name string) {
+ elapsed := time.Since(start)
+ fmt.Printf("%s took %s\n", name, elapsed)
+}
diff --git a/resource/image_test.go b/resource/image_test.go
new file mode 100644
index 000000000..3543abb37
--- /dev/null
+++ b/resource/image_test.go
@@ -0,0 +1,134 @@
+// Copyright 2017-present 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 resource
+
+import (
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseImageConfig(t *testing.T) {
+ for i, this := range []struct {
+ in string
+ expect interface{}
+ }{
+ {"300x400", newImageConfig(300, 400, 0, 0, "", "")},
+ {"100x200 bottomRight", newImageConfig(100, 200, 0, 0, "", "BottomRight")},
+ {"10x20 topleft Lanczos", newImageConfig(10, 20, 0, 0, "Lanczos", "topleft")},
+ {"linear left 10x r180", newImageConfig(10, 0, 0, 180, "linear", "left")},
+ {"x20 riGht Cosine q95", newImageConfig(0, 20, 95, 0, "cosine", "right")},
+
+ {"", false},
+ {"foo", false},
+ } {
+ result, err := parseImageConfig(this.in)
+ if b, ok := this.expect.(bool); ok && !b {
+ if err == nil {
+ t.Errorf("[%d] parseImageConfig didn't return an expected error", i)
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("[%d] err: %s", i, err)
+ }
+ if fmt.Sprint(result) != fmt.Sprint(this.expect) {
+ t.Fatalf("[%d] got\n%v\n but expected\n%v", i, result, this.expect)
+ }
+ }
+ }
+}
+
+func TestImageTransform(t *testing.T) {
+ fiModTimeFunc = func(fi os.FileInfo) int64 {
+ return int64(10111213)
+ }
+
+ assert := require.New(t)
+
+ image := fetchSunset(assert)
+
+ assert.Equal("/a/sunset.jpg", image.RelPermalink())
+ assert.Equal("image", image.ResourceType())
+
+ resized, err := image.Resize("300x200")
+ assert.NoError(err)
+ assert.True(image != resized)
+ assert.True(image.genericResource != resized.genericResource)
+
+ resized0x, err := image.Resize("x200")
+ assert.NoError(err)
+ assert.Equal(320, resized0x.Width())
+ assert.Equal(200, resized0x.Height())
+ assertFileCache(assert, image.spec.Fs, resized0x.RelPermalink(), 320, 200)
+
+ resizedx0, err := image.Resize("200x")
+ assert.NoError(err)
+ assert.Equal(200, resizedx0.Width())
+ assert.Equal(125, resizedx0.Height())
+ assertFileCache(assert, image.spec.Fs, resizedx0.RelPermalink(), 200, 125)
+
+ resizedAndRotated, err := image.Resize("x200 r90")
+ assert.NoError(err)
+ assert.Equal(125, resizedAndRotated.Width())
+ assert.Equal(200, resizedAndRotated.Height())
+ assertFileCache(assert, image.spec.Fs, resizedAndRotated.RelPermalink(), 125, 200)
+
+ assert.Equal("/a/sunset_S90587_T10111213_300x200_resize_q75_box_center.jpg", resized.RelPermalink())
+ assert.Equal(300, resized.Width())
+ assert.Equal(200, resized.Height())
+
+ fitted, err := resized.Fit("50x50")
+ assert.NoError(err)
+ assert.Equal("/a/sunset_S90587_T10111213_300x200_resize_q75_box_center_50x50_fit_q75_box_center.jpg", fitted.RelPermalink())
+ assert.Equal(50, fitted.Width())
+ assert.Equal(31, fitted.Height())
+
+ // Check the MD5 key threshold
+ fittedAgain, _ := fitted.Fit("10x20")
+ fittedAgain, err = fittedAgain.Fit("10x20")
+ assert.NoError(err)
+ assert.Equal("/a/sunset_f1fb715a17c42d5d4602a1870424d590.jpg", fittedAgain.RelPermalink())
+ assert.Equal(10, fittedAgain.Width())
+ assert.Equal(6, fittedAgain.Height())
+
+ filled, err := image.Fill("200x100 bottomLeft")
+ assert.NoError(err)
+ assert.Equal("/a/sunset_S90587_T10111213_200x100_fill_q75_box_bottomleft.jpg", filled.RelPermalink())
+ assert.Equal(200, filled.Width())
+ assert.Equal(100, filled.Height())
+ assertFileCache(assert, image.spec.Fs, filled.RelPermalink(), 200, 100)
+
+ // Check cache
+ filledAgain, err := image.Fill("200x100 bottomLeft")
+ assert.NoError(err)
+ assert.True(filled == filledAgain)
+ assertFileCache(assert, image.spec.Fs, filledAgain.RelPermalink(), 200, 100)
+
+}
+
+func TestDecodeImaging(t *testing.T) {
+ assert := require.New(t)
+ m := map[string]interface{}{
+ "quality": 42,
+ "resampleFilter": "NearestNeighbor",
+ }
+
+ imaging, err := decodeImaging(m)
+
+ assert.NoError(err)
+ assert.Equal(42, imaging.Quality)
+ assert.Equal("nearestneighbor", imaging.ResampleFilter)
+}
diff --git a/resource/resource.go b/resource/resource.go
new file mode 100644
index 000000000..2c934d031
--- /dev/null
+++ b/resource/resource.go
@@ -0,0 +1,275 @@
+// Copyright 2017-present 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 resource
+
+import (
+ "fmt"
+ "mime"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/gohugoio/hugo/media"
+ "github.com/gohugoio/hugo/source"
+
+ "github.com/gohugoio/hugo/helpers"
+)
+
+var (
+ _ Resource = (*genericResource)(nil)
+ _ Source = (*genericResource)(nil)
+ _ Cloner = (*genericResource)(nil)
+)
+
+const DefaultResourceType = "unknown"
+
+type Source interface {
+ AbsSourceFilename() string
+ Publish() error
+}
+
+type Cloner interface {
+ WithNewBase(base string) Resource
+}
+
+// Resource represents a linkable resource, i.e. a content page, image etc.
+type Resource interface {
+ Permalink() string
+ RelPermalink() string
+ ResourceType() string
+}
+
+// Resources represents a slice of resources, which can be a mix of different types.
+// I.e. both pages and images etc.
+type Resources []Resource
+