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.
195 lines
6.8 KiB
Go
195 lines
6.8 KiB
Go
// 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
|
||
}
|