chore(labels): park the label designer and printing on feature/labels
The feature needs more rounds than the next release can wait for, so main goes back to before it: the packages, bindings, migration, UI and i18n all move to the feature/labels branch, which holds every commit. The 0.26.23 block keeps only the Station column. The 0031 migration may already have run on a machine that launched a dev build; the two label tables it created are inert and the recorded migration row is harmless — the runner only applies filenames it has, so re-adding the migration when the branch merges will skip cleanly there and apply everywhere else.
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
-- Label designer: printable labels for paper QSL work.
|
||||
--
|
||||
-- Two tables because the two things have different lifetimes. A STOCK is the
|
||||
-- physical roll in the printer (width, height, margins) — one per label size,
|
||||
-- shared by every design printed on it. A TEMPLATE is one design (what goes on
|
||||
-- the label) and points at the stock it was drawn for. Deleting a design must
|
||||
-- never take the roll definition of the other designs with it.
|
||||
--
|
||||
-- kind separates the two families the operator designs: 'qso' (the label glued
|
||||
-- on the QSL card, with its repeating QSO table) and 'address' (destination or
|
||||
-- return address). is_default is per (kind, profile scope) — printing wants
|
||||
-- "the QSO label" and "the address label" without asking every time.
|
||||
CREATE TABLE label_stocks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE TABLE label_templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
profile_id INTEGER REFERENCES station_profiles(id) ON DELETE SET NULL,
|
||||
stock_id INTEGER REFERENCES label_stocks(id) ON DELETE SET NULL,
|
||||
json TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
@@ -51,8 +51,6 @@ var settingsTables = []string{
|
||||
"operating_stations_new",
|
||||
"award_references",
|
||||
"qsl_templates",
|
||||
"label_stocks",
|
||||
"label_templates",
|
||||
"cluster_servers",
|
||||
"integrations_udp",
|
||||
"callsign_cache",
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package labels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Record is one stored template row; JSON holds the Template document.
|
||||
type Record struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
ProfileID *int64 `json:"profile_id,omitempty"`
|
||||
StockID *int64 `json:"stock_id,omitempty"`
|
||||
JSON string `json:"json"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Repo accesses the label_stocks and label_templates tables. Same shape as the
|
||||
// QSL template repo it is modelled on — the label designer is that feature's
|
||||
// smaller sibling and the storage questions were settled there.
|
||||
type Repo struct{ db *sql.DB }
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
// ── stocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Stocks lists every stored label stock, oldest first (the seeded Brother rolls
|
||||
// keep their familiar order at the top).
|
||||
func (r *Repo) Stocks(ctx context.Context) ([]Stock, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id, json FROM label_stocks ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Stock
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var doc string
|
||||
if err := rows.Scan(&id, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s Stock
|
||||
if err := parseStock(doc, &s); err != nil {
|
||||
continue // one corrupt row must not hide the rest
|
||||
}
|
||||
s.ID = id
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SaveStock upserts one stock (ID 0 creates) and writes the id back.
|
||||
func (r *Repo) SaveStock(ctx context.Context, s *Stock) error {
|
||||
if err := ValidStock(*s); err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := encodeStock(*s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if s.ID == 0 {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO label_stocks (name, json, created_at, updated_at) VALUES (?,?,?,?)`,
|
||||
s.Name, doc, now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert stock: %w", err)
|
||||
}
|
||||
s.ID, _ = res.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
`UPDATE label_stocks SET name = ?, json = ?, updated_at = ? WHERE id = ?`,
|
||||
s.Name, doc, now, s.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteStock removes a stock. Templates pointing at it keep their design and
|
||||
// fall back to "pick a stock" in the editor (the FK nulls the reference).
|
||||
func (r *Repo) DeleteStock(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM label_stocks WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SeedStocks inserts the builtin rolls when the table is empty — first run, or
|
||||
// an operator who deleted everything and wants the presets back gets them by
|
||||
// emptying the table.
|
||||
func (r *Repo) SeedStocks(ctx context.Context) error {
|
||||
var n int
|
||||
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM label_stocks`).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
for _, s := range BuiltinStocks() {
|
||||
st := s
|
||||
if err := r.SaveStock(ctx, &st); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── templates ───────────────────────────────────────────────────────────
|
||||
|
||||
const tplCols = `id, name, kind, profile_id, stock_id, json, is_default, updated_at`
|
||||
|
||||
// ListFor returns the templates visible to a profile (its own plus shared),
|
||||
// defaults first.
|
||||
func (r *Repo) ListFor(ctx context.Context, profileID int64) ([]Record, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT `+tplCols+` FROM label_templates
|
||||
WHERE profile_id = ? OR profile_id IS NULL
|
||||
ORDER BY is_default DESC, id DESC`, profileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanRecords(rows)
|
||||
}
|
||||
|
||||
// List returns every template (no active profile yet).
|
||||
func (r *Repo) List(ctx context.Context) ([]Record, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+tplCols+` FROM label_templates ORDER BY is_default DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanRecords(rows)
|
||||
}
|
||||
|
||||
// Get returns one template.
|
||||
func (r *Repo) Get(ctx context.Context, id int64) (Record, error) {
|
||||
row := r.db.QueryRowContext(ctx, `SELECT `+tplCols+` FROM label_templates WHERE id = ?`, id)
|
||||
return scanRecord(row)
|
||||
}
|
||||
|
||||
// Save upserts a template (ID 0 creates); the id is written back.
|
||||
func (r *Repo) Save(ctx context.Context, rec *Record) error {
|
||||
if rec.Name == "" {
|
||||
return fmt.Errorf("template name required")
|
||||
}
|
||||
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if rec.ID == 0 {
|
||||
res, err := r.db.ExecContext(ctx, `INSERT INTO label_templates
|
||||
(name, kind, profile_id, stock_id, json, is_default, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`,
|
||||
rec.Name, rec.Kind, nullID(rec.ProfileID), nullID(rec.StockID), rec.JSON,
|
||||
boolInt(rec.IsDefault), now, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert label template: %w", err)
|
||||
}
|
||||
rec.ID, _ = res.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE label_templates
|
||||
SET name = ?, kind = ?, profile_id = ?, stock_id = ?, json = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
rec.Name, rec.Kind, nullID(rec.ProfileID), nullID(rec.StockID), rec.JSON, now, rec.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes a template.
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM label_templates WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDefault marks one template as the default FOR ITS KIND within its profile
|
||||
// scope: printing asks for "the QSO label" and "the address label" separately,
|
||||
// so the two defaults must not compete.
|
||||
func (r *Repo) SetDefault(ctx context.Context, id int64) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
var kind string
|
||||
var profileID sql.NullInt64
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT kind, profile_id FROM label_templates WHERE id = ?`, id).Scan(&kind, &profileID); err != nil {
|
||||
return err
|
||||
}
|
||||
if profileID.Valid {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE label_templates SET is_default = 0
|
||||
WHERE kind = ? AND (profile_id = ? OR profile_id IS NULL)`, kind, profileID.Int64)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE label_templates SET is_default = 0 WHERE kind = ?`, kind)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE label_templates SET is_default = 1 WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ── scanning helpers ────────────────────────────────────────────────────
|
||||
|
||||
type rowScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanRecord(row rowScanner) (Record, error) {
|
||||
var rec Record
|
||||
var pid, sid sql.NullInt64
|
||||
var def int
|
||||
var updated string
|
||||
if err := row.Scan(&rec.ID, &rec.Name, &rec.Kind, &pid, &sid, &rec.JSON, &def, &updated); err != nil {
|
||||
return rec, err
|
||||
}
|
||||
if pid.Valid {
|
||||
v := pid.Int64
|
||||
rec.ProfileID = &v
|
||||
}
|
||||
if sid.Valid {
|
||||
v := sid.Int64
|
||||
rec.StockID = &v
|
||||
}
|
||||
rec.IsDefault = def != 0
|
||||
rec.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func scanRecords(rows *sql.Rows) ([]Record, error) {
|
||||
defer rows.Close()
|
||||
var out []Record
|
||||
for rows.Next() {
|
||||
rec, err := scanRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func nullID(p *int64) any {
|
||||
if p == nil || *p == 0 {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package labels
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// The stock row stores its geometry as JSON so adding a field never needs a
|
||||
// migration; the name is duplicated into its own column for listing.
|
||||
func parseStock(doc string, s *Stock) error { return json.Unmarshal([]byte(doc), s) }
|
||||
|
||||
func encodeStock(s Stock) (string, error) {
|
||||
s.ID = 0 // the row id is authoritative; never persist a stale copy inside the blob
|
||||
b, err := json.Marshal(s)
|
||||
return string(b), err
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// Package pdf writes the one kind of PDF the label printer needs: a document
|
||||
// whose every page is a single full-bleed image at an exact physical size.
|
||||
//
|
||||
// Written by hand rather than through a library for two reasons. The build is
|
||||
// pure Go with no room for cgo, and the need is tiny: the pages arrive as
|
||||
// PNGs rasterised by the SAME canvas renderer the designer's preview uses, so
|
||||
// this file only has to carry pixels to paper without touching them. Fonts,
|
||||
// vectors, compression profiles — all already decided upstream.
|
||||
//
|
||||
// The images are stored as 8-bit DeviceGray with FlateDecode: labels are
|
||||
// monochrome, grey keeps antialiased text edges smooth on a 300 dpi thermal
|
||||
// head, and flate is lossless — JPEG artefacts around small print are exactly
|
||||
// what a QSL label cannot afford.
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"fmt"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
const mmToPt = 72.0 / 25.4
|
||||
|
||||
// Doc accumulates pages; Bytes() renders the file.
|
||||
type Doc struct {
|
||||
pages []pageData
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
wPt, hPt float64
|
||||
imgW int
|
||||
imgH int
|
||||
gray []byte // zlib-compressed 8-bit samples
|
||||
}
|
||||
|
||||
// AddImagePage appends one page of wMm×hMm entirely covered by the PNG.
|
||||
// The PNG's aspect ratio is not checked against the page's: the caller
|
||||
// rasterised it AT this size, and a mismatch would be its bug to see.
|
||||
func (d *Doc) AddImagePage(pngBytes []byte, wMm, hMm float64) error {
|
||||
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("page image: %w", err)
|
||||
}
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= 0 || h <= 0 {
|
||||
return fmt.Errorf("page image is empty")
|
||||
}
|
||||
// To 8-bit grey. Luminance weights, not an average: blue text on a designer
|
||||
// screen should darken the way a photocopier would darken it.
|
||||
gray := make([]byte, w*h)
|
||||
i := 0
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bb, _ := img.At(x, y).RGBA()
|
||||
gray[i] = byte((299*r + 587*g + 114*bb) / 1000 >> 8)
|
||||
i++
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := zlib.NewWriter(&buf)
|
||||
if _, err := zw.Write(gray); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
d.pages = append(d.pages, pageData{
|
||||
wPt: wMm * mmToPt, hPt: hMm * mmToPt,
|
||||
imgW: w, imgH: h, gray: buf.Bytes(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bytes renders the whole document.
|
||||
func (d *Doc) Bytes() ([]byte, error) {
|
||||
if len(d.pages) == 0 {
|
||||
return nil, fmt.Errorf("no pages")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
offsets := []int{0} // object 0 is the free-list head
|
||||
obj := func(body func()) int {
|
||||
offsets = append(offsets, out.Len())
|
||||
n := len(offsets) - 1
|
||||
fmt.Fprintf(&out, "%d 0 obj\n", n)
|
||||
body()
|
||||
out.WriteString("endobj\n")
|
||||
return n
|
||||
}
|
||||
|
||||
out.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
|
||||
|
||||
// Objects 1 (catalog) and 2 (pages) reference their children by number, so
|
||||
// the numbering is laid out first: 3 objects per page after the two roots.
|
||||
nPages := len(d.pages)
|
||||
pageObj := func(i int) int { return 3 + i*3 }
|
||||
|
||||
obj(func() { out.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") }) // 1
|
||||
obj(func() { // 2
|
||||
out.WriteString("<< /Type /Pages /Kids [")
|
||||
for i := 0; i < nPages; i++ {
|
||||
fmt.Fprintf(&out, "%d 0 R ", pageObj(i))
|
||||
}
|
||||
fmt.Fprintf(&out, "] /Count %d >>\n", nPages)
|
||||
})
|
||||
for i, p := range d.pages {
|
||||
content := fmt.Sprintf("q %.4f 0 0 %.4f 0 0 cm /Im0 Do Q", p.wPt, p.hPt)
|
||||
obj(func() { // page
|
||||
fmt.Fprintf(&out, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.4f %.4f] /Contents %d 0 R /Resources << /XObject << /Im0 %d 0 R >> >> >>\n",
|
||||
p.wPt, p.hPt, pageObj(i)+1, pageObj(i)+2)
|
||||
})
|
||||
obj(func() { // contents
|
||||
fmt.Fprintf(&out, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
|
||||
})
|
||||
obj(func() { // image
|
||||
fmt.Fprintf(&out, "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>\nstream\n",
|
||||
p.imgW, p.imgH, len(p.gray))
|
||||
out.Write(p.gray)
|
||||
out.WriteString("\nendstream\n")
|
||||
})
|
||||
}
|
||||
|
||||
xref := out.Len()
|
||||
fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
|
||||
for _, off := range offsets[1:] {
|
||||
fmt.Fprintf(&out, "%010d 00000 n \n", off)
|
||||
}
|
||||
fmt.Fprintf(&out, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, h/2, color.Black)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestDocShape(t *testing.T) {
|
||||
var d Doc
|
||||
// A 90×29 mm label at 300 dpi is 1063×343 px.
|
||||
if err := d.AddImagePage(testPNG(t, 1063, 343), 90, 29); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.AddImagePage(testPNG(t, 1063, 343), 90, 29); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := d.Bytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Not a PDF parser — the shape a viewer needs to find its way in.
|
||||
for _, want := range []string{"%PDF-1.4", "/Count 2", "/DeviceGray", "startxref", "%%EOF"} {
|
||||
if !bytes.Contains(b, []byte(want)) {
|
||||
t.Errorf("missing %q in output", want)
|
||||
}
|
||||
}
|
||||
// 90 mm = 255.118 pt — the page size a driver prints 1:1 on the roll.
|
||||
if !bytes.Contains(b, []byte("/MediaBox [0 0 255.1181 82.2047]")) {
|
||||
t.Errorf("media box is not the label size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyDocRefused(t *testing.T) {
|
||||
var d Doc
|
||||
if _, err := d.Bytes(); err == nil {
|
||||
t.Fatal("an empty document should refuse to render")
|
||||
}
|
||||
}
|
||||
+2
-11
@@ -232,11 +232,8 @@ type ListFilter struct {
|
||||
Band string `json:"band,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
StationCallsign string `json:"station_callsign,omitempty"`
|
||||
// QSLSentIn keeps only rows whose paper-QSL sent status is one of these
|
||||
// values — 'R' (requested) and 'Q' (queued) are the label printer's queue.
|
||||
QSLSentIn []string `json:"qsl_sent_in,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
}
|
||||
|
||||
// Repo accesses the qso table.
|
||||
@@ -1214,12 +1211,6 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
|
||||
q += " AND station_callsign = ?"
|
||||
args = append(args, f.StationCallsign)
|
||||
}
|
||||
if len(f.QSLSentIn) > 0 {
|
||||
q += " AND qsl_sent IN (?" + strings.Repeat(",?", len(f.QSLSentIn)-1) + ")"
|
||||
for _, v := range f.QSLSentIn {
|
||||
args = append(args, v)
|
||||
}
|
||||
}
|
||||
q += " ORDER BY qso_date DESC, id DESC"
|
||||
if f.Limit <= 0 {
|
||||
f.Limit = 500
|
||||
|
||||
Reference in New Issue
Block a user