Files
OpsLog/internal/qslcard/template.go
T
2026-06-13 01:34:45 +02:00

341 lines
9.9 KiB
Go

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"`
Hidden bool `json:"hidden,omitempty"` // toggled off in the editor; kept in the template
// 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
}