Perfected Image Rendering in Hugo

Published February 2, 2023 · 2092 words · 10 minute read

Hugo provides everything a user needs to display images in an accessible, performant, and size-conscious manner. I’ve slowly updated my image handling shortcodes for years, and I figured I should share my innovations for others getting started with my favorite static site generator.

My Requirements:

  1. Deliver the image at its smallest possible size while still presenting a resolution-appropriate and sharp image.
  2. Meet accessibility guidelines, provide a comprehensible plaintext alt-tag, stripping links from the developer-provided alt text.
  3. Provide a simple way to provide separate alt-tag and image title.
  4. Clicking the image should open the full-sized image in a new tab.
  5. It’s a static site, so the HTML cannot be composed by the server to meet a given client device and resolution: lazy loading must be implemented to deliver the correct image.
  6. Without JS, the full image should load nicely and without problems.

By default, Hugo provides a very minimal (and not very satisfying) image rendering snippet, something like:

<img src="{{ .Destination | safeURL }}" alt="{{ .Text }}" />

This is stored in your Hugo theme at:

<your theme>/layouts/_default/_markup/render-image.html

While adequate, this snippet fails to address most of the requirements above. Let’s work on rendering this markdown snippet:

![alt text](/images/picture.png 'Image Title')

A first attempt might embellish this with a figure wrap and a caption:

<figure>
    <img src="{{ .Destination | safeURL }}"
      alt="{{ .Text | plainify }}"
      {{ with .Title}}
        title="{{ . | plainify }}"
      {{ end }}
      loading="lazy" />

  {{ if .Title }}
  <figcaption>{{ .Title | safeHTML }}</figcaption>
  {{ else if .Text }}
  <figcaption>{{ .Text | safeHTML }}</figcaption>
  {{ end }}
</figure>

Piping alt text and title text to safeHTML in the caption and plainify in the img tag allows us to provide rich text in the alt of our image markdown and have it render in an accessible way for screen readers while still rendering a link for users with good vision.

To make this clickable to open the source resource in a new tab, wrap the img in a link tag:

<a href="{{ .Destination | safeURL }}"
   target="_blank"
   rel="noopener noreferrer">

  <!-- image -->
</a>

Here’s an example of the above code in action. If we write a caption with alt text like so, the image below should be rendered with an alt text that reads “A Business Case for Cloud Migration”.

