package qslcard import ( "bytes" "encoding/json" "fmt" "regexp" "strings" ) // SchemaVersion is the template document version this package reads/writes. const SchemaVersion = 1 // Card is the physical card geometry. All element coordinates live in this // pixel space; the editor and renderer scale uniformly for display. type Card struct { W int `json:"w"` H int `json:"h"` DPI int `json:"dpi"` BleedPx int `json:"bleed_px"` } // Crop is a rectangle in source-photo pixel space. type Crop struct { X int `json:"x"` Y int `json:"y"` W int `json:"w"` H int `json:"h"` } // Scrim is an optional darkening overlay on the hero photo that improves // text legibility on busy or bright photos. type Scrim struct { Enabled bool `json:"enabled"` Color string `json:"color"` Opacity float64 `json:"opacity"` } // Hero is the full-bleed background photo of the card. type Hero struct { Photo string `json:"photo"` Crop Crop `json:"crop"` Scrim *Scrim `json:"scrim,omitempty"` } // Shine controls the wavy gloss band of the gel presets. type Shine struct { Coverage float64 `json:"coverage"` Opacity float64 `json:"opacity"` } // Halo is the soft blurred glow behind the text stack. type Halo struct { Color string `json:"color"` Blur float64 `json:"blur"` Opacity float64 `json:"opacity"` } // ShadowFx is the offset drop shadow of a text stack. type ShadowFx struct { Dx float64 `json:"dx"` Dy float64 `json:"dy"` Blur float64 `json:"blur"` Color string `json:"color"` Opacity float64 `json:"opacity"` } // Bevel is the dark/light offset pair that fakes an extruded edge. type Bevel struct { Dx float64 `json:"dx"` Dy float64 `json:"dy"` Dark string `json:"dark"` Light string `json:"light"` } // StyleParams are the tunable knobs of a style preset. Which keys a preset // accepts is declared in presets.go; Validate rejects params a preset does // not understand so typos surface in the UI instead of silently dropping. type StyleParams struct { Gradient []string `json:"gradient,omitempty"` Shine *Shine `json:"shine,omitempty"` OutlineColor string `json:"outline_color,omitempty"` OutlineWidth float64 `json:"outline_width,omitempty"` Halo *Halo `json:"halo,omitempty"` Shadow *ShadowFx `json:"shadow,omitempty"` BevelOffset *Bevel `json:"bevel_offset,omitempty"` Color string `json:"color,omitempty"` } // setKeys lists the JSON names of the params that are actually set, for // per-preset whitelisting. func (p *StyleParams) setKeys() []string { if p == nil { return nil } var keys []string if len(p.Gradient) > 0 { keys = append(keys, "gradient") } if p.Shine != nil { keys = append(keys, "shine") } if p.OutlineColor != "" { keys = append(keys, "outline_color") } if p.OutlineWidth != 0 { keys = append(keys, "outline_width") } if p.Halo != nil { keys = append(keys, "halo") } if p.Shadow != nil { keys = append(keys, "shadow") } if p.BevelOffset != nil { keys = append(keys, "bevel_offset") } if p.Color != "" { keys = append(keys, "color") } return keys } // Element types. const ( ElemCallsign = "callsign" ElemOperator = "operator" ElemInfoLine = "info_line" ElemCountry = "country" ElemInsert = "insert" ) // Element is one item placed on the card. The Type discriminator decides // which fields are meaningful (a single struct keeps the JSON and the TS // mirror simple; validation enforces per-type requirements). type Element struct { Type string `json:"type"` X float64 `json:"x"` Y float64 `json:"y"` Rotate float64 `json:"rotate,omitempty"` // Text elements (callsign / operator / info_line). Text string `json:"text,omitempty"` Font string `json:"font,omitempty"` Size float64 `json:"size,omitempty"` StylePreset string `json:"style_preset,omitempty"` StyleParams *StyleParams `json:"style_params,omitempty"` // Country block. "auto" derives flag and label from the active profile // callsign via internal/dxcc at render time. Flag string `json:"flag,omitempty"` Label string `json:"label,omitempty"` // Photo insert. Photo string `json:"photo,omitempty"` W float64 `json:"w,omitempty"` BorderPx int `json:"border_px,omitempty"` Shadow bool `json:"shadow,omitempty"` } // QSOBox is the white confirmation box filled per QSO at render time. type QSOBox struct { Enabled bool `json:"enabled"` X float64 `json:"x"` Y float64 `json:"y"` W float64 `json:"w"` H float64 `json:"h"` BG string `json:"bg"` BGOpacity float64 `json:"bg_opacity"` Radius float64 `json:"radius"` Title string `json:"title"` Fields []string `json:"fields"` Footer string `json:"footer"` } // Template is one full card design (schema v1, section 3 of the spec). type Template struct { Schema int `json:"schema"` Name string `json:"name"` Card Card `json:"card"` Hero Hero `json:"hero"` Elements []Element `json:"elements"` QSOBox *QSOBox `json:"qso_box,omitempty"` } // Parse decodes a template document, rejecting unknown JSON keys so schema // drift is caught immediately rather than silently ignored. func Parse(data []byte) (Template, error) { var t Template dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(&t); err != nil { return t, fmt.Errorf("parse template: %w", err) } return t, nil } // Encode serializes a template to its canonical JSON form. func Encode(t Template) ([]byte, error) { b, err := json.Marshal(t) if err != nil { return nil, fmt.Errorf("encode template: %w", err) } return b, nil } // qsoBoxFields are the QSO fields the renderer knows how to display. var qsoBoxFields = map[string]bool{ "qso_date": true, "time_on": true, "band": true, "mode": true, "rst_sent": true, "freq": true, "submode": true, } // placeholderRe matches {namespace.key} placeholders in text fields. var placeholderRe = regexp.MustCompile(`\{([a-zA-Z_]+)\.([a-zA-Z_0-9]+)\}`) // placeholderNamespaces are the only namespaces a template may reference. var placeholderNamespaces = map[string]bool{"profile": true, "qso": true} // Validate checks a template for structural problems. photoExists reports // whether a photo referenced by the template is available (nil skips the // check, e.g. while a draft is being edited). Errors are descriptive — they // surface verbatim in the designer UI. func Validate(t Template, photoExists func(name string) bool) error { if t.Schema != SchemaVersion { return fmt.Errorf("unsupported schema version %d (expected %d)", t.Schema, SchemaVersion) } if t.Card.W < 100 || t.Card.H < 100 || t.Card.W > 10000 || t.Card.H > 10000 { return fmt.Errorf("card size %dx%d out of range", t.Card.W, t.Card.H) } if t.Card.DPI <= 0 { return fmt.Errorf("card dpi must be positive") } if t.Card.BleedPx < 0 { return fmt.Errorf("card bleed_px must not be negative") } if t.Hero.Photo == "" { return fmt.Errorf("hero photo missing") } if t.Hero.Crop.W <= 0 || t.Hero.Crop.H <= 0 { return fmt.Errorf("hero crop has empty size") } if photoExists != nil && !photoExists(t.Hero.Photo) { return fmt.Errorf("hero photo file %q not found", t.Hero.Photo) } for i := range t.Elements { if err := validateElement(t.Card, t.Elements[i], photoExists); err != nil { return fmt.Errorf("element %d (%s): %w", i, t.Elements[i].Type, err) } } if b := t.QSOBox; b != nil && b.Enabled { if !inBounds(t.Card, b.X, b.Y) || !inBounds(t.Card, b.X+b.W, b.Y+b.H) { return fmt.Errorf("qso box (%g,%g %gx%g) falls outside the card", b.X, b.Y, b.W, b.H) } for _, f := range b.Fields { if !qsoBoxFields[f] { return fmt.Errorf("unknown qso box field %q", f) } } if err := validatePlaceholders(b.Title); err != nil { return err } if err := validatePlaceholders(b.Footer); err != nil { return err } } return nil } func validateElement(c Card, e Element, photoExists func(string) bool) error { switch e.Type { case ElemCallsign, ElemOperator, ElemInfoLine: if strings.TrimSpace(e.Text) == "" { return fmt.Errorf("text is empty") } if e.Font == "" { return fmt.Errorf("font is empty") } if e.Size <= 0 { return fmt.Errorf("size must be positive") } if _, ok := Presets[e.StylePreset]; !ok { return fmt.Errorf("unknown style_preset %q", e.StylePreset) } if err := validateStyleParams(e.StylePreset, e.StyleParams); err != nil { return err } if err := validatePlaceholders(e.Text); err != nil { return err } case ElemCountry: if e.Size <= 0 { return fmt.Errorf("size must be positive") } case ElemInsert: if e.Photo == "" { return fmt.Errorf("photo is empty") } if e.W <= 0 { return fmt.Errorf("width must be positive") } if photoExists != nil && !photoExists(e.Photo) { return fmt.Errorf("photo file %q not found", e.Photo) } if !inBounds(c, e.X+e.W, e.Y) { return fmt.Errorf("insert extends outside the card") } default: return fmt.Errorf("unknown element type %q", e.Type) } if !inBounds(c, e.X, e.Y) { return fmt.Errorf("position (%g,%g) falls outside the card", e.X, e.Y) } return nil } // inBounds allows the bleed margin around the trimmed card. func inBounds(c Card, x, y float64) bool { m := float64(c.BleedPx) return x >= -m && y >= -m && x <= float64(c.W)+m && y <= float64(c.H)+m } func validateStyleParams(preset string, p *StyleParams) error { allowed := Presets[preset].AllowedParams for _, k := range p.setKeys() { if !allowed[k] { return fmt.Errorf("style param %q not accepted by preset %q", k, preset) } } return nil } func validatePlaceholders(text string) error { for _, m := range placeholderRe.FindAllStringSubmatch(text, -1) { if !placeholderNamespaces[m[1]] { return fmt.Errorf("unknown placeholder namespace %q in %q", m[1], m[0]) } } return nil }