#!/usr/bin/env node
// Renders exact text onto generated slide art, locally. The image model never draws the words.
// Usage: node scripts/compose-slides.mjs --plan plan.json --out-dir out/ [--format 4:5|9:16] [--font Georgia] [--font-file brand.ttf] [--body-font Helvetica] [--body-font-file brand-sans.ttf]
// plan.json: { "ink": "#f6ead9", "accent": "#e8a54b", "font": "Georgia", "fontFile": "brand.ttf", "bodyFont": "Helvetica", "bodyFontFile": "sans.ttf",
// "slides": [ { "art": "art/slide-1.png", "kicker": "OSTRA COFFEE", "headline": "Mornings, sorted.", "body": "...", "cta": "Shop Ostra", "align": "center|left" } ] }
// Art paths and font files in the plan are relative to the plan file. Flags override the plan.
// Text is measured with the real font (Pango), so wrapping fits whatever font resolves. Writes slide-1.png ... slide-N.png.
// Needs sharp (npm install sharp).
import { readFileSync, mkdirSync, existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
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 (typeof v !== 'string' || !/^[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 o = parseArgs(process.argv.slice(2), ['plan', 'out-dir', 'format', 'font', 'font-file', 'body-font', 'body-font-file'])
if (!o.plan || !o['out-dir']) fail('Usage: node scripts/compose-slides.mjs --plan plan.json --out-dir out/ [--format 4:5|9:16]')
const FORMATS = { '4:5': { w: 1080, h: 1350, top: 0.09, safeBottom: 0.0, head: 92 }, '9:16': { w: 1080, h: 1920, top: 0.15, safeBottom: 0.2, head: 100 } }
const f = FORMATS[o.format ?? '4:5']
if (!f) fail('--format must be 4:5 or 9:16')
const planPath = resolve(o.plan)
if (!existsSync(planPath)) fail(`Plan file not found: ${planPath}`)
let plan
try { plan = JSON.parse(readFileSync(planPath, 'utf8')) } catch (e) { fail(`The plan is not valid JSON: ${e.message}`) }
if (!Array.isArray(plan.slides) || !plan.slides.length) fail('The plan needs a non-empty "slides" array.')
const rel = (p) => (p ? resolve(dirname(planPath), p) : undefined)
const ink = hex('ink', plan.ink, '#f6ead9')
const accent = hex('accent', plan.accent, '#e8a54b')
const headFont = { family: family('font', o.font ?? plan.font, 'Georgia'), file: o['font-file'] ? resolve(o['font-file']) : rel(plan.fontFile) }
const bodyFont = { family: family('body-font', o['body-font'] ?? plan.bodyFont, 'Helvetica'), file: o['body-font-file'] ? resolve(o['body-font-file']) : rel(plan.bodyFontFile) }
for (const ff of [headFont.file, bodyFont.file]) if (ff && !existsSync(ff)) fail(`Font file not found: ${ff}`)
console.log(`Fonts: headline ${headFont.file ?? headFont.family}; body ${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.`)
for (const [i, s] of plan.slides.entries()) {
if (!s.art || !s.headline) fail(`Slide ${i + 1} needs "art" and "headline".`)
if (!existsSync(rel(s.art))) fail(`Slide ${i + 1}: art file not found: ${rel(s.art)}`)
if (s.align && !['left', 'center'].includes(s.align)) fail(`Slide ${i + 1}: align must be "left" or "center".`)
}
const margin = 90
mkdirSync(o['out-dir'], { recursive: true })
const xml = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' })[c])
const cache = new Map()
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 (t, font, st) => (await line(t, font, st)).width
async function pack(words, font, st, limit) {
const lines = []
let cur = ''
for (const w of words) {
const next = cur ? `${cur} ${w}` : w
if (cur && (await widthOf(next, font, st)) > limit) { lines.push(cur); cur = w } else cur = next
}
if (cur) lines.push(cur)
return lines
}
// Fewest lines (max `maxLines`) that fit in maxWidth, balanced by the narrowest column; returns null if none fit.
async function wrapFit(text, font, st, maxWidth, maxLines) {
const words = text.split(/\s+/).filter(Boolean)
const wordW = await Promise.all(words.map((w) => widthOf(w, font, st)))
const single = await widthOf(words.join(' '), font, st)
if (single <= maxWidth) return [words.join(' ')]
for (let n = 2; n <= maxLines; n += 1) {
if ((await pack(words, font, st, maxWidth)).length > n) continue
let lo = Math.max(...wordW)
let hi = maxWidth
while (hi - lo > 6) {
const mid = Math.round((lo + hi) / 2)
if ((await pack(words, font, st, mid)).length <= n) hi = mid; else lo = mid
}
return pack(words, font, st, hi)
}
return null
}
const total = plan.slides.length
let index = 0
for (const s of plan.slides) {
index += 1
const align = s.align ?? 'center'
const maxW = f.w - 2 * margin
const layers = []
const shapes = []
const put = async (text, font, style, x, y) => {
const l = await line(text, font, style)
const left = align === 'left' || x === 'right' ? Math.round(x === 'right' ? f.w - margin - l.width : margin) : Math.round(f.w / 2 - l.width / 2)
layers.push({ input: l.input, left, top: Math.round(y) })
return l
}
let size = f.head
let lines = null
for (; size >= 48; size -= 4) {
lines = await wrapFit(s.headline, headFont, { size, bold: true, color: ink, spacing: -1.5 }, maxW, 3)
if (lines) break
}
if (!lines) fail(`Slide ${index}: the headline does not fit in 3 lines. Shorten it.`)
// The headline position does not depend on the kicker, so it stays put across slides.
let y = Math.round(f.top * f.h)
if (s.kicker) await put(s.kicker.toUpperCase(), bodyFont, { size: 28, bold: true, color: accent, spacing: 6 }, 'edge', y)
y += 64
const lh = Math.round(size * 1.08)
for (const t of lines) { await put(t, headFont, { size, bold: true, color: ink, spacing: -1.5 }, 'edge', y); y += lh }
y += Math.round(size * 0.04)
if (s.body) {
const bs = 38
const bl = await wrapFit(s.body, bodyFont, { size: bs, color: ink, opacity: 0.85 }, maxW, 3)
if (!bl) fail(`Slide ${index}: the body line does not fit in 3 lines. Shorten it.`)
y += 14
for (const t of bl) { const l = await put(t, bodyFont, { size: bs, color: ink, opacity: 0.85 }, 'edge', y); y += Math.round(l.height * 1.05) }
}
if (s.cta) {
const cs = 32
const st = { size: cs, bold: true, color: '#2a1810', spacing: 3 }
const l = await line(s.cta.toUpperCase(), bodyFont, st)
const bh = 96
const bw = Math.min(maxW, l.width + 110)
const by = y + 40
const bx = align === 'left' ? margin : (f.w - bw) / 2
shapes.push(``)
layers.push({ input: l.input, left: Math.round(bx + (bw - l.width) / 2), top: Math.round(by + (bh - l.height) / 2) })
y = by + bh
}
// page counter and swipe arrow (arrow not on the last slide), clear of the story safe zones
const cy = Math.round(f.h * (1 - f.safeBottom)) - 70
const counter = await line(`${index} / ${total}`, bodyFont, { size: 26, bold: true, color: ink, spacing: 3, opacity: 0.6 })
layers.push({ input: counter.input, left: f.w - margin - counter.width, top: Math.round(f.top * f.h) - 40 })
if (index < total) {
const ax = f.w - margin
shapes.push(``)
shapes.push(``)
}
const pct = Math.round((y / f.h) * 100)
const limit = s.cta ? 46 : 42
if (pct > limit) console.warn(`Warning: slide ${index} text ends at ${pct} % of the height, past ${limit} %, so it may touch the product. Shorten the copy or regenerate the art with more empty space.`)
const base = await sharp(rel(s.art)).resize(f.w, f.h, { fit: 'cover' }).png().toBuffer()
const svg = Buffer.from(``)
const file = `${o['out-dir'].replace(/\/$/, '')}/slide-${index}.png`
await sharp(base).composite([{ input: svg }, ...layers]).png().toFile(file)
console.log(`${file} ${f.w}x${f.h}, headline ${size}px, ${lines.length} line(s), text ends at ${pct} %`)
}