Repository: gohugoio/hugo (language: go)

Issue:
Add image dithering filter to build low-tech websites
It would be great to be able to select a filter to dither image and reduce final website size in image processing. The [lowtech magazine](https://solar.lowtechmagazine.com/) use a filter to dither images. We could have the same in hugo by using [this library](https://github.com/makeworld-the-better-one/dither) and it could be selecting in [image processing](https://gohugo.io/content-management/image-processing/) filters.


Discussion / hints:
We could add this **if we could do it by not adding another dependency**. I think the standard library has a simple dithering filter, which I think should be good enough for this. 
I want to solve this issue
hi @bep 
Is there any documentation for debugging and testing for hugo project? I have changed in filter.go add add some file. But after I run mage install, the change does not work. Is I miss something?

Is any cache mechanism in hugo for last code in shortcode or layout?
Ok, Please explain the features of the dithering image. If any input and output images will be very helpful.
I don't have any stake in this other than that I'll most likely end up maintaining what's added. But I was originally thinking about using `draw.FloydSteinberg` ... but I haven't researched this.

https://golang.org/pkg/image/draw/
https://github.com/disintegration/gift/issues/25
hi bep, Could I use `draw.FloydSteinberg` (but I modified it) to implement `gift.Filter`? 
Or Is there another approach?

thanks

Fix patch:
diff --git a/docs/content/en/functions/images/Dither.md b/docs/content/en/functions/images/Dither.md
new file mode 100644
index 00000000000..8084d83c12d
--- /dev/null
+++ b/docs/content/en/functions/images/Dither.md
@@ -0,0 +1,160 @@
+---
+title: images.Dither
+description: Returns an image filter that dithers an image.
+categories: []
+keywords: []
+action:
+  aliases: []
+  related:
+    - functions/images/Filter
+    - functions/images/Process
+    - methods/resource/Colors
+    - methods/resource/Filter
+  returnType: images.filter
+  signatures: ['images.Dither [OPTIONS]']
+toc: true
+---
+
+## Options
+
+colors
+: (`string array`) A slice of two or more colors that make up the dithering palette, each expressed as an RGB or RGBA [hexadecimal] value, with or without a leading hash mark. The default values are opaque black (`000000ff`) and opaque white (`ffffffff`).
+
+[hexadecimal]: https://developer.mozilla.org/en-US/docs/Web/CSS/hex-color
+
+method
+: (`string`) The dithering method. See the [dithering methods](#dithering-methods) section below for a list of the available methods. Default is `FloydSteinberg`.
+
+serpentine
+: (`bool`) Applicable to error diffusion dithering methods, serpentine controls whether the error diffusion matrix is applied in a serpentine manner, meaning that it goes right-to-left every other line. This greatly reduces line-type artifacts. Default is `true`.
+
+strength
+: (`float`) The strength at which to apply the dithering matrix, typically a value in the range [0, 1]. A value of `1.0` applies the dithering matrix at 100% strength (no modifification of the dither matrix). The `strength` is inversely proportional to contrast; reducing the strength increases the contrast. Setting `strength` to a value such as `0.8` can be useful to reduce noise in the dithered image. Default is `1.0`.
+
+## Usage
+
+Create the options map:
+
+```go-html-template
+{{ $opts := dict
+  "colors" (slice "222222" "808080" "dddddd")
+  "method" "ClusteredDot4x4"
+  "strength" 0.85
+}}
+```
+
+Create the filter:
+
+```go-html-template
+{{ $filter := images.Dither $opts }}
+```
+
+Or create the filter using the default settings:
+
+```go-html-template
+{{ $filter := images.Dither }}
+```
+
+{{% include "functions/images/_common/apply-image-filter.md" %}}
+
+## Dithering methods
+
+See the [Go documentation] for descriptions of each of the dithering methods below.
+
+[Go documentation]: https://pkg.go.dev/github.com/makeworld-the-better-one/dither/v2#pkg-variables 
+
+Error diffusion dithering methods:
+
+- Atkinson
+- Burkes
+- FalseFloydSteinberg
+- FloydSteinberg
+- JarvisJudiceNinke
+- Sierra
+- Sierra2
+- Sierra2_4A
+- Sierra3
+- SierraLite
+- Simple2D
+- StevenPigeon
+- Stucki
+- TwoRowSierra
+
+Ordered dithering methods:
+
+- ClusteredDot4x4
+- ClusteredDot6x6
+- ClusteredDot6x6_2
+- ClusteredDot6x6_3
+- ClusteredDot8x8
+- ClusteredDotDiagonal16x16
+- ClusteredDotDiagonal6x6
+- ClusteredDotDiagonal8x8
+- ClusteredDotDiagonal8x8_2
+- ClusteredDotDiagonal8x8_3
+- ClusteredDotHorizontalLine
+- ClusteredDotSpiral5x5
+- ClusteredDotVerticalLine
+- Horizontal3x5
+- Vertical5x3
+
+## Example
+
+This example uses the default dithering options.
+
+{{< img
+  src="images/examples/zion-national-park.jpg"
+  alt="Zion National Park"
+  filter="Dither"
+  filterArgs=""
+  example=true
+>}}
+
+## Recommendations
+
+Regardless of dithering method, do both of the following to obtain the best results:
+
+1. Scale the image _before_ dithering
+2. Output the image to a lossless format such as GIF or PNG
+
+The example below does both of these, and it sets the dithering palette to the three most dominant colors in the image.
+
+
+```go-html-template
+{{ with resources.Get "original.jpg" }}
+  {{ $opts := dict
+    "method" "ClusteredDotSpiral5x5"
+    "colors" (first 3 .Colors)
+  }}
+  {{ $filters := slice
+    (images.Process "resize 800x")
+    (images.Dither $opts)
+    (images.Process "png")
+  }}
+  {{ with . | images.Filter $filters }}
+    <img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" alt="">
+  {{ end }}
+{{ end }}
+```
+
+For best results, if the dithering palette is grayscale, convert the image to grayscale before dithering.
+
+```go-html-template
+{{ $opts := dict "colors" (slice "222" "808080" "ddd") }}
+{{ $filters := slice
+  (images.Process "resize 800x")
+  (images.Grayscale)
+  (images.Dither $opts)
+  (images.Process "png")
+}}
+{{ with images.Filter $filters . }}
+  <img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" alt="">
+{{ end }}
+```
+
+The example above:
+
+1. Resizes the image to be 800 px wide
+2. Converts the image to grayscale
+3. Dithers the image using the default (`FloydSteinberg`) dithering method with a grayscale palette
+4. Converts the image to the PNG format
diff --git a/docs/layouts/shortcodes/img.html b/docs/layouts/shortcodes/img.html
new file mode 100644
index 00000000000..34476b3d882
--- /dev/null
+++ b/docs/layouts/shortcodes/img.html
@@ -0,0 +1,381 @@
+{{- /*
+Renders the given image using the given filter, if any.
+
+@param {string} src The path to the image which must be a remote, page, or global resource.
+@param {string} [filter] The filter to apply to the image (case-insensitive).
+@param {string} [filterArgs] A comma-delimited list of arguments to pass to the filter.
+@param {bool} [example=false] If true, renders a before/after example.
+@param {int} [exampleWidth=384] Image width, in pixels, when rendering a before/after example.
+
+@returns {template.HTML}
+
+@examples
+
+  {{< img src="zion-national-park.jpg" >}}
+
+  {{< img src="zion-national-park.jpg" alt="Zion National Park" >}}
+
+  {{< img
+    src="zion-national-park.jpg"
+    alt="Zion National Park"
+    filter="grayscale"
+  >}}
+
+  {{< img
+    src="zion-national-park.jpg"
+    alt="Zion National Park"
+    filter="process"
+    filterArgs="resize 400x webp"
+  >}}
+
+  {{< img
+    src="zion-national-park.jpg"
+    alt="Zion National Park"
+    filter="colorize"
+    filterArgs="180,50,20"
+  >}}
+
+  {{< img
+    src="zion-national-park.jpg"
+    alt="Zion National Park"
+    filter="grayscale"
+    example=true
+  >}}
+
+  {{< img
+    src="zion-national-park.jpg"
+    alt="Zion National Park"
+    filter="grayscale"
+    example=true
+    exampleWidth=400
+  >}}
+
+  When using the text filter, provide the arguments in this order:
+
+    0. The text
+    1. The horizontal offset, in pixels, relative to the left of the image (default 20)
+    2. The vertical offset, in pixels, relative to the top of the image (default 20)
+    3. The font size in pixels (default 64)
+    4. The line height (default 1.2)
+    5. The font color (default #ffffff)
+
+  {{< img
+    src="images/examples/zion-national-park.jpg"
+    alt="Zion National Park"
+    filter="Text"
+    filterArgs="Zion National Park,25,250,56"
+    example=true
+  >}}
+
+  When using the padding filter, provide all arguments in this order:
+
+  0. Padding top
+  1. Padding right
+  2. Padding bottom
+  3. Padding right
+  4. Canvas color
+
+  {{< img
+    src="images/examples/zion-national-park.jpg"
+    alt="Zion National Park"
+    filter="Padding"
+    filterArgs="20,50,20,50,#0705"
+    example=true
+  >}}
+
+*/}}
+
+{{- /* Initialize. */}}
+{{- $alt := "" }}
+{{- $src := "" }}
+{{- $filter := "" }}
+{{- $filterArgs := slice }}
+{{- $example := false }}
+{{- $exampleWidth := 384 }}
+
+{{- /* Default values to use with the text filter. */}}
+{{ $textFilterOpts := dict
+  "xOffset" 20
+  "yOffset" 20
+  "fontSize" 64
+  "lineHeight" 1.2
+  "fontColor" "#ffffff"
+  "fontPath" "https://github.com/google/fonts/raw/main/ofl/lato/Lato-Regular.ttf"
+}}
+
+{{- /* Get and validate parameters. */}}
+{{- with .Get "alt" }}
+  {{- $alt = .}}
+{{- end }}
+
+{{- with .Get "src" }}
+  {{- $src = . }}
+{{- else }}
+  {{- errorf "The %q shortcode requires a file parameter. See %s" .Name .Position }}
+{{- end }}
+
+{{- with .Get "filter" }}
+  {{- $filter = . | lower }}
+{{- end }}
+
+{{- $validFilters := slice
+  "autoorient" "brightness" "colorbalance" "colorize" "contrast" "dither"
+  "gamma" "gaussianblur" "grayscale" "hue" "invert" "none" "opacity" "overlay"
+  "padding" "pixelate" "process" "saturation" "sepia" "sigmoid" "text"
+  "unsharpmask"
+}}
+
+{{- with $filter }}
+  {{- if not (in $validFilters .) }}
+    {{- errorf "The filter passed to the %q shortcode is invalid. The filter must be one of %s. See %s" $.Name (delimit $validFilters ", " ", or ") $.Position }}
+  {{- end }}
+{{- end }}
+
+{{- with .Get "filterArgs" }}
+  {{- $filterArgs = split . "," }}
+  {{- $filterArgs = apply $filterArgs "trim" "." " " }}
+{{- end }}
+
+{{- if in (slice "false" false 0) (.Get "example") }}
+  {{- $example = false }}
+{{- else if in (slice "true" true 1) (.Get "example")}}
+  {{- $example = true }}
+{{- end }}
+
+{{- with .Get "exampleWidth" }}
+  {{- $exampleWidth = . | int }}
+{{- end }}
+
+{{- /* Get image. */}}
+{{- $ctx := dict "page" .Page "src" $src "name" .Name "position" .Position }}
+{{- $i := partial "inline/get-resource.html" $ctx }}
+
+{{- /* Resize if rendering before/after examples. */}}
+{{- if $example }}
+  {{- $i = $i.Resize (printf "%dx" $exampleWidth) }}
+{{- end }}
+
+{{- /* Create filter. */}}
+{{- $f := "" }}
+{{- $ctx := dict "filter" $filter "args" $filterArgs "name" .Name "position" .Position }}
+{{- if eq $filter "autoorient" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 0) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $f = images.AutoOrient }}
+{{- else if eq $filter "brightness" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 0) "min" -100 "max" 100) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Brightness (index $filterArgs 0) }}
+{{- else if eq $filter "colorbalance" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 3) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "percentage red" "argValue" (index $filterArgs 0) "min" -100 "max" 500) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $ctx = merge $ctx (dict "argName" "percentage green" "argValue" (index $filterArgs 1) "min" -100 "max" 500) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $ctx = merge $ctx (dict "argName" "percentage blue" "argValue" (index $filterArgs 2) "min" -100 "max" 500) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.ColorBalance (index $filterArgs 0) (index $filterArgs 1) (index $filterArgs 2) }}
+{{- else if eq $filter "colorize" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 3) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "hue" "argValue" (index $filterArgs 0) "min" 0 "max" 360) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $ctx = merge $ctx (dict "argName" "saturation" "argValue" (index $filterArgs 1) "min" 0 "max" 100) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 2) "min" 0 "max" 100) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Colorize (index $filterArgs 0) (index $filterArgs 1) (index $filterArgs 2) }}
+{{- else if eq $filter "contrast" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 0) "min" -100 "max" 100) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Contrast (index $filterArgs 0) }}
+{{- else if eq $filter "dither" }}
+  {{- $f = images.Dither }}
+{{- else if eq $filter "gamma" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "gamma" "argValue" (index $filterArgs 0) "min" 0 "max" 100) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Gamma (index $filterArgs 0) }}
+{{- else if eq $filter "gaussianblur" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "sigma" "argValue" (index $filterArgs 0) "min" 0 "max" 1000) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.GaussianBlur (index $filterArgs 0) }}
+{{- else if eq $filter "grayscale" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 0) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $f = images.Grayscale }}
+{{- else if eq $filter "hue" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "shift" "argValue" (index $filterArgs 0) "min" -180 "max" 180) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Hue (index $filterArgs 0) }}
+{{- else if eq $filter "invert" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 0) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $f = images.Invert }}
+{{- else if eq $filter "opacity" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "opacity" "argValue" (index $filterArgs 0) "min" 0 "max" 1) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Opacity (index $filterArgs 0) }}
+{{- else if eq $filter "overlay" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 3) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $ctx := dict "src" (index $filterArgs 0) "name" .Name "position" .Position }}
+  {{- $overlayImg := partial "inline/get-resource.html" $ctx }}
+  {{- $f = images.Overlay $overlayImg (index $filterArgs 1 | float ) (index $filterArgs 2 | float) }}
+{{- else if eq $filter "padding" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 5) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $f = images.Padding
+    (index $filterArgs 0 | int)
+    (index $filterArgs 1 | int)
+    (index $filterArgs 2 | int)
+    (index $filterArgs 3 | int)
+    (index $filterArgs 4)
+  }}
+{{- else if eq $filter "pixelate" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "size" "argValue" (index $filterArgs 0) "min" 0 "max" 1000) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Pixelate (index $filterArgs 0) }}
+{{- else if eq $filter "process" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $f = images.Process (index $filterArgs 0) }}
+{{- else if eq $filter "saturation" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 0) "min" -100 "max" 500) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Saturation (index $filterArgs 0) }}
+{{- else if eq $filter "sepia" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "percentage" "argValue" (index $filterArgs 0) "min" 0 "max" 100) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Sepia (index $filterArgs 0) }}
+{{- else if eq $filter "sigmoid" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 2) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "midpoint" "argValue" (index $filterArgs 0) "min" 0 "max" 1) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $ctx = merge $ctx (dict "argName" "factor" "argValue" (index $filterArgs 1) "min" -10 "max" 10) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.Sigmoid (index $filterArgs 0) (index $filterArgs 1) }}
+{{- else if eq $filter "text" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 1) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $ctx := dict "src" $textFilterOpts.fontPath "name" .Name "position" .Position }}
+  {{- $font := or (partial "inline/get-resource.html" $ctx) }}
+  {{- $fontSize := or (index $filterArgs 3 | int) $textFilterOpts.fontSize }}
+  {{- $lineHeight := math.Max (or (index $filterArgs 4 | float) $textFilterOpts.lineHeight) 1 }}
+  {{- $opts := dict
+    "x" (or (index $filterArgs 1 | int) $textFilterOpts.xOffset)
+    "y" (or (index $filterArgs 2 | int) $textFilterOpts.yOffset)
+    "size" $fontSize
+    "linespacing" (mul (sub $lineHeight 1) $fontSize)
+    "color" (or (index $filterArgs 5) $textFilterOpts.fontColor)
+    "font" $font
+  }}
+  {{- $f = images.Text (index $filterArgs 0) $opts }}
+{{- else if eq $filter "unsharpmask" }}
+  {{- $ctx = merge $ctx (dict "argsRequired" 3) }}
+  {{- template "validate-arg-count" $ctx }}
+  {{- $filterArgs = apply $filterArgs "float" "." }}
+  {{- $ctx = merge $ctx (dict "argName" "sigma" "argValue" (index $filterArgs 0) "min" 0 "max" 500) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $ctx = merge $ctx (dict "argName" "amount" "argValue" (index $filterArgs 1) "min" 0 "max" 100) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $ctx = merge $ctx (dict "argName" "threshold" "argValue" (index $filterArgs 2) "min" 0 "max" 1) }}
+  {{- template "validate-arg-value" $ctx }}
+  {{- $f = images.UnsharpMask (index $filterArgs 0) (index $filterArgs 1) (index $filterArgs 2) }}
+{{- end }}
+
+{{- /* Apply filter. */}}
+{{- $fi := $i }}
+{{- with $f }}
+  {{- $fi = $i.Filter . }}
+{{- end }}
+
+{{- /* Render. */}}
+{{- if $example }}
+  <p>Original</p>
+  <img class='di ba b--black-20' style="width: initial;" src="{{ $i.RelPermalink }}" alt="{{ $alt }}">
+  <p>Processed</p>
+  <img class='di ba b--black-20' style="width: initial;" src="{{ $fi.RelPermalink }}" alt="{{ $alt }}">
+{{- else -}}
+  <img class='di' style="width: initial;" src="{{ $fi.RelPermalink }}" alt="{{ $alt }}">
+{{- end }}
+
+{{- define "validate-arg-count" }}
+  {{- $msg := "When using the %q filter, the %q shortcode requires an args parameter with %d %s. See %s" }}
+  {{- if lt (len .args) .argsRequired }}
+    {{- $text := "values" }}
+    {{- if eq 1 .argsRequired }}
+      {{- $text = "value" }}
+    {{- end }}
+    {{- errorf $msg .filter .name .argsRequired $text .position }}
+  {{- end }}
+{{- end }}
+
+{{- define "validate-arg-value" }}
+  {{- $msg := "The %q argument passed to the %q shortcode is invalid. Expected a value in the range [%v,%v], but received %v. See %s" }}
+  {{- if or (lt .argValue .min) (gt .argValue .max) }}
+    {{- errorf $msg .argName .name .min .max .argValue .position }}
+  {{- end }}
+{{- end }}
+
+{{- define "partials/inline/get-resource.html" }}
+  {{- $r := "" }}
+  {{- $u := urls.Parse .src }}
+  {{- $msg := "The %q shortcode was unable to resolve %s. See %s" }}
+  {{- if $u.IsAbs }}
+    {{- with resources.GetRemote $u.String }}
+      {{- with .Err }}
+        {{- errorf "%s" }}
+      {{- else }}
+        {{- /* This is a remote resource. */}}
+        {{- $r = . }}
+      {{- end }}
+    {{- else }}
+      {{- errorf $msg $.name $u.String $.position }}
+    {{- end }}
+  {{- else }}
+    {{- with .page.Resources.Get (strings.TrimPrefix "./" $u.Path) }}
+      {{- /* This is a page resource. */}}
+      {{- $r = . }}
+    {{- else }}
+      {{- with resources.Get $u.Path }}
+        {{- /* This is a global resource. */}}
+        {{- $r = . }}
+      {{- else }}
+        {{- errorf $msg $.name $u.Path $.position }}
+      {{- end }}
+    {{- end }}
+  {{- end }}
+  {{- return $r}}
+{{- end -}}
diff --git a/go.mod b/go.mod
index f61ff8c78c5..07f60fa28df 100644
--- a/go.mod
+++ b/go.mod
@@ -46,6 +46,7 @@ require (
 	github.com/kylelemons/godebug v1.1.0
 	github.com/kyokomi/emoji/v2 v2.2.12
 	github.com/magefile/mage v1.15.0
+	github.com/makeworld-the-better-one/dither/v2 v2.4.0
 	github.com/marekm4/color-extractor v1.2.1
 	github.com/mattn/go-isatty v0.0.20
 	github.com/mitchellh/hashstructure v1.1.0
diff --git a/go.sum b/go.sum
index 18d51c61baa..914f63efccc 100644
--- a/go.sum
+++ b/go.sum
@@ -347,6 +347,8 @@ github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
 github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
+github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
 github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
 github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
diff --git a/resources/images/dither.go b/resources/images/dither.go
new file mode 100644
index 00000000000..19d7e088d60
--- /dev/null
+++ b/resources/images/dither.go
@@ -0,0 +1,71 @@
+// Copyright 2024 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package images
+
+import (
+	"image"
+	"image/draw"
+
+	"github.com/disintegration/gift"
+	"github.com/makeworld-the-better-one/dither/v2"
+)
+
+var _ gift.Filter = (*ditherFilter)(nil)
+
+type ditherFilter struct {
+	ditherer *dither.Ditherer
+}
+
+var ditherMethodsErrorDiffusion = map[string]dither.ErrorDiffusionMatrix{
+	"atkinson":            dither.Atkinson,
+	"burkes":              dither.Burkes,
+	"falsefloydsteinberg": dither.FalseFloydSteinberg,
+	"floydsteinberg":      dither.FloydSteinberg,
+	"jarvisjudiceninke":   dither.JarvisJudiceNinke,
+	"sierra":              dither.Sierra,
+	"sierra2":             dither.Sierra2,
+	"sierra2_4a":          dither.Sierra2_4A,
+	"sierra3":             dither.Sierra3,
+	"sierralite":          dither.SierraLite,
+	"simple2d":            dither.Simple2D,
+	"stevenpigeon":        dither.StevenPigeon,
+	"stucki":              dither.Stucki,
+	"tworowsierra":        dither.TwoRowSierra,
+}
+
+var ditherMethodsOrdered = map[string]dither.OrderedDitherMatrix{
+	"clustereddot4x4":            dither.ClusteredDot4x4,
+	"clustereddot6x6":            dither.ClusteredDot6x6,
+	"clustereddot6x6_2":          dither.ClusteredDot6x6_2,
+	"clustereddot6x6_3":          dither.ClusteredDot6x6_3,
+	"clustereddot8x8":            dither.ClusteredDot8x8,
+	"clustereddotdiagonal16x16":  dither.ClusteredDotDiagonal16x16,
+	"clustereddotdiagonal6x6":    dither.ClusteredDotDiagonal6x6,
+	"clustereddotdiagonal8x8":    dither.ClusteredDotDiagonal8x8,
+	"clustereddotdiagonal8x8_2":  dither.ClusteredDotDiagonal8x8_2,
+	"clustereddotdiagonal8x8_3":  dither.ClusteredDotDiagonal8x8_3,
+	"clustereddothorizontalline": dither.ClusteredDotHorizontalLine,
+	"clustereddotspiral5x5":      dither.ClusteredDotSpiral5x5,
+	"clustereddotverticalline":   dither.ClusteredDotVerticalLine,
+	"horizontal3x5":              dither.Horizontal3x5,
+	"vertical5x3":                dither.Vertical5x3,
+}
+
+func (f ditherFilter) Draw(dst draw.Image, src image.Image, options *gift.Options) {
+	gift.New().Draw(dst, f.ditherer.Dither(src))
+}
+
+func (f ditherFilter) Bounds(srcBounds image.Rectangle) image.Rectangle {
+	return image.Rect(0, 0, srcBounds.Dx(), srcBounds.Dy())
+}
diff --git a/resources/images/filters.go b/resources/images/filters.go
index 572a10d711a..53818c97d9c 100644
--- a/resources/images/filters.go
+++ b/resources/images/filters.go
@@ -17,10 +17,13 @@ package images
 import (
 	"fmt"
 	"image/color"
+	"strings"
 
 	"github.com/gohugoio/hugo/common/hugio"
 	"github.com/gohugoio/hugo/common/maps"
 	"github.com/gohugoio/hugo/resources/resource"
+	"github.com/makeworld-the-better-one/dither/v2"
+	"github.com/mitchellh/mapstructure"
 
 	"github.com/disintegration/gift"
 	"github.com/spf13/cast"
@@ -174,6 +177,56 @@ func (*Filters) Padding(args ...any) gift.Filter {
 	}
 }
 
+// Dither creates a filter that dithers an image.
+func (*Filters) Dither(options ...any) gift.Filter {
+	ditherOptions := struct {
+		Colors     []string
+		Method     string
+		Serpentine bool
+		Strength   float32
+	}{
+		Colors:     []string{"000000ff", "ffffffff"},
+		Method:     "floydsteinberg",
+		Serpentine: true,
+		Strength:   1.0,
+	}
+
+	if len(options) != 0 {
+		err := mapstructure.WeakDecode(options[0], &ditherOptions)
+		if err != nil {
+			panic(fmt.Sprintf("failed to decode options: %s", err))
+		}
+	}
+
+	if len(ditherOptions.Colors) < 2 {
+		panic("palette must have at least two colors")
+	}
+
+	var palette []color.Color
+	for _, c := range ditherOptions.Colors {
+		cc, err := hexStringToColor(c)
+		if err != nil {
+			panic(fmt.Sprintf("%q is an invalid color: specify RGB or RGBA using hexadecimal notation", c))
+		}
+		palette = append(palette, cc)
+	}
+
+	d := dither.NewDitherer(palette)
+	if method, ok := ditherMethodsErrorDiffusion[strings.ToLower(ditherOptions.Method)]; ok {
+		d.Matrix = dither.ErrorDiffusionStrength(method, ditherOptions.Strength)
+		d.Serpentine = ditherOptions.Serpentine
+	} else if method, ok := ditherMethodsOrdered[strings.ToLower(ditherOptions.Method)]; ok {
+		d.Mapper = dither.PixelMapperFromMatrix(method, ditherOptions.Strength)
+	} else {
+		panic(fmt.Sprintf("%q is an invalid dithering method: see documentation", ditherOptions.Method))
+	}
+
+	return filter{
+		Options: newFilterOpts(ditherOptions),
+		Filter:  ditherFilter{ditherer: d},
+	}
+}
+
 // AutoOrient creates a filter that rotates and flips an image as needed per
 // its EXIF orientation tag.
 func (*Filters) AutoOrient() gift.Filter {


Test patch: