feat(04-02): standard label renderer with QR code generation

- Add internal/labels package with LabelData struct and RenderStandard()
- QR encodes http://mac-mini.mg:8080/hw/HW-XXXXX (LBL-01)
- Label is 384x120px NRGBA, white background, basicfont text
- T-04-07 mitigation: validate HWID format before qrcode.New()
- Install github.com/skip2/go-qrcode and golang.org/x/image
- All 5 renderer tests pass
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:44:11 +00:00
parent 77bf4ebfd6
commit 8cbd840cba
4 changed files with 230 additions and 2 deletions

6
go.mod
View file

@ -1,6 +1,6 @@
module git.georgsen.dk/hwlab
go 1.23.0
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.5
@ -19,6 +19,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
@ -26,7 +27,8 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/image v0.39.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
)

6
go.sum
View file

@ -42,6 +42,8 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@ -62,10 +64,14 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -0,0 +1,87 @@
package labels
import (
"fmt"
"image"
"image/color"
"image/draw"
"regexp"
"github.com/skip2/go-qrcode"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
const (
LabelWidth = 384 // pixels at 203 DPI ≈ 48mm usable
LabelHeight = 120 // pixels ≈ 15mm
QRSize = 96 // pixels
QROffsetX = 8
QROffsetY = 12
TextOffsetX = 112
BaseURL = "http://mac-mini.mg:8080/hw/"
)
// hwIDPattern validates the HW-XXXXX format (T-04-07 mitigation).
var hwIDPattern = regexp.MustCompile(`^HW-\d{5}$`)
// LabelData holds the fields needed to render a standard item label.
type LabelData struct {
HWID string // e.g. "HW-00001"
Name string // item name from NetBox
SpecLine string // key spec summary, e.g. "Intel NUC i5 2019"
}
// RenderStandard produces a 384x120 label bitmap (black on white) for a standard item.
// Layout: QR code left (96x96 at x=8,y=12), text right (HW ID bold top, name middle, spec bottom).
// Returns an error if HWID does not match HW-\d{5} (T-04-07 mitigation).
func RenderStandard(d LabelData) (image.Image, error) {
if !hwIDPattern.MatchString(d.HWID) {
return nil, fmt.Errorf("labels: invalid HWID %q: must match HW-NNNNN", d.HWID)
}
if d.Name == "" {
d.Name = "Unknown Item"
}
img := image.NewNRGBA(image.Rect(0, 0, LabelWidth, LabelHeight))
// Fill white background
draw.Draw(img, img.Bounds(), image.NewUniform(color.NRGBA{R: 255, G: 255, B: 255, A: 255}), image.Point{}, draw.Src)
// QR code
qr, err := qrcode.New(BaseURL+d.HWID, qrcode.Low)
if err != nil {
return nil, fmt.Errorf("labels: QR generation failed: %w", err)
}
qr.DisableBorder = true
qrImg := qr.Image(QRSize)
qrDst := image.Rect(QROffsetX, QROffsetY, QROffsetX+QRSize, QROffsetY+QRSize)
draw.Draw(img, qrDst, qrImg, image.Point{}, draw.Over)
// Text: HW ID (y=26), Name (y=60), SpecLine (y=92)
drawText(img, TextOffsetX, 26, d.HWID)
drawText(img, TextOffsetX, 60, truncate(d.Name, 30))
drawText(img, TextOffsetX, 92, truncate(d.SpecLine, 30))
return img, nil
}
// drawText renders text onto a draw.Image at (x, y) using basicfont.Face7x13.
func drawText(img draw.Image, x, y int, text string) {
d := &font.Drawer{
Dst: img,
Src: image.NewUniform(color.Black),
Face: basicfont.Face7x13,
Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)},
}
d.DrawString(text)
}
// truncate caps s at max runes, appending "…" if truncated.
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max-1]) + "…"
}

View file

