github.com/olliephillips/hugo@v0.42.2/resource/image.go (about) 1 // Copyright 2017-present The Hugo Authors. All rights reserved. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // http://www.apache.org/licenses/LICENSE-2.0 7 // 8 // Unless required by applicable law or agreed to in writing, software 9 // distributed under the License is distributed on an "AS IS" BASIS, 10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 // See the License for the specific language governing permissions and 12 // limitations under the License. 13 14 package resource 15 16 import ( 17 "errors" 18 "fmt" 19 "image/color" 20 "io" 21 "os" 22 "path/filepath" 23 "strconv" 24 "strings" 25 26 "github.com/mitchellh/mapstructure" 27 28 "github.com/gohugoio/hugo/helpers" 29 "github.com/spf13/afero" 30 31 // Importing image codecs for image.DecodeConfig 32 "image" 33 "image/draw" 34 _ "image/gif" 35 "image/jpeg" 36 _ "image/png" 37 38 "github.com/disintegration/imaging" 39 // Import webp codec 40 "sync" 41 42 _ "golang.org/x/image/webp" 43 ) 44 45 var ( 46 _ Resource = (*Image)(nil) 47 _ Source = (*Image)(nil) 48 _ Cloner = (*Image)(nil) 49 ) 50 51 // Imaging contains default image processing configuration. This will be fetched 52 // from site (or language) config. 53 type Imaging struct { 54 // Default image quality setting (1-100). Only used for JPEG images. 55 Quality int 56 57 // Resample filter used. See https://github.com/disintegration/imaging 58 ResampleFilter string 59 60 // The anchor used in Fill. Default is "smart", i.e. Smart Crop. 61 Anchor string 62 } 63 64 const ( 65 defaultJPEGQuality = 75 66 defaultResampleFilter = "box" 67 ) 68 69 var ( 70 imageFormats = map[string]imaging.Format{ 71 ".jpg": imaging.JPEG, 72 ".jpeg": imaging.JPEG, 73 ".png": imaging.PNG, 74 ".tif": imaging.TIFF, 75 ".tiff": imaging.TIFF, 76 ".bmp": imaging.BMP, 77 ".gif": imaging.GIF, 78 } 79 80 // Add or increment if changes to an image format's processing requires 81 // re-generation. 82 imageFormatsVersions = map[imaging.Format]int{ 83 imaging.PNG: 2, // Floyd Steinberg dithering 84 } 85 86 // Increment to mark all processed images as stale. Only use when absolutely needed. 87 // See the finer grained smartCropVersionNumber and imageFormatsVersions. 88 mainImageVersionNumber = 0 89 ) 90 91 var anchorPositions = map[string]imaging.Anchor{ 92 strings.ToLower("Center"): imaging.Center, 93 strings.ToLower("TopLeft"): imaging.TopLeft, 94 strings.ToLower("Top"): imaging.Top, 95 strings.ToLower("TopRight"): imaging.TopRight, 96 strings.ToLower("Left"): imaging.Left, 97 strings.ToLower("Right"): imaging.Right, 98 strings.ToLower("BottomLeft"): imaging.BottomLeft, 99 strings.ToLower("Bottom"): imaging.Bottom, 100 strings.ToLower("BottomRight"): imaging.BottomRight, 101 } 102 103 var imageFilters = map[string]imaging.ResampleFilter{ 104 strings.ToLower("NearestNeighbor"): imaging.NearestNeighbor, 105 strings.ToLower("Box"): imaging.Box, 106 strings.ToLower("Linear"): imaging.Linear, 107 strings.ToLower("Hermite"): imaging.Hermite, 108 strings.ToLower("MitchellNetravali"): imaging.MitchellNetravali, 109 strings.ToLower("CatmullRom"): imaging.CatmullRom, 110 strings.ToLower("BSpline"): imaging.BSpline, 111 strings.ToLower("Gaussian"): imaging.Gaussian, 112 strings.ToLower("Lanczos"): imaging.Lanczos, 113 strings.ToLower("Hann"): imaging.Hann, 114 strings.ToLower("Hamming"): imaging.Hamming, 115 strings.ToLower("Blackman"): imaging.Blackman, 116 strings.ToLower("Bartlett"): imaging.Bartlett, 117 strings.ToLower("Welch"): imaging.Welch, 118 strings.ToLower("Cosine"): imaging.Cosine, 119 } 120 121 type Image struct { 122 config image.Config 123 configInit sync.Once 124 configLoaded bool 125 126 copyToDestinationInit sync.Once 127 128 // Lock used when creating alternate versions of this image. 129 createMu sync.Mutex 130 131 imaging *Imaging 132 133 format imaging.Format 134 135 hash string 136 137 *genericResource 138 } 139 140 func (i *Image) Width() int { 141 i.initConfig() 142 return i.config.Width 143 } 144 145 func (i *Image) Height() int { 146 i.initConfig() 147 return i.config.Height 148 } 149 150 // Implement the Cloner interface. 151 func (i *Image) WithNewBase(base string) Resource { 152 return &Image{ 153 imaging: i.imaging, 154 hash: i.hash, 155 format: i.format, 156 genericResource: i.genericResource.WithNewBase(base).(*genericResource)} 157 } 158 159 // Resize resizes the image to the specified width and height using the specified resampling 160 // filter and returns the transformed image. If one of width or height is 0, the image aspect 161 // ratio is preserved. 162 func (i *Image) Resize(spec string) (*Image, error) { 163 return i.doWithImageConfig("resize", spec, func(src image.Image, conf imageConfig) (image.Image, error) { 164 return imaging.Resize(src, conf.Width, conf.Height, conf.Filter), nil 165 }) 166 } 167 168 // Fit scales down the image using the specified resample filter to fit the specified 169 // maximum width and height. 170 func (i *Image) Fit(spec string) (*Image, error) { 171 return i.doWithImageConfig("fit", spec, func(src image.Image, conf imageConfig) (image.Image, error) { 172 return imaging.Fit(src, conf.Width, conf.Height, conf.Filter), nil 173 }) 174 } 175 176 // Fill scales the image to the smallest possible size that will cover the specified dimensions, 177 // crops the resized image to the specified dimensions using the given anchor point. 178 // Space delimited config: 200x300 TopLeft 179 func (i *Image) Fill(spec string) (*Image, error) { 180 return i.doWithImageConfig("fill", spec, func(src image.Image, conf imageConfig) (image.Image, error) { 181 if conf.AnchorStr == smartCropIdentifier { 182 return smartCrop(src, conf.Width, conf.Height, conf.Anchor, conf.Filter) 183 } 184 return imaging.Fill(src, conf.Width, conf.Height, conf.Anchor, conf.Filter), nil 185 }) 186 } 187 188 // Holds configuration to create a new image from an existing one, resize etc. 189 type imageConfig struct { 190 Action string 191 192 // Quality ranges from 1 to 100 inclusive, higher is better. 193 // This is only relevant for JPEG images. 194 // Default is 75. 195 Quality int 196 197 // Rotate rotates an image by the given angle counter-clockwise. 198 // The rotation will be performed first. 199 Rotate int 200 201 Width int 202 Height int 203 204 Filter imaging.ResampleFilter 205 FilterStr string 206 207 Anchor imaging.Anchor 208 AnchorStr string 209 } 210 211 func (i *Image) isJPEG() bool { 212 name := strings.ToLower(i.relTargetPath.file) 213 return strings.HasSuffix(name, ".jpg") || strings.HasSuffix(name, ".jpeg") 214 } 215 216 func (i *Image) doWithImageConfig(action, spec string, f func(src image.Image, conf imageConfig) (image.Image, error)) (*Image, error) { 217 conf, err := parseImageConfig(spec) 218 if err != nil { 219 return nil, err 220 } 221 conf.Action = action 222 223 if conf.Quality <= 0 && i.isJPEG() { 224 // We need a quality setting for all JPEGs 225 conf.Quality = i.imaging.Quality 226 } 227 228 if conf.FilterStr == "" { 229 conf.FilterStr = i.imaging.ResampleFilter 230 conf.Filter = imageFilters[conf.FilterStr] 231 } 232 233 if conf.AnchorStr == "" { 234 conf.AnchorStr = i.imaging.Anchor 235 if !strings.EqualFold(conf.AnchorStr, smartCropIdentifier) { 236 conf.Anchor = anchorPositions[conf.AnchorStr] 237 } 238 } 239 240 return i.spec.imageCache.getOrCreate(i, conf, func(resourceCacheFilename string) (*Image, error) { 241 ci := i.clone() 242 243 errOp := action 244 errPath := i.AbsSourceFilename() 245 246 ci.setBasePath(conf) 247 248 src, err := i.decodeSource() 249 if err != nil { 250 return nil, &os.PathError{Op: errOp, Path: errPath, Err: err} 251 } 252 253 if conf.Rotate != 0 { 254 // Rotate it before any scaling to get the dimensions correct. 255 src = imaging.Rotate(src, float64(conf.Rotate), color.Transparent) 256 } 257 258 converted, err := f(src, conf) 259 if err != nil { 260 return ci, &os.PathError{Op: errOp, Path: errPath, Err: err} 261 } 262 263 if i.format == imaging.PNG { 264 // Apply the colour palette from the source 265 if paletted, ok := src.(*image.Paletted); ok { 266 tmp := image.NewPaletted(converted.Bounds(), paletted.Palette) 267 draw.FloydSteinberg.Draw(tmp, tmp.Bounds(), converted, converted.Bounds().Min) 268 converted = tmp 269 } 270 } 271 272 b := converted.Bounds() 273 ci.config = image.Config{Width: b.Max.X, Height: b.Max.Y} 274 ci.configLoaded = true 275 276 return ci, i.encodeToDestinations(converted, conf, resourceCacheFilename, ci.target()) 277 }) 278 279 } 280 281 func (i imageConfig) key(format imaging.Format) string { 282 k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height) 283 if i.Action != "" { 284 k += "_" + i.Action 285 } 286 if i.Quality > 0 { 287 k += "_q" + strconv.Itoa(i.Quality) 288 } 289 if i.Rotate != 0 { 290 k += "_r" + strconv.Itoa(i.Rotate) 291 } 292 anchor := i.AnchorStr 293 if anchor == smartCropIdentifier { 294 anchor = anchor + strconv.Itoa(smartCropVersionNumber) 295 } 296 297 k += "_" + i.FilterStr 298 299 if strings.EqualFold(i.Action, "fill") { 300 k += "_" + anchor 301 } 302 303 if v, ok := imageFormatsVersions[format]; ok { 304 k += "_" + strconv.Itoa(v) 305 } 306 307 if mainImageVersionNumber > 0 { 308 k += "_" + strconv.Itoa(mainImageVersionNumber) 309 } 310 311 return k 312 } 313 314 func newImageConfig(width, height, quality, rotate int, filter, anchor string) imageConfig { 315 var c imageConfig 316 317 c.Width = width 318 c.Height = height 319 c.Quality = quality 320 c.Rotate = rotate 321 322 if filter != "" { 323 filter = strings.ToLower(filter) 324 if v, ok := imageFilters[filter]; ok { 325 c.Filter = v 326 c.FilterStr = filter 327 } 328 } 329 330 if anchor != "" { 331 anchor = strings.ToLower(anchor) 332 if v, ok := anchorPositions[anchor]; ok { 333 c.Anchor = v 334 c.AnchorStr = anchor 335 } 336 } 337 338 return c 339 } 340 341 func parseImageConfig(config string) (imageConfig, error) { 342 var ( 343 c imageConfig 344 err error 345 ) 346 347 if config == "" { 348 return c, errors.New("image config cannot be empty") 349 } 350 351 parts := strings.Fields(config) 352 for _, part := range parts { 353 part = strings.ToLower(part) 354 355 if part == smartCropIdentifier { 356 c.AnchorStr = smartCropIdentifier 357 } else if pos, ok := anchorPositions[part]; ok { 358 c.Anchor = pos 359 c.AnchorStr = part 360 } else if filter, ok := imageFilters[part]; ok { 361 c.Filter = filter 362 c.FilterStr = part 363 } else if part[0] == 'q' { 364 c.Quality, err = strconv.Atoi(part[1:]) 365 if err != nil { 366 return c, err 367 } 368 if c.Quality < 1 && c.Quality > 100 { 369 return c, errors.New("quality ranges from 1 to 100 inclusive") 370 } 371 } else if part[0] == 'r' { 372 c.Rotate, err = strconv.Atoi(part[1:]) 373 if err != nil { 374 return c, err 375 } 376 } else if strings.Contains(part, "x") { 377 widthHeight := strings.Split(part, "x") 378 if len(widthHeight) <= 2 { 379 first := widthHeight[0] 380 if first != "" { 381 c.Width, err = strconv.Atoi(first) 382 if err != nil { 383 return c, err 384 } 385 } 386 387 if len(widthHeight) == 2 { 388 second := widthHeight[1] 389 if second != "" { 390 c.Height, err = strconv.Atoi(second) 391 if err != nil { 392 return c, err 393 } 394 } 395 } 396 } else { 397 return c, errors.New("invalid image dimensions") 398 } 399 400 } 401 } 402 403 if c.Width == 0 && c.Height == 0 { 404 return c, errors.New("must provide Width or Height") 405 } 406 407 return c, nil 408 } 409 410 func (i *Image) initConfig() error { 411 var err error 412 i.configInit.Do(func() { 413 if i.configLoaded { 414 return 415 } 416 417 var ( 418 f afero.File 419 config image.Config 420 ) 421 422 f, err = i.sourceFs().Open(i.AbsSourceFilename()) 423 if err != nil { 424 return 425 } 426 defer f.Close() 427 428 config, _, err = image.DecodeConfig(f) 429 if err != nil { 430 return 431 } 432 i.config = config 433 }) 434 435 if err != nil { 436 return fmt.Errorf("failed to load image config: %s", err) 437 } 438 439 return nil 440 } 441 442 func (i *Image) decodeSource() (image.Image, error) { 443 file, err := i.sourceFs().Open(i.AbsSourceFilename()) 444 if err != nil { 445 return nil, fmt.Errorf("failed to open image for decode: %s", err) 446 } 447 defer file.Close() 448 img, _, err := image.Decode(file) 449 return img, err 450 } 451 452 func (i *Image) copyToDestination(src string) error { 453 var res error 454 i.copyToDestinationInit.Do(func() { 455 target := i.target() 456 457 // Fast path: 458 // This is a processed version of the original. 459 // If it exists on destination with the same filename and file size, it is 460 // the same file, so no need to transfer it again. 461 if fi, err := i.spec.BaseFs.PublishFs.Stat(target); err == nil && fi.Size() == i.osFileInfo.Size() { 462 return 463 } 464 465 in, err := i.sourceFs().Open(src) 466 if err != nil { 467 res = err 468 return 469 } 470 defer in.Close() 471 472 out, err := i.spec.BaseFs.PublishFs.Create(target) 473 if err != nil && os.IsNotExist(err) { 474 // When called from shortcodes, the target directory may not exist yet. 475 // See https://github.com/gohugoio/hugo/issues/4202 476 if err = i.spec.BaseFs.PublishFs.MkdirAll(filepath.Dir(target), os.FileMode(0755)); err != nil { 477 res = err 478 return 479 } 480 out, err = i.spec.BaseFs.PublishFs.Create(target) 481 if err != nil { 482 res = err 483 return 484 } 485 } else if err != nil { 486 res = err 487 return 488 } 489 defer out.Close() 490 491 _, err = io.Copy(out, in) 492 if err != nil { 493 res = err 494 return 495 } 496 }) 497 498 if res != nil { 499 return fmt.Errorf("failed to copy image to destination: %s", res) 500 } 501 return nil 502 } 503 504 func (i *Image) encodeToDestinations(img image.Image, conf imageConfig, resourceCacheFilename, filename string) error { 505 target := filepath.Clean(filename) 506 507 file1, err := i.spec.BaseFs.PublishFs.Create(target) 508 if err != nil && os.IsNotExist(err) { 509 // When called from shortcodes, the target directory may not exist yet. 510 // See https://github.com/gohugoio/hugo/issues/4202 511 if err = i.spec.BaseFs.PublishFs.MkdirAll(filepath.Dir(target), os.FileMode(0755)); err != nil { 512 return err 513 } 514 file1, err = i.spec.BaseFs.PublishFs.Create(target) 515 if err != nil { 516 return err 517 } 518 } else if err != nil { 519 return err 520 } 521 522 defer file1.Close() 523 524 var w io.Writer 525 526 if resourceCacheFilename != "" { 527 // Also save it to the image resource cache for later reuse. 528 if err = i.spec.BaseFs.ResourcesFs.MkdirAll(filepath.Dir(resourceCacheFilename), os.FileMode(0755)); err != nil { 529 return err 530 } 531 532 file2, err := i.spec.BaseFs.ResourcesFs.Create(resourceCacheFilename) 533 if err != nil { 534 return err 535 } 536 537 w = io.MultiWriter(file1, file2) 538 defer file2.Close() 539 } else { 540 w = file1 541 } 542 543 switch i.format { 544 case imaging.JPEG: 545 546 var rgba *image.RGBA 547 quality := conf.Quality 548 549 if nrgba, ok := img.(*image.NRGBA); ok { 550 if nrgba.Opaque() { 551 rgba = &image.RGBA{ 552 Pix: nrgba.Pix, 553 Stride: nrgba.Stride, 554 Rect: nrgba.Rect, 555 } 556 } 557 } 558 if rgba != nil { 559 return jpeg.Encode(w, rgba, &jpeg.Options{Quality: quality}) 560 } else { 561 return jpeg.Encode(w, img, &jpeg.Options{Quality: quality}) 562 } 563 default: 564 return imaging.Encode(w, img, i.format) 565 } 566 567 } 568 569 func (i *Image) clone() *Image { 570 g := *i.genericResource 571 g.resourceContent = &resourceContent{} 572 573 return &Image{ 574 imaging: i.imaging, 575 hash: i.hash, 576 format: i.format, 577 genericResource: &g} 578 } 579 580 func (i *Image) setBasePath(conf imageConfig) { 581 i.relTargetPath = i.relTargetPathFromConfig(conf) 582 } 583 584 func (i *Image) relTargetPathFromConfig(conf imageConfig) dirFile { 585 p1, p2 := helpers.FileAndExt(i.relTargetPath.file) 586 587 idStr := fmt.Sprintf("_hu%s_%d", i.hash, i.osFileInfo.Size()) 588 589 // Do not change for no good reason. 590 const md5Threshold = 100 591 592 key := conf.key(i.format) 593 594 // It is useful to have the key in clear text, but when nesting transforms, it 595 // can easily be too long to read, and maybe even too long 596 // for the different OSes to handle. 597 if len(p1)+len(idStr)+len(p2) > md5Threshold { 598 key = helpers.MD5String(p1 + key + p2) 599 huIdx := strings.Index(p1, "_hu") 600 if huIdx != -1 { 601 p1 = p1[:huIdx] 602 } else { 603 // This started out as a very long file name. Making it even longer 604 // could melt ice in the Arctic. 605 p1 = "" 606 } 607 } else if strings.Contains(p1, idStr) { 608 // On scaling an already scaled image, we get the file info from the original. 609 // Repeating the same info in the filename makes it stuttery for no good reason. 610 idStr = "" 611 } 612 613 return dirFile{ 614 dir: i.relTargetPath.dir, 615 file: fmt.Sprintf("%s%s_%s%s", p1, idStr, key, p2), 616 } 617 618 } 619 620 func decodeImaging(m map[string]interface{}) (Imaging, error) { 621 var i Imaging 622 if err := mapstructure.WeakDecode(m, &i); err != nil { 623 return i, err 624 } 625 626 if i.Quality == 0 { 627 i.Quality = defaultJPEGQuality 628 } else if i.Quality < 0 || i.Quality > 100 { 629 return i, errors.New("JPEG quality must be a number between 1 and 100") 630 } 631 632 if i.Anchor == "" || strings.EqualFold(i.Anchor, smartCropIdentifier) { 633 i.Anchor = smartCropIdentifier 634 } else { 635 i.Anchor = strings.ToLower(i.Anchor) 636 if _, found := anchorPositions[i.Anchor]; !found { 637 return i, errors.New("invalid anchor value in imaging config") 638 } 639 } 640 641 if i.ResampleFilter == "" { 642 i.ResampleFilter = defaultResampleFilter 643 } else { 644 filter := strings.ToLower(i.ResampleFilter) 645 _, found := imageFilters[filter] 646 if !found { 647 return i, fmt.Errorf("%q is not a valid resample filter", filter) 648 } 649 i.ResampleFilter = filter 650 } 651 652 return i, nil 653 }