#!/usr/bin/env node // Composes a finished static ad: generated art + exact headline, sub-line and CTA button, rendered locally. // Usage: // node scripts/compose-ad.mjs --art art.png --format 4:5 --headline "Glow that lasts all day." \ // --subline "Vitamin C serum, 30 ml" --cta "Shop Halden" --output ad-4x5.png // Options: // --format 1:1|4:5|9:16 (default 4:5) // --ink "#3b2314" headline and sub-line colour (#hex) // --button "#3b2314" button fill (#hex); --button-text "#fbf1e4" label colour (#hex) // --font "Georgia" headline font family (default Georgia; Linux falls back to DejaVu Serif) // --font-file brand.ttf a font file for the headline (exact brand type; use a bold weight) // --body-font "Helvetica" sub-line and button font family; --body-font-file brand-sans.ttf // --text-top 0.06 where the text block starts, as a fraction of the height (default per format) // --text-bottom 0.42 only sets the warning threshold: a warning prints if the block runs past it // Text is measured with the real font, so wrapping fits whatever font resolves. Needs sharp (npm install sharp). let sharp try { sharp = (await import('sharp')).default } catch { console.error('This script needs the sharp package. Run `npm install sharp` in the Skill folder (Node 20.9 or newer), then try again.') process.exit(1) } function fail(message) { console.error(message); process.exit(1) } function parseArgs(argv, known) { const out = {} for (let i = 0; i < argv.length; i += 1) { const k = argv[i] if (!k.startsWith('--')) fail(`Unexpected argument "${k}".`) const name = k.slice(2) if (!known.includes(name)) fail(`Unknown flag --${name}. Known: ${known.map((x) => '--' + x).join(', ')}`) const v = argv[i + 1] if (v === undefined || v.startsWith('--')) fail(`Flag --${name} needs a value.`) out[name] = v i += 1 } return out } const hex = (name, value, fallback) => { const v = value ?? fallback if (typeof v !== 'string' || !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) fail(`${name} must be a #hex colour like #3b2314.`) return v.length === 4 ? '#' + [...v.slice(1)].map((c) => c + c).join('') : v.toLowerCase() } const family = (name, value, fallback) => { const v = value ?? fallback if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,60}$/.test(v)) fail(`--${name} must be a plain font family name (letters, digits, spaces).`) return v } const frac = (name, value, fallback) => { if (value === undefined) return fallback const n = Number(value) if (!Number.isFinite(n) || n < 0 || n > 0.95) fail(`--${name} must be a number between 0 and 0.95.`) return n } const o = parseArgs(process.argv.slice(2), ['art', 'format', 'headline', 'subline', 'cta', 'ink', 'button', 'button-text', 'font', 'font-file', 'body-font', 'body-font-file', 'text-top', 'text-bottom', 'output']) if (!o.art || !o.headline || !o.output) fail('Usage: node scripts/compose-ad.mjs --art art.png --headline "..." [--subline "..."] [--cta "..."] [--format 4:5] --output ad.png') // Layout. The 9:16 text stays inside the safe zone (top 14 % and bottom 20 % are covered by app UI). const FORMATS = { '1:1': { w: 1080, h: 1080, top: 0.06, bottom: 0.4, head: 78 }, '4:5': { w: 1080, h: 1350, top: 0.09, bottom: 0.42, head: 100 }, '9:16': { w: 1080, h: 1920, top: 0.15, bottom: 0.43, head: 108 }, } const f = FORMATS[o.format ?? '4:5'] if (!f) fail('--format must be 1:1, 4:5 or 9:16') const ink = hex('ink', o.ink, '#3b2314') const buttonFill = hex('button', o.button, ink) const buttonText = hex('button-text', o['button-text'], '#fbf1e4') const headFont = { family: family('font', o.font, 'Georgia'), file: o['font-file'] } const bodyFont = { family: family('body-font', o['body-font'], 'Helvetica'), file: o['body-font-file'] } import { existsSync } from 'node:fs' if (!existsSync(o.art)) fail(`Art file not found: ${o.art}`) for (const ff of [headFont.file, bodyFont.file]) if (ff && !existsSync(ff)) fail(`Font file not found: ${ff}`) console.log(`Fonts: headline ${headFont.file ? 'file ' + headFont.file : headFont.family}; body ${bodyFont.file ? 'file ' + bodyFont.file : bodyFont.family}. System fonts are resolved by fontconfig (a missing family falls back, DejaVu on Linux); pass --font-file for exact brand type.`) const xml = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' })[c]) const cache = new Map() // Renders one line of text with Pango (the real font) and returns { input, width, height }. async function line(text, font, { size, bold, color, spacing = 0, opacity = 1 }) { const key = JSON.stringify([text, font, size, bold, color, spacing, opacity]) if (cache.has(key)) return cache.get(key) const alpha = Math.round(opacity * 255).toString(16).padStart(2, '0') const markup = `${xml(text)}` const options = { text: markup, font: `${font.family}${bold ? ' Bold' : ''} ${size}`, rgba: true, dpi: 72 } if (font.file) options.fontfile = font.file const { data, info } = await sharp({ text: options }).png().toBuffer({ resolveWithObject: true }) const r = { input: data, width: info.width, height: info.height } cache.set(key, r) return r } const widthOf = async (text, font, style) => (await line(text, font, style)).width // Fewest lines (max 3) that fit in maxWidth, balanced by the narrowest column; "|" forces a break. async function wrapFit(text, font, style, maxWidth) { if (text.includes('|')) { const lines = text.split('|').map((s) => s.trim()) const widths = await Promise.all(lines.map((l) => widthOf(l, font, style))) return Math.max(...widths) <= maxWidth ? lines : null } const words = text.split(/\s+/).filter(Boolean) const wordW = await Promise.all(words.map((w) => widthOf(w, font, style))) for (let n = 1; n <= 3; n += 1) { let lo = Math.max(...wordW) let hi = Math.min(maxWidth, await widthOf(words.join(' '), font, style)) if (n === 1) { if (hi <= maxWidth && (await widthOf(words.join(' '), font, style)) <= maxWidth) return [words.join(' ')]; continue } const pack = async (limit) => { const lines = [] let cur = '' for (const w of words) { const next = cur ? `${cur} ${w}` : w if (cur && (await widthOf(next, font, style)) > limit) { lines.push(cur); cur = w } else cur = next } if (cur) lines.push(cur) return lines } if ((await pack(maxWidth)).length > n) continue hi = maxWidth while (hi - lo > 6) { const mid = Math.round((lo + hi) / 2) if ((await pack(mid)).length <= n) hi = mid; else lo = mid } return pack(hi) } return null } const textTop = Math.round(frac('text-top', o['text-top'], f.top) * f.h) const textBottom = Math.round(frac('text-bottom', o['text-bottom'], f.bottom) * f.h) const maxWidth = f.w - 2 * Math.round(f.w * 0.1) const cx = f.w / 2 let size = f.head let lines = null for (; size >= 40; size -= 2) { lines = await wrapFit(o.headline, headFont, { size, bold: true, color: ink, spacing: -1.5 }, maxWidth) if (lines) break } if (!lines) fail('The headline does not fit even at the smallest size. Shorten it.') const layers = [] const place = async (text, font, style, y) => { const l = await line(text, font, style) layers.push({ input: l.input, left: Math.round(cx - l.width / 2), top: Math.round(y) }) return l } let y = textTop const lineHeight = Math.round(size * 1.08) for (const t of lines) { await place(t, headFont, { size, bold: true, color: ink, spacing: -1.5 }, y) y += lineHeight } y += Math.round(size * 0.04) if (o.subline) { const subSize = Math.round(size * 0.36) const l = await place(o.subline, bodyFont, { size: subSize, color: ink, spacing: 1.5 }, y + Math.round(subSize * 0.4)) y += Math.round(subSize * 0.4) + l.height } const shapes = [] if (o.cta) { const ctaSize = Math.round(size * 0.3) const style = { size: ctaSize, bold: true, color: buttonText, spacing: 3 } const label = o.cta.toUpperCase() const l = await line(label, bodyFont, style) const bh = Math.round(ctaSize * 2.9) const bw = Math.min(maxWidth, l.width + Math.round(ctaSize * 3.2)) const by = y + Math.round(size * 0.3) shapes.push(``) layers.push({ input: l.input, left: Math.round(cx - l.width / 2), top: Math.round(by + (bh - l.height) / 2) }) y = by + bh } const pct = Math.round((y / f.h) * 100) if (y > textBottom) console.warn(`Warning: the text block ends at ${pct} % of the height, past the ${Math.round((textBottom / f.h) * 100)} % limit, so it may touch the product. Shorten the copy, move --text-top up, or regenerate the art with the product lower.`) const base = await sharp(o.art).resize(f.w, f.h, { fit: 'cover' }).png().toBuffer() const svg = Buffer.from(`${shapes.join('')}`) // order: button shape first, then all text layers on top await sharp(base).composite([{ input: svg }, ...layers]).png().toFile(o.output) console.log(`${o.output} ${f.w}x${f.h}, headline ${size}px, ${lines.length} line(s), text ends at ${pct} %`)