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