tpl/images: Add images.QR function

Closes #13205
This commit is contained in:
Joe Mooring
2024-12-31 17:43:35 -05:00
committed by Bjørn Erik Pedersen
parent 5d2cbee989
commit 4ea94c451d
10 changed files with 495 additions and 28 deletions

View File

@@ -16,11 +16,18 @@ package images
import (
"errors"
"fmt"
"image"
"path"
"sync"
"github.com/bep/overlayfs"
"github.com/gohugoio/hugo/common/hashing"
"github.com/gohugoio/hugo/common/hugio"
"github.com/gohugoio/hugo/resources/images"
"github.com/gohugoio/hugo/resources/resource_factories/create"
"github.com/mitchellh/mapstructure"
"rsc.io/qr"
// Importing image codecs for image.DecodeConfig
_ "image/gif"
@@ -50,21 +57,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 +125,77 @@ 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,
}
// QR encodes the given text into a QR code using the specified options,
// returning an image resource.
func (ns *Namespace) QR(options any) (images.ImageResource, error) {
const (
qrDefaultErrorCorrectionLevel = "medium"
qrDefaultScale = 4
)
opts := struct {
Text string // text to encode
Level string // error correction level; one of low, medium, quartile, or high
Scale int // number of image pixels per QR code module
TargetDir string // target directory relative to publishDir
}{
Level: qrDefaultErrorCorrectionLevel,
Scale: qrDefaultScale,
}
err := mapstructure.WeakDecode(options, &opts)
if err != nil {
return nil, err
}
if opts.Text == "" {
return nil, errors.New("cannot encode an empty string")
}
level, ok := qrErrorCorrectionLevels[opts.Level]
if !ok {
return nil, errors.New("error correction level must be one of low, medium, quartile, or high")
}
if opts.Scale < 2 {
return nil, errors.New("scale must be an integer greater than or equal to 2")
}
targetPath := path.Join(opts.TargetDir, fmt.Sprintf("qr_%s.png", hashing.HashString(opts)))
r, err := ns.createClient.FromOpts(
create.Options{
TargetPath: targetPath,
TargetPathHasHash: true,
CreateContent: func() (func() (hugio.ReadSeekCloser, error), error) {
code, err := qr.Encode(opts.Text, level)
if err != nil {
return nil, err
}
code.Scale = opts.Scale
png := code.PNG()
return func() (hugio.ReadSeekCloser, error) {
return hugio.NewReadSeekerNoOpCloserFromBytes(png), nil
}, nil
},
},
)
if err != nil {
return nil, err
}
ir, ok := r.(images.ImageResource)
if !ok {
panic("bug: resource is not an image resource")
}
return ir, nil
}

View File

@@ -14,8 +14,10 @@
package images_test
import (
"strings"
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/hugolib"
)
@@ -49,3 +51,52 @@ 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 --
{{- $text := "https://gohugo.io" }}
{{- $optionMaps := slice
(dict "text" $text)
(dict "text" $text "level" "medium")
(dict "text" $text "level" "medium" "scale" 4)
(dict "text" $text "level" "low" "scale" 2)
(dict "text" $text "level" "medium" "scale" 3)
(dict "text" $text "level" "quartile" "scale" 5)
(dict "text" $text "level" "high" "scale" 6)
(dict "text" $text "level" "high" "scale" 6 "targetDir" "foo/bar")
}}
{{- range $k, $opts := $optionMaps }}
{{- with images.QR $opts }}
<img data-id="{{ $k }}" data-img-hash="{{ .Content | hash.XxHash }}" data-level="{{ $opts.level }}" data-scale="{{ $opts.scale }}" data-targetDir="{{ $opts.targetDir }}" src="{{ .RelPermalink }}">
{{- end }}
{{- end }}
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
`<img data-id="0" data-img-hash="6ccacf8056c41475" data-level="" data-scale="" data-targetDir="" src="/qr_3891606335523452031.png">`,
`<img data-id="1" data-img-hash="6ccacf8056c41475" data-level="medium" data-scale="" data-targetDir="" src="/qr_3891606335523452031.png">`,
`<img data-id="2" data-img-hash="6ccacf8056c41475" data-level="medium" data-scale="4" data-targetDir="" src="/qr_3891606335523452031.png">`,
`<img data-id="3" data-img-hash="c29338c3d105b156" data-level="low" data-scale="2" data-targetDir="" src="/qr_2532593520456080065.png">`,
`<img data-id="4" data-img-hash="8f7a639cea917b0e" data-level="medium" data-scale="3" data-targetDir="" src="/qr_6630115934366238432.png">`,
`<img data-id="5" data-img-hash="2d15d6dcb861b5da" data-level="quartile" data-scale="5" data-targetDir="" src="/qr_14167719016579496966.png">`,
`<img data-id="6" data-img-hash="113c45f2c091bc4d" data-level="high" data-scale="6" data-targetDir="" src="/qr_1700475212608554630.png">`,
`<img data-id="7" data-img-hash="113c45f2c091bc4d" data-level="high" data-scale="6" data-targetDir="foo/bar" src="/foo/bar/qr_12381230670040458986.png">`,
)
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")
}

