qsl designer
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user