homelabby/internal/labels/renderer.go
Mikkel Georgsen 8cbd840cba 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
2026-04-10 06:44:11 +00:00

87 lines
2.5 KiB
Go

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]) + "…"
}