1 use std::collections::hash_map::Entry as HEntry;
2 use std::collections::HashMap;
3 use std::error::Error as StdError;
4 use std::ffi::OsStr;
5 use std::fs::{self, File};
6 use std::hash::{Hash, Hasher};
7 use std::path::{Path, PathBuf};
8 use std::{collections::hash_map::DefaultHasher, io::Write};
9
10 use image::error::ImageResult;
11 use image::io::Reader as ImgReader;
12 use image::{imageops::FilterType, EncodableLayout};
13 use image::{ImageFormat, ImageOutputFormat};
14 use lazy_static::lazy_static;
15 use rayon::prelude::*;
16 use regex::Regex;
17 use serde::{Deserialize, Serialize};
18 use svg_metadata::Metadata as SvgMetadata;
19
20 use config::Config;
21 use errors::{Error, Result};
22 use utils::fs as ufs;
23
24 static RESIZED_SUBDIR: &str = "processed_images";
25 const DEFAULT_Q_JPG: u8 = 75;
26
27 lazy_static! {
28 pub static ref RESIZED_FILENAME: Regex =
29 Regex::new(r#"([0-9a-f]{16})([0-9a-f]{2})[.](jpg|png|webp)"#).unwrap();
30 }
31
32 /// Size and format read cheaply with `image`'s `Reader`.
33 #[derive(Debug)]
34 struct ImageMeta {
35 size: (u32, u32),
36 format: Option<ImageFormat>,
37 }
38
39 impl ImageMeta {
read(path: &Path) -> ImageResult<Self>40 fn read(path: &Path) -> ImageResult<Self> {
41 let reader = ImgReader::open(path).and_then(ImgReader::with_guessed_format)?;
42 let format = reader.format();
43 let size = reader.into_dimensions()?;
44
45 Ok(Self { size, format })
46 }
47
is_lossy(&self) -> bool48 fn is_lossy(&self) -> bool {
49 use ImageFormat::*;
50
51 // We assume lossy by default / if unknown format
52 let format = self.format.unwrap_or(Jpeg);
53 !matches!(format, Png | Pnm | Tiff | Tga | Bmp | Ico | Hdr | Farbfeld)
54 }
55 }
56
57 /// De-serialized & sanitized arguments of `resize_image`
58 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
59 pub enum ResizeArgs {
60 /// A simple scale operation that doesn't take aspect ratio into account
61 Scale(u32, u32),
62 /// Scales the image to a specified width with height computed such
63 /// that aspect ratio is preserved
64 FitWidth(u32),
65 /// Scales the image to a specified height with width computed such
66 /// that aspect ratio is preserved
67 FitHeight(u32),
68 /// If the image is larger than the specified width or height, scales the image such
69 /// that it fits within the specified width and height preserving aspect ratio.
70 /// Either dimension may end up being smaller, but never larger than specified.
71 Fit(u32, u32),
72 /// Scales the image such that it fills the specified width and height.
73 /// Output will always have the exact dimensions specified.
74 /// The part of the image that doesn't fit in the thumbnail due to differing
75 /// aspect ratio will be cropped away, if any.
76 Fill(u32, u32),
77 }
78
79 impl ResizeArgs {
from_args(op: &str, width: Option<u32>, height: Option<u32>) -> Result<Self>80 pub fn from_args(op: &str, width: Option<u32>, height: Option<u32>) -> Result<Self> {
81 use ResizeArgs::*;
82
83 // Validate args:
84 match op {
85 "fit_width" => {
86 if width.is_none() {
87 return Err("op=\"fit_width\" requires a `width` argument".into());
88 }
89 }
90 "fit_height" => {
91 if height.is_none() {
92 return Err("op=\"fit_height\" requires a `height` argument".into());
93 }
94 }
95 "scale" | "fit" | "fill" => {
96 if width.is_none() || height.is_none() {
97 return Err(
98 format!("op={} requires a `width` and `height` argument", op).into()
99 );
100 }
101 }
102 _ => return Err(format!("Invalid image resize operation: {}", op).into()),
103 };
104
105 Ok(match op {
106 "scale" => Scale(width.unwrap(), height.unwrap()),
107 "fit_width" => FitWidth(width.unwrap()),
108 "fit_height" => FitHeight(height.unwrap()),
109 "fit" => Fit(width.unwrap(), height.unwrap()),
110 "fill" => Fill(width.unwrap(), height.unwrap()),
111 _ => unreachable!(),
112 })
113 }
114 }
115
116 /// Contains image crop/resize instructions for use by `Processor`
117 ///
118 /// The `Processor` applies `crop` first, if any, and then `resize`, if any.
119 #[derive(Clone, PartialEq, Eq, Hash, Default, Debug)]
120 struct ResizeOp {
121 crop: Option<(u32, u32, u32, u32)>, // x, y, w, h
122 resize: Option<(u32, u32)>, // w, h
123 }
124
125 impl ResizeOp {
new(args: ResizeArgs, (orig_w, orig_h): (u32, u32)) -> Self126 fn new(args: ResizeArgs, (orig_w, orig_h): (u32, u32)) -> Self {
127 use ResizeArgs::*;
128
129 let res = ResizeOp::default();
130
131 match args {
132 Scale(w, h) => res.resize((w, h)),
133 FitWidth(w) => {
134 let h = (orig_h as u64 * w as u64) / orig_w as u64;
135 res.resize((w, h as u32))
136 }
137 FitHeight(h) => {
138 let w = (orig_w as u64 * h as u64) / orig_h as u64;
139 res.resize((w as u32, h))
140 }
141 Fit(w, h) => {
142 if orig_w <= w && orig_h <= h {
143 return res; // ie. no-op
144 }
145
146 let orig_w_h = orig_w as u64 * h as u64;
147 let orig_h_w = orig_h as u64 * w as u64;
148
149 if orig_w_h > orig_h_w {
150 Self::new(FitWidth(w), (orig_w, orig_h))
151 } else {
152 Self::new(FitHeight(h), (orig_w, orig_h))
153 }
154 }
155 Fill(w, h) => {
156 const RATIO_EPSILLION: f32 = 0.1;
157
158 let factor_w = orig_w as f32 / w as f32;
159 let factor_h = orig_h as f32 / h as f32;
160
161 if (factor_w - factor_h).abs() <= RATIO_EPSILLION {
162 // If the horizontal and vertical factor is very similar,
163 // that means the aspect is similar enough that there's not much point
164 // in cropping, so just perform a simple scale in this case.
165 res.resize((w, h))
166 } else {
167 // We perform the fill such that a crop is performed first
168 // and then resize_exact can be used, which should be cheaper than
169 // resizing and then cropping (smaller number of pixels to resize).
170 let (crop_w, crop_h) = if factor_w < factor_h {
171 (orig_w, (factor_w * h as f32).round() as u32)
172 } else {
173 ((factor_h * w as f32).round() as u32, orig_h)
174 };
175
176 let (offset_w, offset_h) = if factor_w < factor_h {
177 (0, (orig_h - crop_h) / 2)
178 } else {
179 ((orig_w - crop_w) / 2, 0)
180 };
181
182 res.crop((offset_w, offset_h, crop_w, crop_h)).resize((w, h))
183 }
184 }
185 }
186 }
187
crop(mut self, crop: (u32, u32, u32, u32)) -> Self188 fn crop(mut self, crop: (u32, u32, u32, u32)) -> Self {
189 self.crop = Some(crop);
190 self
191 }
192
resize(mut self, size: (u32, u32)) -> Self193 fn resize(mut self, size: (u32, u32)) -> Self {
194 self.resize = Some(size);
195 self
196 }
197 }
198
199 /// Thumbnail image format
200 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
201 pub enum Format {
202 /// JPEG, The `u8` argument is JPEG quality (in percent).
203 Jpeg(u8),
204 /// PNG
205 Png,
206 /// WebP, The `u8` argument is WebP quality (in percent), None meaning lossless.
207 WebP(Option<u8>),
208 }
209
210 impl Format {
from_args(meta: &ImageMeta, format: &str, quality: Option<u8>) -> Result<Format>211 fn from_args(meta: &ImageMeta, format: &str, quality: Option<u8>) -> Result<Format> {
212 use Format::*;
213 if let Some(quality) = quality {
214 assert!(quality > 0 && quality <= 100, "Quality must be within the range [1; 100]");
215 }
216 let jpg_quality = quality.unwrap_or(DEFAULT_Q_JPG);
217 match format {
218 "auto" => {
219 if meta.is_lossy() {
220 Ok(Jpeg(jpg_quality))
221 } else {
222 Ok(Png)
223 }
224 }
225 "jpeg" | "jpg" => Ok(Jpeg(jpg_quality)),
226 "png" => Ok(Png),
227 "webp" => Ok(WebP(quality)),
228 _ => Err(format!("Invalid image format: {}", format).into()),
229 }
230 }
231
232 /// Looks at file's extension and, if it's a supported image format, returns whether the format is lossless
is_lossy<P: AsRef<Path>>(p: P) -> Option<bool>233 pub fn is_lossy<P: AsRef<Path>>(p: P) -> Option<bool> {
234 p.as_ref()
235 .extension()
236 .and_then(std::ffi::OsStr::to_str)
237 .map(|ext| match ext.to_lowercase().as_str() {
238 "jpg" | "jpeg" => Some(true),
239 "png" => Some(false),
240 "gif" => Some(false),
241 "bmp" => Some(false),
242 // It is assumed that webp is lossy, but it can be both
243 "webp" => Some(true),
244 _ => None,
245 })
246 .unwrap_or(None)
247 }
248
extension(&self) -> &str249 fn extension(&self) -> &str {
250 // Kept in sync with RESIZED_FILENAME and op_filename
251 use Format::*;
252
253 match *self {
254 Png => "png",
255 Jpeg(_) => "jpg",
256 WebP(_) => "webp",
257 }
258 }
259 }
260
261 #[allow(clippy::derive_hash_xor_eq)]
262 impl Hash for Format {
hash<H: Hasher>(&self, hasher: &mut H)263 fn hash<H: Hasher>(&self, hasher: &mut H) {
264 use Format::*;
265
266 let q = match *self {
267 Png => 0,
268 Jpeg(q) => q,
269 WebP(None) => 0,
270 WebP(Some(q)) => q,
271 };
272
273 hasher.write_u8(q);
274 hasher.write(self.extension().as_bytes());
275 }
276 }
277
278 /// Holds all data needed to perform a resize operation
279 #[derive(Debug, PartialEq, Eq)]
280 pub struct ImageOp {
281 /// This is the source input path string as passed in the template, we need this to compute the hash.
282 /// Hashing the resolved `input_path` would include the absolute path to the image
283 /// with all filesystem components.
284 input_src: String,
285 input_path: PathBuf,
286 op: ResizeOp,
287 format: Format,
288 /// Hash of the above parameters
289 hash: u64,
290 /// If there is a hash collision with another ImageOp, this contains a sequential ID > 1
291 /// identifying the collision in the order as encountered (which is essentially random).
292 /// Therefore, ImageOps with collisions (ie. collision_id > 0) are always considered out of date.
293 /// Note that this is very unlikely to happen in practice
294 collision_id: u32,
295 }
296
297 impl ImageOp {
298 const RESIZE_FILTER: FilterType = FilterType::Lanczos3;
299
new(input_src: String, input_path: PathBuf, op: ResizeOp, format: Format) -> ImageOp300 fn new(input_src: String, input_path: PathBuf, op: ResizeOp, format: Format) -> ImageOp {
301 let mut hasher = DefaultHasher::new();
302 hasher.write(input_src.as_ref());
303 op.hash(&mut hasher);
304 format.hash(&mut hasher);
305 let hash = hasher.finish();
306
307 ImageOp { input_src, input_path, op, format, hash, collision_id: 0 }
308 }
309
perform(&self, target_path: &Path) -> Result<()>310 fn perform(&self, target_path: &Path) -> Result<()> {
311 if !ufs::file_stale(&self.input_path, target_path) {
312 return Ok(());
313 }
314
315 let mut img = image::open(&self.input_path)?;
316
317 let img = match self.op.crop {
318 Some((x, y, w, h)) => img.crop(x, y, w, h),
319 None => img,
320 };
321 let img = match self.op.resize {
322 Some((w, h)) => img.resize_exact(w, h, Self::RESIZE_FILTER),
323 None => img,
324 };
325
326 let mut f = File::create(target_path)?;
327
328 match self.format {
329 Format::Png => {
330 img.write_to(&mut f, ImageOutputFormat::Png)?;
331 }
332 Format::Jpeg(q) => {
333 img.write_to(&mut f, ImageOutputFormat::Jpeg(q))?;
334 }
335 Format::WebP(q) => {
336 let encoder = webp::Encoder::from_image(&img);
337 let memory = match q {
338 Some(q) => encoder.encode(q as f32),
339 None => encoder.encode_lossless(),
340 };
341 f.write_all(memory.as_bytes())?;
342 }
343 }
344
345 Ok(())
346 }
347 }
348
349 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
350 pub struct EnqueueResponse {
351 /// The final URL for that asset
352 pub url: String,
353 /// The path to the static asset generated
354 pub static_path: String,
355 /// New image width
356 pub width: u32,
357 /// New image height
358 pub height: u32,
359 /// Original image width
360 pub orig_width: u32,
361 /// Original image height
362 pub orig_height: u32,
363 }
364
365 impl EnqueueResponse {
new(url: String, static_path: PathBuf, meta: &ImageMeta, op: &ResizeOp) -> Self366 fn new(url: String, static_path: PathBuf, meta: &ImageMeta, op: &ResizeOp) -> Self {
367 let static_path = static_path.to_string_lossy().into_owned();
368 let (width, height) = op.resize.unwrap_or(meta.size);
369 let (orig_width, orig_height) = meta.size;
370
371 Self { url, static_path, width, height, orig_width, orig_height }
372 }
373 }
374
375 /// A struct into which image operations can be enqueued and then performed.
376 /// All output is written in a subdirectory in `static_path`,
377 /// taking care of file stale status based on timestamps and possible hash collisions.
378 #[derive(Debug)]
379 pub struct Processor {
380 /// The base path of the Zola site
381 base_path: PathBuf,
382 base_url: String,
383 output_dir: PathBuf,
384 /// A map of a ImageOps by their stored hash.
385 /// Note that this cannot be a HashSet, because hashset handles collisions and we don't want that,
386 /// we need to be aware of and handle collisions ourselves.
387 img_ops: HashMap<u64, ImageOp>,
388 /// Hash collisions go here:
389 img_ops_collisions: Vec<ImageOp>,
390 }
391
392 impl Processor {
new(base_path: PathBuf, config: &Config) -> Processor393 pub fn new(base_path: PathBuf, config: &Config) -> Processor {
394 Processor {
395 output_dir: base_path.join("static").join(RESIZED_SUBDIR),
396 base_url: config.make_permalink(RESIZED_SUBDIR),
397 base_path,
398 img_ops: HashMap::new(),
399 img_ops_collisions: Vec::new(),
400 }
401 }
402
set_base_url(&mut self, config: &Config)403 pub fn set_base_url(&mut self, config: &Config) {
404 self.base_url = config.make_permalink(RESIZED_SUBDIR);
405 }
406
num_img_ops(&self) -> usize407 pub fn num_img_ops(&self) -> usize {
408 self.img_ops.len() + self.img_ops_collisions.len()
409 }
410
411 #[allow(clippy::too_many_arguments)]
enqueue( &mut self, input_src: String, input_path: PathBuf, op: &str, width: Option<u32>, height: Option<u32>, format: &str, quality: Option<u8>, ) -> Result<EnqueueResponse>412 pub fn enqueue(
413 &mut self,
414 input_src: String,
415 input_path: PathBuf,
416 op: &str,
417 width: Option<u32>,
418 height: Option<u32>,
419 format: &str,
420 quality: Option<u8>,
421 ) -> Result<EnqueueResponse> {
422 let meta = ImageMeta::read(&input_path).map_err(|e| {
423 Error::chain(format!("Failed to read image: {}", input_path.display()), e)
424 })?;
425
426 let args = ResizeArgs::from_args(op, width, height)?;
427 let op = ResizeOp::new(args, meta.size);
428 let format = Format::from_args(&meta, format, quality)?;
429 let img_op = ImageOp::new(input_src, input_path, op.clone(), format);
430 let (static_path, url) = self.insert(img_op);
431
432 Ok(EnqueueResponse::new(url, static_path, &meta, &op))
433 }
434
insert_with_collisions(&mut self, mut img_op: ImageOp) -> u32435 fn insert_with_collisions(&mut self, mut img_op: ImageOp) -> u32 {
436 match self.img_ops.entry(img_op.hash) {
437 HEntry::Occupied(entry) => {
438 if *entry.get() == img_op {
439 return 0;
440 }
441 }
442 HEntry::Vacant(entry) => {
443 entry.insert(img_op);
444 return 0;
445 }
446 }
447
448 // If we get here, that means a hash collision.
449 // This is detected when there is an ImageOp with the same hash in the `img_ops`
450 // map but which is not equal to this one.
451 // To deal with this, all collisions get a (random) sequential ID number.
452
453 // First try to look up this ImageOp in `img_ops_collisions`, maybe we've
454 // already seen the same ImageOp.
455 // At the same time, count IDs to figure out the next free one.
456 // Start with the ID of 2, because we'll need to use 1 for the ImageOp
457 // already present in the map:
458 let mut collision_id = 2;
459 for op in self.img_ops_collisions.iter().filter(|op| op.hash == img_op.hash) {
460 if *op == img_op {
461 // This is a colliding ImageOp, but we've already seen an equal one
462 // (not just by hash, but by content too), so just return its ID:
463 return collision_id;
464 } else {
465 collision_id += 1;
466 }
467 }
468
469 // If we get here, that means this is a new colliding ImageOp and
470 // `collision_id` is the next free ID
471 if collision_id == 2 {
472 // This is the first collision found with this hash, update the ID
473 // of the matching ImageOp in the map.
474 self.img_ops.get_mut(&img_op.hash).unwrap().collision_id = 1;
475 }
476 img_op.collision_id = collision_id;
477 self.img_ops_collisions.push(img_op);
478 collision_id
479 }
480
op_filename(hash: u64, collision_id: u32, format: Format) -> String481 fn op_filename(hash: u64, collision_id: u32, format: Format) -> String {
482 // Please keep this in sync with RESIZED_FILENAME
483 assert!(collision_id < 256, "Unexpectedly large number of collisions: {}", collision_id);
484 format!("{:016x}{:02x}.{}", hash, collision_id, format.extension())
485 }
486
487 /// Adds the given operation to the queue but do not process it immediately.
488 /// Returns (path in static folder, final URL).
insert(&mut self, img_op: ImageOp) -> (PathBuf, String)489 fn insert(&mut self, img_op: ImageOp) -> (PathBuf, String) {
490 let hash = img_op.hash;
491 let format = img_op.format;
492 let collision_id = self.insert_with_collisions(img_op);
493 let filename = Self::op_filename(hash, collision_id, format);
494 let url = format!("{}{}", self.base_url, filename);
495 (Path::new("static").join(RESIZED_SUBDIR).join(filename), url)
496 }
497
498 /// Remove stale processed images in the output directory
prune(&self) -> Result<()>499 pub fn prune(&self) -> Result<()> {
500 // Do not create folders if they don't exist
501 if !self.output_dir.exists() {
502 return Ok(());
503 }
504
505 ufs::ensure_directory_exists(&self.output_dir)?;
506 let entries = fs::read_dir(&self.output_dir)?;
507 for entry in entries {
508 let entry_path = entry?.path();
509 if entry_path.is_file() {
510 let filename = entry_path.file_name().unwrap().to_string_lossy();
511 if let Some(capts) = RESIZED_FILENAME.captures(filename.as_ref()) {
512 let hash = u64::from_str_radix(capts.get(1).unwrap().as_str(), 16).unwrap();
513 let collision_id =
514 u32::from_str_radix(capts.get(2).unwrap().as_str(), 16).unwrap();
515
516 if collision_id > 0 || !self.img_ops.contains_key(&hash) {
517 fs::remove_file(&entry_path)?;
518 }
519 }
520 }
521 }
522 Ok(())
523 }
524
525 /// Run the enqueued image operations
do_process(&mut self) -> Result<()>526 pub fn do_process(&mut self) -> Result<()> {
527 if !self.img_ops.is_empty() {
528 ufs::ensure_directory_exists(&self.output_dir)?;
529 }
530
531 self.img_ops
532 .par_iter()
533 .map(|(hash, op)| {
534 let target =
535 self.output_dir.join(Self::op_filename(*hash, op.collision_id, op.format));
536 op.perform(&target).map_err(|e| {
537 Error::chain(format!("Failed to process image: {}", op.input_path.display()), e)
538 })
539 })
540 .collect::<Result<()>>()
541 }
542 }
543
544 #[derive(Debug, Serialize, Eq, PartialEq)]
545 pub struct ImageMetaResponse {
546 pub width: u32,
547 pub height: u32,
548 pub format: Option<&'static str>,
549 }
550
551 impl ImageMetaResponse {
new_svg(width: u32, height: u32) -> Self552 pub fn new_svg(width: u32, height: u32) -> Self {
553 Self { width, height, format: Some("svg") }
554 }
555 }
556
557 impl From<ImageMeta> for ImageMetaResponse {
from(im: ImageMeta) -> Self558 fn from(im: ImageMeta) -> Self {
559 Self {
560 width: im.size.0,
561 height: im.size.1,
562 format: im.format.and_then(|f| f.extensions_str().get(0)).copied(),
563 }
564 }
565 }
566
567 impl From<webp::WebPImage> for ImageMetaResponse {
from(img: webp::WebPImage) -> Self568 fn from(img: webp::WebPImage) -> Self {
569 Self { width: img.width(), height: img.height(), format: Some("webp") }
570 }
571 }
572
573 /// Read image dimensions (cheaply), used in `get_image_metadata()`, supports SVG
read_image_metadata<P: AsRef<Path>>(path: P) -> Result<ImageMetaResponse>574 pub fn read_image_metadata<P: AsRef<Path>>(path: P) -> Result<ImageMetaResponse> {
575 let path = path.as_ref();
576 let ext = path.extension().and_then(OsStr::to_str).unwrap_or("").to_lowercase();
577
578 let error = |e: Box<dyn StdError + Send + Sync>| {
579 Error::chain(format!("Failed to read image: {}", path.display()), e)
580 };
581
582 match ext.as_str() {
583 "svg" => {
584 let img = SvgMetadata::parse_file(&path).map_err(|e| error(e.into()))?;
585 match (img.height(), img.width(), img.view_box()) {
586 (Some(h), Some(w), _) => Ok((h, w)),
587 (_, _, Some(view_box)) => Ok((view_box.height, view_box.width)),
588 _ => Err("Invalid dimensions: SVG width/height and viewbox not set.".into()),
589 }
590 .map(|(h, w)| ImageMetaResponse::new_svg(h as u32, w as u32))
591 }
592 "webp" => {
593 // Unfortunatelly we have to load the entire image here, unlike with the others :|
594 let data = fs::read(path).map_err(|e| error(e.into()))?;
595 let decoder = webp::Decoder::new(&data[..]);
596 decoder.decode().map(ImageMetaResponse::from).ok_or_else(|| {
597 Error::msg(format!("Failed to decode WebP image: {}", path.display()))
598 })
599 }
600 _ => ImageMeta::read(path).map(ImageMetaResponse::from).map_err(|e| error(e.into())),
601 }
602 }
603
604 /// Assert that `address` matches `prefix` + RESIZED_FILENAME regex + "." + `extension`,
605 /// this is useful in test so that we don't need to hardcode hash, which is annoying.
assert_processed_path_matches(path: &str, prefix: &str, extension: &str)606 pub fn assert_processed_path_matches(path: &str, prefix: &str, extension: &str) {
607 let filename = path
608 .strip_prefix(prefix)
609 .unwrap_or_else(|| panic!("Path `{}` doesn't start with `{}`", path, prefix));
610
611 let suffix = format!(".{}", extension);
612 assert!(filename.ends_with(&suffix), "Path `{}` doesn't end with `{}`", path, suffix);
613
614 assert!(
615 RESIZED_FILENAME.is_match_at(filename, 0),
616 "In path `{}`, file stem `{}` doesn't match the RESIZED_FILENAME regex",
617 path,
618 filename
619 );
620 }
621