github.com/neohugo/neohugo@v0.123.8/resources/images/config.go (about) 1 // Copyright 2019 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 images 15 16 import ( 17 "errors" 18 "fmt" 19 "image/color" 20 "strconv" 21 "strings" 22 23 "github.com/mitchellh/mapstructure" 24 "github.com/neohugo/neohugo/common/maps" 25 "github.com/neohugo/neohugo/config" 26 "github.com/neohugo/neohugo/media" 27 28 "github.com/bep/gowebp/libwebp/webpoptions" 29 30 "github.com/disintegration/gift" 31 ) 32 33 const ( 34 ActionResize = "resize" 35 ActionCrop = "crop" 36 ActionFit = "fit" 37 ActionFill = "fill" 38 ) 39 40 var ( 41 imageFormats = map[string]Format{ 42 ".jpg": JPEG, 43 ".jpeg": JPEG, 44 ".jpe": JPEG, 45 ".jif": JPEG, 46 ".jfif": JPEG, 47 ".png": PNG, 48 ".tif": TIFF, 49 ".tiff": TIFF, 50 ".bmp": BMP, 51 ".gif": GIF, 52 ".webp": WEBP, 53 } 54 55 imageFormatsBySubType = map[string]Format{ 56 media.Builtin.JPEGType.SubType: JPEG, 57 media.Builtin.PNGType.SubType: PNG, 58 media.Builtin.TIFFType.SubType: TIFF, 59 media.Builtin.BMPType.SubType: BMP, 60 media.Builtin.GIFType.SubType: GIF, 61 media.Builtin.WEBPType.SubType: WEBP, 62 } 63 64 // Add or increment if changes to an image format's processing requires 65 // re-generation. 66 imageFormatsVersions = map[Format]int{ 67 PNG: 3, // Fix transparency issue with 32 bit images. 68 WEBP: 2, // Fix transparency issue with 32 bit images. 69 GIF: 1, // Fix resize issue with animated GIFs when target != GIF. 70 } 71 72 // Increment to mark all processed images as stale. Only use when absolutely needed. 73 // See the finer grained smartCropVersionNumber and imageFormatsVersions. 74 mainImageVersionNumber = 0 75 ) 76 77 var anchorPositions = map[string]gift.Anchor{ 78 strings.ToLower("Center"): gift.CenterAnchor, 79 strings.ToLower("TopLeft"): gift.TopLeftAnchor, 80 strings.ToLower("Top"): gift.TopAnchor, 81 strings.ToLower("TopRight"): gift.TopRightAnchor, 82 strings.ToLower("Left"): gift.LeftAnchor, 83 strings.ToLower("Right"): gift.RightAnchor, 84 strings.ToLower("BottomLeft"): gift.BottomLeftAnchor, 85 strings.ToLower("Bottom"): gift.BottomAnchor, 86 strings.ToLower("BottomRight"): gift.BottomRightAnchor, 87 } 88 89 // These encoding hints are currently only relevant for Webp. 90 var hints = map[string]webpoptions.EncodingPreset{ 91 "picture": webpoptions.EncodingPresetPicture, 92 "photo": webpoptions.EncodingPresetPhoto, 93 "drawing": webpoptions.EncodingPresetDrawing, 94 "icon": webpoptions.EncodingPresetIcon, 95 "text": webpoptions.EncodingPresetText, 96 } 97 98 var imageFilters = map[string]gift.Resampling{ 99 strings.ToLower("NearestNeighbor"): gift.NearestNeighborResampling, 100 strings.ToLower("Box"): gift.BoxResampling, 101 strings.ToLower("Linear"): gift.LinearResampling, 102 strings.ToLower("Hermite"): hermiteResampling, 103 strings.ToLower("MitchellNetravali"): mitchellNetravaliResampling, 104 strings.ToLower("CatmullRom"): catmullRomResampling, 105 strings.ToLower("BSpline"): bSplineResampling, 106 strings.ToLower("Gaussian"): gaussianResampling, 107 strings.ToLower("Lanczos"): gift.LanczosResampling, 108 strings.ToLower("Hann"): hannResampling, 109 strings.ToLower("Hamming"): hammingResampling, 110 strings.ToLower("Blackman"): blackmanResampling, 111 strings.ToLower("Bartlett"): bartlettResampling, 112 strings.ToLower("Welch"): welchResampling, 113 strings.ToLower("Cosine"): cosineResampling, 114 } 115 116 func ImageFormatFromExt(ext string) (Format, bool) { 117 f, found := imageFormats[ext] 118 return f, found 119 } 120 121 func ImageFormatFromMediaSubType(sub string) (Format, bool) { 122 f, found := imageFormatsBySubType[sub] 123 return f, found 124 } 125 126 const ( 127 defaultJPEGQuality = 75 128 defaultResampleFilter = "box" 129 defaultBgColor = "#ffffff" 130 defaultHint = "photo" 131 ) 132 133 var ( 134 defaultImaging = map[string]any{ 135 "resampleFilter": defaultResampleFilter, 136 "bgColor": defaultBgColor, 137 "hint": defaultHint, 138 "quality": defaultJPEGQuality, 139 } 140 141 defaultImageConfig *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal] 142 ) 143 144 func init() { 145 var err error 146 defaultImageConfig, err = DecodeConfig(defaultImaging) 147 if err != nil { 148 panic(err) 149 } 150 } 151 152 func DecodeConfig(in map[string]any) (*config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], error) { 153 if in == nil { 154 in = make(map[string]any) 155 } 156 157 buildConfig := func(in any) (ImagingConfigInternal, any, error) { 158 m, err := maps.ToStringMapE(in) 159 if err != nil { 160 return ImagingConfigInternal{}, nil, err 161 } 162 // Merge in the defaults. 163 maps.MergeShallow(m, defaultImaging) 164 165 var i ImagingConfigInternal 166 if err := mapstructure.Decode(m, &i.Imaging); err != nil { 167 return i, nil, err 168 } 169 170 if err := i.Imaging.init(); err != nil { 171 return i, nil, err 172 } 173 174 i.BgColor, err = hexStringToColor(i.Imaging.BgColor) 175 if err != nil { 176 return i, nil, err 177 } 178 179 if i.Imaging.Anchor != "" && i.Imaging.Anchor != smartCropIdentifier { 180 anchor, found := anchorPositions[i.Imaging.Anchor] 181 if !found { 182 return i, nil, fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor) 183 } 184 i.Anchor = anchor 185 } 186 187 filter, found := imageFilters[i.Imaging.ResampleFilter] 188 if !found { 189 return i, nil, fmt.Errorf("%q is not a valid resample filter", filter) 190 } 191 192 i.ResampleFilter = filter 193 194 return i, nil, nil 195 } 196 197 ns, err := config.DecodeNamespace[ImagingConfig](in, buildConfig) 198 if err != nil { 199 return nil, fmt.Errorf("failed to decode media types: %w", err) 200 } 201 return ns, nil 202 } 203 204 func DecodeImageConfig(action string, options []string, defaults *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal], sourceFormat Format) (ImageConfig, error) { 205 var ( 206 c ImageConfig = GetDefaultImageConfig(action, defaults) 207 err error 208 ) 209 210 action = strings.ToLower(action) 211 212 c.Action = action 213 214 if options == nil { 215 return c, errors.New("image options cannot be empty") 216 } 217 218 for _, part := range options { 219 part = strings.ToLower(part) 220 221 if part == smartCropIdentifier { 222 c.AnchorStr = smartCropIdentifier 223 } else if pos, ok := anchorPositions[part]; ok { 224 c.Anchor = pos 225 c.AnchorStr = part 226 } else if filter, ok := imageFilters[part]; ok { 227 c.Filter = filter 228 c.FilterStr = part 229 } else if hint, ok := hints[part]; ok { 230 c.Hint = hint 231 } else if part[0] == '#' { 232 c.BgColorStr = part[1:] 233 c.BgColor, err = hexStringToColor(c.BgColorStr) 234 if err != nil { 235 return c, err 236 } 237 } else if part[0] == 'q' { 238 c.Quality, err = strconv.Atoi(part[1:]) 239 if err != nil { 240 return c, err 241 } 242 if c.Quality < 1 || c.Quality > 100 { 243 return c, errors.New("quality ranges from 1 to 100 inclusive") 244 } 245 c.qualitySetForImage = true 246 } else if part[0] == 'r' { 247 c.Rotate, err = strconv.Atoi(part[1:]) 248 if err != nil { 249 return c, err 250 } 251 } else if strings.Contains(part, "x") { 252 widthHeight := strings.Split(part, "x") 253 if len(widthHeight) <= 2 { 254 first := widthHeight[0] 255 if first != "" { 256 c.Width, err = strconv.Atoi(first) 257 if err != nil { 258 return c, err 259 } 260 } 261 262 if len(widthHeight) == 2 { 263 second := widthHeight[1] 264 if second != "" { 265 c.Height, err = strconv.Atoi(second) 266 if err != nil { 267 return c, err 268 } 269 } 270 } 271 } else { 272 return c, errors.New("invalid image dimensions") 273 } 274 } else if f, ok := ImageFormatFromExt("." + part); ok { 275 c.TargetFormat = f 276 } 277 } 278 279 switch c.Action { 280 case ActionCrop, ActionFill, ActionFit: 281 if c.Width == 0 || c.Height == 0 { 282 return c, errors.New("must provide Width and Height") 283 } 284 case ActionResize: 285 if c.Width == 0 && c.Height == 0 { 286 return c, errors.New("must provide Width or Height") 287 } 288 default: 289 if c.Width != 0 || c.Height != 0 { 290 return c, errors.New("width or height are not supported for this action") 291 } 292 } 293 294 if action != "" && c.FilterStr == "" { 295 c.FilterStr = defaults.Config.Imaging.ResampleFilter 296 c.Filter = defaults.Config.ResampleFilter 297 } 298 299 if c.Hint == 0 { 300 c.Hint = webpoptions.EncodingPresetPhoto 301 } 302 303 if action != "" && c.AnchorStr == "" { 304 c.AnchorStr = defaults.Config.Imaging.Anchor 305 c.Anchor = defaults.Config.Anchor 306 } 307 308 // default to the source format 309 if c.TargetFormat == 0 { 310 c.TargetFormat = sourceFormat 311 } 312 313 if c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() { 314 // We need a quality setting for all JPEGs and WEBPs. 315 c.Quality = defaults.Config.Imaging.Quality 316 } 317 318 if c.BgColor == nil && c.TargetFormat != sourceFormat { 319 if sourceFormat.SupportsTransparency() && !c.TargetFormat.SupportsTransparency() { 320 c.BgColor = defaults.Config.BgColor 321 c.BgColorStr = defaults.Config.Imaging.BgColor 322 } 323 } 324 325 return c, nil 326 } 327 328 // ImageConfig holds configuration to create a new image from an existing one, resize etc. 329 type ImageConfig struct { 330 // This defines the output format of the output image. It defaults to the source format. 331 TargetFormat Format 332 333 Action string 334 335 // If set, this will be used as the key in filenames etc. 336 Key string 337 338 // Quality ranges from 1 to 100 inclusive, higher is better. 339 // This is only relevant for JPEG and WEBP images. 340 // Default is 75. 341 Quality int 342 qualitySetForImage bool // Whether the above is set for this image. 343 344 // Rotate rotates an image by the given angle counter-clockwise. 345 // The rotation will be performed first. 346 Rotate int 347 348 // Used to fill any transparency. 349 // When set in site config, it's used when converting to a format that does 350 // not support transparency. 351 // When set per image operation, it's used even for formats that does support 352 // transparency. 353 BgColor color.Color 354 BgColorStr string 355 356 // Hint about what type of picture this is. Used to optimize encoding 357 // when target is set to webp. 358 Hint webpoptions.EncodingPreset 359 360 Width int 361 Height int 362 363 Filter gift.Resampling 364 FilterStr string 365 366 Anchor gift.Anchor 367 AnchorStr string 368 } 369 370 func (i ImageConfig) GetKey(format Format) string { 371 if i.Key != "" { 372 return i.Action + "_" + i.Key 373 } 374 375 k := strconv.Itoa(i.Width) + "x" + strconv.Itoa(i.Height) 376 if i.Action != "" { 377 k += "_" + i.Action 378 } 379 // This slightly odd construct is here to preserve the old image keys. 380 if i.qualitySetForImage || i.TargetFormat.RequiresDefaultQuality() { 381 k += "_q" + strconv.Itoa(i.Quality) 382 } 383 if i.Rotate != 0 { 384 k += "_r" + strconv.Itoa(i.Rotate) 385 } 386 if i.BgColorStr != "" { 387 k += "_bg" + i.BgColorStr 388 } 389 390 if i.TargetFormat == WEBP { 391 k += "_h" + strconv.Itoa(int(i.Hint)) 392 } 393 394 anchor := i.AnchorStr 395 if anchor == smartCropIdentifier { 396 anchor = anchor + strconv.Itoa(smartCropVersionNumber) 397 } 398 399 k += "_" + i.FilterStr 400 401 if i.Action == ActionFill || i.Action == ActionCrop { 402 k += "_" + anchor 403 } 404 405 if v, ok := imageFormatsVersions[format]; ok { 406 k += "_" + strconv.Itoa(v) 407 } 408 409 if mainImageVersionNumber > 0 { 410 k += "_" + strconv.Itoa(mainImageVersionNumber) 411 } 412 413 return k 414 } 415 416 type ImagingConfigInternal struct { 417 BgColor color.Color 418 Hint webpoptions.EncodingPreset 419 ResampleFilter gift.Resampling 420 Anchor gift.Anchor 421 422 Imaging ImagingConfig 423 } 424 425 func (i *ImagingConfigInternal) Compile(externalCfg *ImagingConfig) error { 426 var err error 427 i.BgColor, err = hexStringToColor(externalCfg.BgColor) 428 if err != nil { 429 return err 430 } 431 432 if externalCfg.Anchor != "" && externalCfg.Anchor != smartCropIdentifier { 433 anchor, found := anchorPositions[externalCfg.Anchor] 434 if !found { 435 return fmt.Errorf("invalid anchor value %q in imaging config", i.Anchor) 436 } 437 i.Anchor = anchor 438 } 439 440 filter, found := imageFilters[externalCfg.ResampleFilter] 441 if !found { 442 return fmt.Errorf("%q is not a valid resample filter", filter) 443 } 444 i.ResampleFilter = filter 445 446 return nil 447 } 448 449 // ImagingConfig contains default image processing configuration. This will be fetched 450 // from site (or language) config. 451 type ImagingConfig struct { 452 // Default image quality setting (1-100). Only used for JPEG images. 453 Quality int 454 455 // Resample filter to use in resize operations. 456 ResampleFilter string 457 458 // Hint about what type of image this is. 459 // Currently only used when encoding to Webp. 460 // Default is "photo". 461 // Valid values are "picture", "photo", "drawing", "icon", or "text". 462 Hint string 463 464 // The anchor to use in Fill. Default is "smart", i.e. Smart Crop. 465 Anchor string 466 467 // Default color used in fill operations (e.g. "fff" for white). 468 BgColor string 469 470 Exif ExifConfig 471 } 472 473 func (cfg *ImagingConfig) init() error { 474 if cfg.Quality < 0 || cfg.Quality > 100 { 475 return errors.New("image quality must be a number between 1 and 100") 476 } 477 478 cfg.BgColor = strings.ToLower(strings.TrimPrefix(cfg.BgColor, "#")) 479 cfg.Anchor = strings.ToLower(cfg.Anchor) 480 cfg.ResampleFilter = strings.ToLower(cfg.ResampleFilter) 481 cfg.Hint = strings.ToLower(cfg.Hint) 482 483 if cfg.Anchor == "" { 484 cfg.Anchor = smartCropIdentifier 485 } 486 487 if strings.TrimSpace(cfg.Exif.IncludeFields) == "" && strings.TrimSpace(cfg.Exif.ExcludeFields) == "" { 488 // Don't change this for no good reason. Please don't. 489 cfg.Exif.ExcludeFields = "GPS|Exif|Exposure[M|P|B]|Contrast|Resolution|Sharp|JPEG|Metering|Sensing|Saturation|ColorSpace|Flash|WhiteBalance" 490 } 491 492 return nil 493 } 494 495 type ExifConfig struct { 496 // Regexp matching the Exif fields you want from the (massive) set of Exif info 497 // available. As we cache this info to disk, this is for performance and 498 // disk space reasons more than anything. 499 // If you want it all, put ".*" in this config setting. 500 // Note that if neither this or ExcludeFields is set, Hugo will return a small 501 // default set. 502 IncludeFields string 503 504 // Regexp matching the Exif fields you want to exclude. This may be easier to use 505 // than IncludeFields above, depending on what you want. 506 ExcludeFields string 507 508 // Hugo extracts the "photo taken" date/time into .Date by default. 509 // Set this to true to turn it off. 510 DisableDate bool 511 512 // Hugo extracts the "photo taken where" (GPS latitude and longitude) into 513 // .Long and .Lat. Set this to true to turn it off. 514 DisableLatLong bool 515 }