From 21d9057dbfe64f668d5fc6a7f458e0984fbf7e56 Mon Sep 17 00:00:00 2001 From: Joe Mooring Date: Wed, 7 Feb 2024 15:42:27 -0800 Subject: [PATCH] Add images.Dither filter Closes #8598 --- docs/content/en/functions/images/Dither.md | 160 +++++++++ docs/layouts/shortcodes/img.html | 381 +++++++++++++++++++++ go.mod | 1 + go.sum | 2 + resources/images/dither.go | 71 ++++ resources/images/filters.go | 53 +++ 6 files changed, 668 insertions(+) create mode 100644 docs/content/en/functions/images/Dither.md create mode 100644 docs/layouts/shortcodes/img.html create mode 100644 resources/images/dither.go diff --git a/docs/content/en/functions/images/Dither.md b/docs/content/en/functions/images/Dither.md new file mode 100644 index 000000000..8084d83c1 --- /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 }} + + {{ 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 . }} + +{{ 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 000000000..34476b3d8 --- /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 }} +

Original

+ {{ $alt }} +

Processed

+ {{ $alt }} +{{- else -}} + {{ $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 f61ff8c78..07f60fa28 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 18d51c61b..914f63efc 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 000000000..19d7e088d --- /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 572a10d71..53818c97d 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 {