#!/usr/bin/env node
/**
* Add text overlays to slideshow images using node-canvas.
*
* Usage: node add-text-overlay.js --input
--texts
*
* texts.json format:
* [
* "Slide 1 text with manual\nline breaks preferred",
* "Slide 2 text",
* ... 6 total
* ]
*
* TEXT RULES:
* - Use \n for manual line breaks (PREFERRED — gives you control)
* - If no \n provided, the script auto-wraps to fit within maxWidth
* - Keep lines to 4-6 words max for readability
* - Text is REACTIONS not labels ("Wait... this is nice??" not "Modern style")
* - No emoji (canvas can't render them)
*
* Reads slide1_raw.png through slide6_raw.png (or slide_1.png etc)
* Outputs slide1.png through slide6.png (or final_1.png etc)
*/
const { createCanvas, loadImage } = require('canvas');
const fs = require('fs');
const path = require('path');
const args = process.argv.slice(2);
function getArg(name) {
const idx = args.indexOf(`--${name}`);
return idx !== -1 ? args[idx + 1] : null;
}
const inputDir = getArg('input');
const textsPath = getArg('texts');
if (!inputDir || !textsPath) {
console.error('Usage: node add-text-overlay.js --input --texts ');
process.exit(1);
}
const texts = JSON.parse(fs.readFileSync(textsPath, 'utf-8'));
if (texts.length !== 6) {
console.error('ERROR: texts.json must have exactly 6 entries');
process.exit(1);
}
/**
* Word-wrap text to fit within maxWidth.
* If the text already contains \n, splits on those first,
* then wraps any lines that are still too wide.
*/
function wrapText(ctx, text, maxWidth) {
// Strip emoji (canvas can't render them reliably)
const cleanText = text.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '').trim();
// Split on manual line breaks first
const manualLines = cleanText.split('\n');
const wrappedLines = [];
for (const line of manualLines) {
// Check if this line fits as-is
if (ctx.measureText(line.trim()).width <= maxWidth) {
wrappedLines.push(line.trim());
continue;
}
// Auto-wrap: split into words, build lines that fit
const words = line.trim().split(/\s+/);
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (ctx.measureText(testLine).width <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) wrappedLines.push(currentLine);
currentLine = word;
}
}
if (currentLine) wrappedLines.push(currentLine);
}
return wrappedLines;
}
async function addTextOverlay(imgPath, text, outPath) {
const img = await loadImage(imgPath);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// ─── Text settings (match our proven viral format) ───
const fontSize = Math.round(img.width * 0.065); // 6.5% of image width (~66px on 1024w)
const outlineWidth = Math.round(fontSize * 0.15); // 15% of font size for thick outline
const maxWidth = img.width * 0.75; // 75% of image width (padding for TikTok UI)
const lineHeight = fontSize * 1.25; // 125% line height for readability
ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// Wrap text to fit within maxWidth
const lines = wrapText(ctx, text, maxWidth);
// Calculate vertical position
// Center the text block at 30% from top
const totalTextHeight = lines.length * lineHeight;
const startY = (img.height * 0.30) - (totalTextHeight / 2) + (lineHeight / 2);
// Ensure text stays in safe zones (not top 10%, not bottom 20%)
const minY = img.height * 0.10;
const maxY = img.height * 0.80 - totalTextHeight;
const safeY = Math.max(minY, Math.min(startY, maxY));
const x = img.width / 2; // Center horizontally
for (let i = 0; i < lines.length; i++) {
const y = safeY + (i * lineHeight);
// Black outline (stroke first, then fill on top)
ctx.strokeStyle = '#000000';
ctx.lineWidth = outlineWidth;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeText(lines[i], x, y);
// White fill
ctx.fillStyle = '#FFFFFF';
ctx.fillText(lines[i], x, y);
}
fs.writeFileSync(outPath, canvas.toBuffer('image/png'));
// Log the actual text layout for debugging
console.log(` ✅ ${path.basename(outPath)} — ${lines.length} lines:`);
lines.forEach(l => console.log(` "${l}"`));
}
// Find input files (supports multiple naming conventions)
function findSlideFile(dir, num) {
const candidates = [
`slide${num}_raw.png`,
`slide_${num}.png`,
`slide${num}.png`,
`raw_${num}.png`,
`${num}.png`
];
for (const name of candidates) {
const p = path.join(dir, name);
if (fs.existsSync(p)) return p;
}
return null;
}
function outputName(dir, num, inputName) {
// If input is slide1_raw.png → output slide1.png
// If input is slide_1.png → output final_1.png
if (inputName.includes('_raw')) {
return path.join(dir, inputName.replace('_raw', ''));
}
if (inputName.startsWith('slide_')) {
return path.join(dir, `final_${num}.png`);
}
return path.join(dir, `slide${num}_final.png`);
}
(async () => {
console.log('📝 Adding text overlays...\n');
console.log('Settings:');
console.log(' Font size: 6.5% of image width');
console.log(' Position: centered at ~30% from top');
console.log(' Max width: 75% of image');
console.log(' Style: white fill, black outline\n');
let success = 0;
for (let i = 0; i < 6; i++) {
const num = i + 1;
const inputFile = findSlideFile(inputDir, num);
if (!inputFile) {
console.error(` ❌ Slide ${num}: no input file found in ${inputDir}`);
continue;
}
const outPath = outputName(inputDir, num, path.basename(inputFile));
await addTextOverlay(inputFile, texts[i], outPath);
success++;
}
console.log(`\n✨ ${success}/6 overlays complete!`);
if (success < 6) process.exit(1);
})();