// CardPreview — the single SVG rendering implementation for the QSL card. // Proposals, the live editor, thumbnails and final rasterization ALL go // through this component so the output can never drift from the preview. // Style presets are layer-stack recipes (see internal/qslcard/presets.go and // section 4 of the design spec); the gel stack renders the text 7 times, // back to front: halo, drop shadow, outline, bevel base, bevel light edge, // gradient face, wavy gloss band clipped to the glyphs. import { useEffect, useMemo, useRef, useState } from 'react'; import type { RenderModel, CardElement, StyleParams, QSOBox } from './qslTypes'; import { QSO_FIELD_LABELS } from './qslTypes'; import type { CardAssets } from './qslAssets'; import { FONT_WEIGHTS } from './qslAssets'; import { renderTextFx, type TextFxParams, type TextFxKind, type TextFxResult } from './textFx'; // An editor selection: an element index or the QSO box. export type CardSelection = number | 'box' | null; // Fraction of the font size between a glyph's em-box top and its cap line. // Used to make a text element's y align to the visible letter tops. const CAP_INSET = 0.18; interface Props { model: RenderModel; assets: CardAssets; width: number; // display width in CSS px selected?: CardSelection; onSelect?: (sel: CardSelection) => void; onMove?: (sel: Exclude, x: number, y: number) => void; svgRef?: (el: SVGSVGElement | null) => void; } // Fallbacks mirroring the preset defaults in presets.go, applied when a // document omits a knob the stack needs. const GOLD = ['#FFD83A', '#FFC312', '#EE9400']; const DEF_SHINE = { coverage: 0.5, opacity: 0.95 }; const DEF_HALO = { color: '#cdd9e4', blur: 6, opacity: 0.4 }; const DEF_SHADOW = { dx: 5, dy: 8, blur: 5, color: '#14243a', opacity: 0.5 }; const DEF_BEVEL = { dx: -2, dy: -4, dark: '#C27500', light: '#FFEFA0' }; // fontSpec maps a template font name to a concrete family + weight. // "system-bold-sans" is the info-line workhorse: heavy UI sans, no embed. function fontSpec(font?: string): { family: string; weight: number | 'normal' } { if (!font || font === 'system-bold-sans') { return { family: "'Segoe UI', Arial, sans-serif", weight: 800 }; } return { family: `'${font}'`, weight: FONT_WEIGHTS[font] ?? 'normal' }; } // Presets whose call text is rendered with the ported canvas FX (the only way // to get the exact glossy/western look). Everything else stays plain SVG text. const FX_GLOSSY = new Set(['gel_gold', 'gel_silver']); const FX_WESTERN = new Set(['gel_gold_grunge', 'gel_silver_grunge']); function fxKindFor(preset?: string): TextFxKind | null { if (preset && FX_GLOSSY.has(preset)) return 'glossy'; if (preset && FX_WESTERN.has(preset)) return 'western'; return null; } function buildFxParams(e: CardElement): TextFxParams | null { const kind = fxKindFor(e.style_preset); if (!kind || !e.size) return null; const fs = fontSpec(e.font); const sp = e.style_params ?? {}; const fx = sp.fx ?? {}; const grad = sp.gradient ?? (kind === 'western' ? ['#FFC83A', '#F0A21E', '#D87A00'] : ['#ffe22d', '#ffd600', '#ffcc00']); const silver = e.style_preset?.includes('silver'); return { kind, text: e.text ?? '', weight: String(fs.weight === 'normal' ? 400 : fs.weight), family: fs.family, size: e.size, space: e.size * (kind === 'western' ? 0.08 : 0.04), cTop: grad[0], cMid: grad[1] ?? grad[0], cBot: grad[2] ?? grad[1] ?? grad[0], // Dark inter-letter edge + rim — from the chosen colour palette, else the // default near-black edge (the navy outline adaptStyle sets is for the old // SVG stack, not this look). cDark: fx.dark ?? (kind === 'western' ? '#1c130a' : '#262630'), cOuter: fx.outer ?? (silver ? '#e8edf2' : '#ced3db'), // Per-call overrides from the editor (undefined → renderer default). plump: fx.plump, edge: fx.edge, outerw: fx.outerw, gloss: fx.gloss, glossH: fx.gloss_h, glossI: fx.gloss_i, innerB: fx.inner_b, depth: fx.depth, angle: fx.angle, slant: fx.slant, grunge: fx.grunge, bevel: fx.bevel, seed: fx.seed, }; } // Stable signature of an element's FX-relevant fields, so dragging (x/y only) // doesn't trigger an expensive re-render of the canvas text. function fxSignature(e: CardElement): string { if (!fxKindFor(e.style_preset)) return ''; return [e.text, e.font, e.size, e.style_preset, (e.style_params?.gradient ?? []).join(','), JSON.stringify(e.style_params?.fx ?? {})].join('|'); } function elementTransform(e: CardElement): string { const r = e.rotate ? ` rotate(${e.rotate})` : ''; return `translate(${e.x} ${e.y})${r}`; } // Shared layer of a style stack. All layers must carry identical // glyph geometry — only paint/transform/filter differ. function Layer({ e, content, fill, stroke, strokeWidth, transform, filter, opacity, paintOrder, }: { e: CardElement; content: string; fill: string; stroke?: string; strokeWidth?: number; transform?: string; filter?: string; opacity?: number; paintOrder?: 'stroke'; }) { const f = fontSpec(e.font); return ( {content} ); } // glossWavePath builds the gel highlight band: full glyph width, bottom // edge a gentle quadratic wave (amplitude ≈ 4.5% of the font size, period // ≈ 25% — i.e. ~10 px and ~55 px at the 220 px design size). The wavy line // is what produces the "gel" look; the band is clipped to the glyphs. function glossWavePath(e: CardElement, content: string, coverage: number): string { const size = e.size ?? 100; const w = 0.78 * size * Math.max(content.length, 1); const capTop = 0.16 * size; // cap height starts below the em-box top const bottom = capTop + coverage * 0.72 * size; const amp = 0.045 * size; const period = 0.25 * size; let d = `M 0 0 H ${w.toFixed(1)} V ${bottom.toFixed(1)}`; let i = 0; for (let x = w; x > 0.1; x -= period, i++) { const x2 = Math.max(0, x - period); const mid = (x + x2) / 2; const dy = i % 2 === 0 ? amp : -amp; d += ` Q ${mid.toFixed(1)} ${(bottom + dy).toFixed(1)} ${x2.toFixed(1)} ${bottom.toFixed(1)}`; } return d + ' Z'; } // TextStack renders one text element through its style preset. function TextStack({ e, idx, content }: { e: CardElement; idx: number; content: string }) { const p: StyleParams = e.style_params ?? {}; const preset = e.style_preset ?? 'flat_modern'; const ow = p.outline_width ?? 0; if (preset === 'flat_modern') { return ; } if (preset === 'script_white' || preset === 'outlined_white') { return ( <> {p.shadow && ( )} ); } // Gel family + classic white outline: the layered stack. const gradient = p.gradient ?? GOLD; const shadow = p.shadow ?? DEF_SHADOW; const outline = p.outline_color ?? '#2a3f5c'; const outlineW = ow || 9; const isGrunge = preset === 'gel_gold_grunge' || preset === 'gel_silver_grunge'; const isGel = preset === 'gel_gold' || preset === 'gel_silver' || isGrunge; const halo = p.halo ?? DEF_HALO; const bevel = p.bevel_offset ?? DEF_BEVEL; const shine = p.shine ?? DEF_SHINE; const faceT = isGel ? `translate(${bevel.dx} ${bevel.dy})` : undefined; return ( <> {isGel && ( )} {isGel && } {isGel && ( )} {isGel && ( )} {/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */} {isGrunge && ( )} {/* defs that belong to this stack (unique ids per element index) */} {gradient.map((c, i) => ( ))} {isGel && ( <> {content} )} {isGrunge && ( {/* sparse dark specks: fractal noise → threshold into the alpha */} )} ); } // QSO box field column weights — the date string is far wider than the rest, // so equal columns let it spill into the next field. Wider weight = more room. const QSO_FIELD_WEIGHT: Record = { qso_date: 2, time_on: 1, band: 1, mode: 1, rst_sent: 0.9, freq: 1.4, submode: 1.1, }; // QSOBoxView renders the confirmation box with per-QSO values. function QSOBoxView({ box, values }: { box: QSOBox; values: Record }) { const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity) const avail = box.w - 56; const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1; let cursor = 28; const cols = box.fields.map((f) => { const w = (avail * (QSO_FIELD_WEIGHT[f] ?? 1)) / total; const col = { f, x: cursor, w }; cursor += w; return col; }); return ( <> {box.title} {cols.map(({ f, x }) => ( {(QSO_FIELD_LABELS[f] ?? f).toUpperCase()} {values[f] ?? ''} ))} {box.footer && ( {box.footer} )} ); } export function CardPreview({ model, assets, width, selected, onSelect, onMove, svgRef }: Props) { const { template: t, qso_fields } = model; const card = t.card; const scale = card.w / width; const rootRef = useRef(null); const groupRefs = useRef(new Map()); const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null); const drag = useRef<{ sel: Exclude; sx: number; sy: number; ox: number; oy: number } | null>(null); const measureCanvas = useRef(null); // Embedded fonts may still be loading when the card first mounts; the canvas // FX needs them for correct metrics, so (re)render once they're ready. const [fontsReady, setFontsReady] = useState(false); useEffect(() => { let alive = true; const ready = (document as Document & { fonts?: FontFaceSet }).fonts?.ready ?? Promise.resolve(); void ready.then(() => { if (alive) setFontsReady(true); }); return () => { alive = false; }; }, [assets]); // Canvas-rendered call text (glossy/western), keyed on the FX-relevant fields // so dragging doesn't regenerate it. Maps element index → PNG + size. const fxSig = t.elements.map(fxSignature).join(';'); const fxImages = useMemo(() => { const map = new Map(); if (!fontsReady) return map; t.elements.forEach((e, idx) => { const params = buildFxParams(e); if (!params) return; try { map.set(idx, renderTextFx(params)); } catch { /* leave unrendered */ } }); return map; // eslint-disable-next-line react-hooks/exhaustive-deps }, [fxSig, fontsReady]); // Measure the selected group for the dashed selection outline. For plain SVG // text we use the glyphs' real ink metrics — getBBox would span the font's // full ascent/descent. FX call images fall through to getBBox (already tight). useEffect(() => { if (selected === null || selected === undefined) { setSelBox(null); return; } const key = String(selected); const g = groupRefs.current.get(key); if (!g) { setSelBox(null); return; } const transform = g.getAttribute('transform') ?? ''; const el = typeof selected === 'number' ? t.elements[selected] : undefined; const isFx = el ? !!fxKindFor(el.style_preset) : false; if (!isFx && el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) { const cv = (measureCanvas.current ??= document.createElement('canvas')); const ctx = cv.getContext('2d'); if (ctx) { const f = fontSpec(el.font); ctx.font = `${f.weight} ${el.size}px ${f.family}`; const m = ctx.measureText(el.text ?? ''); const ascent = m.fontBoundingBoxAscent ?? el.size * 0.8; const baselineY = -el.size * CAP_INSET + ascent; // -CAP_INSET matches the render's inner shift const inkTop = baselineY - (m.actualBoundingBoxAscent ?? el.size * 0.7); const inkBottom = baselineY + (m.actualBoundingBoxDescent ?? 0); const left = -(m.actualBoundingBoxLeft ?? 0); const right = m.actualBoundingBoxRight ?? m.width; setSelBox({ t: transform, x: left, y: inkTop, w: right - left, h: inkBottom - inkTop }); return; } } const b = g.getBBox(); setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height }); }, [selected, model, assets, width, t.elements, fxImages]); const startDrag = (sel: Exclude, ox: number, oy: number) => (ev: React.PointerEvent) => { onSelect?.(sel); if (!onMove) return; ev.stopPropagation(); drag.current = { sel, sx: ev.clientX, sy: ev.clientY, ox, oy }; (ev.currentTarget as Element).setPointerCapture(ev.pointerId); }; const onPointerMove = (ev: React.PointerEvent) => { const d = drag.current; if (!d || !onMove) return; const x = Math.round(d.ox + (ev.clientX - d.sx) * scale); const y = Math.round(d.oy + (ev.clientY - d.sy) * scale); onMove(d.sel, x, y); }; const endDrag = () => { drag.current = null; }; const hero = assets.photos[t.hero.photo]; const crop = t.hero.crop; const setGroupRef = (key: string) => (el: SVGGElement | null) => { if (el) groupRefs.current.set(key, el); else groupRefs.current.delete(key); }; return ( { rootRef.current = el; svgRef?.(el); }} viewBox={`0 0 ${card.w} ${card.h}`} width={width} height={Math.round(width * (card.h / card.w))} xmlns="http://www.w3.org/2000/svg" style={{ display: 'block', borderRadius: 4 }} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerLeave={endDrag} onPointerDown={() => onSelect?.(null)} > {/* Fonts must live INSIDE the SVG so rasterization (SVG → → canvas) sees them; document styles don't apply there. */} {/* hero photo, source crop mapped onto the full card */} {hero && crop.w > 0 && crop.h > 0 && ( )} {t.hero.scrim?.enabled && ( )} {t.elements.map((e, idx) => { const key = String(idx); if (e.hidden) return null; // toggled off in the editor if (e.type === 'insert') { const ph = assets.photos[e.photo ?? '']; if (!ph || !e.w) return null; const h = (e.w * ph.h) / ph.w; const b = e.border_px ?? 0; return ( ); } if (e.type === 'country') { const size = e.size ?? 30; const flagH = size * 1.4; const flagW = flagH * (4 / 3); const hasFlag = !!assets.flagUrl; return ( {hasFlag && } {e.label === 'auto' ? '' : e.label} ); } // Text elements. Glossy/western calls render through the ported canvas // FX as an (its trimmed bounds already give a tight frame, so // no CAP_INSET shim). Everything else is plain SVG text; the inner // translate pulls the glyphs up to the em-box cap line so the element's // y means "top of the visible letters". const fx = fxImages.get(idx); return ( {fx ? ( ) : ( )} ); })} {t.qso_box?.enabled && ( )} {/* selection outline (editor only) */} {selBox && onSelect && ( 3 ? 3 * scale : 3} strokeDasharray={`${10 * scale} ${6 * scale}`} /> )} ); }