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.
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.
{{- /* 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.
{{- $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:
strings.TrimLeft CUTSET STRINGThe cutset (the set of characters to strip) comes first. The string to operate on comes second.
{{- /* 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.
rm -rf resources/_gen/images/
npm run startThis 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.htmlshortcode:image.Resize "1920x webp"+ watermark filter → URL goes intodata-srcon lightbox trigger buttonsgpxmap.htmlshortcode:image.Resize "1920x webp"+ watermark filter → URL goes intodata-photo-markersJSON, 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.
{{- /* 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.
{{- 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
| Gotcha | Symptom | Fix |
|---|---|---|
| Default font is ASCII-only | © and other non-ASCII chars silently absent | Provide a TrueType font via font parameter |
strings.TrimLeft argument order | Empty string returned, lookups fail silently | Cutset first: strings.TrimLeft "/" .Name |
| Dev server caches stale images | Watermark changes don’t appear | rm -rf resources/_gen/images/ then restart |
| Different operations = different URLs | Marker click-through silently fails | Keep all templates that process the same image in sync |
resources.Match returns full paths | GPS/data lookups fail, captions wrong | Use 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.