Image Processing
Hwaro can automatically generate resized image variants during build. This is useful for responsive images, thumbnails, and performance optimization. No external tools are required, since image processing is built into the binary using stb libraries.
Supported Formats
| Format | Read | Write |
|---|---|---|
| JPEG (.jpg, .jpeg) | Yes | Yes |
| PNG (.png) | Yes | Yes |
| BMP (.bmp) | Yes | Yes |
Configuration
Enable image processing in config.toml:
[image_processing]
enabled = true
widths = [320, 640, 1024, 1280]
quality = 85
| Key | Type | Default | Description |
|---|---|---|---|
| enabled | bool | false | Enable image resizing |
| widths | array | [] | Target widths to generate (in pixels) |
| quality | int | 85 | JPEG output quality (1-100) |
| dimensions | bool | false | Add intrinsic width/height to content images (works without enabled) |
LQIP (Low-Quality Image Placeholders)
Enable LQIP to generate tiny base64-encoded placeholder images and extract dominant colors at build time. This eliminates CLS (Cumulative Layout Shift) and provides instant visual feedback while full images load.
[image_processing.lqip]
enabled = true
width = 32
quality = 20
| Key | Type | Default | Description |
|---|---|---|---|
| enabled | bool | false | Enable LQIP generation |
| width | int | 32 | Placeholder image width in pixels (8-128) |
| quality | int | 20 | JPEG quality for placeholder (1-100, lower = smaller) |
A width of 32 and quality of 20 typically produces ~400-800 byte base64 strings per image, small enough to inline directly in HTML.
Image Dimensions
Set dimensions = true to stamp every local image in rendered page content with its intrinsic size, so the browser reserves the right box before the file arrives (no layout shift):
[image_processing]
dimensions = true # does not need enabled = true
<!--  renders as -->
<img width="512" height="512" src="/hwaro.png" alt="Logo" />
- Sizes are read from file headers, not by decoding: JPEG, PNG, BMP, GIF, WebP and SVG (
width/heightin pixels, falling back to theviewBox). - Only images that resolve to a local file are touched: page bundle assets,
static/files and[content.files]copies. External URLs and missing or unreadable files are left as they are. - A tag that already sets
widthorheightis left untouched. - When responsive
srcsetvariants are added, the attributes are still the original image's size.
How It Works
- During build, Hwaro scans images in three locations:
- Page bundle assets (images colocated with
index.md) - Content files (images published via
[content.files]config) - Static files (images in the
static/directory)
- Page bundle assets (images colocated with
- For each image, resized variants are generated for every configured width
- Aspect ratio is always preserved
- If the target width is larger than the source, the original is copied as-is (no upscaling)
- Each source image is decoded only once, then resized to all widths (efficient)
- JPEG photos carrying an EXIF orientation (phone and camera shots) are rotated upright first, so variants, LQIP placeholders and
fill/cropresults match what browsers show for the original;dimensions = truestamps the displayed size
Output Naming
Resized images follow the naming convention {name}_{width}w.{ext}. For example, with static/hwaro.png:
static/hwaro.png
-> public/hwaro_320w.png
-> public/hwaro_640w.png
-> public/hwaro_1024w.png
-> public/hwaro_1280w.png
A file you publish yourself under one of these names (for example an authored static/hwaro_320w.png) is never overwritten: that width is skipped and left out of the srcset.
Using in Templates
Use the resize_image() function to get the URL of a resized variant:
{% set img = resize_image(path="/hwaro.png", width=640) %}
<img src="{{ img.url }}" width="{{ img.width }}">
For responsive images with srcset:
{% set sm = resize_image(path="/hwaro.png", width=320) %}
{% set md = resize_image(path="/hwaro.png", width=640) %}
{% set lg = resize_image(path="/hwaro.png", width=1024) %}
<img
src="{{ md.url }}"
srcset="{{ sm.url }} 320w, {{ md.url }} 640w, {{ lg.url }} 1024w"
sizes="(max-width: 640px) 320px, (max-width: 1024px) 640px, 1024px"
alt="Hwaro logo"
>
The function selects the closest available width. If you request width=500 and the configured widths are [320, 640, 1024], it returns the 640px variant (smallest width >= requested). If no variant is large enough, it falls back to the largest available.
Using LQIP Placeholders
When LQIP is enabled, resize_image() returns two additional properties: lqip (a base64 data URI) and dominant_color (a hex color string). Use them for blur-up effects or solid color placeholders:
Blur-up effect:
{% set img = resize_image(path="/images/hero.jpg", width=1024) %}
<img
src="{{ img.url }}"
style="background-image: url({{ img.lqip }}); background-size: cover;"
loading="lazy"
alt="Hero image"
>
Dominant color placeholder:
{% set img = resize_image(path="/images/hero.jpg", width=1024) %}
<img
src="{{ img.url }}"
style="background-color: {{ img.dominant_color }}"
loading="lazy"
alt="Hero image"
>
Combined approach (color first, then blur, then full image):
{% set img = resize_image(path="/images/hero.jpg", width=1024) %}
<div style="background-color: {{ img.dominant_color }}">
<img
src="{{ img.url }}"
style="background-image: url({{ img.lqip }}); background-size: cover;"
loading="lazy"
alt="Hero image"
>
</div>
When LQIP is disabled, lqip and dominant_color return empty strings, so templates work without changes.
For images with transparency (PNG logos, icons), dominant_color is weighted by opacity, so transparent pixels do not darken it, and the lqip JPEG (which has no alpha channel) shows transparent areas as white.
Crop and Fill
resize_image() takes an op and an anchor for thumbnails and fixed-size cards:
{# Cover a 400x300 box and crop the overflow from the centre #}
{% set card = resize_image(path="/images/hero.jpg", width=400, height=300, op="fill") %}
<img src="{{ card.url }}" width="{{ card.width }}" height="{{ card.height }}">
{# Cut an unscaled 200x200 region from the top-left corner #}
{% set corner = resize_image(path="/images/hero.jpg", width=200, height=200, op="crop", anchor="top_left") %}
| op | Behaviour |
|---|---|
fit |
Default. Picks the closest pre-generated width variant (see above). Never upscales. |
fill |
Scales the image to cover widthxheight, then crops it to exactly that size around anchor. |
crop |
No scaling. Cuts a widthxheight region at anchor (clamped to the image size). |
anchor is one of center (default), top, bottom, left, right, top_left, top_right, bottom_left, bottom_right.
fillandcropneed bothwidthandheight. A missing size, or an unknownoporanchor, is a template error.- These variants are written the first time a page renders and reused on warm builds while the source and
qualityare unchanged (the record is kept in.hwaro/). They do not needenabled = trueor anywidths. With--skip-image-processingthe original URL is returned. - The file name carries the size, op and anchor, e.g.
hero_400x300_fill_center.jpg, next to the original. width/heightin the result are the written file's size.lqipanddominant_colorare empty for these variants.- Only JPEG, PNG and BMP sources can be cropped. Other formats return the original URL.
Live Demo
Resize Demo
This docs site has image processing enabled with widths = [128, 256, 512] and LQIP enabled. The images below are automatically generated resized variants of static/hwaro.png:
Original (hwaro.png):
128px (hwaro_128w.png):
256px (hwaro_256w.png):
512px (hwaro_512w.png):
These files are generated at build time, so no runtime resizing or external services are needed. In your templates, use resize_image() to reference them:
{% set img = resize_image(path="/hwaro.png", width=256) %}
<img src="{{ img.url }}">
{# renders as: <img src="/hwaro_256w.png"> #}
LQIP Demo
The resize_image() function also provides LQIP data. Here is the live output for hwaro.png:
#713434
Performance
- Single decode: Each source image is decoded once and resized to all target widths in memory
- Parallel processing: Multiple images are processed concurrently using a worker pool
- No upscaling: Images smaller than the target width are simply copied
- Efficient LQIP: LQIP thumbnails are generated from the smallest resize variant (not the full-resolution original), and dominant color is computed from the thumbnail
Quick Example
Suppose your site has static/hwaro.png (the Hwaro logo) and you want to display it as a responsive image:
config.toml:
[image_processing]
enabled = true
widths = [128, 256, 512]
quality = 90
[image_processing.lqip]
enabled = true
width = 32
quality = 20
Template:
{% set logo_sm = resize_image(path="/hwaro.png", width=128) %}
{% set logo_md = resize_image(path="/hwaro.png", width=256) %}
{% set logo_lg = resize_image(path="/hwaro.png", width=512) %}
<img
src="{{ logo_md.url }}"
srcset="{{ logo_sm.url }} 128w, {{ logo_md.url }} 256w, {{ logo_lg.url }} 512w"
sizes="(max-width: 480px) 128px, (max-width: 768px) 256px, 512px"
style="background-color: {{ logo_md.dominant_color }}"
loading="lazy"
alt="Hwaro"
>
Build output:
public/
hwaro.png (original, copied by static files)
hwaro_128w.png (128px wide)
hwaro_256w.png (256px wide)
hwaro_512w.png (512px wide)
Blog Post Images
For blog posts with a hero image in front matter:
[image_processing]
enabled = true
widths = [320, 640, 1024]
quality = 85
[image_processing.lqip]
enabled = true
[content.files]
allow_extensions = ["jpg", "jpeg", "png"]
{% if page.image %}
{% set hero = resize_image(path=page.image, width=1024) %}
{% set thumb = resize_image(path=page.image, width=320) %}
<picture>
<source media="(min-width: 768px)" srcset="{{ hero.url }}">
<img
src="{{ thumb.url }}"
style="background-color: {{ thumb.dominant_color }}"
loading="lazy"
alt="{{ page.title }}"
>
</picture>
{% endif %}
See Also
- Content Files — Publishing non-Markdown files from content/
- Auto OG Images — Auto-generated Open Graph preview images
- Functions — Template function reference