|
| 1 | +// Layer-spec → SVG renderer. |
| 2 | +// |
| 3 | +// Same layer schema the browser editor produces (text / box / logo). |
| 4 | +// Rendered server-side as SVG (the Workers runtime has no canvas); |
| 5 | +// the SVG is sent to the client where the browser rasterizes it, |
| 6 | +// the OG card scraper rasterizes it, or Twitter/Facebook caches a |
| 7 | +// rasterized version. |
| 8 | +// |
| 9 | +// Key differences vs canvas rendering: |
| 10 | +// |
| 11 | +// - Web fonts have to be requested by the BROWSER (or the OG |
| 12 | +// scraper) — we just reference them by family. The SVG <style> |
| 13 | +// block declares an @import from fonts.googleapis.com so a |
| 14 | +// fresh paint loads the right font. Google's OG previewer does |
| 15 | +// execute the @import and pick up the right typography; some |
| 16 | +// older scrapers don't and fall back to the family stack. |
| 17 | +// |
| 18 | +// - Text wrapping isn't automatic. We measure char widths heuristic- |
| 19 | +// ally and break at word boundaries to fit the layer width. The |
| 20 | +// measurements aren't pixel-perfect (the browser does the real |
| 21 | +// layout) but they're close enough that titles don't overflow. |
| 22 | +// |
| 23 | +// - Rotation is applied via a transform on the group, not a per- |
| 24 | +// layer matrix. |
| 25 | + |
| 26 | +import { renderTemplate } from './template.js'; |
| 27 | + |
| 28 | +// SVG-attribute XML escape. NOT the same as HTML escape — we need to |
| 29 | +// quote the five XML entities. Caller is responsible for ensuring |
| 30 | +// strings going into class/font-family/href are safe. |
| 31 | +function xml(s) { |
| 32 | + return String(s ?? '').replace(/[&<>"']/g, (c) => |
| 33 | + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 34 | +} |
| 35 | + |
| 36 | +// Greedy word-wrap given an approximate "characters per line" budget. |
| 37 | +// We compute that from the layer's pixel width and the font size, |
| 38 | +// using a rough 0.55 em-width-per-character heuristic (works well for |
| 39 | +// most proportional fonts at heading sizes). |
| 40 | +function wrapText(text, fontSize, layerWidthPx, maxLines = 6) { |
| 41 | + const charBudget = Math.max(8, Math.floor(layerWidthPx / (fontSize * 0.55))); |
| 42 | + const lines = []; |
| 43 | + for (const para of String(text).split('\n')) { |
| 44 | + const words = para.split(/\s+/).filter(Boolean); |
| 45 | + if (!words.length) { lines.push(''); continue; } |
| 46 | + let line = ''; |
| 47 | + for (const w of words) { |
| 48 | + const trial = line ? line + ' ' + w : w; |
| 49 | + if (trial.length <= charBudget) line = trial; |
| 50 | + else { |
| 51 | + if (line) lines.push(line); |
| 52 | + line = w; |
| 53 | + if (lines.length >= maxLines) break; |
| 54 | + } |
| 55 | + } |
| 56 | + if (line && lines.length < maxLines) lines.push(line); |
| 57 | + } |
| 58 | + // Add ellipsis if we ran out of room. |
| 59 | + if (lines.length >= maxLines) { |
| 60 | + lines[maxLines - 1] = lines[maxLines - 1].replace(/\s*\S{1,8}$/, '') + '…'; |
| 61 | + } |
| 62 | + return lines.slice(0, maxLines); |
| 63 | +} |
| 64 | + |
| 65 | +// Extract the first quoted family from a CSS family stack so we can |
| 66 | +// declare an @import for it. Returns '' for system stacks where no |
| 67 | +// @import is needed. |
| 68 | +function googleFontFamily(stack) { |
| 69 | + const m = String(stack || '').match(/"([^"]+)"/); |
| 70 | + if (!m) return ''; |
| 71 | + const fam = m[1].trim(); |
| 72 | + // Skip families that are guaranteed to be in the system or aren't |
| 73 | + // on Google Fonts. |
| 74 | + if (/^(Times New Roman|Helvetica Neue|Courier New|Trebuchet MS|Arial|Impact)$/i.test(fam)) return ''; |
| 75 | + return fam; |
| 76 | +} |
| 77 | + |
| 78 | +// Convert a hex/rgba colour into a string SVG accepts. SVG accepts |
| 79 | +// CSS-style rgba() and hex directly, so we mostly pass through. |
| 80 | +function colour(v) { |
| 81 | + if (!v) return '#000'; |
| 82 | + return String(v); |
| 83 | +} |
| 84 | + |
| 85 | +// Render one layer as an SVG fragment. |
| 86 | +function renderLayer(layer, ctx) { |
| 87 | + const opacity = layer.opacity != null ? layer.opacity : 1; |
| 88 | + const rot = layer.rotation || 0; |
| 89 | + const cx = layer.x + layer.w / 2; |
| 90 | + const cy = layer.y + layer.h / 2; |
| 91 | + const transform = rot ? ` transform="rotate(${rot} ${cx} ${cy})"` : ''; |
| 92 | + const opAttr = opacity < 1 ? ` opacity="${opacity}"` : ''; |
| 93 | + |
| 94 | + if (layer.kind === 'box') { |
| 95 | + const fill = colour(layer.fill || 'rgba(0,0,0,0.55)'); |
| 96 | + const r = layer.radius || 0; |
| 97 | + return `<rect x="${layer.x}" y="${layer.y}" width="${layer.w}" height="${layer.h}" rx="${r}" ry="${r}" fill="${xml(fill)}"${opAttr}${transform}/>`; |
| 98 | + } |
| 99 | + |
| 100 | + if (layer.kind === 'text') { |
| 101 | + const fontSize = layer.size || 60; |
| 102 | + const family = layer.family || 'system-ui, sans-serif'; |
| 103 | + const weight = layer.weight || '600'; |
| 104 | + const italic = layer.italic ? 'italic' : 'normal'; |
| 105 | + const align = layer.align || 'left'; |
| 106 | + const color = colour(layer.color || '#ffffff'); |
| 107 | + const lineH = fontSize * (layer.lineHeight || 1.15); |
| 108 | + |
| 109 | + // Expand tokens in the text string against the context. |
| 110 | + const display = renderTemplate(layer.text || '', ctx); |
| 111 | + const lines = wrapText(display, fontSize, layer.w); |
| 112 | + |
| 113 | + // text-anchor maps from CSS text-align. |
| 114 | + const anchor = align === 'center' ? 'middle' : align === 'right' ? 'end' : 'start'; |
| 115 | + const anchorX = align === 'center' ? (layer.x + layer.w / 2) |
| 116 | + : align === 'right' ? (layer.x + layer.w) |
| 117 | + : layer.x; |
| 118 | + |
| 119 | + // Drop shadow: SVG supports the same shadow-blur via <filter>. |
| 120 | + // We define an inline filter per text layer that wants shadow. |
| 121 | + const shadowId = layer.shadow ? `shadow-${Math.random().toString(36).slice(2, 8)}` : ''; |
| 122 | + const shadowDef = layer.shadow ? ` |
| 123 | + <defs> |
| 124 | + <filter id="${shadowId}" x="-10%" y="-10%" width="120%" height="120%"> |
| 125 | + <feDropShadow dx="0" dy="2" stdDeviation="${(layer.shadowBlur != null ? layer.shadowBlur : 8) / 2}" flood-color="${xml(layer.shadowColor || 'rgba(0,0,0,0.6)')}"/> |
| 126 | + </filter> |
| 127 | + </defs>` : ''; |
| 128 | + const filterAttr = shadowId ? ` filter="url(#${shadowId})"` : ''; |
| 129 | + |
| 130 | + const tspans = lines.map((line, i) => |
| 131 | + `<tspan x="${anchorX}" dy="${i === 0 ? 0 : lineH}">${xml(line)}</tspan>` |
| 132 | + ).join(''); |
| 133 | + |
| 134 | + return `${shadowDef}<text x="${anchorX}" y="${layer.y + fontSize * 0.85}" |
| 135 | + font-family="${xml(family)}" |
| 136 | + font-size="${fontSize}" |
| 137 | + font-weight="${xml(weight)}" |
| 138 | + font-style="${italic}" |
| 139 | + fill="${xml(color)}" |
| 140 | + text-anchor="${anchor}"${opAttr}${filterAttr}${transform}>${tspans}</text>`; |
| 141 | + } |
| 142 | + |
| 143 | + if (layer.kind === 'logo' && layer.url) { |
| 144 | + // SVG <image> takes href + preserveAspectRatio. We use xMidYMid |
| 145 | + // meet so the logo scales to fit without distortion (same as the |
| 146 | + // canvas renderer's Math.min(w/iw, h/ih) cover-fit). |
| 147 | + const href = layer.url; |
| 148 | + return `<image href="${xml(href)}" x="${layer.x}" y="${layer.y}" width="${layer.w}" height="${layer.h}" preserveAspectRatio="xMidYMid meet"${opAttr}${transform}/>`; |
| 149 | + } |
| 150 | + |
| 151 | + return ''; |
| 152 | +} |
| 153 | + |
| 154 | +// Public entry. Renders the entire template against ctx and returns |
| 155 | +// a self-contained SVG document string. |
| 156 | +// |
| 157 | +// spec — the cover_template spec_json (parsed): { width, height, |
| 158 | +// background, layers: [...], __official?, __version? } |
| 159 | +// ctx — template context (see buildBrandContext in template.js) |
| 160 | +// |
| 161 | +// Returns the SVG string, ready to send with content-type: |
| 162 | +// image/svg+xml. |
| 163 | +export function renderCoverSvg(spec, ctx) { |
| 164 | + const W = spec?.width || 1200; |
| 165 | + const H = spec?.height || 630; |
| 166 | + const layers = Array.isArray(spec?.layers) ? spec.layers : []; |
| 167 | + |
| 168 | + // Collect unique Google Fonts referenced by text layers. |
| 169 | + const families = new Set(); |
| 170 | + for (const l of layers) { |
| 171 | + if (l.kind === 'text') { |
| 172 | + const fam = googleFontFamily(l.family); |
| 173 | + if (fam) families.add(fam); |
| 174 | + } |
| 175 | + } |
| 176 | + // Compose the @import URL. fonts.googleapis.com supports loading |
| 177 | + // multiple families in one stylesheet — concatenate with &. |
| 178 | + const fontImport = families.size |
| 179 | + ? '@import url("https://fonts.googleapis.com/css2?' + |
| 180 | + [...families].map((f) => |
| 181 | + `family=${encodeURIComponent(f).replace(/%20/g, '+')}:wght@300;400;500;600;700;800` |
| 182 | + ).join('&') + '&display=swap");' |
| 183 | + : ''; |
| 184 | + |
| 185 | + // Background. Either an asset URL (covers the whole viewport) or a |
| 186 | + // solid colour from the first 'backdrop' box layer if present. |
| 187 | + let backgroundEl = ''; |
| 188 | + if (spec?.background?.url) { |
| 189 | + backgroundEl = `<image href="${xml(spec.background.url)}" x="0" y="0" width="${W}" height="${H}" preserveAspectRatio="xMidYMid slice"/>`; |
| 190 | + } else { |
| 191 | + // Fall back to a near-black backdrop so transparent text doesn't |
| 192 | + // render on a transparent canvas. |
| 193 | + backgroundEl = `<rect x="0" y="0" width="${W}" height="${H}" fill="#0a0c10"/>`; |
| 194 | + } |
| 195 | + |
| 196 | + const layerEls = layers.map((l) => renderLayer(l, ctx)).filter(Boolean).join('\n '); |
| 197 | + |
| 198 | + return `<?xml version="1.0" encoding="UTF-8"?> |
| 199 | +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"> |
| 200 | + <style>${fontImport}</style> |
| 201 | + ${backgroundEl} |
| 202 | + ${layerEls} |
| 203 | +</svg>`; |
| 204 | +} |
0 commit comments