// 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 ), 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 }