]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
Add images.Dither filter
authorJoe Mooring <joe.mooring@veriphor.com>
Wed, 7 Feb 2024 23:42:27 +0000 (15:42 -0800)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sun, 11 Feb 2024 20:51:11 +0000 (22:51 +0200)
Closes #8598

docs/content/en/functions/images/Dither.md [new file with mode: 0644]
docs/layouts/shortcodes/img.html [new file with mode: 0644]
go.mod
go.sum
resources/images/dither.go [new file with mode: 0644]
resources/images/filters.go

diff --git a/docs/content/en/functions/images/Dither.md b/docs/content/en/functions/images/Dither.md
new file mode 100644 (file)
index 0000000..8084d83
--- /dev/null
@@ -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 (file)
index 0000000..34476b3
--- /dev/null
@@ -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 f61ff8c78c50215bdd4913b817058b6b99361dd3..07f60fa28df4bcd1d0a35327c8d84525892d482f 100644 (file)
--- 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 18d51c61baaa2f79dcf6508efefde017c7561fda..914f63efccc0ec0d3023b7b8e16158889ea37197 100644 (file)
--- 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 (file)
index 0000000..19d7e08
--- /dev/null
@@ -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())
+}
index 572a10d711a354f6d60fd738e0dff0eea7a76e7e..53818c97d9c48a2105e936e32459c6c9c52fe23b 100644 (file)
@@ -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 {