From 98c9cccdd2baafb3a4c1ccd76830566872e7099a Mon Sep 17 00:00:00 2001 From: Joe Mooring Date: Tue, 31 Dec 2024 17:43:35 -0500 Subject: [PATCH] tpl/images: Add images.QR function Closes #13205 --- docs/content/en/functions/images/QR.md | 44 +++++++++++++ go.mod | 1 + go.sum | 2 + tpl/images/images.go | 85 +++++++++++++++++++++++--- tpl/images/images_integration_test.go | 34 +++++++++++ 5 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 docs/content/en/functions/images/QR.md diff --git a/docs/content/en/functions/images/QR.md b/docs/content/en/functions/images/QR.md new file mode 100644 index 00000000000..d33201f29ec --- /dev/null +++ b/docs/content/en/functions/images/QR.md @@ -0,0 +1,44 @@ +--- +title: images.QR +description: Encodes the given text into a QR code, returning an image resource. +categories: [] +keywords: [] +action: + aliases: [] + related: [] + returnType: images.ImageResource + signatures: ['images.QR TEXT [LEVEL]'] +--- + +{{< new-in 0.141.0 >}} + +The `images.QR` function encodes the given text to a [QR code] using the given error correction level, returning an image resource. + +[QR code]: https://en.wikipedia.org/wiki/QR_code + +The default error correction level is sufficient for most applications. + +Error correction level|Image size (pixels)|Redundancy +:--|:--|:-- +low|232x232|20% +medium (default)|264x264|38% +quartile|264x264|55% +high|296x296|65% + +To render a QR code with the default error correction level: + +```go-html-template +{{ with images.QR "https://gohugo.io" }} + +{{ end }} +``` + +To render a QR code with a "high" error correction level: + +```go-html-template +{{ with images.QR "https://gohugo.io" "high" }} + +{{ end }} +``` + +Always test the rendered QR code after resizing, both on-screen and in print. diff --git a/go.mod b/go.mod index 1f420e2fa51..4ee08313e3d 100644 --- a/go.mod +++ b/go.mod @@ -165,6 +165,7 @@ require ( google.golang.org/protobuf v1.35.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect howett.net/plist v1.0.0 // indirect + rsc.io/qr v0.2.0 // indirect software.sslmate.com/src/go-pkcs12 v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 58d002d5a59..0db43c53586 100644 --- a/go.sum +++ b/go.sum @@ -878,6 +878,8 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= software.sslmate.com/src/go-pkcs12 v0.2.0 h1:nlFkj7bTysH6VkC4fGphtjXRbezREPgrHuJG20hBGPE= diff --git a/tpl/images/images.go b/tpl/images/images.go index 02ffb333fa9..4d08257ab0d 100644 --- a/tpl/images/images.go +++ b/tpl/images/images.go @@ -16,11 +16,15 @@ package images import ( "errors" + "fmt" "image" "sync" "github.com/bep/overlayfs" + "github.com/gohugoio/hugo/common/hashing" "github.com/gohugoio/hugo/resources/images" + "github.com/gohugoio/hugo/resources/resource_factories/create" + "rsc.io/qr" // Importing image codecs for image.DecodeConfig _ "image/gif" @@ -50,21 +54,22 @@ func New(d *deps.Deps) *Namespace { } return &Namespace{ - readFileFs: readFileFs, - Filters: &images.Filters{}, - cache: map[string]image.Config{}, - deps: d, + readFileFs: readFileFs, + Filters: &images.Filters{}, + cache: map[string]image.Config{}, + deps: d, + createClient: create.New(d.ResourceSpec), } } // Namespace provides template functions for the "images" namespace. type Namespace struct { *images.Filters - readFileFs afero.Fs - cacheMu sync.RWMutex - cache map[string]image.Config - - deps *deps.Deps + readFileFs afero.Fs + cacheMu sync.RWMutex + cache map[string]image.Config + deps *deps.Deps + createClient *create.Client } // Config returns the image.Config for the specified path relative to the @@ -117,3 +122,65 @@ func (ns *Namespace) Filter(args ...any) (images.ImageResource, error) { return img.Filter(filtersv...) } + +var ( + qrErrorCorrectionLevels = map[string]qr.Level{ + "low": qr.L, + "medium": qr.M, + "quartile": qr.Q, + "high": qr.H, + } + qrDefaultErrorCorrectLevel = "medium" +) + +// QR Encodes the given text to a QR code using the given error correction +// level, returning an image resource. +func (ns *Namespace) QR(args ...any) (images.ImageResource, error) { + if len(args) == 0 || len(args) > 2 { + return nil, errors.New("requires one or two arguments") + } + + text, err := cast.ToStringE(args[0]) + if err != nil { + return nil, err + } + if text == "" { + return nil, errors.New("cannot encode an empty string") + } + + levels := qrDefaultErrorCorrectLevel + if len(args) == 2 { + levels, err = cast.ToStringE(args[1]) + if err != nil { + return nil, err + } + if levels == "" { + levels = qrDefaultErrorCorrectLevel + } + } + + level, ok := qrErrorCorrectionLevels[levels] + if !ok { + return nil, errors.New("error correction level must be one of low, medium, quartile, or high") + } + + code, err := qr.Encode(text, level) + if err != nil { + return nil, err + } + + png := code.PNG() + targetPath := fmt.Sprintf("qr_%v.png", hashing.XxHashFromStringHexEncoded(text+levels)) + + r, err := ns.createClient.FromString(targetPath, string(png)) + if err != nil { + return nil, err + } + + ir, ok := r.(images.ImageResource) + if !ok { + return nil, errors.New("bug: resource is not an image resource") + } + + return ir, nil +} diff --git a/tpl/images/images_integration_test.go b/tpl/images/images_integration_test.go index 003422aedaf..4688e3b4e0a 100644 --- a/tpl/images/images_integration_test.go +++ b/tpl/images/images_integration_test.go @@ -14,8 +14,10 @@ package images_test import ( + "strings" "testing" + qt "github.com/frankban/quicktest" "github.com/gohugoio/hugo/hugolib" ) @@ -49,3 +51,35 @@ fileExists2 OK: true| imageConfig2 OK: 1| `) } + +func TestQR(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- layouts/index.html -- +{{ $u := "https://gohugo.io" }} +{{ images.QR $u | hash.XxHash }}| +{{ images.QR $u "low" | hash.XxHash }}| +{{ images.QR $u "medium" | hash.XxHash }}| +{{ images.QR $u "quartile" | hash.XxHash }}| +{{ images.QR $u "high" | hash.XxHash}}| +` + + b := hugolib.Test(t, files) + b.AssertFileContent("public/index.html", + "1a9c0fbd502c7d70|\ndd8ef3a47a7f70ed|\n1a9c0fbd502c7d70|\n3c471aab148b1e3c|\n9cb4dc156db3dfdf|", + ) + + files = strings.ReplaceAll(files, "low", "foo") + + b, err := hugolib.TestE(t, files) + b.Assert(err.Error(), qt.Contains, "error correction level must be one of low, medium, quartile, or high") + + files = strings.ReplaceAll(files, "foo", "low") + files = strings.ReplaceAll(files, "https://gohugo.io", "") + + b, err = hugolib.TestE(t, files) + b.Assert(err.Error(), qt.Contains, "cannot encode an empty string") +}