Browse Source

Merge pull request #566 from vojtechkral/imgproc

Implement suggestions in #546
index-subcmd
Vincent Prouillet GitHub 5 years ago
parent
commit
fdb6a2864c
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 133 additions and 50 deletions
  1. +95
    -30
      components/imageproc/src/lib.rs
  2. +6
    -1
      components/templates/src/global_fns/mod.rs
  3. BIN
      docs/content/documentation/content/image-processing/01-zola.png
  4. BIN
      docs/content/documentation/content/image-processing/02-zola-manet.png
  5. BIN
      docs/content/documentation/content/image-processing/03-zola-cezanne.png
  6. +0
    -0
      docs/content/documentation/content/image-processing/04-gutenberg.jpg
  7. +0
    -0
      docs/content/documentation/content/image-processing/05-example.jpg
  8. +0
    -0
      docs/content/documentation/content/image-processing/06-example.jpg
  9. +0
    -0
      docs/content/documentation/content/image-processing/07-example.jpg
  10. +0
    -0
      docs/content/documentation/content/image-processing/08-example.jpg
  11. +29
    -16
      docs/content/documentation/content/image-processing/index.md
  12. BIN
      docs/static/processed_images/0478482c742970ac00.jpg
  13. BIN
      docs/static/processed_images/1794115ed20fc20b00.jpg
  14. BIN
      docs/static/processed_images/1cec18975099962e00.png
  15. BIN
      docs/static/processed_images/2b6a3e5a28bab1f100.jpg
  16. BIN
      docs/static/processed_images/3dba59a146f3bc0900.jpg
  17. BIN
      docs/static/processed_images/4c2ee08a8b7c98fd00.png
  18. BIN
      docs/static/processed_images/5e399fa94c88057a00.jpg
  19. BIN
      docs/static/processed_images/60097aeed903cf3b00.png
  20. BIN
      docs/static/processed_images/60327c08d512e16800.png
  21. BIN
      docs/static/processed_images/63d5c27341a9885c00.jpg
  22. BIN
      docs/static/processed_images/63fe884d13fd318d00.jpg
  23. BIN
      docs/static/processed_images/67f2ebdd806283e900.jpg
  24. BIN
      docs/static/processed_images/70513837257b310c00.jpg
  25. BIN
      docs/static/processed_images/7459e23e962c9d2f00.png
  26. BIN
      docs/static/processed_images/8b446e542d0b692d00.jpg
  27. BIN
      docs/static/processed_images/a9f5475850972f8500.png
  28. BIN
      docs/static/processed_images/ab39b603591b3e3300.jpg
  29. BIN
      docs/static/processed_images/aebd0f00cf9232d000.jpg
  30. BIN
      docs/static/processed_images/baf5a4139772f2c700.png
  31. BIN
      docs/static/processed_images/d364fb703e1e0b3200.jpg
  32. BIN
      docs/static/processed_images/d91d0751df06edce00.jpg
  33. BIN
      docs/static/processed_images/e1f961e8b8cb30b500.png
  34. BIN
      docs/static/processed_images/e690cdfaf053bbd700.jpg
  35. +3
    -3
      docs/templates/shortcodes/gallery.html

+ 95
- 30
components/imageproc/src/lib.rs View File

@@ -15,6 +15,7 @@ use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};

use image::jpeg::JPEGEncoder;
use image::png::PNGEncoder;
use image::{FilterType, GenericImageView};
use rayon::prelude::*;
use regex::Regex;
@@ -26,7 +27,7 @@ static RESIZED_SUBDIR: &'static str = "processed_images";

lazy_static! {
pub static ref RESIZED_FILENAME: Regex =
Regex::new(r#"([0-9a-f]{16})([0-9a-f]{2})[.]jpg"#).unwrap();
Regex::new(r#"([0-9a-f]{16})([0-9a-f]{2})[.](jpg|png)"#).unwrap();
}

/// Describes the precise kind of a resize operation
@@ -136,12 +137,78 @@ impl Hash for ResizeOp {
}
}

/// Thumbnail image format
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
/// JPEG, The `u8` argument is JPEG quality (in percent).
Jpeg(u8),
/// PNG
Png,
}

