#!/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(`${shapes.join('')}`) 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} %`) }