// 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 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>, 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>, 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 }; }