impl Format {
pub fn from_args(source: &str, format: &str, quality: u8) -> Result<Format> {
use Format::*;

assert!(quality > 0 && quality <= 100, "Jpeg quality must be within the range [1; 100]");

match format {
"auto" => match Self::is_lossy(source) {
Some(true) => Ok(Jpeg(quality)),
Some(false) => Ok(Png),
None => Err(format!("Unsupported image file: {}", source).into()),
},
"jpeg" | "jpg" => Ok(Jpeg(quality)),
"png" => Ok(Png),
_ => Err(format!("Invalid image format: {}", format).into()),
}
}

/// Looks at file's extension and, if it's a supported image format, returns whether the format is lossless
pub fn is_lossy<P: AsRef<Path>>(p: P) -> Option<bool> {
p.as_ref()
.extension()
.and_then(|s| s.to_str())
.map(|ext| match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => Some(true),
"png" => Some(false),
"gif" => Some(false),
"bmp" => Some(false),
_ => None,
})
.unwrap_or(None)
}

fn extension(&self) -> &str {
// Kept in sync with RESIZED_FILENAME and op_filename
use Format::*;

match *self {
Png => "png",
Jpeg(_) => "jpg",
}
}
}

impl Hash for Format {
fn hash<H: Hasher>(&self, hasher: &mut H) {
use Format::*;

let q = match *self {
Png => 0,
Jpeg(q) => q,
};

hasher.write_u8(q);
}
}

