367 lines
11 KiB
Go
367 lines
11 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"`
|
|
Fx *FxParams `json:"fx,omitempty"`
|
|
}
|
|
|
|
// FxParams tunes the canvas call renderer (glossy bubble / western 3D). All
|
|
// optional pointers — nil falls back to the renderer's per-preset default.
|
|
// Persisted in the template so per-call tweaks survive a round-trip; the values
|
|
// are consumed entirely by the frontend (textFx.ts).
|
|
type FxParams struct {
|
|
Plump *float64 `json:"plump,omitempty"`
|
|
Edge *float64 `json:"edge,omitempty"`
|
|
OuterW *float64 `json:"outerw,omitempty"`
|
|
Gloss *float64 `json:"gloss,omitempty"`
|
|
GlossH *float64 `json:"gloss_h,omitempty"`
|
|
GlossI *float64 `json:"gloss_i,omitempty"`
|
|
InnerB *float64 `json:"inner_b,omitempty"`
|
|
Depth *float64 `json:"depth,omitempty"`
|
|
Angle *float64 `json:"angle,omitempty"`
|
|
Slant *float64 `json:"slant,omitempty"`
|
|
Grunge *float64 `json:"grunge,omitempty"`
|
|
Bevel *float64 `json:"bevel,omitempty"`
|
|
Seed *float64 `json:"seed,omitempty"`
|
|
Dark string `json:"dark,omitempty"` // dark inter-letter edge (per colour palette)
|
|
Outer string `json:"outer,omitempty"` // silver/rim colour (glossy)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if p.Fx != nil {
|
|
keys = append(keys, "fx")
|
|
}
|
|
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
|
|
}
|