#!/usr/bin/env node // Renders a title or end card as a transparent PNG from a TrueType font file. // Pure Node 18+, no npm packages and no ffmpeg drawtext needed (many ffmpeg builds ship without it). // The text is drawn exactly as typed, so spelling never depends on an image or video model. // // Usage: // node scripts/text-card.mjs --out title.png --width 1280 --height 720 \ // --title "Your Title" --title-font fonts/CormorantGaramond-Italic.ttf \ // [--kicker "SMALL LINE ABOVE"] [--kicker-font fonts/CormorantGaramond.ttf] \ // [--subline "small line below"] [--color "#F3ECE0"] [--position center|lower-third|upper-third] \ // [--title-size 0.11] [--kicker-size 0.026] [--tracking 0.01] [--kicker-tracking 0.32] [--shadow 0.35] // // Fonts: TrueType outlines (.ttf with a glyf table), e.g. Google Fonts OFL serifs such as // Cormorant Garamond, EB Garamond or Playfair Display. CFF-based .otf files are refused with a message. import { readFileSync, writeFileSync } from 'node:fs' import { deflateSync } from 'node:zlib' import { fileURLToPath } from 'node:url' // ---------- TrueType parsing ---------- const tag = (b, o) => String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]) export function loadFont(path) { let buf try { buf = readFileSync(path) } catch { throw new Error(`Font file not found: ${path}. Download a free .ttf (for example Cormorant Garamond from Google Fonts) and pass its path.`) } const v = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) const sig = tag(buf, 0) if (sig === 'OTTO') throw new Error(`${path} is a CFF/PostScript .otf font. Use a TrueType .ttf (glyf outlines) instead, such as the Google Fonts .ttf download.`) if (sig === 'ttcf') throw new Error(`${path} is a font collection (.ttc). Pass a single .ttf file.`) if (v.getUint32(0) !== 0x00010000 && sig !== 'true') throw new Error(`${path} does not look like a TrueType font.`) const tables = {} const n = v.getUint16(4) for (let i = 0; i < n; i++) { const r = 12 + i * 16 tables[tag(buf, r)] = { offset: v.getUint32(r + 8), length: v.getUint32(r + 12) } } for (const t of ['head', 'hhea', 'hmtx', 'maxp', 'cmap', 'loca', 'glyf']) { if (!tables[t]) throw new Error(`${path} is missing the ${t} table. Use a TrueType .ttf font.`) } const head = tables.head.offset const unitsPerEm = v.getUint16(head + 18) const longLoca = v.getInt16(head + 50) === 1 const numGlyphs = v.getUint16(tables.maxp.offset + 4) const hhea = tables.hhea.offset const ascender = v.getInt16(hhea + 4) const descender = v.getInt16(hhea + 6) const numHMetrics = v.getUint16(hhea + 34) const advance = (g) => v.getUint16(tables.hmtx.offset + 4 * Math.min(g, numHMetrics - 1)) const locaAt = (g) => (longLoca ? v.getUint32(tables.loca.offset + 4 * g) : v.getUint16(tables.loca.offset + 2 * g) * 2) // cmap: prefer a full-Unicode format 12 table, then format 4. const cmap = tables.cmap.offset let f4 = null let f12 = null for (let i = 0; i < v.getUint16(cmap + 2); i++) { const r = cmap + 4 + i * 8 const platform = v.getUint16(r) const encoding = v.getUint16(r + 2) const sub = cmap + v.getUint32(r + 4) const format = v.getUint16(sub) const unicode = platform === 0 || (platform === 3 && (encoding === 1 || encoding === 10)) if (!unicode) continue if (format === 12 && !f12) f12 = sub if (format === 4 && !f4) f4 = sub } function glyphFor(code) { if (f12) { const groups = v.getUint32(f12 + 12) for (let i = 0; i < groups; i++) { const g = f12 + 16 + i * 12 const start = v.getUint32(g) const end = v.getUint32(g + 4) if (code >= start && code <= end) return v.getUint32(g + 8) + (code - start) } return 0 } if (f4) { const segX2 = v.getUint16(f4 + 6) const ends = f4 + 14 const starts = ends + segX2 + 2 const deltas = starts + segX2 const ranges = deltas + segX2 for (let i = 0; i < segX2 / 2; i++) { const end = v.getUint16(ends + 2 * i) if (code > end) continue const start = v.getUint16(starts + 2 * i) if (code < start) return 0 const delta = v.getInt16(deltas + 2 * i) const ro = v.getUint16(ranges + 2 * i) if (ro === 0) return (code + delta) & 0xffff const gi = v.getUint16(ranges + 2 * i + ro + 2 * (code - start)) return gi === 0 ? 0 : (gi + delta) & 0xffff } } return 0 } // Glyph outlines as arrays of contours of {x, y, on}. function outline(g, depth = 0) { if (g >= numGlyphs || depth > 8) return [] const start = locaAt(g) const end = locaAt(g + 1) if (end <= start) return [] const o = tables.glyf.offset + start const contours = v.getInt16(o) if (contours >= 0) { const endPts = [] for (let i = 0; i < contours; i++) endPts.push(v.getUint16(o + 10 + 2 * i)) const count = contours ? endPts[contours - 1] + 1 : 0 let p = o + 10 + 2 * contours p += 2 + v.getUint16(p) const flags = [] while (flags.length < count) { const f = buf[p++] flags.push(f) if (f & 8) { let rep = buf[p++] while (rep-- > 0) flags.push(f) } } const xs = [] let x = 0 for (const f of flags) { if (f & 2) { const d = buf[p++]; x += f & 16 ? d : -d } else if (!(f & 16)) { x += v.getInt16(p); p += 2 } xs.push(x) } const ys = [] let y = 0 for (const f of flags) { if (f & 4) { const d = buf[p++]; y += f & 32 ? d : -d } else if (!(f & 32)) { y += v.getInt16(p); p += 2 } ys.push(y) } const out = [] let s = 0 for (const e of endPts) { const c = [] for (let i = s; i <= e; i++) c.push({ x: xs[i], y: ys[i], on: (flags[i] & 1) === 1 }) out.push(c) s = e + 1 } return out } // Compound glyph: components with offsets and optional scale. const out = [] let p = o + 10 for (;;) { const flags = v.getUint16(p) const gi = v.getUint16(p + 2) p += 4 let dx let dy if (flags & 1) { dx = v.getInt16(p); dy = v.getInt16(p + 2); p += 4 } else { dx = v.getInt8(p); dy = v.getInt8(p + 1); p += 2 } let a = 1, b = 0, c = 0, d = 1 const f2 = (q) => v.getInt16(q) / 16384 if (flags & 8) { a = d = f2(p); p += 2 } else if (flags & 0x40) { a = f2(p); d = f2(p + 2); p += 4 } else if (flags & 0x80) { a = f2(p); b = f2(p + 2); c = f2(p + 4); d = f2(p + 6); p += 8 } if (!(flags & 2)) { dx = 0; dy = 0 } // point-matching offsets are rare in Latin text fonts; ignore them for (const contour of outline(gi, depth + 1)) out.push(contour.map((pt) => ({ x: a * pt.x + c * pt.y + dx, y: b * pt.x + d * pt.y + dy, on: pt.on }))) if (!(flags & 0x20)) break } return out } const kerning = readGposKerning(buf, v, tables.GPOS) return { unitsPerEm, ascender, descender, glyphFor, advance, outline, kern: (a, b) => kerning(a, b) } } // Pair kerning from GPOS lookup type 2 (and type 9 extensions). Returns (left, right) => adjustment in font units. function readGposKerning(buf, v, table) { if (!table) return () => 0 const base = table.offset const lookupList = base + v.getUint16(base + 8) const subtables = [] for (let i = 0; i < v.getUint16(lookupList); i++) { const lookup = lookupList + v.getUint16(lookupList + 2 + 2 * i) const type = v.getUint16(lookup) for (let j = 0; j < v.getUint16(lookup + 4); j++) { let st = lookup + v.getUint16(lookup + 6 + 2 * j) let t = type if (t === 9) { t = v.getUint16(st + 2); st += v.getUint32(st + 4) } if (t === 2) subtables.push(st) } } const coverageIndex = (cov, g) => { const format = v.getUint16(cov) if (format === 1) { for (let i = 0; i < v.getUint16(cov + 2); i++) if (v.getUint16(cov + 4 + 2 * i) === g) return i } else if (format === 2) { for (let i = 0; i < v.getUint16(cov + 2); i++) { const r = cov + 4 + 6 * i if (g >= v.getUint16(r) && g <= v.getUint16(r + 2)) return v.getUint16(r + 4) + g - v.getUint16(r) } } return -1 } const classOf = (cd, g) => { const format = v.getUint16(cd) if (format === 1) { const start = v.getUint16(cd + 2) const count = v.getUint16(cd + 4) return g >= start && g < start + count ? v.getUint16(cd + 6 + 2 * (g - start)) : 0 } if (format === 2) { for (let i = 0; i < v.getUint16(cd + 2); i++) { const r = cd + 4 + 6 * i if (g >= v.getUint16(r) && g <= v.getUint16(r + 2)) return v.getUint16(r + 4) } } return 0 } const valueSize = (f) => { let n = 0; for (let x = f; x; x >>= 1) n += x & 1; return n * 2 } const xAdvance = (rec, f) => { // ValueRecord field order: XPlacement, YPlacement, XAdvance ... if (!(f & 4)) return 0 let off = 0 if (f & 1) off += 2 if (f & 2) off += 2 return v.getInt16(rec + off) } const cache = new Map() return (a, b) => { const k = a * 65536 + b if (cache.has(k)) return cache.get(k) let result = 0 for (const st of subtables) { const format = v.getUint16(st) const ci = coverageIndex(st + v.getUint16(st + 2), a) if (ci < 0) continue const vf1 = v.getUint16(st + 4) const vf2 = v.getUint16(st + 6) if (format === 1) { const set = st + v.getUint16(st + 10 + 2 * ci) const recSize = 2 + valueSize(vf1) + valueSize(vf2) let found = false for (let i = 0; i < v.getUint16(set); i++) { const r = set + 2 + i * recSize if (v.getUint16(r) === b) { result = xAdvance(r + 2, vf1); found = true; break } } if (found) break } else if (format === 2) { const c1 = classOf(st + v.getUint16(st + 8), a) const c2 = classOf(st + v.getUint16(st + 10), b) const class2Count = v.getUint16(st + 14) const recSize = valueSize(vf1) + valueSize(vf2) const r = st + 16 + (c1 * class2Count + c2) * recSize result = xAdvance(r, vf1) if (result) break } } cache.set(k, result) return result } } // ---------- Layout and rasterising ---------- function flatten(contour, toPx) { // Converts quadratic TrueType contours into polygons in pixel space. const pts = contour if (!pts.length) return [] let startIdx = pts.findIndex((p) => p.on) let seq if (startIdx === -1) { const m = { x: (pts[0].x + pts[1 % pts.length].x) / 2, y: (pts[0].y + pts[1 % pts.length].y) / 2, on: true } seq = [m, ...pts.slice(1), pts[0]] } else seq = [...pts.slice(startIdx), ...pts.slice(0, startIdx)] const poly = [] const push = (x, y) => poly.push(toPx(x, y)) let cur = seq[0] push(cur.x, cur.y) for (let i = 1; i <= seq.length; i++) { const p = seq[i % seq.length] if (p.on) { push(p.x, p.y); cur = p; continue } let next = seq[(i + 1) % seq.length] let end = next.on ? next : { x: (p.x + next.x) / 2, y: (p.y + next.y) / 2, on: true } const steps = 10 for (let s = 1; s <= steps; s++) { const t = s / steps const x = (1 - t) * (1 - t) * cur.x + 2 * (1 - t) * t * p.x + t * t * end.x const y = (1 - t) * (1 - t) * cur.y + 2 * (1 - t) * t * p.y + t * t * end.y push(x, y) } cur = end if (next.on) i++ } return poly } function layoutLine(font, text, sizePx, trackingEm) { const scale = sizePx / font.unitsPerEm const glyphs = [] let pen = 0 let prev = null const chars = [...text] chars.forEach((ch, i) => { const g = font.glyphFor(ch.codePointAt(0)) if (g === 0 && ch.trim()) throw new Error(`The font has no glyph for "${ch}". Pick a font that covers your text.`) if (prev !== null) pen += font.kern(prev, g) * scale glyphs.push({ g, x: pen }) pen += font.advance(g) * scale if (i < chars.length - 1) pen += trackingEm * sizePx prev = g }) return { glyphs, width: pen, scale } } function rasterise(polys, width, height, ss = 4) { // Non-zero winding scanline fill at ss x ss supersampling, box-filtered down to an alpha mask. const W = width * ss const H = height * ss const acc = new Float32Array(width * height) const edges = [] for (const poly of polys) { for (let i = 0; i < poly.length; i++) { const [x0, y0] = poly[i] const [x1, y1] = poly[(i + 1) % poly.length] if (y0 === y1) continue edges.push(y0 < y1 ? { x0: x0 * ss, y0: y0 * ss, x1: x1 * ss, y1: y1 * ss, dir: 1 } : { x0: x1 * ss, y0: y1 * ss, x1: x0 * ss, y1: y0 * ss, dir: -1 }) } } if (!edges.length) return acc let minY = Infinity let maxY = -Infinity for (const e of edges) { minY = Math.min(minY, e.y0); maxY = Math.max(maxY, e.y1) } const from = Math.max(0, Math.floor(minY)) const to = Math.min(H - 1, Math.ceil(maxY)) const hits = [] for (let sy = from; sy <= to; sy++) { const y = sy + 0.5 hits.length = 0 for (const e of edges) { if (y < e.y0 || y >= e.y1) continue hits.push({ x: e.x0 + ((y - e.y0) / (e.y1 - e.y0)) * (e.x1 - e.x0), dir: e.dir }) } if (hits.length < 2) continue hits.sort((a, b) => a.x - b.x) let wind = 0 const row = Math.floor(sy / ss) * width for (let i = 0; i < hits.length - 1; i++) { wind += hits[i].dir if (wind === 0) continue const xa = Math.max(0, hits[i].x) const xb = Math.min(W, hits[i + 1].x) for (let sx = Math.ceil(xa - 0.5); sx < xb - 0.5; sx++) acc[row + Math.floor(sx / ss)] += 1 } } const norm = 1 / (ss * ss) for (let i = 0; i < acc.length; i++) acc[i] = Math.min(1, acc[i] * norm) return acc } function blur(mask, w, h, r) { if (r < 1) return mask const pass = (src, horizontal) => { const out = new Float32Array(src.length) for (let a = 0; a < (horizontal ? h : w); a++) { let sum = 0 const len = horizontal ? w : h const at = (i) => (horizontal ? a * w + i : i * w + a) for (let i = -r; i <= r; i++) sum += src[at(Math.min(len - 1, Math.max(0, i)))] for (let i = 0; i < len; i++) { out[at(i)] = sum / (2 * r + 1) sum += src[at(Math.min(len - 1, i + r + 1))] - src[at(Math.max(0, i - r))] } } return out } let m = mask for (let k = 0; k < 3; k++) m = pass(pass(m, true), false) return m } // ---------- PNG ---------- const CRC = (() => { const t = new Uint32Array(256) for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 } return t })() function crc32(bytes) { let c = 0xffffffff; for (const b of bytes) c = CRC[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0 } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length) const td = Buffer.concat([Buffer.from(type, 'ascii'), data]) const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td)) return Buffer.concat([len, td, crc]) } export function encodePng(rgba, w, h) { const raw = Buffer.alloc((w * 4 + 1) * h) for (let y = 0; y < h; y++) { raw[y * (w * 4 + 1)] = 0; rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4) } const ihdr = Buffer.alloc(13) ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0 return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]) } // ---------- Card ---------- export function parseHex(value, name = 'colour') { if (!/^#?[0-9a-fA-F]{6}$/.test(String(value))) throw new Error(`${name} must be a 6-digit hex colour like #F3ECE0, got "${value}".`) const h = String(value).replace('#', '') return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) } /** * lines: [{ text, font (path), size (fraction of height), tracking (em), opacity }], stacked top to bottom. * position: center | lower-third | upper-third. Returns a PNG buffer the size of the frame. */ export function renderCard({ width, height, lines, color = '#F3ECE0', position = 'center', shadow = 0.35, gap = 0.7 }) { if (!Number.isInteger(width) || !Number.isInteger(height) || width < 64 || height < 64 || width > 4096 || height > 4096) throw new Error('width and height must be whole numbers between 64 and 4096.') if (!['center', 'lower-third', 'upper-third'].includes(position)) throw new Error(`position must be center, lower-third or upper-third, not "${position}".`) const rgb = parseHex(color) const fonts = new Map() const prepared = lines.filter((l) => l.text && l.text.trim()).map((l) => { if (!fonts.has(l.font)) fonts.set(l.font, loadFont(l.font)) const font = fonts.get(l.font) const sizePx = l.size * height const lay = layoutLine(font, l.text, sizePx, l.tracking ?? 0) return { ...l, font, sizePx, lay } }) if (!prepared.length) throw new Error('Nothing to draw: the card text is empty.') for (const p of prepared) { if (p.lay.width > width * 0.9) throw new Error(`"${p.text}" is too wide for the frame at this size. Shorten it or lower its size.`) } // Vertical stack: each line takes its ascent + descent; the gap between lines scales with the smaller line. const asc = (p) => p.sizePx * 0.78 const desc = (p) => p.sizePx * 0.26 const gapAfter = (i) => (i < prepared.length - 1 ? gap * Math.min(prepared[i].sizePx, prepared[i + 1].sizePx) : 0) const total = prepared.reduce((a, p, i) => a + asc(p) + desc(p) + gapAfter(i), 0) const centreY = position === 'center' ? height / 2 : position === 'lower-third' ? height * 0.7 : height * 0.3 let top = centreY - total / 2 const polys = [] const alphaFor = [] prepared.forEach((p, i) => { const baseline = top + asc(p) const left = (width - p.lay.width) / 2 const scale = p.lay.scale const group = [] for (const { g, x } of p.lay.glyphs) { for (const contour of p.font.outline(g)) group.push(flatten(contour, (fx, fy) => [left + x + fx * scale, baseline - fy * scale])) } polys.push(group) alphaFor.push(Math.max(0, Math.min(1, p.opacity ?? 1))) top = baseline + desc(p) + gapAfter(i) }) const mask = new Float32Array(width * height) polys.forEach((group, i) => { const m = rasterise(group, width, height) for (let k = 0; k < m.length; k++) mask[k] = Math.min(1, mask[k] + m[k] * alphaFor[i]) }) const rgba = Buffer.alloc(width * height * 4) const shade = shadow > 0 ? blur(mask, width, height, Math.max(2, Math.round(height / 240))) : null const dy = Math.max(1, Math.round(height / 400)) for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = y * width + x const a = mask[i] const s = shade ? (shade[Math.max(0, y - dy) * width + x] || 0) * shadow : 0 const outA = a + s * (1 - a) const o = i * 4 if (outA <= 0) continue for (let c = 0; c < 3; c++) rgba[o + c] = Math.round((rgb[c] * a) / outA) rgba[o + 3] = Math.round(outA * 255) } } return encodePng(rgba, width, height) } // ---------- CLI ---------- const FLAGS = new Set(['out', 'width', 'height', 'title', 'title-font', 'kicker', 'kicker-font', 'subline', 'subline-font', 'color', 'position', 'title-size', 'kicker-size', 'subline-size', 'tracking', 'kicker-tracking', 'shadow']) export function parseFlags(argv, allowed) { const out = {} for (let i = 0; i < argv.length; i++) { const a = argv[i] if (!a.startsWith('--')) throw new Error(`Unexpected argument "${a}". Flags look like --name value.`) const key = a.slice(2) if (!allowed.has(key)) throw new Error(`Unknown flag --${key}. Allowed: ${[...allowed].map((f) => `--${f}`).join(', ')}`) const value = argv[i + 1] if (value === undefined || value.startsWith('--')) throw new Error(`--${key} needs a value.`) out[key] = value i++ } return out } export function num(value, name, min, max, fallback) { if (value === undefined) return fallback const n = Number(value) if (!Number.isFinite(n) || n < min || n > max) throw new Error(`--${name} must be a number from ${min} to ${max}, got "${value}".`) return n } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { try { const f = parseFlags(process.argv.slice(2), FLAGS) if (!f.out || !f.title || !f['title-font']) throw new Error('Usage: node scripts/text-card.mjs --out card.png --title "Your Title" --title-font path/to/font.ttf [--width 1280 --height 720] [--kicker "..."] [--subline "..."]') const width = num(f.width, 'width', 64, 4096, 1280) const height = num(f.height, 'height', 64, 4096, 720) const lines = [] if (f.kicker) lines.push({ text: f.kicker, font: f['kicker-font'] || f['title-font'], size: num(f['kicker-size'], 'kicker-size', 0.01, 0.2, 0.026), tracking: num(f['kicker-tracking'], 'kicker-tracking', 0, 1, 0.32), opacity: 0.9 }) lines.push({ text: f.title, font: f['title-font'], size: num(f['title-size'], 'title-size', 0.02, 0.4, 0.11), tracking: num(f.tracking, 'tracking', -0.1, 1, 0.01) }) if (f.subline) lines.push({ text: f.subline, font: f['subline-font'] || f['kicker-font'] || f['title-font'], size: num(f['subline-size'], 'subline-size', 0.01, 0.2, 0.024), tracking: num(f['kicker-tracking'], 'kicker-tracking', 0, 1, 0.32), opacity: 0.85 }) const png = renderCard({ width: Math.round(width), height: Math.round(height), lines, color: f.color ?? '#F3ECE0', position: f.position ?? 'center', shadow: num(f.shadow, 'shadow', 0, 1, 0.35) }) writeFileSync(f.out, png) console.log(f.out) } catch (error) { console.error(error.message) process.exit(1) } }