Skip to content
/ hugo Public
forked from gohugoio/hugo

Commit

Permalink
tpl/images: Add images.QR function
Browse files Browse the repository at this point in the history
  • Loading branch information
jmooring committed Jan 3, 2025
1 parent d913f46 commit ae08c96
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 9 deletions.
85 changes: 85 additions & 0 deletions docs/content/en/functions/images/QR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
title: images.QR
description: Encodes the given text into a QR code using the given options, returning an image resource.
categories: []
keywords: []
action:
aliases: []
related: []
returnType: images.ImageResource
signatures: ['images.QR TEXT [OPTIONS]']
toc: true
---

{{< new-in 0.141.0 >}}

The `images.QR` function generates a [QR code] from the provided text and options. The generated image will always be at least 232x232 pixels, with each QR code module represented by 8 image pixels.

[QR code]: https://en.wikipedia.org/wiki/QR_code

The size of the generated image is variable and depends on two factors:

- Length of the encoded text: Longer text requires a larger image to encode all the information.
- Error correction level: Higher error correction levels lead to a slightly larger image size to ensure better readability even if the code is damaged.

See the [resizing](#resizing) section below if you wish to decrease the image size. Always test the rendered QR code after resizing, both on-screen and in print.

## Options

level
: (`string`) The error correction level to use when encoding the text, one of `low`, `medium`, `quartile`, or `high`. The default value is sufficient for most applications.

Error correction level|Redundancy
:--|:--|:--
low|20%
medium (default)|38%
quartile|55%
high|65%

targetDir
: (`string`) The subdirectory within the [`publishDir`] where Hugo will place the generated image. If empty or not provided, the image is placed directly in the `publishDir` root. Hugo automatically creates the directory if it doesn't exist.

[`publishDir`]: /getting-started/configuration/#publishdir

## Examples

To render a QR code with the default error correction level:

```go-html-template
{{ with images.QR "https://gohugo.io" }}
<img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" alt="">
{{ end }}
```

To render a QR code with a "high" error correction level and publish it to a "qr" directory within your `publishDir`:

```go-html-template
{{ $opts := dict "level" "high" "targetDir" "qr" }}
{{ with images.QR "https://gohugo.io" $opts }}
<img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" alt="">
{{ end }}
```

## Resizing

Use the [`Resize`] method to scale the generated image as needed.

[`Resize`]: /methods/resource/resize/

Due to the variable size of the generated image, do not specify a fixed width when resizing. Instead, calculate the new width by multiplying the original width by a scale factor. The scale factor must be a multiple of 0.125 to maintain an even number of pixels per QR code module.

```go-html-template
{{ $scaleFactor := 0.375 }}
{{ with images.QR "https://gohugo.io" }}
{{ $width := .Width | mul $scaleFactor | math.Round | int }}
{{ with .Resize (printf "%dx" $width) }}
<img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" alt="">
{{ end }}
{{ end }}
```

As you decrease the size of a QR code, the maximum distance at which it can be reliably scanned by a device also decreases.

{{% note %}}
Always test the rendered QR code after resizing, both on-screen and in print.
{{% /note %}}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
91 changes: 82 additions & 9 deletions tpl/images/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@ package images

import (
"errors"
"fmt"
"image"
"path/filepath"
"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"
"github.com/mitchellh/mapstructure"
"rsc.io/qr"

// Importing image codecs for image.DecodeConfig
_ "image/gif"
Expand Down Expand Up @@ -50,21 +56,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
Expand Down Expand Up @@ -117,3 +124,69 @@ func (ns *Namespace) Filter(args ...any) (images.ImageResource, error) {

return img.Filter(filtersv...)
}

type qrOptions struct {
Level string
TargetDir string
}

var qrErrorCorrectionLevels = map[string]qr.Level{
"low": qr.L,
"medium": qr.M,
"quartile": qr.Q,
"high": qr.H,
}

const qrDefaultErrorCorrectionLevel = "medium"

// QR encodes the given text to a QR code using the given options, 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")
}

opts := qrOptions{
Level: qrDefaultErrorCorrectionLevel,
}

if len(args) == 2 {
err = mapstructure.WeakDecode(args[1], &opts)
if err != nil {
return nil, err
}
}

level, ok := qrErrorCorrectionLevels[opts.Level]
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
}

hash := hashing.XxHashFromStringHexEncoded(text + opts.Level)
targetPath := filepath.Join(opts.TargetDir, fmt.Sprintf("qr_%s.png", hash))

r, err := ns.createClient.FromString(targetPath, string(code.PNG()))
if err != nil {
return nil, err
}

ir, ok := r.(images.ImageResource)
if !ok {
panic("bug: resource is not an image resource")
}

return ir, nil
}
53 changes: 53 additions & 0 deletions tpl/images/images_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
package images_test

import (
"strings"
"testing"

qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/hugolib"
)

Expand Down Expand Up @@ -49,3 +51,54 @@ 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)
(dict "level" "low")
(dict "level" "medium")
(dict "level" "quartile")
(dict "level" "high")
(dict "targetDir" "foo")
(dict "level" "high" "targetDir" "foo/bar")
}}
{{ range $k, $opts := $optionMaps }}
{{ with images.QR $text $opts }}
<img src="{{ .RelPermalink }}" data-id="{{ $k }}" data-level="{{ $opts.level }}" data-targetDir="{{ $opts.targetDir }}" data-hash="{{ .Content | hash.XxHash }}">
{{ end }}
{{ end }}
{{ with images.QR $text }}
<img src="{{ .RelPermalink }}" data-id="7" data-level="" data-targetdir="" data-hash="{{ .Content | hash.XxHash }}">
{{ end }}
`

b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
`<img src="/qr_30596359e57a22d8.png" data-id="0" data-level="" data-targetDir="" data-hash="5c8f15c6a5da74b1">`,
`<img src="/qr_9fa47d3ceaf0993c.png" data-id="1" data-level="low" data-targetDir="" data-hash="863b6fca7913f6ec">`,
`<img src="/qr_30596359e57a22d8.png" data-id="2" data-level="medium" data-targetDir="" data-hash="5c8f15c6a5da74b1">`,
`<img src="/qr_0a5f8db4478f4066.png" data-id="3" data-level="quartile" data-targetDir="" data-hash="2e6b70fbd4a4442d">`,
`<img src="/qr_a6f66d8f08c8af75.png" data-id="4" data-level="high" data-targetDir="" data-hash="b2d62c862af4e3f6">`,
`<img src="/foo/qr_30596359e57a22d8.png" data-id="5" data-level="" data-targetDir="foo" data-hash="5c8f15c6a5da74b1">`,
`<img src="/foo/bar/qr_a6f66d8f08c8af75.png" data-id="6" data-level="high" data-targetDir="foo/bar" data-hash="b2d62c862af4e3f6">`,
`<img src="/qr_30596359e57a22d8.png" data-id="7" data-level="" data-targetdir="" data-hash="5c8f15c6a5da74b1">`,
)

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")
}

0 comments on commit ae08c96

Please sign in to comment.