![A [Business Case](https://learn.ms.com/...) for Cloud Migration.](/img/bccm.png)

A caption with an embedded link beneath an image
A caption with an embedded link beneath an image

A touch of css can be applied to give us that fun magnifying glass when we want to expand our image to full size in a new tab (thanks to the target="_blank" property on our url.)

figure img {
  cursor: zoom-in;
}

…now comes the really intricate/fun part, which is to use Hugo’s image rendering pipeline to process our images into multiple sizes and formats to deliver the smallest possible image to the client. A golang library called disintegration is used internally to resize and blur images. I’m going to lay out the entire render-image.html here first for reference and we can pick it apart together afterwards.

<figure>
  <a href="{{ .Destination | safeURL }}"
     target="_blank"
     rel="noopener noreferrer">

    <!-- Set up variables to pass to partials -->
    {{ $image_ext := path.Ext .Destination }}
    {{ $text := .Text | plainify }}
    {{ $title := .Title | plainify}}
    {{ $context := (dict
        "context" .
        "Destination" .Destination
        "Title" $title
        "Text" $text
      )}}

    <!-- Render external images with a normal img tag -->
    {{- if (strings.HasPrefix .Destination "http") -}}

        <img class="rcf-image external-image"
            src="{{ .Destination | safeURL }}"
            {{- if $text -}}  alt="{{ $text }}"  {{ end }}
            {{- if $title -}}  title="{{ $title }}"  {{ end }}
            loading="lazy"
        />

        <!-- Otherwise, pass to an image rendering partial -->
        {{- else if (strings.HasSuffix $image_ext "svg") -}}
            {{ partial "compressed-svg.html" $context}}

        {{- else if (strings.HasSuffix $image_ext "gif") -}}
            {{ partial "compressed-gif.html" $context}}

        {{- else -}}
            <!-- Renderer for all other static image types -->
            {{ partial "compressed-image.html" $context}}

    {{- end -}}

  </a>

  <!-- Caption the image if a title or alt text is given -->
  {{ if .Title }}
    <figcaption>{{ .Title | safeHTML }}</figcaption>
  {{ else if .Text }}
    <figcaption>{{ .Text | safeHTML }}</figcaption>
  {{ end }}
</figure>

When rendering a hugo partial, additional context data must be passed as a dictionary. The first few lines within the <a> tag assembles this dictionary to be passed to our rendering partials later.

If the image is external, it does not need to be processed and rendered, and is returned as the original <img> tag format we put together. If the image is in the assets folder and is internal, it will be rendered by one of the partials if the found extension is correct.

Images in SVG or GIF format are pulled aside and presented with the appropriate preprocessing, which for SVGs is none and for GIFs is the preparation of a tiny, compressed, blurred thumbnail, which will be discussed later in the article.

Let’s tackle the primary image handler. At this point it must be mentioned that I am using the lazy-loading library lazysizes which allows you to provide a LQIP/blurry image placeholder like so:

<!-- from: https://github.com/aFarkas/lazysizes -->
<!-- responsive example: -->
<img
  data-sizes="auto"
  src="lqip-src.jpg"
  data-srcset="lqip-src.jpg 220w,
    image2.jpg 300w,
    image3.jpg 600w,
    image4.jpg 900w"
  class="lazyload"
/>

<!-- or non-responsive: -->
<img src="lqip-src.jpg" data-src="image.jpg" class="lazyload" />

On page load, lazysizes will correctly replace the src or srcset elements to enable the correct images to load, while keeping the initial page time ultralow, especially if base64 encoded images are added in the src property. Unsurprisingly this is exactly what we do.

Generally, we are trying to:

  1. Create a low-resolution, blurred thumbnail.
  2. Create a set of scaled images to send based on device width.

We accomplish this by utilizing the srcset property on HTML5 image tags. In a nutshell, you provide a set of links in the format image size in a string and HTML5 handles the rest:

<img
  srcset="
    /wp-content/uploads/flamingo4x.jpg 4x,
    /wp-content/uploads/flamingo3x.jpg 3x,
    /wp-content/uploads/flamingo2x.jpg 2x,
    /wp-content/uploads/flamingo1x.jpg 1x
  "
  src="/wp-content/uploads/flamingo-fallback.jpg"
/>

Here’s an example of exactly the above with some images placed in my Hugo site’s static image folder. You’ll be able to tell what resolution your browser is operating at:

<img
  data-sizes="auto"
  src="/rez/1x.png"
  data-srcset="
    /rez/4x.png 4x,
    /rez/2x.png 2x,
    /rez/1-5x.png 1.5x,
    /rez/1-25x.png 1.25x,
    /rez/1x.png 1x"
  class="lazyload"
/>

Within our Hugo template, these sizes are each generated with a snippet like this:

{{ if ge $image.Width "850" }}
  {{ $resized := $image.Resize "850x q90 jpg" }}
  {{ $src_set = (print $src_set $resized.RelPermalink " 1.25x, ") }}
{{ end }}

If an image is large enough that the quality loss won’t be too high from resizing to a similar size (less than one full size/step down,) we compress it. This is done with the image.Resize function which takes size, quality, and format parameters.

In my scenario the content width is going to be 680 pixels wide with high consistency, so it will be best to use a srcset to provide images at a variety of scales, and not a variety of widths.

With the content @680px wide:
 -> 1x    = 680w
 -> 1.25x = 850w
 -> 1.5x  = 1020w
 -> 2x    = 1360w
 -> 3x    = 2040w
 -> 4x    = 2720w

The rest of this exercise is simply a jigsaw puzzle falling into place. Regular images will require a variety of dimensions, GIFs will still require a thumbnail but cannot be effectively compressed by Hugo, and SVGs can be cleaned and placed within a <div> tag.

With that said, this is how we assemble that srcset for our run of the mill image formats:

{{/* If the file exists and the filename isn't blank, */}}
{{ if (and (fileExists (print "/assets" .Destination)) (not (eq .Destination ""))) }}
 
{{/* Grab the image and ensure it has a width property, */}}
  {{ $image := resources.Get .Destination }}
  
  {{/* The reference to the fallback/fallback image */}}
  {{ $fallback := $image }}

  {{/* We build our src-set step by step. */}}
  {{ $src_set := "" }}
  
  <!-- ADD IMAGES TO SRC-SET -->
  
  {{ if ge $image.Width "2720" }}
    {{ $resized := $image.Resize "2070x q90 jpg" }}
    {{ $src_set = (print $src_set $resized.RelPermalink " 4x, ") }}
  {{ end }}
  
  {{ if ge $image.Width "2040" }}
    {{ $resized := $image.Resize "2040x q90 jpg" }}
    {{ $src_set = (print $src_set $resized.RelPermalink " 3x, ") }}
  {{ end }}
  
  {{ if ge $image.Width "1360" }}
    {{ $resized := $image.Resize "1360x q90 jpg" }}
    {{ $src_set = (print $src_set $resized.RelPermalink " 2x, ") }}
  {{ end }}
  
  {{ if ge $image.Width "1020" }}
    {{ $resized := $image.Resize "1020x q90 jpg" }}
    {{ $src_set = (print $src_set $resized.RelPermalink " 1.5x, ") }}
  {{ end }}
  
  {{ if ge $image.Width "850" }}
    {{ $resized := $image.Resize "850x q90 jpg" }}
    {{ $src_set = (print $src_set $resized.RelPermalink " 1.25x, ") }}
  {{ end }}


  {{ $resized := $image.Resize "680x q90 jpg" }}
  {{ $src_set = (print $src_set $resized.RelPermalink " 1x") }}
  {{ $fallback = $resized }}

  {{ $placeholder := ($image.Resize "128x q20") | images.Filter (images.GaussianBlur 1) }}
  {{ $alt := .PlainText | safeHTML }}

    <!-- If no JS, present a normal src/srcset pairing -->
    <!--  + the show-if-js class will be hidden (see head) -->
    <noscript>
      <img
        class="rcf-image"
        {{ printf "srcset=%q" $src_set | safeHTMLAttr }}
        src="{{ $fallback.RelPermalink }}"
        {{- if .Text -}} alt="{{ .Text }}" {{ end }}
        {{- if .Title -}} title="{{ .Title }}" {{ end }}
        loading="lazy"
      />
    </noscript>

    <!-- Otherwise the properly-->
    <img
      class="rcf-image lazyload show-if-js"
      {{ printf "data-srcset=%q" $src_set | safeHTMLAttr }}
      data-src="{{ $image.RelPermalink }}"
      src="data:image/jpeg;base64,{{ $placeholder.Content | base64Encode }}"
      data-sizes="auto"
      width="{{ $image.Width }}"
      height="{{ $image.Height }}"
      {{- if .Text -}} alt="{{ .Text }}" {{ end }}
      {{- if .Title -}} title="{{ .Title }}" {{ end }}
      loading="lazy"
    />

  {{ else }}
    <!-- In case the image is not found on the filesystem for some reason... -->
    <img 
      class="rcf-image external-image unoptimized" 
      src="{{ .Destination | safeURL }}" 
      {{- if .Text -}} alt="{{ .Text }}" {{ end }}
      {{- if .Title -}} title="{{ .Title }}" {{ end }}
      loading="lazy" 
    />
{{ end }}

The srcset needs to be printed in this way because Hugo recognizes scales with a decimal as fundamentally unsafe, though I haven’t looked into why. In this case, we render the attribute as a string and add it with the safeHTMLAttr escape.

As an example, the full figure output (after it is updated by lazysizes,) by this compression code will be rendered as follows. Note that the filenames and base64 encoded image have all been shortened with an ellipsis “...”.

<img 
  class="rcf-image lazyload show-if-js" 
  data-srcset="
    /picow/scope-...2070x0_resize_q90_lanczos.jpg 4x, 
    /picow/scope-...2040x0_resize_q90_lanczos.jpg 3x,
    /picow/scope-...1360x0_resize_q90_lanczos.jpg 2x,
    /picow/scope-...1020x0_resize_q90_lanczos.jpg 1.5x,
    /picow/scope-...850x0_resize_q90_lanczos.jpg 1.25x,
    /picow/scope-...680x0_resize_q90_lanczos.jpg 1x" 
  data-src="/picow/scope-50-z.jpg" 
  src="data:image/jpeg;base64,/9j/2wC...AH//Z" 
  data-sizes="auto" 
  width="4608" height="3456" loading="lazy">

…if you try blocking scripts, the noscript tag takes over and renders an equally pleasant image for our more privacy-conscious friends.

At this point, all we have to do is provide rendering options for our other kinds of images, GIFs and SVGs. These don’t do well in Hugo’s image compression pipeline so special care must be taken to present these formats in the correct manner.

The gif rendering template:

{{ if (and 
        (fileExists (print "/assets" .Destination)) 
        (not (eq .Destination ""))) }}

  {{ $image := resources.Get (printf "%s" .Destination) }}

  {{ $placeholder := ($image.Resize "128x q20") 
                     | images.Filter (images.GaussianBlur 2) }}


  <!-- If no JS, present a normal src/srcset pairing -->
  <!--  + the show-if-js class will be hidden (see head) -->
  <noscript>
    <img
      class="rcf-gif"
      src="{{ $image.RelPermalink }}"
      {{- if .Text -}} alt="{{ .Text }}" {{ end }}
      {{- if .Title -}} title="{{ .Title }}" {{ end }}
      loading="lazy"
    />
  </noscript>

  <img
    class="rcf-gif lazyload show-if-js"
    srcset="data:image/jpeg;base64,{{ $placeholder.Content | base64Encode }}"
    data-sizes="auto"
    data-srcset="{{ $image.RelPermalink }} {{ $image.Width }}w"
    data-src="{{ $image.RelPermalink }}"
    width="{{ $image.Width }}"
    height="{{ $image.Height }}"
    {{- if .Text -}} alt="{{ .Text }}" {{ end }}
    {{- if .Title -}} title="{{ .Title }}" {{ end }}
    loading="lazy"
  />

  {{ else }}
  <!-- ... -->
{{ end }}

The svg rendering template:

{{ if (and 
        (fileExists (print "/assets" .Destination)) 
        (not (eq .Destination ""))) }}

  {{ $image := resources.Get (printf "%s" .Destination) }}

  <div class="rcf-svg">
    {{ $image.Content | safeHTML }}
  </div>
  {{ else }}
  <!-- ... -->
{{ end }}

Why go through all this effort when an <img> tag would do? Ultimately, I am a bandwidth respecter, and I don’t like paying for a lot of mobile data. Even in an age of hyperconnectivity, I still think it is important to reduce the amount of data and computing resources required to run even the smaller things. With this philosophy (and other asset compression,) you too can ensure that your static site is as performant and pleasing as possible for your end users.

Comments