Files
OpsLog/internal/labels/labels.go
T
rouggy 1a169fdb4f feat(labels): the Label Designer — QSO and address labels for paper QSL
The designer's data model lives in internal/labels: STOCKS are the physical
roll in the printer (geometry in mm, margins, dpi — seeded with the common
Brother DK sizes, the QL family being what prompted the feature), TEMPLATES are
one design each, of two kinds: the QSO label glued on the card, whose repeating
table carries several contacts of the same station, and the address label for
the envelope, whose lines collapse when a variable is empty.

Everything is measured in millimetres — labels are sold in mm, and an operator
lining a design up against a physical sticker thinks in mm; pixels exist only in
the renderer, at the stock's dpi. The canvas renderer is shared between the
editor's preview and the future print path, so there is no second
implementation for the preview to disagree with.

The preview is fed with the log's latest contacts rather than lorem ipsum: real
data shows a too-narrow column immediately.

Printing (PDF, one page per label at exact size) is the next module; nothing
here prints yet.
2026-08-28 19:41:15 +02:00

195 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package labels holds the label designer's data model: the printable labels
// an operator sticks on a QSL card (the QSO table) or an envelope (addresses).
//
// Everything is measured in MILLIMETRES. Label stock is sold in mm (a Brother
// DK-11201 is 29×90), printer margins are quoted in mm, and an operator lining
// a design up against a physical label thinks in mm — pixels only exist at
// render time, where the frontend rasterises at the stock's dpi. Storing mm
// keeps a template meaningful if it is ever printed at another resolution.
//
// The document is deliberately much simpler than the QSL card designer's: a
// label is monochrome text on a small sticker, so there are no photos, no
// effects, no presets — four element types and a geometry.
package labels
import (
"encoding/json"
"fmt"
"strings"
)
// Stock is one physical label size — the roll in the printer. Designs point at
// a stock rather than embedding the geometry so that changing "my printer's
// margins are actually 2 mm" fixes every design at once.
type Stock struct {
ID int64 `json:"id,omitempty"`
Name string `json:"name"`
WMm float64 `json:"w_mm"`
HMm float64 `json:"h_mm"`
// Margins are the unprintable border, in mm from each edge.
MarginTop float64 `json:"margin_top_mm"`
MarginRight float64 `json:"margin_right_mm"`
MarginBottom float64 `json:"margin_bottom_mm"`
MarginLeft float64 `json:"margin_left_mm"`
DPI int `json:"dpi"`
}
// BuiltinStocks are the label sizes seeded on first run — the common Brother DK
// rolls (the QL family is what prompted the feature) plus a 62 mm continuous
// strip. Ordinary rows once seeded: an operator with different margins edits
// them like any stock.
func BuiltinStocks() []Stock {
m := func(name string, w, h float64) Stock {
return Stock{Name: name, WMm: w, HMm: h,
MarginTop: 1.5, MarginRight: 3, MarginBottom: 1.5, MarginLeft: 3, DPI: 300}
}
return []Stock{
m("Brother DK-11201 · 29×90 mm (address)", 90, 29),
m("Brother DK-11202 · 62×100 mm (shipping)", 100, 62),
m("Brother DK-11208 · 38×90 mm (large address)", 90, 38),
m("Brother DK-22205 · 62 mm continuous (cut 100 mm)", 100, 62),
}
}
// Element is one thing drawn on the label. Type selects which fields matter:
//
// text X/Y/W, Text (with <VARIABLES>), Size, Bold, Align
// line X/Y/W, Thickness — a horizontal rule
// qso_table X/Y/W, Columns, RowsMax, RowH, Header — the repeating QSO block
// addr_block X/Y, Lines, Size, Bold, LineGap — address lines, blanks collapsed
//
// One struct with optional fields rather than a type per element: the document
// crosses the Wails boundary as JSON and the frontend edits it in place, and a
// closed union would buy safety here at the price of a parallel hierarchy on
// both sides of that boundary.
type Element struct {
Type string `json:"type"`
XMm float64 `json:"x_mm"`
YMm float64 `json:"y_mm"`
WMm float64 `json:"w_mm,omitempty"`
// text
Text string `json:"text,omitempty"`
SizePt float64 `json:"size_pt,omitempty"`
Bold bool `json:"bold,omitempty"`
Italic bool `json:"italic,omitempty"`
Align string `json:"align,omitempty"` // left | center | right
// line
ThicknessMm float64 `json:"thickness_mm,omitempty"`
// qso_table
Columns []Column `json:"columns,omitempty"`
RowsMax int `json:"rows_max,omitempty"`
RowHMm float64 `json:"row_h_mm,omitempty"`
Header bool `json:"header,omitempty"`
// addr_block
Lines []string `json:"lines,omitempty"`
LineGap float64 `json:"line_gap_mm,omitempty"`
}
// Column is one column of the QSO table. Field names the QSO field (the same
// lower-case keys the grids use: qso_date, time_on, band, freq, mode, rst_sent,
// rst_rcvd, …); Label is the printed header.
type Column struct {
Field string `json:"field"`
Label string `json:"label"`
WMm float64 `json:"w_mm"`
Align string `json:"align,omitempty"`
}
// Template is one label design.
type Template struct {
Version int `json:"version"`
Kind string `json:"kind"` // qso | address
Name string `json:"name,omitempty"`
StockID int64 `json:"stock_id"`
FontName string `json:"font,omitempty"` // one face for the whole label; "" = the renderer's default
Elements []Element `json:"elements"`
}
// Parse decodes a template document.
func Parse(doc []byte) (Template, error) {
var t Template
if err := json.Unmarshal(doc, &t); err != nil {
return t, fmt.Errorf("label template: %w", err)
}
return t, nil
}
// Encode is the inverse of Parse.
func Encode(t Template) ([]byte, error) { return json.MarshalIndent(t, "", " ") }
// Validate rejects a document that could not be rendered or printed sensibly.
// Geometry beyond the stock is NOT an error — the editor lets an element be
// dragged around freely and clips at render time — but nonsense that would make
// rendering undefined (unknown types, absurd sizes) is refused at save.
func Validate(t Template) error {
if t.Version != 1 {
return fmt.Errorf("unsupported label template version %d", t.Version)
}
if t.Kind != "qso" && t.Kind != "address" {
return fmt.Errorf("unknown label kind %q", t.Kind)
}
if len(t.Elements) == 0 {
return fmt.Errorf("the label has no elements")
}
if len(t.Elements) > 64 {
return fmt.Errorf("too many elements (%d)", len(t.Elements))
}
for i, e := range t.Elements {
switch e.Type {
case "text":
if strings.TrimSpace(e.Text) == "" {
return fmt.Errorf("element %d: empty text", i+1)
}
case "line":
if e.WMm <= 0 {
return fmt.Errorf("element %d: a line needs a width", i+1)
}
case "qso_table":
if len(e.Columns) == 0 {
return fmt.Errorf("element %d: the QSO table has no columns", i+1)
}
if e.RowsMax < 1 || e.RowsMax > 20 {
return fmt.Errorf("element %d: rows must be 1-20", i+1)
}
for _, c := range e.Columns {
if strings.TrimSpace(c.Field) == "" || c.WMm <= 0 {
return fmt.Errorf("element %d: every column needs a field and a width", i+1)
}
}
case "addr_block":
if len(e.Lines) == 0 {
return fmt.Errorf("element %d: the address block has no lines", i+1)
}
default:
return fmt.Errorf("element %d: unknown type %q", i+1, e.Type)
}
if e.SizePt < 0 || e.SizePt > 72 {
return fmt.Errorf("element %d: font size out of range", i+1)
}
}
return nil
}
// ValidStock rejects geometry no label printer produces.
func ValidStock(s Stock) error {
if strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("the stock needs a name")
}
if s.WMm < 10 || s.WMm > 300 || s.HMm < 6 || s.HMm > 300 {
return fmt.Errorf("label size out of range (10-300 mm wide, 6-300 mm high)")
}
for _, m := range []float64{s.MarginTop, s.MarginRight, s.MarginBottom, s.MarginLeft} {
if m < 0 || m*2 >= s.HMm || m*2 >= s.WMm {
return fmt.Errorf("margins leave no printable area")
}
}
if s.DPI != 0 && (s.DPI < 72 || s.DPI > 1200) {
return fmt.Errorf("dpi out of range")
}
return nil
}