#!/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(``)
// 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} %`)