Files
homebox/backend/pkgs/labelmaker/labelmaker.go

326 lines
9.0 KiB
Go

// Package labelmaker provides functionality for generating and printing labels for items, locations and assets stored in Homebox
package labelmaker
import (
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"time"
"github.com/golang/freetype"
"github.com/golang/freetype/truetype"
"github.com/skip2/go-qrcode"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/gomedium"
)
type GenerateParameters struct {
Width int
Height int
QrSize int
Margin int
ComponentPadding int
TitleText string
TitleFontSize float64
DescriptionText string
DescriptionFontSize float64
AdditionalInformation *string
Dpi float64
URL string
DynamicLength bool
}
func (p *GenerateParameters) Validate() error {
if p.Width <= 0 {
return fmt.Errorf("invalid width")
}
if p.Height <= 0 {
return fmt.Errorf("invalid height")
}
if p.Margin < 0 {
return fmt.Errorf("invalid margin")
}
if p.ComponentPadding < 0 {
return fmt.Errorf("invalid component padding")
}
return nil
}
func NewGenerateParams(width int, height int, margin int, padding int, fontSize float64, title string, description string, url string, dynamicLength bool, additionalInformation *string) GenerateParameters {
return GenerateParameters{
Width: width,
Height: height,
QrSize: height - (padding * 2),
Margin: margin,
ComponentPadding: padding,
TitleText: title,
DescriptionText: description,
TitleFontSize: fontSize,
DescriptionFontSize: fontSize * 0.8,
Dpi: 72,
URL: url,
AdditionalInformation: additionalInformation,
DynamicLength: dynamicLength,
}
}
func measureString(text string, face font.Face, ctx *freetype.Context) int {
width := 0
for _, r := range text {
awidth, _ := face.GlyphAdvance(r)
width += awidth.Round()
}
return ctx.PointToFixed(float64(width)).Round()
}
func wrapText(text string, face font.Face, maxWidth int, maxHeight int, lineHeight int, ctx *freetype.Context) ([]string, string) {
lines := strings.Split(text, "\n")
unlimitedHeight := maxHeight == -1
var wrappedLines []string
currentHeight := 0
processedChars := 0
for _, line := range lines {
words := strings.Fields(line)
if len(words) == 0 {
wrappedLines = append(wrappedLines, "")
processedChars += 1
if !unlimitedHeight {
currentHeight += lineHeight
if currentHeight > maxHeight {
return wrappedLines[:len(wrappedLines)-1], text[processedChars:]
}
}
continue
}
currentLine := words[0]
for _, word := range words[1:] {
testLine := currentLine + " " + word
width := measureString(testLine, face, ctx)
if width <= maxWidth {
currentLine = testLine
} else {
wrappedLines = append(wrappedLines, currentLine)
processedChars += len(currentLine) + 1
if !unlimitedHeight {
currentHeight += lineHeight
if currentHeight > maxHeight {
return wrappedLines[:len(wrappedLines)-1], text[processedChars-len(currentLine)-1:]
}
}
currentLine = word
}
}
wrappedLines = append(wrappedLines, currentLine)
processedChars += len(currentLine) + 1
if !unlimitedHeight {
currentHeight += lineHeight
if currentHeight > maxHeight {
return wrappedLines[:len(wrappedLines)-1], text[processedChars-len(currentLine)-1:]
}
}
}
return wrappedLines, ""
}
func GenerateLabel(w io.Writer, params *GenerateParameters) error {
if err := params.Validate(); err != nil {
return err
}
bodyText := params.DescriptionText
if params.AdditionalInformation != nil {
bodyText = bodyText + "\n" + *params.AdditionalInformation
}
// Create QR code
qr, err := qrcode.New(params.URL, qrcode.Medium)
if err != nil {
return err
}
qr.DisableBorder = true
qrImage := qr.Image(params.QrSize)
regularFont, err := truetype.Parse(gomedium.TTF)
if err != nil {
return err
}
boldFont, err := truetype.Parse(gobold.TTF)
if err != nil {
return err
}
regularFace := truetype.NewFace(regularFont, &truetype.Options{
Size: params.DescriptionFontSize,
DPI: params.Dpi,
})
boldFace := truetype.NewFace(boldFont, &truetype.Options{
Size: params.TitleFontSize,
DPI: params.Dpi,
})
// Calculate text area dimensions
maxWidth := params.Width - (params.Margin * 2) - params.ComponentPadding
// Create temporary contexts for text measurement
tmpImg := image.NewRGBA(image.Rect(0, 0, 1, 1))
boldContext := createContext(boldFont, params.TitleFontSize, tmpImg, params.Dpi)
regularContext := createContext(regularFont, params.DescriptionFontSize, tmpImg, params.Dpi)
// Calculate total height needed
totalHeight := params.Margin
titleLineSpacing := boldContext.PointToFixed(params.TitleFontSize).Round()
titleLines, _ := wrapText(params.TitleText, boldFace, maxWidth-params.QrSize, -1, titleLineSpacing, boldContext)
titleHeight := titleLineSpacing * len(titleLines)
totalHeight += titleHeight
totalHeight += params.ComponentPadding / 4
regularLineSpacing := regularContext.PointToFixed(params.DescriptionFontSize).Round()
descriptionLinesRight, descriptionRemaining := wrapText(bodyText, regularFace, maxWidth-params.QrSize, params.QrSize-titleHeight, regularLineSpacing, regularContext)
totalHeight += regularLineSpacing * len(descriptionLinesRight)
var textYBottomText int
var descriptionLinesBottom []string
hasBottomText := descriptionRemaining != ""
if hasBottomText {
totalHeight = max(params.Margin+params.QrSize+params.ComponentPadding/2, totalHeight)
textYBottomText = totalHeight
descriptionLinesBottom, _ = wrapText(descriptionRemaining, regularFace, maxWidth, -1, regularLineSpacing, regularContext)
totalHeight += regularLineSpacing * len(descriptionLinesBottom)
totalHeight += params.Margin
}
var requiredHeight int
if params.DynamicLength {
requiredHeight = max(totalHeight, params.QrSize+(params.Margin*2))
} else {
requiredHeight = params.Height
}
// Create the actual image with calculated height
bounds := image.Rect(0, 0, params.Width, requiredHeight)
img := image.NewRGBA(bounds)
draw.Draw(img, bounds, &image.Uniform{color.White}, image.Point{}, draw.Src)
// Draw QR code onto the image
draw.Draw(img,
image.Rect(params.Margin, params.Margin, params.QrSize+params.Margin, params.QrSize+params.Margin),
qrImage,
image.Point{},
draw.Over)
// Create final drawing contexts
boldContext = createContext(boldFont, params.TitleFontSize, img, params.Dpi)
regularContext = createContext(regularFont, params.DescriptionFontSize, img, params.Dpi)
textXRight := params.Margin + params.ComponentPadding + params.QrSize
textY := params.Margin - 8
// Draw title
for _, line := range titleLines {
pt := freetype.Pt(textXRight, textY+titleLineSpacing)
if _, err = boldContext.DrawString(line, pt); err != nil {
return err
}
textY += titleLineSpacing
}
// Draw description right from QR Code
textY += params.ComponentPadding / 4
for _, line := range descriptionLinesRight {
pt := freetype.Pt(textXRight, textY+regularLineSpacing)
if _, err = regularContext.DrawString(line, pt); err != nil {
return err
}
textY += regularLineSpacing
}
// Draw description below QR Code
if hasBottomText {
for _, line := range descriptionLinesBottom {
pt := freetype.Pt(params.Margin, textYBottomText+regularLineSpacing)
if _, err = regularContext.DrawString(line, pt); err != nil {
return err
}
textYBottomText += regularLineSpacing
}
}
return png.Encode(w, img)
}
// Helper function to create freetype context
func createContext(font *truetype.Font, size float64, img *image.RGBA, dpi float64) *freetype.Context {
c := freetype.NewContext()
c.SetDPI(dpi)
c.SetFont(font)
c.SetFontSize(size)
c.SetClip(img.Bounds())
c.SetDst(img)
c.SetSrc(image.NewUniform(color.Black))
return c
}
func PrintLabel(cfg *config.Config, params *GenerateParameters) error {
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("label-%d.png", time.Now().UnixNano()))
f, err := os.OpenFile(tmpFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
defer func() {
_ = f.Close()
if err := os.Remove(f.Name()); err != nil {
log.Printf("failed to remove temporary label file: %v", err)
}
}()
err = GenerateLabel(f, params)
if err != nil {
return err
}
if cfg.LabelMaker.PrintCommand == nil {
return fmt.Errorf("no print command specified")
}
commandTemplate := template.Must(template.New("command").Parse(*cfg.LabelMaker.PrintCommand))
builder := &strings.Builder{}
if err := commandTemplate.Execute(builder, map[string]string{
"FileName": f.Name(),
}); err != nil {
return err
}
commandParts := strings.Fields(builder.String())
if len(commandParts) == 0 {
return nil
}
command := exec.Command(commandParts[0], commandParts[1:]...)
_, err = command.CombinedOutput()
if err != nil {
return err
}
return nil
}