View File

@@ -0,0 +1,76 @@
{{- /*
Encodes the given text into a QR code using the specified options and renders the resulting image.
@param {string} text The text to encode, falling back to the text between the opening and closing shortcode tags.
@param {string} [level=medium] The error correction level to use when encoding the text, one of low, medium, quartile, or high.
@param {int} [scale=4] The number of image pixels per QR code module. Must be greater than or equal to 2.
@param {string} [targetDir] The subdirectory within the publishDir where Hugo will place the generated image.
@param {string} [alt] The alt attribute of the img element.
@param {string} [class] The class attribute of the img element.
@param {string} [id] The id attribute of the img element.
@param {string} [title] The title attribute of the img element.
@returns {template.HTML}
@examples
{{< qr text="https://gohugo.io" />}}
{{< qr >}}
https://gohugo.io"
{{< /qr >}}
{{< qr
text="https://gohugo.io"
level="high"
scale=4
targetDir="codes"
alt="QR code linking to https://gohugo.io"
class="my-class"
id="my-id"
title="My Title"
/>}}
*/}}
{{- /* Constants. */}}
{{- $validLevels := slice "low" "medium" "quartile" "high" }}
{{- $minimumScale := 2 }}
{{- /* Get arguments. */}}
{{- $text := or (.Get "text") (strings.TrimSpace .Inner) "" }}
{{- $level := or (.Get "level") "medium" }}
{{- $scale := or (.Get "scale") 4 }}
{{- $targetDir := or (.Get "targetDir") "" }}
{{- $alt := or (.Get "alt") "" }}
{{- $class := or (.Get "class") "" }}
{{- $id := or (.Get "id") "" }}
{{- $title := or (.Get "title") "" }}
{{- /* Validate arguments. */}}
{{- $errors := false}}
{{- if not $text }}
{{- errorf "The %q shortcode requires a %q argument. See %s" .Name "text" .Position }}
{{- $errors = true }}
{{- end }}
{{- if not (in $validLevels $level) }}
{{- errorf "The %q argument passed to the %q shortcode must be one of %s. See %s" "level" .Name (delimit $validLevels ", " ", or ") .Position }}
{{- $errors = true }}
{{- end }}
{{- if or (lt $scale $minimumScale) (ne $scale (int $scale)) }}
{{- errorf "The %q argument passed to the %q shortcode must be an integer greater than or equal to %d. See %s" "scale" .Name $minimumScale .Position }}
{{- $errors = true }}
{{- end }}
{{- /* Render image. */}}
{{- if not $errors }}
{{- $opts := dict "text" $text "level" $level "scale" $scale "targetDir" $targetDir }}
{{- with images.QR $opts -}}
<img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}"
{{- with $alt }} alt="{{ $alt }}" {{- end }}
{{- with $class }} class="{{ $class }}" {{- end }}
{{- with $id }} id="{{ $id }}" {{- end }}
{{- with $title }} title="{{ $title }}" {{- end -}}
>
{{- end }}
{{- end -}}

View File

@@ -698,3 +698,39 @@ Home!
b.BuildPartial("/mybundle1/")
b.AssertFileContent("public/mybundle1/index.html", "Baseof!!")
}
func TestQRShortcode(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ['page','rss','section','sitemap','taxonomy','term']
-- layouts/index.html --
{{ .Content }}
-- content/_index.md --
---
title: home
---
{{< qr
text="https://gohugo.io"
level="high"
scale=4
targetDir="codes"
alt="QR code linking to https://gohugo.io"
class="my-class"
id="my-id"
title="My Title"
/>}}
{{< qr >}}
https://gohugo.io"
{{< /qr >}}
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
`<img src="/codes/qr_1933601158373371382.png" width="148" height="148" alt="QR code linking to https://gohugo.io" class="my-class" id="my-id" title="My Title">`,
`<img src="/qr_8288684942309665993.png" width="132" height="132">`,
)
}