This commit is contained in:
2026-06-13 01:34:45 +02:00
parent 408b29896c
commit 3cb2e466d8
21 changed files with 1285 additions and 130 deletions
+2
View File
@@ -1217,6 +1217,7 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
}
return id, err
}
@@ -5391,6 +5392,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
if a.extsvc != nil {
a.extsvc.OnQSOLogged(id)
}
a.maybeAutoSendEQSL(q)
return id, nil
}
+99 -7
View File
@@ -31,6 +31,7 @@ import (
const (
keyQSLEmailSubject = "qsl.email_subject"
keyQSLEmailBody = "qsl.email_body"
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
)
const (
@@ -73,7 +74,7 @@ type QSLPresetInfo struct {
// so picking through the native dialog is the reliable route to real paths).
func (a *App) QSLPickPhotos() ([]string, error) {
return wruntime.OpenMultipleFilesDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Choose card photos (16)",
Title: "Choose card photos (13)",
Filters: []wruntime.FileFilter{
{DisplayName: "Photos (*.jpg;*.jpeg;*.png)", Pattern: "*.jpg;*.jpeg;*.png"},
},
@@ -86,8 +87,8 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
if len(photoPaths) == 0 {
return nil, fmt.Errorf("no photos selected")
}
if len(photoPaths) > 6 {
return nil, fmt.Errorf("at most 6 photos (got %d)", len(photoPaths))
if len(photoPaths) > 3 {
return nil, fmt.Errorf("at most 3 photos (got %d)", len(photoPaths))
}
photos := make([]qslcard.PhotoAnalysis, 0, len(photoPaths))
for _, p := range photoPaths {
@@ -433,10 +434,11 @@ func (a *App) SendEQSL(qsoID int64, templateID int64, jpegB64 string) error {
return nil
}
// QSLEmailTemplates is the subject/body pair of the eQSL e-mail.
// QSLEmailTemplates is the eQSL e-mail subject/body plus the auto-send toggle.
type QSLEmailTemplates struct {
Subject string `json:"subject"`
Body string `json:"body"`
AutoSend bool `json:"auto_send"`
}
// QSLGetEmailTemplates returns the eQSL e-mail templates (with defaults).
@@ -445,7 +447,7 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
if a.settings == nil {
return out, nil
}
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody)
m, err := a.settings.GetMany(a.ctx, keyQSLEmailSubject, keyQSLEmailBody, keyQSLAutoSend)
if err != nil {
return out, err
}
@@ -455,10 +457,11 @@ func (a *App) QSLGetEmailTemplates() (QSLEmailTemplates, error) {
if b := m[keyQSLEmailBody]; b != "" {
out.Body = b
}
out.AutoSend = m[keyQSLAutoSend] == "1"
return out, nil
}
// QSLSaveEmailTemplates persists the eQSL e-mail templates.
// QSLSaveEmailTemplates persists the eQSL e-mail templates and auto-send flag.
func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
@@ -466,7 +469,43 @@ func (a *App) QSLSaveEmailTemplates(t QSLEmailTemplates) error {
if err := a.settings.Set(a.ctx, keyQSLEmailSubject, t.Subject); err != nil {
return err
}
return a.settings.Set(a.ctx, keyQSLEmailBody, t.Body)
if err := a.settings.Set(a.ctx, keyQSLEmailBody, t.Body); err != nil {
return err
}
v := "0"
if t.AutoSend {
v = "1"
}
return a.settings.Set(a.ctx, keyQSLAutoSend, v)
}
// maybeAutoSendEQSL fires an eQSL render+send for a freshly-logged QSO when the
// user enabled auto-send and the prerequisites hold: a recipient e-mail, a
// default template for the active profile, and the QSO not already confirmed.
// Rasterization happens in the webview, so this only emits an event ("qsl:autosend")
// the frontend acts on — never sends from here. Silent when any condition fails.
func (a *App) maybeAutoSendEQSL(q qso.QSO) {
if a.settings == nil || a.qslTemplates == nil || a.profiles == nil || a.ctx == nil {
return
}
if v, _ := a.settings.Get(a.ctx, keyQSLAutoSend); v != "1" {
return
}
if strings.TrimSpace(q.Email) == "" || strings.EqualFold(q.EQSLSent, "Y") {
return
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return
}
rec, err := a.qslTemplates.DefaultFor(a.ctx, p.ID)
if err != nil {
applog.Printf("qsl: auto-send skipped for %s — no template (%v)", q.Callsign, err)
return
}
wruntime.EventsEmit(a.ctx, "qsl:autosend", map[string]any{
"qsoId": q.ID, "templateId": rec.ID, "callsign": q.Callsign,
})
}
// ── helpers ─────────────────────────────────────────────────────────────
@@ -493,6 +532,8 @@ func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
Callsign: p.Callsign,
Operator: p.OpName, // personal name for the QSL script (not the OPERATOR callsign)
Grid: p.MyGrid,
Rig: p.MyRig,
Antenna: p.MyAntenna,
}
if p.MyCQZone != nil {
info.CQZone = *p.MyCQZone
@@ -510,9 +551,57 @@ func (a *App) qslProfileInfo() (qslcard.ProfileInfo, error) {
}
}
}
// Most users define rig/antenna in the operating manager (band defaults),
// not the profile's MY_RIG/MY_ANTENNA text fields. Fall back to a band
// default so the designer knows the station line has data to show.
if (info.Rig == "" || info.Antenna == "") && a.operating != nil {
for _, band := range []string{"20m", "40m", "80m", "10m", "2m", "160m"} {
d, _, e := a.operating.BandDefault(a.ctx, p.ID, band)
if e != nil {
continue
}
if info.Rig == "" {
info.Rig = d.StationName
}
if info.Antenna == "" {
info.Antenna = d.AntennaName
}
if info.Rig != "" && info.Antenna != "" {
break
}
}
}
return info, nil
}
// qslRigAntenna resolves the rig and antenna names for a QSO: the values
// stamped on it (MY_RIG/MY_ANTENNA), falling back to the operating manager's
// default for the QSO's band (so the preview and back-entered QSOs aren't
// blank).
func (a *App) qslRigAntenna(q qso.QSO) (rig, ant string) {
rig, ant = q.MyRig, q.MyAntenna
if (rig != "" && ant != "") || a.operating == nil || a.profiles == nil {
return rig, ant
}
p, err := a.profiles.Active(a.ctx)
if err != nil {
return rig, ant
}
band := q.Band
if band == "" {
band = "20m"
}
if d, _, e := a.operating.BandDefault(a.ctx, p.ID, band); e == nil {
if rig == "" {
rig = d.StationName
}
if ant == "" {
ant = d.AntennaName
}
}
return rig, ant
}
// qslVars builds the full placeholder map (profile + QSO) and the country
// block resolution for one render.
func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error) {
@@ -532,6 +621,8 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
"profile.grid": info.Grid,
"profile.cq_zone": zone(info.CQZone),
"profile.itu_zone": zone(info.ITUZone),
"profile.rig": info.Rig,
"profile.antenna": info.Antenna,
"qso.callsign": q.Callsign,
"qso.qso_date": q.QSODate.UTC().Format("2006-01-02"),
@@ -543,6 +634,7 @@ func (a *App) qslVars(q qso.QSO) (map[string]string, qslcard.CountryInfo, error)
"qso.qsl_msg": q.QSLMsg,
"qso.name": q.Name,
}
vars["qso.my_rig"], vars["qso.my_antenna"] = a.qslRigAntenna(q)
if q.FreqHz != nil {
vars["qso.freq"] = strconv.FormatFloat(float64(*q.FreqHz)/1e6, 'f', 3, 64) + " MHz"
}
+39 -5
View File
@@ -41,6 +41,7 @@ import { Menubar, type Menu } from '@/components/Menubar';
import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
@@ -631,6 +632,19 @@ export default function App() {
return next;
});
}, []);
// Single band map docked beside the table (toggled by the toolbar button,
// visible across tabs). Independent of the multi-band "Band Map" tab.
const [showBandMap, setShowBandMap] = useState(false);
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
);
const toggleBandMapSide = useCallback(() => {
setBandMapSide((s) => {
const next = s === 'right' ? 'left' : 'right';
writeUiPref('bandmap.side', next);
return next;
});
}, []);
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
@@ -2256,10 +2270,10 @@ export default function App() {
</Button>
)}
<Button
variant={activeTab === 'bandmap' ? 'default' : 'outline'}
variant={showBandMap ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveTab('bandmap')}
title="Open the Band Map tab (several bands side by side)"
onClick={() => setShowBandMap((v) => !v)}
title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
className="h-8"
>
Band map
@@ -2376,7 +2390,7 @@ export default function App() {
className={cn('bg-card shadow-sm border-border',
compact
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[760px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[660px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault();
@@ -2552,7 +2566,8 @@ export default function App() {
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <>
<div className="grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)] grid-cols-[1fr]">
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
<TabsList className="px-3 shrink-0">
@@ -3081,6 +3096,21 @@ export default function App() {
</TabsContent>
</Tabs>
</section>
{showBandMap && (
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
<BandMap
side={bandMapSide}
onToggleSide={toggleBandMapSide}
band={band}
spots={spots.filter((s) => s.band === band)}
spotStatus={spotStatus}
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick}
onClose={() => setShowBandMap(false)}
/>
</div>
)}
</div>
</>}
@@ -3173,6 +3203,10 @@ export default function App() {
/>
)}
<AutoEQSL
onSent={(call) => showToast(`eQSL sent to ${call}`)}
onError={(msg) => showToast(msg)}
/>
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
<SendEQSLModal
open={eqslQsoId !== null}
+30 -5
View File
@@ -17,6 +17,7 @@ import {
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetSecretStatus, SetPassphrase, RemovePassphrase,
GetEmailSettings, SaveEmailSettings, TestEmail,
QSLGetEmailTemplates, QSLSaveEmailTemplates,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
@@ -462,6 +463,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
});
const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// eQSL card e-mail (subject/body templates + auto-send on log).
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false });
const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
// ClubLog Country File (cty.xml) exception status.
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
@@ -632,6 +637,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
reloadAudioDevices();
reloadDvk();
} catch (e: any) {
@@ -791,6 +797,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any);
await QSLSaveEmailTemplates(eqslCfg as any);
await SaveBackupSettings(backupCfg as any);
await SaveQSLDefaults(qslDefaults as any);
await SaveExternalServices(extSvc as any);
@@ -860,16 +867,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
<div className="text-[10px] text-muted-foreground">Who's at the radio (ADIF OPERATOR).</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Operator name</Label>
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
<div className="text-[10px] text-muted-foreground">Your first name used as the signature on QSL cards.</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Owner callsign</Label>
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
<div className="text-[10px] text-muted-foreground">Legal station owner only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Operator name</Label>
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
<div className="text-[10px] text-muted-foreground">Your first name used as the signature on QSL cards.</div>
</div>
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
Auto-filled from the callsign editable (stamped as MY_* on each QSO)
</div>
@@ -3077,6 +3084,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</Button>
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</div>
<div className="pt-2 mt-2 border-t border-border space-y-2">
<Label className="text-sm font-semibold">eQSL card e-mail</Label>
<div className="text-[11px] text-muted-foreground">
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
</div>
<Input className="h-8" placeholder="Subject" value={eqslCfg.subject}
onChange={(e) => setEqslField({ subject: e.target.value })} />
<Textarea rows={3} className="text-sm" placeholder="Body" value={eqslCfg.body}
onChange={(e) => setEqslField({ body: e.target.value })} />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
Auto-send eQSL when a QSO is logged
</label>
<div className="text-[11px] text-muted-foreground">
Sends automatically only when the contact has an e-mail address and a default QSL template exists.
</div>
</div>
</div>
</>
);
+100
View File
@@ -0,0 +1,100 @@
// AutoEQSL drives automatic eQSL sending. The backend decides eligibility on
// log (auto-send on, recipient e-mail known, default template, not already
// sent) and emits "qsl:autosend"; this component does the part Go can't —
// render the card off-screen, rasterize it to JPEG and hand it back to
// SendEQSL. Jobs are processed one at a time. Mounted once, near the app root.
import { useCallback, useEffect, useRef, useState } from 'react';
import { EventsOn } from '../../../wailsjs/runtime/runtime';
import { RenderEQSL, SendEQSL } from '../../../wailsjs/go/main/App';
import type { RenderModel } from './qslTypes';
import { loadCardAssets, type CardAssets } from './qslAssets';
import { CardPreview } from './CardPreview';
import { rasterizeCard } from './rasterize';
interface Job {
qsoId: number;
templateId: number;
callsign: string;
}
interface Props {
onSent?: (callsign: string) => void;
onError?: (message: string) => void;
}
export function AutoEQSL({ onSent, onError }: Props) {
const queue = useRef<Job[]>([]);
const busy = useRef(false);
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
const svgEl = useRef<SVGSVGElement | null>(null);
// Pull the next job, fetch its render model + assets, then mount it (the
// effect below rasterizes once the DOM has it).
const pump = useCallback(async () => {
if (busy.current) return;
const job = queue.current.shift();
if (!job) return;
busy.current = true;
try {
const model = JSON.parse(await RenderEQSL(job.qsoId, job.templateId)) as RenderModel;
const assets = await loadCardAssets(model.template, job.templateId);
setCurrent({ job, model, assets });
} catch (e) {
onError?.(`Auto eQSL: ${e}`);
busy.current = false;
void pump();
}
}, [onError]);
useEffect(() => {
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
void pump();
});
return () => off();
}, [pump]);
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
useEffect(() => {
if (!current) return;
let cancelled = false;
void (async () => {
try {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
if (cancelled || !svgEl.current) return;
const card = current.model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
onSent?.(current.job.callsign);
} catch (e) {
onError?.(`Auto eQSL: ${e}`);
} finally {
if (!cancelled) {
setCurrent(null);
busy.current = false;
void pump();
}
}
})();
return () => { cancelled = true; };
}, [current, pump, onSent, onError]);
if (!current) return null;
// Off-screen at full card resolution so the rasterized output matches the
// editor preview exactly.
return (
<div
aria-hidden
style={{ position: 'fixed', left: -100000, top: 0, opacity: 0, pointerEvents: 'none' }}
>
<CardPreview
model={current.model}
assets={current.assets}
width={current.model.template.card.w}
svgRef={(el) => { svgEl.current = el; }}
/>
</div>
);
}
+77 -9
View File
@@ -15,6 +15,10 @@ import { FONT_WEIGHTS } from './qslAssets';
// 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;
@@ -140,7 +144,8 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
const shadow = p.shadow ?? DEF_SHADOW;
const outline = p.outline_color ?? '#2a3f5c';
const outlineW = ow || 9;
const isGel = preset === 'gel_gold' || preset === 'gel_silver';
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;
@@ -179,6 +184,19 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
opacity={shine.opacity}
/>
)}
{/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */}
{isGrunge && (
<rect
x={0} y={0}
width={0.82 * (e.size ?? 0) * Math.max(content.length, 1)}
height={(e.size ?? 0) * 1.05}
fill="#241405"
filter={`url(#qsl-grunge-${idx})`}
clipPath={`url(#qsl-clip-${idx})`}
transform={faceT}
opacity={0.6}
/>
)}
{/* defs that belong to this stack (unique ids per element index) */}
<defs>
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
@@ -205,6 +223,14 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
</filter>
</>
)}
{isGrunge && (
<filter id={`qsl-grunge-${idx}`} x="-5%" y="-5%" width="110%" height="110%">
{/* sparse dark specks: fractal noise → threshold into the alpha */}
<feTurbulence type="fractalNoise" baseFrequency="0.14 0.2" numOctaves={2} seed={9} result="n" />
<feColorMatrix in="n" type="matrix"
values="0 0 0 0 0.14 0 0 0 0 0.08 0 0 0 0 0.02 0 0 0 6 -4.2" />
</filter>
)}
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={shadow.blur} />
</filter>
@@ -213,9 +239,23 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
);
}
// 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<string, number> = {
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<string, string> }) {
const colW = (box.w - 56) / Math.max(box.fields.length, 1);
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 (
<>
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
@@ -223,13 +263,13 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{box.title}
</text>
{box.fields.map((f, i) => (
<g key={f} transform={`translate(${28 + i * colW} ${box.h * 0.42})`}>
{cols.map(({ f, x }) => (
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
</text>
<text y={26} fontSize={30} fontWeight={700} fill="#14243a"
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{values[f] ?? ''}
</text>
@@ -253,17 +293,39 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
const groupRefs = useRef(new Map<string, SVGGElement>());
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
const measureCanvas = useRef<HTMLCanvasElement | null>(null);
// Measure the selected group for the dashed selection outline.
// Measure the selected group for the dashed selection outline. For text we
// use the glyphs' real ink metrics — getBBox would span the font's full
// ascent/descent, leaving a tall dead band above the letters and a frame
// that doesn't hug the call.
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 b = g.getBBox();
const transform = g.getAttribute('transform') ?? '';
const el = typeof selected === 'number' ? t.elements[selected] : undefined;
if (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]);
}, [selected, model, assets, width, t.elements]);
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
(ev: React.PointerEvent<SVGGElement>) => {
@@ -331,6 +393,7 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
{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;
@@ -372,15 +435,20 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
</g>
);
}
// text elements
// Text elements. The inner translate pulls the glyphs up by the em-box
// top→cap-height gap so the element's y means "top of the visible
// letters" — without it the call sits ~0.2em below its box and can't
// be pushed to the card's top edge.
return (
<g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
<TextStack e={e} idx={idx} content={e.text ?? ''} />
</g>
</g>
);
})}
+38 -3
View File
@@ -9,6 +9,7 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
import type { CardSelection } from './CardPreview';
import { StylePresetPicker, NumberField } from './StylePresetPicker';
@@ -21,16 +22,29 @@ interface Props {
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
onPatchBox: (patch: Partial<QSOBox>) => void;
onScrim: (enabled: boolean) => void;
onSelect?: (sel: CardSelection) => void;
}
const TYPE_LABELS: Record<string, string> = {
callsign: 'Callsign',
operator: 'Operator name',
info_line: 'Info line',
country: 'Country block',
country: 'Country + flag',
insert: 'Photo insert',
};
// A readable layer name, disambiguating the two info lines and numbering inserts.
function layerLabel(el: CardElement, i: number, all: CardElement[]): string {
if (el.type === 'info_line') {
return /rig|antenna|ant\b/i.test(el.text ?? '') ? 'Station (rig / ant)' : 'Info line (zones)';
}
if (el.type === 'insert') {
const n = all.slice(0, i + 1).filter((x) => x.type === 'insert').length;
return `Photo insert ${n}`;
}
return TYPE_LABELS[el.type] ?? el.type;
}
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
onPatch: (idx: number, patch: Partial<CardElement>) => void;
@@ -71,12 +85,12 @@ function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
);
}
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim }: Props) {
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim, onSelect }: Props) {
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
const box = template.qso_box;
return (
<div className="flex w-72 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
<label className="flex items-center gap-2 text-sm">
@@ -88,6 +102,27 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
</label>
</div>
{/* Layers: show/hide each piece, click a name to edit it. */}
<div className="space-y-1 rounded-md border border-border p-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Show on card</div>
{template.elements.map((el, i) => (
<div key={i} className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === i && 'bg-muted')}>
<Checkbox checked={!el.hidden} onCheckedChange={(v) => onPatchElement(i, { hidden: v !== true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.(i)}>
{layerLabel(el, i, template.elements)}
</button>
</div>
))}
{box && (
<div className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === 'box' && 'bg-muted')}>
<Checkbox checked={box.enabled} onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.('box')}>
QSO info box
</button>
</div>
)}
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
@@ -11,14 +11,11 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
QSLGetEmailTemplates, QSLSaveEmailTemplates,
} from '../../../wailsjs/go/main/App';
import { main } from '../../../wailsjs/go/models';
import type {
CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo,
} from './qslTypes';
@@ -66,9 +63,6 @@ export function QslDesignerModal({ open, onClose }: Props) {
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
const [deleteArm, setDeleteArm] = useState(0);
const [mailSubject, setMailSubject] = useState('');
const [mailBody, setMailBody] = useState('');
const [mailSaved, setMailSaved] = useState(false);
const svgEl = useRef<SVGSVGElement | null>(null);
useEffect(() => {
@@ -80,7 +74,6 @@ export function QslDesignerModal({ open, onClose }: Props) {
setEditing(null);
void refreshSaved();
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
void QSLGetEmailTemplates().then((t) => { setMailSubject(t.subject); setMailBody(t.body); setMailSaved(false); });
void loadFonts().then(({ fonts }) =>
setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans']));
}, [open]);
@@ -103,7 +96,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
async function choosePhotos() {
try {
const paths = ((await QSLPickPhotos()) ?? []) as string[];
if (paths.length) setPhotoPaths(paths.slice(0, 6));
if (paths.length) setPhotoPaths(paths.slice(0, 3));
} catch (e) {
setError(String(e));
}
@@ -237,7 +230,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[1180px]">
<DialogContent className="max-w-[1260px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-5 text-amber-500" />
@@ -261,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
<section className="space-y-2">
<h3 className="text-sm font-semibold">New design</h3>
<p className="text-xs text-muted-foreground">
Pick 16 photos OpsLog analyzes them and proposes three card designs
Pick 13 photos OpsLog analyzes them and proposes three card designs
with your callsign, name, zones and country placed automatically.
</p>
<div className="flex items-center gap-2">
@@ -317,25 +310,9 @@ export function QslDesignerModal({ open, onClose }: Props) {
</div>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">eQSL e-mail message</h3>
<p className="text-xs text-muted-foreground">
{'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'} fill in per QSO.
The eQSL e-mail message and the auto-send option are in Settings E-mail (SMTP).
</p>
<Input className="h-8" value={mailSubject} placeholder="Subject"
onChange={(e) => { setMailSubject(e.target.value); setMailSaved(false); }} />
<Textarea rows={3} value={mailBody} placeholder="Body"
onChange={(e) => { setMailBody(e.target.value); setMailSaved(false); }} />
<Button variant="outline" size="sm" disabled={mailSaved}
onClick={async () => {
try {
await QSLSaveEmailTemplates(new main.QSLEmailTemplates({ subject: mailSubject, body: mailBody }));
setMailSaved(true);
} catch (e) { setError(String(e)); }
}}>
{mailSaved ? 'Saved' : 'Save message'}
</Button>
</section>
</div>
)}
@@ -392,6 +369,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
onPatchElement={patchElement}
onPatchBox={patchBox}
onScrim={onScrim}
onSelect={setSel}
/>
</div>
)}
@@ -35,10 +35,10 @@ function SliderRow({ label, value, min, max, step, onChange }: {
}) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="w-24 shrink-0 text-xs text-muted-foreground">{label}</Label>
<input type="range" className="flex-1" min={min} max={max} step={step}
<Label className="w-20 shrink-0 text-xs text-muted-foreground">{label}</Label>
<input type="range" className="min-w-0 flex-1" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
<span className="w-8 text-right text-xs tabular-nums">{value}</span>
<span className="w-10 shrink-0 text-right text-xs tabular-nums">{value}</span>
</div>
);
}
+1
View File
@@ -72,6 +72,7 @@ export interface CardElement {
x: number;
y: number;
rotate?: number;
hidden?: boolean; // toggled off in the editor
// text elements
text?: string;
font?: string;
+576
View File
@@ -0,0 +1,576 @@
// Canvas-2D call-text renderer, ported faithfully from the user's two
// reference generators ("glossy bulle" / XV9Q and "western 3D" / HS0ZLE).
// SVG filters can only approximate these looks; the exact technique is
// canvas — inflated glyphs (fill + stroke to dilate), contour bands via
// boolean compositing, a candy gloss that hugs the outline, an orange inner
// shadow, and organic multi-layer grunge.
//
// renderTextFx() runs the recipe offscreen, trims the transparent padding,
// and returns a PNG data URL + its size in logical (card) pixels. CardPreview
// embeds that as an <image> so the same output drives the editor preview,
// thumbnails and the final e-mail rasterization.
export type TextFxKind = 'glossy' | 'western';
export interface TextFxParams {
kind: TextFxKind;
text: string;
weight: string; // canvas font weight, e.g. "800" or "400"
family: string; // canvas font family, e.g. '"Baloo 2", sans-serif'
size: number; // font size in card px
space: number; // letter spacing in px
cTop: string;
cMid: string;
cBot: string;
cDark: string; // dark edge / outline
cOuter?: string; // silver rim (glossy only)
// glossy
plump?: number; // 0..0.06 — inflation as a fraction of size
edge?: number; // 0..1 — dark edge weight
outerw?: number; // 0..1 — silver rim weight
gloss?: number; // 0..1 — gloss intensity
glossH?: number; // 0..1 — gloss height
glossI?: number; // 0..0.14 — gloss inset
innerB?: number; // 0..1 — bottom inner shadow
// western
depth?: number; // 3D extrusion depth in px
angle?: number; // extrusion direction in radians
slant?: number; // shear, -0.4..0.4
grunge?: number; // 0..1 — distress amount
bevel?: number; // 0..1 — top-edge light
seed?: number;
}
export interface TextFxResult {
url: string; // PNG data URL of the trimmed text
w: number; // logical (card-px) width
h: number; // logical (card-px) height
}
const SS = 2; // supersample for crisp downscaling
function supportsFilter(c: CanvasRenderingContext2D) {
return typeof c.filter === 'string';
}
function shade(hex: string, pct: number) {
const n = parseInt(hex.slice(1), 16);
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v + (255 * pct) / 100)));
return 'rgb(' + f(n >> 16) + ',' + f((n >> 8) & 255) + ',' + f(n & 255) + ')';
}
function deepen(hex: string) {
const n = parseInt(hex.slice(1), 16);
return 'rgb(' + Math.round((n >> 16) * 0.96) + ',' + Math.round(((n >> 8) & 255) * 0.62) + ',' + Math.round((n & 255) * 0.25) + ')';
}
function rustOf(hex: string) {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255, Math.round((n >> 16) * 0.72));
const g = Math.round(((n >> 8) & 255) * 0.49);
const b = Math.min(255, Math.round((n & 255) * 0.3) + 10);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
function mulberry32(seed: number) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashStr(s: string) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// ── glossy bubble (XV9Q) ────────────────────────────────────────────────
function renderGlossy(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'plump' | 'edge' | 'outerw' | 'gloss' | 'glossH' | 'glossI' | 'innerB' | 'cTop' | 'cMid' | 'cBot' | 'cDark' | 'cOuter'>>, scale: number): HTMLCanvasElement {
const probe = document.createElement('canvas').getContext('2d')!;
const font = p.weight + ' ' + p.size + 'px ' + p.family;
probe.letterSpacing = p.space + 'px';
probe.font = font;
const m = probe.measureText(p.text);
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
const capH = asc + desc;
const plumpPx = p.size * p.plump;
const edgeW = p.size * 0.1 * p.edge;
const outerW = edgeW + p.size * 0.22 * p.outerw + 2 * plumpPx;
const pad = Math.ceil(outerW / 2 + p.size * 0.12);
const w = Math.ceil(m.width + pad * 2);
const h = Math.ceil(capH + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const c = canvas.getContext('2d')!;
const x = pad, y = pad + asc;
const top = y - asc - plumpPx, bot = y + desc + plumpPx;
const mk = () => {
const k = document.createElement('canvas');
k.width = canvas.width;
k.height = canvas.height;
return k;
};
const setT = (cx: CanvasRenderingContext2D) => {
cx.setTransform(scale, 0, 0, scale, 0, 0);
cx.letterSpacing = p.space + 'px';
cx.font = font;
cx.textBaseline = 'alphabetic';
cx.lineJoin = 'round';
cx.lineCap = 'round';
};
const glyph = (delta: number, dx?: number, dy?: number) => {
const k = mk();
const g = k.getContext('2d')!;
setT(g);
g.fillStyle = '#fff';
g.strokeStyle = '#fff';
g.fillText(p.text, x + (dx || 0), y + (dy || 0));
if (delta > 0.1) {
g.lineWidth = delta * 2;
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
} else if (delta < -0.1) {
g.globalCompositeOperation = 'destination-out';
g.lineWidth = -delta * 2;
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
}
return k;
};
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-in';
g.drawImage(mask, 0, 0);
g.restore();
return k;
};
const contourBand = (shiftX: number, shiftY: number, insetPx: number) => {
const k = glyph(plumpPx, 0, 0);
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-out';
g.drawImage(glyph(plumpPx, shiftX, shiftY), 0, 0);
g.restore();
return clipTo(k, glyph(plumpPx - insetPx, 0, 0));
};
const vRamp = (k: HTMLCanvasElement, f0: number, f1: number, keepTop: boolean) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(scale, 0, 0, scale, 0, 0);
g.globalCompositeOperation = 'destination-in';
const gr = g.createLinearGradient(0, top, 0, bot);
const a1 = keepTop ? 1 : 0, a2 = keepTop ? 0 : 1;
gr.addColorStop(0, 'rgba(255,255,255,' + a1 + ')');
gr.addColorStop(Math.max(0.001, Math.min(0.998, f0)), 'rgba(255,255,255,' + a1 + ')');
gr.addColorStop(Math.max(0.002, Math.min(0.999, f1)), 'rgba(255,255,255,' + a2 + ')');
gr.addColorStop(1, 'rgba(255,255,255,' + a2 + ')');
g.fillStyle = gr;
g.fillRect(0, 0, k.width / scale, k.height / scale);
g.restore();
return k;
};
const blurred = (src: HTMLCanvasElement, px: number) => {
const k = mk();
const g = k.getContext('2d')!;
if (supportsFilter(g) && px > 0.3) {
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
}
return k;
};
const tint = (k: HTMLCanvasElement, color: string, alpha: number) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color;
g.fillRect(0, 0, k.width, k.height);
g.restore();
if (alpha < 1) {
const k2 = mk();
const g2 = k2.getContext('2d')!;
g2.globalAlpha = alpha;
g2.drawImage(k, 0, 0);
return k2;
}
return k;
};
const paintText = (cx: CanvasRenderingContext2D, paint: string | CanvasGradient) => {
cx.fillStyle = paint;
cx.fillText(p.text, x, y);
if (plumpPx > 0.1) {
cx.strokeStyle = paint;
cx.lineWidth = plumpPx * 2;
cx.strokeText(p.text, x, y);
}
};
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, canvas.width, canvas.height);
setT(c);
// drop shadow
c.save();
c.shadowColor = 'rgba(10,15,25,0.40)';
c.shadowBlur = p.size * 0.045 * scale;
c.shadowOffsetY = p.size * 0.03 * scale;
c.lineWidth = Math.max(outerW, edgeW + 2 * plumpPx, 1);
c.strokeStyle = 'rgba(10,15,25,0.40)';
c.strokeText(p.text, x, y);
c.restore();
const face = mk();
const f = face.getContext('2d')!;
setT(f);
if (p.outerw > 0) {
const gOuter = f.createLinearGradient(0, top - outerW / 2, 0, bot + outerW / 2);
gOuter.addColorStop(0, '#ffffff');
gOuter.addColorStop(0.45, p.cOuter);
gOuter.addColorStop(1, shade(p.cOuter, -22));
f.lineWidth = outerW;
f.strokeStyle = gOuter;
f.strokeText(p.text, x, y);
}
if (p.edge > 0) {
f.lineWidth = edgeW + 2 * plumpPx;
f.strokeStyle = p.cDark;
f.strokeText(p.text, x, y);
}
const gBody = f.createLinearGradient(0, top, 0, bot);
gBody.addColorStop(0, p.cTop);
gBody.addColorStop(0.55, p.cMid);
gBody.addColorStop(1, p.cBot);
paintText(f, gBody);
if (p.innerB > 0) {
let band = contourBand(0, -0.22 * capH, p.size * 0.03);
band = blurred(band, p.size * 0.04);
band = clipTo(band, glyph(plumpPx, 0, 0));
band = vRamp(band, 0.45, 0.8, false);
band = tint(band, deepen(p.cBot), 0.7);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.globalAlpha = Math.min(1, p.innerB);
f.drawImage(band, 0, 0);
f.restore();
}
if (p.gloss > 0) {
const shiftY = p.glossH * capH;
const shiftX = 0.12 * shiftY;
const inset = p.size * p.glossI;
const clip0 = p.glossH * 0.6, clip1 = Math.min(0.95, p.glossH * 1.4);
let band = contourBand(shiftX, shiftY, inset);
band = vRamp(band, clip0, clip1, true);
let halo = blurred(band, p.size * 0.014);
halo = clipTo(halo, glyph(plumpPx - inset * 0.5, 0, 0));
let core = mk();
core.getContext('2d')!.drawImage(band, 0, 0);
core = clipTo(core, glyph(plumpPx - inset * 1.35, 0, 0));
core = blurred(core, p.size * 0.007);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.globalAlpha = Math.min(1, 0.85 * p.gloss);
f.drawImage(halo, 0, 0);
f.globalAlpha = Math.min(1, 1.15 * p.gloss);
f.drawImage(core, 0, 0);
f.restore();
}
c.setTransform(1, 0, 0, 1, 0, 0);
c.drawImage(face, 0, 0);
return canvas;
}
// ── western 3D (HS0ZLE) ─────────────────────────────────────────────────
function noiseMask(W: number, H: number, featurePx: number, thresh: number, seed: number, softenPx: number, stretchY = 1): HTMLCanvasElement {
const sw = Math.max(2, Math.round(W / featurePx));
const sh = Math.max(2, Math.round(H / (featurePx * stretchY)));
const small = document.createElement('canvas');
small.width = sw;
small.height = sh;
const sg = small.getContext('2d')!;
const id = sg.createImageData(sw, sh);
const rnd = mulberry32(seed);
for (let i = 0; i < sw * sh; i++) {
const v = (rnd() * 255) | 0;
id.data[i * 4] = v;
id.data[i * 4 + 1] = v;
id.data[i * 4 + 2] = v;
id.data[i * 4 + 3] = 255;
}
sg.putImageData(id, 0, 0);
const big = document.createElement('canvas');
big.width = W;
big.height = H;
const bg = big.getContext('2d')!;
bg.imageSmoothingEnabled = true;
bg.imageSmoothingQuality = 'high';
if (supportsFilter(bg)) bg.filter = 'blur(' + (featurePx * 0.35).toFixed(1) + 'px)';
bg.drawImage(small, 0, 0, W, H);
if (supportsFilter(bg)) bg.filter = 'none';
const src = bg.getImageData(0, 0, W, H);
const out = bg.createImageData(W, H);
const k = 1 / Math.max(1e-6, (1 - thresh) * 0.35);
for (let i = 0; i < W * H; i++) {
const v = src.data[i * 4] / 255;
let a = (v - thresh) * k;
a = a < 0 ? 0 : a > 1 ? 1 : a;
out.data[i * 4] = 255;
out.data[i * 4 + 1] = 255;
out.data[i * 4 + 2] = 255;
out.data[i * 4 + 3] = (a * 255) | 0;
}
const mask = document.createElement('canvas');
mask.width = W;
mask.height = H;
const mg = mask.getContext('2d')!;
if (supportsFilter(mg) && softenPx > 0.3) mg.filter = 'blur(' + softenPx.toFixed(1) + 'px)';
const tmp = document.createElement('canvas');
tmp.width = W;
tmp.height = H;
tmp.getContext('2d')!.putImageData(out, 0, 0);
mg.drawImage(tmp, 0, 0);
if (supportsFilter(mg)) mg.filter = 'none';
return mask;
}
function renderWestern(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'depth' | 'angle' | 'slant' | 'grunge' | 'bevel' | 'seed' | 'cTop' | 'cMid' | 'cBot' | 'cDark'>>, scale: number): HTMLCanvasElement {
const probe = document.createElement('canvas').getContext('2d')!;
const font = p.weight + ' ' + p.size + 'px ' + p.family;
probe.letterSpacing = p.space + 'px';
probe.font = font;
const m = probe.measureText(p.text);
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
const capH = asc + desc;
const edgeW = p.size * 0.07;
const dx = Math.cos(p.angle) * p.depth, dy = Math.sin(p.angle) * p.depth;
const slantPad = Math.abs(p.slant) * capH;
const pad = Math.ceil(edgeW + p.size * 0.1 + p.depth + slantPad);
const w = Math.ceil(m.width + pad * 2);
const h = Math.ceil(capH + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const c = canvas.getContext('2d')!;
const x = pad - Math.min(0, dx), y = pad + asc - Math.min(0, dy);
const top = y - asc, bot = y + desc;
const mk = () => {
const k = document.createElement('canvas');
k.width = canvas.width;
k.height = canvas.height;
return k;
};
const setT = (cx: CanvasRenderingContext2D) => {
cx.setTransform(scale, 0, 0, scale, 0, 0);
cx.transform(1, 0, -p.slant, 1, p.slant * y, 0);
cx.letterSpacing = p.space + 'px';
cx.font = font;
cx.textBaseline = 'alphabetic';
cx.lineJoin = 'round';
cx.lineCap = 'butt'; // their source set 'miter' here, which browsers ignore on lineCap
};
const glyph = (dxx?: number, dyy?: number) => {
const k = mk();
const g = k.getContext('2d')!;
setT(g);
g.fillStyle = '#fff';
g.fillText(p.text, x + (dxx || 0), y + (dyy || 0));
return k;
};
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-in';
g.drawImage(mask, 0, 0);
g.restore();
return k;
};
const blurred = (src: HTMLCanvasElement, px: number) => {
const k = mk();
const g = k.getContext('2d')!;
if (supportsFilter(g) && px > 0.3) {
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
}
return k;
};
const tintAlpha = (k: HTMLCanvasElement, color: string, alpha: number) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color;
g.fillRect(0, 0, k.width, k.height);
g.restore();
const k2 = mk();
const g2 = k2.getContext('2d')!;
g2.globalAlpha = alpha;
g2.drawImage(k, 0, 0);
return k2;
};
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, canvas.width, canvas.height);
setT(c);
// drop shadow
c.save();
c.shadowColor = 'rgba(15,8,4,0.50)';
c.shadowBlur = p.size * 0.035 * scale;
c.shadowOffsetX = p.size * 0.02 * scale;
c.shadowOffsetY = p.size * 0.035 * scale;
c.lineWidth = edgeW;
c.strokeStyle = 'rgba(15,8,4,0.50)';
c.fillStyle = 'rgba(15,8,4,0.50)';
c.strokeText(p.text, x, y);
c.fillText(p.text, x, y);
c.restore();
// 3D extrusion
if (p.depth > 0) {
const deepCol = shade(p.cDark, -4);
c.lineWidth = edgeW;
const steps = Math.ceil(p.depth);
for (let i = steps; i >= 1; i--) {
const t = i / steps;
c.strokeStyle = deepCol;
c.fillStyle = deepCol;
c.strokeText(p.text, x + dx * t, y + dy * t);
c.fillText(p.text, x + dx * t, y + dy * t);
}
}
const face = mk();
const f = face.getContext('2d')!;
setT(f);
f.lineWidth = edgeW;
f.strokeStyle = p.cDark;
f.strokeText(p.text, x, y);
const gBody = f.createLinearGradient(0, top, 0, bot);
gBody.addColorStop(0, p.cTop);
gBody.addColorStop(0.5, p.cMid);
gBody.addColorStop(1, p.cBot);
f.fillStyle = gBody;
f.fillText(p.text, x, y);
const M = glyph(0, 0);
if (p.bevel > 0) {
let band = glyph(0, 0);
const g = band.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-out';
g.drawImage(glyph(0.02 * p.size, 0.06 * capH), 0, 0);
g.restore();
band = blurred(band, p.size * 0.008);
band = clipTo(band, M);
band = tintAlpha(band, '#ffeba8', p.bevel);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.drawImage(band, 0, 0);
f.restore();
}
if (p.grunge > 0) {
const W = canvas.width, H = canvas.height;
const S = p.size * scale;
const seed = (hashStr(p.text + '|' + p.family) ^ (p.seed * 2654435761)) >>> 0;
const rust = rustOf(p.cBot);
const layers = [
{ mask: noiseMask(W, H, 0.022 * S, 0.72, seed, 0.6 * scale), color: rust, a: 0.8 },
{ mask: noiseMask(W, H, 0.011 * S, 0.7, seed + 11, 0.5 * scale, 4.0), color: rust, a: 0.6 },
{ mask: noiseMask(W, H, 0.0066 * S, 0.74, seed + 7, 0.3 * scale), color: rust, a: 0.75 },
{ mask: noiseMask(W, H, 0.0154 * S, 0.86, seed + 3, 0.4 * scale), color: p.cDark, a: 0.9 },
];
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
for (const L of layers) {
let k = clipTo(L.mask, M);
k = tintAlpha(k, L.color, L.a * p.grunge);
f.drawImage(k, 0, 0);
}
f.restore();
}
c.setTransform(1, 0, 0, 1, 0, 0);
c.drawImage(face, 0, 0);
return canvas;
}
// Trim transparent padding so the element's x/y maps to the visible glyphs.
function trim(canvas: HTMLCanvasElement): { canvas: HTMLCanvasElement; w: number; h: number } {
const ctx = canvas.getContext('2d')!;
const W = canvas.width, H = canvas.height;
const data = ctx.getImageData(0, 0, W, H).data;
let minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (data[(y * W + x) * 4 + 3] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < minX) return { canvas, w: W, h: H };
const cw = maxX - minX + 1, ch = maxY - minY + 1;
const out = document.createElement('canvas');
out.width = cw;
out.height = ch;
out.getContext('2d')!.drawImage(canvas, minX, minY, cw, ch, 0, 0, cw, ch);
return { canvas: out, w: cw, h: ch };
}
export function renderTextFx(p: TextFxParams): TextFxResult {
let raw: HTMLCanvasElement;
if (p.kind === 'western') {
raw = renderWestern(
{
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
depth: p.depth ?? p.size * 0.06, angle: p.angle ?? (112 * Math.PI) / 180,
slant: p.slant ?? 0.08, grunge: p.grunge ?? 0.8, bevel: p.bevel ?? 0.45, seed: p.seed ?? 7,
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark,
},
SS,
);
} else {
raw = renderGlossy(
{
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
plump: p.plump ?? 0.03, edge: p.edge ?? 0.5, outerw: p.outerw ?? 0.45,
gloss: p.gloss ?? 0.85, glossH: p.glossH ?? 0.45, glossI: p.glossI ?? 0.08, innerB: p.innerB ?? 0.7,
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark, cOuter: p.cOuter ?? '#ced3db',
},
SS,
);
}
const t = trim(raw);
return { url: t.canvas.toDataURL('image/png'), w: t.w / SS, h: t.h / SS };
}
+2
View File
@@ -1129,6 +1129,7 @@ export namespace main {
export class QSLEmailTemplates {
subject: string;
body: string;
auto_send: boolean;
static createFrom(source: any = {}) {
return new QSLEmailTemplates(source);
@@ -1138,6 +1139,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source);
this.subject = source["subject"];
this.body = source["body"];
this.auto_send = source["auto_send"];
}
}
export class QSLFontInfo {
@@ -0,0 +1,93 @@
Copyright 2016 The Alfa Slab One Project Authors (http://www.jmsole.cl | info@jmsole.cl), with Reserved Font Name "Alfa Slab".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+94
View File
@@ -0,0 +1,94 @@
Copyright (c) 2011 by Sorkin Type Co (www.sorkintype.com),
with Reserved Font Name "Rye".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+2
View File
@@ -28,6 +28,8 @@ var fontFiles = []struct {
{"Lilita One", "display", "LilitaOne-Regular.ttf", false},
{"Baloo 2", "display", "Baloo2-Variable.ttf", true},
{"Oswald", "display", "Oswald-Variable.ttf", true},
{"Alfa Slab One", "display", "AlfaSlabOne-Regular.ttf", false}, // heavy slab serif (HS0ZLE-style)
{"Rye", "display", "Rye-Regular.ttf", false}, // Western slab
{"Great Vibes", "script", "GreatVibes-Regular.ttf", false},
{"Allura", "script", "Allura-Regular.ttf", false},
}
+68 -46
View File
@@ -59,6 +59,8 @@ type ProfileInfo struct {
Grid string
CQZone int
ITUZone int
Rig string
Antenna string
}
// LayoutEngine proposes card templates from analyzed photos. The heuristic
@@ -253,27 +255,24 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T
cool: sorted[0].Warmth < 0.02,
}
if len(sorted) > 1 {
plan.inserts = sorted[1:min(len(sorted), 5)] // up to 4 (bottom strip), side column uses 3
plan.inserts = sorted[1:min(len(sorted), 3)] // hero + up to 2 inserts
}
// Only side-column inserts (or none): a bottom strip collides with the QSO
// box, which is what produced the overlapping mess.
primary := ArchetypeSideColumn
if len(plan.inserts) == 0 {
primary = ArchetypeFullBleed
} else if len(plan.inserts) >= 4 && bandQuiet(plan.hero, gridRows-5, gridRows) {
primary = ArchetypeBottomStrip
}
alternate := alternateArchetype(primary, len(plan.inserts))
// Three proposals that vary BOTH the call style and its vertical position
// (top / bottom / natural-best), so the user gets genuinely distinct cards
// to choose from rather than three near-identical golds.
styles := []string{"gel_gold", "gel_silver", "classic_white_outline"}
if plan.cool {
styles[0], styles[1] = styles[1], styles[0] // lead with silver on cool photos
}
p1 := buildTemplateBiased(plan, primary, styles[0], flipTop)
p2 := buildTemplateBiased(plan, alternate, styles[1], flipBottom)
p3 := buildTemplateBiased(plan, alternate, styles[2], flipNatural)
// Three proposals that showcase genuinely different call looks (font +
// style + position), echoing the classic printed-QSL styles: a rounded
// glossy gold, a distressed slab "vintage", and an angular silver. The
// middle one is a clean full-bleed (no inserts) so the set always offers a
// minimal option.
p1 := buildTemplateBiased(plan, primary, "gel_gold", "Baloo 2", flipTop)
p2 := buildTemplateBiased(plan, ArchetypeFullBleed, "gel_gold_grunge", "Alfa Slab One", flipBottom)
p3 := buildTemplateBiased(plan, primary, "gel_silver", "Archivo Black", flipNatural)
out := []Template{p1, p2, p3}
for i := range out {
@@ -285,20 +284,6 @@ func (HeuristicEngine) Propose(photos []PhotoAnalysis, profile ProfileInfo) ([]T
return out, nil
}
func alternateArchetype(primary string, inserts int) string {
switch primary {
case ArchetypeSideColumn:
if inserts >= 2 {
return ArchetypeBottomStrip
}
return ArchetypeFullBleed
case ArchetypeBottomStrip:
return ArchetypeSideColumn
default:
return ArchetypeFullBleed
}
}
func archetypeLabel(t Template) string {
inserts := 0
for _, e := range t.Elements {
@@ -312,20 +297,6 @@ func archetypeLabel(t Template) string {
return "with inserts"
}
// bandQuiet reports whether the mean detail of grid rows [r0,r1) is low
// enough to host inserts or text.
func bandQuiet(p PhotoAnalysis, r0, r1 int) bool {
var sum float64
n := 0
for r := r0; r < r1; r++ {
for c := 0; c < gridCols; c++ {
sum += p.Cells[r][c].Detail
n++
}
}
return n > 0 && sum/float64(n) < 0.25
}
// pxRect is a rectangle in card pixel space.
type pxRect struct{ x, y, w, h float64 }
@@ -336,7 +307,23 @@ const (
flipBottom // restrict the callsign to the bottom half
)
func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) Template {
// charWidthFactor is a rough per-font average glyph width (in ems) for the
// heavy display faces, used to size the callsign so it fills its zone without
// overflowing. The frontend can refine with real measureText later.
func charWidthFactor(font string) float64 {
switch font {
case "Alfa Slab One", "Rye":
return 0.82
case "Baloo 2":
return 0.72
case "Oswald":
return 0.55
default: // Archivo Black, Lilita One
return 0.72
}
}
func buildTemplateBiased(plan proposalPlan, archetype, style, font string, flip int) Template {
hero := plan.hero
crop := cropForCard(hero)
@@ -371,13 +358,14 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) T
params := adaptStyle(style, zoneLuma, hero.Warmth)
call := plan.profile.Callsign
size := math.Min(zone.w*0.9/(0.72*float64(max(len(call), 3))), zone.h*0.95)
callW := 0.72 * size * float64(len(call))
cw := charWidthFactor(font)
size := math.Min(zone.w*0.9/(cw*float64(max(len(call), 3))), zone.h*0.95)
callW := cw * size * float64(len(call))
callX := zone.x + (zone.w-callW)/2
callY := zone.y + (zone.h-size)*0.3
t.Elements = append(t.Elements, Element{
Type: ElemCallsign, Text: "{profile.callsign}",
Font: FontDisplayDefault, Size: math.Round(size),
Font: font, Size: math.Round(size),
X: math.Round(clamp(callX, 40, cardW-80)), Y: math.Round(clamp(callY, 30, cardH-size-30)),
StylePreset: style, StyleParams: params,
})
@@ -417,6 +405,22 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) T
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
})
// Operating conditions (rig / antenna), stacked next to the zones line.
// Always added so the "Show on card" toggle exists, but hidden by default
// when the profile has no rig/antenna yet (avoids an empty "Rig · Ant").
txt, stationHidden := stationLineText(plan.profile)
stationY := infoY + 40
if infoY < callY { // zones line sits above the call → stack station above it
stationY = infoY - 40
}
t.Elements = append(t.Elements, Element{
Type: ElemInfoLine, Text: txt, Hidden: stationHidden,
Font: FontInfoLine, Size: 26,
X: math.Round(clamp(zone.x, 40, cardW-700)), Y: math.Round(clamp(stationY, 30, cardH-60)),
StylePreset: "outlined_white",
StyleParams: &StyleParams{OutlineColor: "#22364e", OutlineWidth: 5},
})
occupied := []pxRect{zone}
if hasInserts {
t.Elements = append(t.Elements, insertEls...)
@@ -435,6 +439,24 @@ func buildTemplateBiased(plan proposalPlan, archetype, style string, flip int) T
return t
}
// stationLineText builds the rig/antenna line for the profile and whether it
// should start hidden. When the profile has neither, it returns the full
// template text with hidden=true so the editor still offers the toggle (it
// resolves cleanly once the user fills My rig / My antenna). Values fill in
// from {profile.*} at render time.
func stationLineText(p ProfileInfo) (text string, hidden bool) {
switch {
case p.Rig != "" && p.Antenna != "":
return "Rig {qso.my_rig} · Ant {qso.my_antenna}", false
case p.Rig != "":
return "Rig {qso.my_rig}", false
case p.Antenna != "":
return "Ant {qso.my_antenna}", false
default:
return "Rig {qso.my_rig} · Ant {qso.my_antenna}", true
}
}
// cropForCard crops the photo to the card aspect ratio, sliding the window
// toward the detail centroid so the interesting content stays visible.
func cropForCard(p PhotoAnalysis) Crop {
+14 -10
View File
@@ -164,19 +164,23 @@ func TestProposeThreeDistinctWithInserts(t *testing.T) {
t.Errorf("proposal %d invalid: %v", i, err)
}
}
// The three proposals must use three distinct call styles from the palette
// (the user asked for variety in both style and position).
allowed := map[string]bool{"gel_gold": true, "gel_silver": true, "classic_white_outline": true}
seen := map[string]bool{}
// The three proposals must showcase distinct call looks: each style is a
// known preset, and no two proposals share the same font+style pairing.
seenStyle := map[string]bool{}
seenFont := map[string]bool{}
for i, tmpl := range out {
s := findElement(t, tmpl, ElemCallsign).StylePreset
if !allowed[s] {
t.Errorf("proposal %d: unexpected call style %q", i, s)
call := findElement(t, tmpl, ElemCallsign)
if _, ok := Presets[call.StylePreset]; !ok {
t.Errorf("proposal %d: unknown call style %q", i, call.StylePreset)
}
if seen[s] {
t.Errorf("proposal %d reuses call style %q", i, s)
if seenStyle[call.StylePreset] {
t.Errorf("proposal %d reuses call style %q", i, call.StylePreset)
}
seen[s] = true
if seenFont[call.Font] {
t.Errorf("proposal %d reuses call font %q", i, call.Font)
}
seenStyle[call.StylePreset] = true
seenFont[call.Font] = true
}
// At least one proposal uses the insert photos.
if countInserts(out[0]) == 0 && countInserts(out[1]) == 0 && countInserts(out[2]) == 0 {
+36 -10
View File
@@ -23,12 +23,12 @@ var Presets = map[string]Preset{
Label: "Gel gold",
AllowedParams: gelParams,
Defaults: StyleParams{
Gradient: []string{"#FFD83A", "#FFC312", "#EE9400"},
Shine: &Shine{Coverage: 0.5, Opacity: 0.95},
OutlineColor: "#2a3f5c", OutlineWidth: 9,
Gradient: []string{"#FFE15A", "#FFC312", "#E07A00"},
Shine: &Shine{Coverage: 0.52, Opacity: 0.95},
OutlineColor: "#2a3f5c", OutlineWidth: 10,
Halo: &Halo{Color: "#cdd9e4", Blur: 6, Opacity: 0.4},
Shadow: &ShadowFx{Dx: 5, Dy: 8, Blur: 5, Color: "#14243a", Opacity: 0.5},
BevelOffset: &Bevel{Dx: -2, Dy: -4, Dark: "#C27500", Light: "#FFEFA0"},
Shadow: &ShadowFx{Dx: 6, Dy: 9, Blur: 5, Color: "#14243a", Opacity: 0.55},
BevelOffset: &Bevel{Dx: -3, Dy: -6, Dark: "#A85F00", Light: "#FFF6C8"},
},
},
"gel_silver": {
@@ -36,12 +36,38 @@ var Presets = map[string]Preset{
Label: "Gel silver",
AllowedParams: gelParams,
Defaults: StyleParams{
Gradient: []string{"#F4F7FA", "#C9D4DE", "#93A3B3"},
Shine: &Shine{Coverage: 0.5, Opacity: 0.95},
OutlineColor: "#3c4654", OutlineWidth: 9,
Gradient: []string{"#FBFDFF", "#C9D4DE", "#8496A8"},
Shine: &Shine{Coverage: 0.52, Opacity: 0.95},
OutlineColor: "#3c4654", OutlineWidth: 10,
Halo: &Halo{Color: "#dfe7ee", Blur: 6, Opacity: 0.4},
Shadow: &ShadowFx{Dx: 5, Dy: 8, Blur: 5, Color: "#1b2530", Opacity: 0.5},
BevelOffset: &Bevel{Dx: -2, Dy: -4, Dark: "#76879a", Light: "#FFFFFF"},
Shadow: &ShadowFx{Dx: 6, Dy: 9, Blur: 5, Color: "#1b2530", Opacity: 0.55},
BevelOffset: &Bevel{Dx: -3, Dy: -6, Dark: "#67788b", Light: "#FFFFFF"},
},
},
"gel_gold_grunge": {
Name: "gel_gold_grunge",
Label: "Gel gold (vintage)",
AllowedParams: gelParams,
Defaults: StyleParams{
Gradient: []string{"#FFC83A", "#F0A21E", "#D87A00"},
Shine: &Shine{Coverage: 0.42, Opacity: 0.8},
OutlineColor: "#1c130a", OutlineWidth: 11,
Halo: &Halo{Color: "#d8c08a", Blur: 5, Opacity: 0.3},
Shadow: &ShadowFx{Dx: 5, Dy: 8, Blur: 4, Color: "#1a0f04", Opacity: 0.55},
BevelOffset: &Bevel{Dx: -2, Dy: -4, Dark: "#A85F00", Light: "#FFE08A"},
},
},
"gel_silver_grunge": {
Name: "gel_silver_grunge",
Label: "Gel silver (vintage)",
AllowedParams: gelParams,
Defaults: StyleParams{
Gradient: []string{"#EEF2F6", "#C2CDD8", "#8593A3"},
Shine: &Shine{Coverage: 0.42, Opacity: 0.8},
OutlineColor: "#16191f", OutlineWidth: 11,
Halo: &Halo{Color: "#cdd6df", Blur: 5, Opacity: 0.3},
Shadow: &ShadowFx{Dx: 5, Dy: 8, Blur: 4, Color: "#10141a", Opacity: 0.55},
BevelOffset: &Bevel{Dx: -2, Dy: -4, Dark: "#6c7d8f", Light: "#FFFFFF"},
},
},
"classic_white_outline": {
+1
View File
@@ -138,6 +138,7 @@ type Element struct {
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"`