/// Holds all data needed to perform a resize operation
#[derive(Debug, PartialEq, Eq)]
pub struct ImageOp {
source: String,
op: ResizeOp,
quality: u8,
format: Format,
/// Hash of the above parameters
hash: u64,
/// If there is a hash collision with another ImageOp, this contains a sequential ID > 1
@@ -152,14 +219,14 @@ pub struct ImageOp {
}

impl ImageOp {
pub fn new(source: String, op: ResizeOp, quality: u8) -> ImageOp {
pub fn new(source: String, op: ResizeOp, format: Format) -> ImageOp {
let mut hasher = DefaultHasher::new();
hasher.write(source.as_ref());
op.hash(&mut hasher);
hasher.write_u8(quality);
format.hash(&mut hasher);
let hash = hasher.finish();

ImageOp { source, op, quality, hash, collision_id: 0 }
ImageOp { source, op, format, hash, collision_id: 0 }
}

pub fn from_args(
@@ -167,10 +234,12 @@ impl ImageOp {
op: &str,
width: Option<u32>,
height: Option<u32>,
format: &str,
quality: u8,
) -> Result<ImageOp> {
let op = ResizeOp::from_args(op, width, height)?;
Ok(Self::new(source, op, quality))
let format = Format::from_args(&source, format, quality)?;
Ok(Self::new(source, op, format))
}

fn perform(&self, content_path: &Path, target_path: &Path) -> Result<()> {
@@ -184,7 +253,7 @@ impl ImageOp {
let mut img = image::open(&src_path)?;
let (img_w, img_h) = img.dimensions();

const RESIZE_FILTER: FilterType = FilterType::Gaussian;
const RESIZE_FILTER: FilterType = FilterType::Lanczos3;
const RATIO_EPSILLION: f32 = 0.1;

let img = match self.op {
@@ -223,9 +292,19 @@ impl ImageOp {
};

let mut f = File::create(target_path)?;
let mut enc = JPEGEncoder::new_with_quality(&mut f, self.quality);
let (img_w, img_h) = img.dimensions();
enc.encode(&img.raw_pixels(), img_w, img_h, img.color())?;

match self.format {
Format::Png => {
let mut enc = PNGEncoder::new(&mut f);
enc.encode(&img.raw_pixels(), img_w, img_h, img.color())?;
},
Format::Jpeg(q) => {
let mut enc = JPEGEncoder::new_with_quality(&mut f, q);
enc.encode(&img.raw_pixels(), img_w, img_h, img.color())?;
},
}

Ok(())
}
}
@@ -323,20 +402,21 @@ impl Processor {
collision_id
}

fn op_filename(hash: u64, collision_id: u32) -> String {
fn op_filename(hash: u64, collision_id: u32, format: Format) -> String {
// Please keep this in sync with RESIZED_FILENAME
assert!(collision_id < 256, "Unexpectedly large number of collisions: {}", collision_id);
format!("{:016x}{:02x}.jpg", hash, collision_id)
format!("{:016x}{:02x}.{}", hash, collision_id, format.extension())
}

fn op_url(&self, hash: u64, collision_id: u32) -> String {
format!("{}/{}", &self.resized_url, Self::op_filename(hash, collision_id))
fn op_url(&self, hash: u64, collision_id: u32, format: Format) -> String {
format!("{}/{}", &self.resized_url, Self::op_filename(hash, collision_id, format))
}

pub fn insert(&mut self, img_op: ImageOp) -> String {
let hash = img_op.hash;
let format = img_op.format;
let collision_id = self.insert_with_collisions(img_op);
self.op_url(hash, collision_id)
self.op_url(hash, collision_id, format)
}

pub fn prune(&self) -> Result<()> {
@@ -373,25 +453,10 @@ impl Processor {
self.img_ops
.par_iter()
.map(|(hash, op)| {
let target = self.resized_path.join(Self::op_filename(*hash, op.collision_id));
let target = self.resized_path.join(Self::op_filename(*hash, op.collision_id, op.format));
op.perform(&self.content_path, &target)
.chain_err(|| format!("Failed to process image: {}", op.source))
})
.collect::<Result<()>>()
}
}

/// Looks at file's extension and returns whether it's a supported image format
pub fn file_is_img<P: AsRef<Path>>(p: P) -> bool {
p.as_ref()
.extension()
.and_then(|s| s.to_str())
.map(|ext| match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => true,
"png" => true,
"gif" => true,
"bmp" => true,
_ => false,
})
.unwrap_or(false)
}

+ 6
- 1
components/templates/src/global_fns/mod.rs View File

@@ -196,6 +196,7 @@ pub fn make_get_taxonomy_url(all_taxonomies: &[Taxonomy]) -> GlobalFn {

pub fn make_resize_image(imageproc: Arc<Mutex<imageproc::Processor>>) -> GlobalFn {
static DEFAULT_OP: &'static str = "fill";
static DEFAULT_FMT: &'static str = "auto";
const DEFAULT_Q: u8 = 75;

Box::new(move |args| -> Result<Value> {
@@ -216,6 +217,10 @@ pub fn make_resize_image(imageproc: Arc<Mutex<imageproc::Processor>>) -> GlobalF
);
let op = optional_arg!(String, args.get("op"), "`resize_image`: `op` must be a string")
.unwrap_or_else(|| DEFAULT_OP.to_string());

let format = optional_arg!(String, args.get("format"), "`resize_image`: `format` must be a string")
.unwrap_or_else(|| DEFAULT_FMT.to_string());

let quality =
optional_arg!(u8, args.get("quality"), "`resize_image`: `quality` must be a number")
.unwrap_or(DEFAULT_Q);
@@ -228,7 +233,7 @@ pub fn make_resize_image(imageproc: Arc<Mutex<imageproc::Processor>>) -> GlobalF
return Err(format!("`resize_image`: Cannot find path: {}", path).into());
}

let imageop = imageproc::ImageOp::from_args(path, &op, width, height, quality)
let imageop = imageproc::ImageOp::from_args(path, &op, width, height, &format, quality)
.map_err(|e| format!("`resize_image`: {}", e))?;
let url = imageproc.insert(imageop);



BIN
docs/content/documentation/content/image-processing/01-zola.png View File

Before After
Width: 300  |  Height: 380  |  Size: 120KB

BIN
docs/content/documentation/content/image-processing/02-zola-manet.png View File

Before After
Width: 400  |  Height: 527  |  Size: 324KB

BIN
docs/content/documentation/content/image-processing/03-zola-cezanne.png View File

Before After
Width: 491  |  Height: 400  |  Size: 357KB

docs/content/documentation/content/image-processing/gutenberg.jpg → docs/content/documentation/content/image-processing/04-gutenberg.jpg View File


docs/content/documentation/content/image-processing/example-00.jpg → docs/content/documentation/content/image-processing/05-example.jpg View File


docs/content/documentation/content/image-processing/example-01.jpg → docs/content/documentation/content/image-processing/06-example.jpg View File


docs/content/documentation/content/image-processing/example-02.jpg → docs/content/documentation/content/image-processing/07-example.jpg View File


docs/content/documentation/content/image-processing/example-03.jpg → docs/content/documentation/content/image-processing/08-example.jpg View File


+ 29
- 16
docs/content/documentation/content/image-processing/index.md View File

@@ -16,10 +16,22 @@ resize_image(path, width, height, op, quality)

- `path`: The path to the source image relative to the `content` directory in the [directory structure](./documentation/getting-started/directory-structure.md).
- `width` and `height`: The dimensions in pixels of the resized image. Usage depends on the `op` argument.
- `op`: Resize operation. This can be one of five choices: `"scale"`, `"fit_width"`, `"fit_height"`, `"fit"`, or `"fill"`.
What each of these does is explained below.
This argument is optional, default value is `"fill"`.
- `quality`: JPEG quality of the resized image, in percents. Optional argument, default value is `75`.
- `op` (_optional_): Resize operation. This can be one of:
- `"scale"`
- `"fit_width"`
- `"fit_height"`
- `"fit"`
- `"fill"`

What each of these does is explained below. The default is `"fill"`.
- `format` (_optional_): Encoding format of the resized image. May be one of:
- `"auto"`
- `"jpg"`
- `"png"`

The default is `"auto"`, this means the format is chosen based on input image format.
JPEG is chosen for JPEGs and other lossy formats, while PNG is chosen for PNGs and other lossless formats.
- `quality` (_optional_): JPEG quality of the resized image, in percents. Only used when encoding JPEGs, default value is `75`.

### Image processing and return value

@@ -29,7 +41,7 @@ Zola performs image processing during the build process and places the resized i
static/processed_images/
```

Resized images are JPEGs. Filename of each resized image is a hash of the function arguments,
Filename of each resized image is a hash of the function arguments,
which means that once an image is resized in a certain way, it will be stored in the above directory and will not
need to be resized again during subsequent builds (unless the image itself, the dimensions, or other arguments are changed).
Therefore, if you have a large number of images, they will only need to be resized once.
@@ -40,14 +52,14 @@ The function returns a full URL to the resized image.

The source for all examples is this 300 Ă— 380 pixels image:

![gutenberg](gutenberg.jpg)
![zola](01-zola.png)

### **`"scale"`**
Simply scales the image to the specified dimensions (`width` & `height`) irrespective of the aspect ratio.

`resize_image(..., width=150, height=150, op="scale")`

{{ resize_image(path="documentation/content/image-processing/gutenberg.jpg", width=150, height=150, op="scale") }}
{{ resize_image(path="documentation/content/image-processing/01-zola.png", width=150, height=150, op="scale") }}

### **`"fit_width"`**
Resizes the image such that the resulting width is `width` and height is whatever will preserve the aspect ratio.
@@ -55,7 +67,7 @@ The source for all examples is this 300 Ă— 380 pixels image:

`resize_image(..., width=100, op="fit_width")`

{{ resize_image(path="documentation/content/image-processing/gutenberg.jpg", width=100, height=0, op="fit_width") }}
{{ resize_image(path="documentation/content/image-processing/01-zola.png", width=100, height=0, op="fit_width") }}

### **`"fit_height"`**
Resizes the image such that the resulting height is `height` and width is whatever will preserve the aspect ratio.
@@ -63,7 +75,7 @@ The source for all examples is this 300 Ă— 380 pixels image:

`resize_image(..., height=150, op="fit_height")`

{{ resize_image(path="documentation/content/image-processing/gutenberg.jpg", width=0, height=150, op="fit_height") }}
{{ resize_image(path="documentation/content/image-processing/01-zola.png", width=0, height=150, op="fit_height") }}

### **`"fit"`**
Like `"fit_width"` and `"fit_height"` combined.
@@ -72,7 +84,7 @@ The source for all examples is this 300 Ă— 380 pixels image:

`resize_image(..., width=150, height=150, op="fit")`

{{ resize_image(path="documentation/content/image-processing/gutenberg.jpg", width=150, height=150, op="fit") }}
{{ resize_image(path="documentation/content/image-processing/01-zola.png", width=150, height=150, op="fit") }}

### **`"fill"`**
This is the default operation. It takes the image's center part with the same aspect ratio as the `width` & `height` given and resizes that
@@ -80,7 +92,7 @@ The source for all examples is this 300 Ă— 380 pixels image:

`resize_image(..., width=150, height=150, op="fill")`

{{ resize_image(path="documentation/content/image-processing/gutenberg.jpg", width=150, height=150, op="fill") }}
{{ resize_image(path="documentation/content/image-processing/01-zola.png", width=150, height=150, op="fill") }}


## Using `resize_image` in markdown via shortcodes
@@ -96,11 +108,11 @@ The examples above were generated using a shortcode file named `resize_image.htm

## Creating picture galleries

The `resize_image()` can be used multiple times and/or in loops as it is designed to handle this efficiently.
The `resize_image()` can be used multiple times and/or in loops. It is designed to handle this efficiently.

This can be used along with `assets` [page metadata](./documentation/templates/pages-sections.md) to create picture galleries.
The `assets` variable holds paths to all assets in the directory of a page with resources
(see [Assets colocation](./documentation/content/overview.md#assets-colocation)): if you have files other than images you
(see [assets colocation](./documentation/content/overview.md#assets-colocation)): if you have files other than images you
will need to filter them out in the loop first like in the example below.

This can be used in shortcodes. For example, we can create a very simple html-only clickable
@@ -108,7 +120,7 @@ picture gallery with the following shortcode named `gallery.html`:

```jinja2
{% for asset in page.assets %}
{% if asset is ending_with(".jpg") %}
{% if asset is matching("[.](jpg|png)$") %}
<a href="{{ get_url(path=asset) }}">
<img src="{{ resize_image(path=asset, width=240, height=180, op="fill") }}" />
</a>
@@ -117,7 +129,8 @@ picture gallery with the following shortcode named `gallery.html`:
{% endfor %}
```

As you can notice, we didn't specify an `op` argument, which means it'll default to `"fill"`. Similarly, the JPEG quality will default to `75`.
As you can notice, we didn't specify an `op` argument, which means it'll default to `"fill"`. Similarly, the format will default to
`"auto"` (choosing PNG or JPEG as appropriate) and the JPEG quality will default to `75`.

To call it from a markdown file, simply do:

@@ -130,5 +143,5 @@ Here is the result:
{{ gallery() }}

<small>
Image attribution: example-01: Willi Heidelbach, example-02: Daniel Ullrich, others: public domain.
Image attribution: Public domain, except: _06-example.jpg_: Willi Heidelbach, _07-example.jpg_: Daniel Ullrich.
</small>

BIN
docs/static/processed_images/0478482c742970ac00.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 4.5KB

BIN
docs/static/processed_images/1794115ed20fc20b00.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 18KB

BIN
docs/static/processed_images/1cec18975099962e00.png View File

Before After
Width: 240  |  Height: 180  |  Size: 55KB

BIN
docs/static/processed_images/2b6a3e5a28bab1f100.jpg View File

Before After
Width: 150  |  Height: 150  |  Size: 5.4KB

BIN
docs/static/processed_images/3dba59a146f3bc0900.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 10KB

BIN
docs/static/processed_images/4c2ee08a8b7c98fd00.png View File

Before After
Width: 150  |  Height: 150  |  Size: 29KB

BIN
docs/static/processed_images/5e399fa94c88057a00.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 12KB

BIN
docs/static/processed_images/60097aeed903cf3b00.png View File

Before After
Width: 100  |  Height: 126  |  Size: 17KB

BIN
docs/static/processed_images/60327c08d512e16800.png View File

Before After
Width: 240  |  Height: 180  |  Size: 95KB

BIN
docs/static/processed_images/63d5c27341a9885c00.jpg View File

Before After
Width: 118  |  Height: 150  |  Size: 4.4KB

BIN
docs/static/processed_images/63fe884d13fd318d00.jpg View File

Before After
Width: 100  |  Height: 126  |  Size: 3.4KB

BIN
docs/static/processed_images/67f2ebdd806283e900.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 5.1KB

BIN
docs/static/processed_images/70513837257b310c00.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 12KB

BIN
docs/static/processed_images/7459e23e962c9d2f00.png View File

Before After
Width: 150  |  Height: 150  |  Size: 29KB

BIN
docs/static/processed_images/8b446e542d0b692d00.jpg View File

Before After
Width: 118  |  Height: 150  |  Size: 4.4KB

BIN
docs/static/processed_images/a9f5475850972f8500.png View File

Before After
Width: 118  |  Height: 150  |  Size: 29KB

BIN
docs/static/processed_images/ab39b603591b3e3300.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 9.6KB

BIN
docs/static/processed_images/aebd0f00cf9232d000.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 15KB

BIN
docs/static/processed_images/baf5a4139772f2c700.png View File

Before After
Width: 118  |  Height: 150  |  Size: 29KB

BIN
docs/static/processed_images/d364fb703e1e0b3200.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 12KB

BIN
docs/static/processed_images/d91d0751df06edce00.jpg View File

Before After
Width: 150  |  Height: 150  |  Size: 5.5KB

BIN
docs/static/processed_images/e1f961e8b8cb30b500.png View File

Before After
Width: 240  |  Height: 180  |  Size: 106KB

BIN
docs/static/processed_images/e690cdfaf053bbd700.jpg View File

Before After
Width: 240  |  Height: 180  |  Size: 15KB

+ 3
- 3
docs/templates/shortcodes/gallery.html View File

@@ -1,7 +1,7 @@
{% for asset in page.assets -%}
{%- if asset is ending_with(".jpg") -%}
<a href="{{ get_url(path=asset) | safe }}">
<img src="{{ resize_image(path=asset, width=240, height=180, op="fill") | safe }}" />
{%- if asset is matching("[.](jpg|png)$") -%}
<a href="{{ get_url(path=asset) | safe }}" target="_blank">
<img src="{{ resize_image(path=asset, width=240, height=180, op="fill", format="auto") | safe }}" />
</a>
&ensp;
{%- endif %}


Loading…
Cancel
Save