← all posts · hugo

Hugo image processing gotchas: what the docs don't warn you about

A collection of non-obvious traps in Hugo's image processing pipeline: the ASCII-only default font, the strings.TrimLeft argument order, stale image caches, and why two shortcodes processing the same image can produce different URLs.

27 Apr 2026 · 5 min read · Stephen Masters hugodevops

Hugo’s image processing pipeline is powerful, but it has some sharp edges that are easy to hit and hard to diagnose because they all fail silently. This is a collection of the ones I’ve run into while building Velostevie.


1. The default font for images.Text is ASCII-only

Hugo’s images.Text filter uses Go’s basicfont.Face7x13 by default — a small bitmap font covering printable ASCII (0x20–0x7E). If you include any non-ASCII character in your text, it will not render. There is no error. The character is silently dropped or produces a blank glyph.

The most common casualty: the copyright symbol ©, which is U+00A9.

go-html-template
12
{{- /* This produces "2025 Stephen Masters" with a gap where © should be */ -}}
{{- $wm := images.Text "© 2025 Stephen Masters" (dict "size" 14) -}}

Fix: provide a TrueType font via the font parameter.

go-html-template
12
{{- $font := resources.Get "fonts/watermark.ttf" -}}
{{- $wm := images.Text "© 2025 Stephen Masters" (dict "size" 14 "font" $font) -}}

The font must be a resource in assets/. DejaVu Sans is a good choice for watermarks: open-source, comprehensive Unicode support, freely redistributable.


2. strings.TrimLeft takes the cutset first

This one is a classic Go template trap. Hugo’s strings.TrimLeft signature is:

text
1
strings.TrimLeft CUTSET STRING

The cutset (the set of characters to strip) comes first. The string to operate on comes second.

go-html-template
123456
{{- /* Correct — strips leading "/" from .Name */ -}}
{{- $key := strings.TrimLeft "/" .Name -}}

{{- /* Wrong — treats .Name as the cutset, strips those characters from "/" */ -}}
{{- /* Returns "" because every character in "/" is in the cutset.         */ -}}
{{- $key := strings.TrimLeft .Name "/" -}}

The wrong version returns an empty string and produces no error. I hit this when building the GPS data lookup: the key came back empty, every GPS lookup returned nil, and no photo markers appeared. The fix was trivial once found, but finding it took a while.

This affects strings.TrimLeft, strings.TrimRight, and strings.Trim — all three take the cutset first.


3. Changing filter parameters doesn’t automatically invalidate the dev server cache

Hugo caches processed images in resources/_gen/images/. The cache key is derived from the source image and the processing operations applied. When you change filter parameters (font, size, colour, position), the cache key changes — so a new build will produce a new image.

However, the dev server (hugo server) does not always detect that filter parameters have changed and re-run the template. In practice, if you change your images.Text parameters and the watermark looks wrong (or unchanged), the server may still be serving the old processed file from cache.

Fix: clear the image cache and restart.

bash
12
rm -rf resources/_gen/images/
npm run start

This forces Hugo to reprocess every image from scratch. The first build after clearing will be slow; subsequent builds only reprocess changed files.


4. Two templates processing the same image can produce different URLs

Hugo’s image pipeline is deterministic: the same source file + the same operations = the same output file at the same URL. This is how the cache works, and it’s usually what you want.

The trap: if the same image is processed in two different templates with different operations, you get two different output files at two different URLs — and any code that expects them to match will fail silently.

On Velostevie, gallery images are processed in two places:

  • gallery.html shortcode: image.Resize "1920x webp" + watermark filter → URL goes into data-src on lightbox trigger buttons
  • gpxmap.html shortcode: image.Resize "1920x webp" + watermark filter → URL goes into data-photo-markers JSON, used by the map to open the lightbox when a marker is clicked

The JavaScript match is: decodeURIComponent(marker.url) === decodeURIComponent(trigger.dataset.src). If the two shortcodes produce different URLs for the same image, this comparison silently fails and clicking a map marker does nothing.

Fix: ensure both templates apply identical processing steps in the same order with the same parameters.

go-html-template
1234567891011
{{- /* gallery.html */ -}}
{{- $base := .Resize "1920x webp" -}}
{{- $shadow := images.Text $copyright (dict "color" "#000000cc" "size" 16 "font" $font "x" (add $wmX 1) "y" (add $wmY 1)) -}}
{{- $text   := images.Text $copyright (dict "color" "#ffffff"   "size" 16 "font" $font "x" $wmX "y" $wmY) -}}
{{- $full := $base | images.Filter $shadow $text -}}

{{- /* gpxmap.html — identical */ -}}
{{- $base := .Resize "1920x webp" -}}
{{- $shadow := images.Text $copyright (dict "color" "#000000cc" "size" 16 "font" $font "x" (add $wmX 1) "y" (add $wmY 1)) -}}
{{- $text   := images.Text $copyright (dict "color" "#ffffff"   "size" 16 "font" $font "x" $wmX "y" $wmY) -}}
{{- $full := $base | images.Filter $shadow $text -}}

Since both templates are on the same page, variables like $copyright (derived from .Page.Date) and $wmX/$wmY (derived from $base.Width/$base.Height) will have the same values in both. Hugo returns the same cached image resource and the URLs match.


5. resources.Match returns full paths with a leading slash

When you call resources.Match "images/gallery/*", the .Name property on each result is the full path relative to assets/, with a leading / — e.g. /images/gallery/foo.png, not foo.png.

This matters when you need to use the path as a lookup key in a data file (where the key was written without a leading slash) or when extracting just the filename.

go-html-template
123456789
{{- range $images -}}
  {{- /* .Name is "/images/gallery/foo.png" */ -}}

  {{- /* Filename only */ -}}
  {{- $filename := path.Base .Name -}}         {{- /* "foo.png" */ -}}

  {{- /* Key for data lookup (no leading slash) */ -}}
  {{- $key := strings.TrimLeft "/" .Name -}}   {{- /* "images/gallery/foo.png" */ -}}
{{- end -}}

Remember: strings.TrimLeft "/" .Name — cutset first (see gotcha 2).


Summary

GotchaSymptomFix
Default font is ASCII-only© and other non-ASCII chars silently absentProvide a TrueType font via font parameter
strings.TrimLeft argument orderEmpty string returned, lookups fail silentlyCutset first: strings.TrimLeft "/" .Name
Dev server caches stale imagesWatermark changes don’t appearrm -rf resources/_gen/images/ then restart
Different operations = different URLsMarker click-through silently failsKeep all templates that process the same image in sync
resources.Match returns full pathsGPS/data lookups fail, captions wrongUse path.Base .Name for filename, strings.TrimLeft "/" .Name for keys

All five of these fail silently. None produce a Hugo build error. The only diagnostic is to add logging or inspect the generated HTML to check what’s actually in the processed attributes.

SM
Stephen Masters

Software developer and architect. I build systems for places that move energy, commodities, and money around. I keep a bike-packing journal at velostevie.com.