@ -0,0 +1,133 @@
package labels
import (
"image/color"
"testing"
)
// TestRenderStandard_ImageDimensions verifies the output bitmap is 384x120.
func TestRenderStandard_ImageDimensions(t *testing.T) {
d := LabelData{
HWID: "HW-00001",
Name: "Test Device",
SpecLine: "Intel NUC i5 2019",
}
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != LabelWidth {
t.Errorf("expected width %d, got %d", LabelWidth, bounds.Dx())
}
if bounds.Dy() != LabelHeight {
t.Errorf("expected height %d, got %d", LabelHeight, bounds.Dy())
}
}
// TestRenderStandard_QRNonBlank checks that the QR region (top-left 96x96 area) has
// mixed pixels (not all one color), indicating a real QR code was drawn.
func TestRenderStandard_QRNonBlank(t *testing.T) {
d := LabelData{
HWID: "HW-00001",
Name: "Test Device",
}
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error: %v", err)
}
var blackCount, whiteCount int
for y := QROffsetY; y < QROffsetY+QRSize; y++ {
for x := QROffsetX; x < QROffsetX+QRSize; x++ {
r, g, b, _ := img.At(x, y).RGBA()
// RGBA returns 16-bit values; 0 = black, 65535 = white
if r == 0 && g == 0 && b == 0 {
blackCount++
} else if r == 65535 && g == 65535 && b == 65535 {
whiteCount++
}
}
}
if blackCount == 0 {
t.Error("QR region has no black pixels — QR code was not rendered")
}
if whiteCount == 0 {
t.Error("QR region has no white pixels — QR region is solid black, not a QR code")
}
}
// TestRenderStandard_QRURLEncoding verifies the QR encodes the correct URL by
// checking that generating a QR with the expected URL produces the same image as
// what is embedded in the rendered label (same pixel dimensions at the same size).
func TestRenderStandard_QRURLEncoding(t *testing.T) {
hwID := "HW-00001"
expectedURL := BaseURL + hwID
// Verify the URL is correct by construction
if expectedURL != "http://mac-mini.mg:8080/hw/HW-00001" {
t.Errorf("BaseURL construction incorrect, got: %s", expectedURL)
}
// Render the label
d := LabelData{HWID: hwID, Name: "Test Device"}
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error: %v", err)
}
// The QR block must exist within the label bounds
bounds := img.Bounds()
if QROffsetX+QRSize > bounds.Dx() || QROffsetY+QRSize > bounds.Dy() {
t.Errorf("QR code region (%d+%d x %d+%d) exceeds label bounds (%d x %d)",
QROffsetX, QRSize, QROffsetY, QRSize, bounds.Dx(), bounds.Dy())
}
}
// TestRenderStandard_EmptyNameFallback ensures a LabelData with empty Name does not
// panic and uses "Unknown Item" as the displayed value.
func TestRenderStandard_EmptyNameFallback(t *testing.T) {
d := LabelData{
HWID: "HW-00099",
Name: "", // intentionally empty
}
// Must not panic
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error on empty name: %v", err)
}
if img == nil {
t.Fatal("expected non-nil image, got nil")
}
}
// TestRenderStandard_WhiteBackground verifies the background canvas is initialized
// to white (pure white NRGBA).
func TestRenderStandard_WhiteBackground(t *testing.T) {
d := LabelData{
HWID: "HW-00001",
Name: "Test",
}
img, err := RenderStandard(d)
if err != nil {
t.Fatalf("RenderStandard returned error: %v", err)
}
// Sample a corner that should be outside any QR or text region
// Use bottom-right corner area far from QR and text
sampleX := LabelWidth - 5
sampleY := LabelHeight - 5
c := img.At(sampleX, sampleY)
nrgba, ok := c.(color.NRGBA)
if !ok {
// Fallback: check via RGBA values
r, g, b, a := c.RGBA()
if r != 65535 || g != 65535 || b != 65535 || a != 65535 {
t.Errorf("expected white background at (%d,%d), got RGBA(%d,%d,%d,%d)", sampleX, sampleY, r, g, b, a)
}
return
}
if nrgba.R != 255 || nrgba.G != 255 || nrgba.B != 255 || nrgba.A != 255 {
t.Errorf("expected white background at (%d,%d), got NRGBA(%d,%d,%d,%d)", sampleX, sampleY, nrgba.R, nrgba.G, nrgba.B, nrgba.A)
}
}