/** * Pure layout layer for release highlight cards: plans which cards a release * gets from its `highlight:` notes and builds the SVG for each. Rendering to * PNG (sharp) lives in generate-highlight-cards.mjs; everything here is * deterministic string work, so it is unit-testable without an image library. * * Card size is the 1200×630 Open Graph format — right for Telegram/Reddit * link previews and reusable as a blog hero. */ import path from 'node:path'; import { groupNotes } from './release-notes.mjs'; export const CARD_WIDTH = 1200; export const CARD_HEIGHT = 630; /** Screenshot strip: fills the card bottom under the text block. */ export const SHOT_WIDTH = 880; export const SHOT_TOP = 330; export const SHOT_LEFT = (CARD_WIDTH - SHOT_WIDTH) / 2; export const SHOT_RADIUS = 14; /** Left margin, and the width text may occupy before the right margin. */ const TEXT_LEFT = 64; export const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_LEFT * 2; /** Hero bullet lines start further right, after the accent dot. */ const HERO_BULLET_LEFT = 100; export const HERO_BULLET_MAX_WIDTH = CARD_WIDTH - HERO_BULLET_LEFT - TEXT_LEFT; /** Feature-card text block, laid out to always clear the screenshot frame. */ const HEADLINE_TOP = 168; const HEADLINE_LINE_HEIGHT = 58; const BODY_LINE_HEIGHT = 32; const BODY_GAP = 8; /** Lowest permitted body baseline: the frame starts at SHOT_TOP - 2. */ export const TEXT_BOTTOM = SHOT_TOP - 24; const BRAND = { backgroundTop: '#0a0a08', backgroundBottom: '#141412', text: '#f0f0eb', muted: '#8a8a80', accent: '#20a8a8', accentBright: '#38c4c4', warm: '#d4a853', frame: '#2e2e28', }; const FONT_STACK = "'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif"; /** Human count labels for the hero footer, singular and plural. */ const COUNT_LABELS = { breaking: ['breaking change', 'breaking changes'], feature: ['feature', 'features'], fix: ['fix', 'fixes'], perf: ['performance win', 'performance wins'], }; export function escapeXml(text) { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Per-character advance width as a fraction of the font size. * * Counting characters is not a width budget: 34 `W` at font-size 52 measures * ~1948px where only ~1072px are available, so a character-capped line still * overflowed the canvas. * * The model is deliberately inverted — narrow characters are enumerated and * **everything else is assumed wide**. Enumerating the wide ones instead is a * game that cannot be won: successive passes each found another * under-estimated glyph (`W`, then CJK and emoji, then the `ae` ligature), * and any glyph the list misses crops the card silently. In this shape the * estimate can only ever run high, and running high costs an early line * break nobody sees. */ const WIDEST_FACTOR = 1.25; /** * ASCII advance factors. The SVG names `DM Sans` but nothing guarantees it is * installed, so every host resolves the fallback chain differently and the * same line renders at different widths: `r` measures 0.389 em here and about * 0.49 em in the environment that reported this. These factors sit above the * widest of those observations with margin, because the failure that matters * is one-directional — an over-estimate wraps early, an under-estimate crops. */ const ASCII_FACTORS = new Map([ [' ', 0.4], ...[...".,;:'\"`!|()[]{}/\\-ilIjtfr"].map((character) => [character, 0.55]), ...[...'MWmw@%&#'].map((character) => [character, WIDEST_FACTOR]), ]); /** * @param {string} character a single code point * @returns {number} advance width as a fraction of the font size */ function advanceFactor(character) { const known = ASCII_FACTORS.get(character); if (known !== undefined) { return known; } if (character >= 'a' && character <= 'z') { return 0.75; } if (character >= 'A' && character <= 'Z') { return 0.92; } if (character >= '0' && character <= '9') { return 0.75; } // Everything else: accented Latin, ligatures, Cyrillic, Greek, CJK, kana, // hangul, emoji, and whatever else a headline turns out to carry. return WIDEST_FACTOR; } /** * @param {string} text * @param {number} fontSize * @returns {number} estimated rendered width in pixels */ export function estimateTextWidth(text, fontSize) { let units = 0; // Iterating the string yields code points, so an emoji counts once. for (const character of text) { units += advanceFactor(character); } return units * fontSize; } /** Splits one overlong word into chunks that each fit `maxWidth`. */ function breakWord(word, maxWidth, fontSize) { const chunks = []; let chunk = ''; for (const character of word) { if ( chunk && estimateTextWidth(chunk + character, fontSize) > maxWidth ) { chunks.push(chunk); chunk = character; continue; } chunk += character; } if (chunk) { chunks.push(chunk); } return chunks; } /** * Greedy word wrap by estimated rendered width. SVG has no automatic text * layout, so the wrap has to decide the breaks itself; every returned line is * estimated to fit `maxWidth`, including when a single word does not — such a * word is broken rather than left to run off the canvas. * * @param {string} text * @param {{ maxWidth: number, fontSize: number, maxLines: number }} options * @returns {string[]} at most maxLines lines, the last ellipsized on overflow */ export function wrapText(text, { maxWidth, fontSize, maxLines }) { const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean); const lines = []; let current = ''; for (const word of words) { if (estimateTextWidth(word, fontSize) > maxWidth) { if (current) { lines.push(current); } lines.push(...breakWord(word, maxWidth, fontSize)); // Keep the final chunk open so a following short word can join it. current = lines.pop() ?? ''; continue; } const candidate = current ? `${current} ${word}` : word; if (!current || estimateTextWidth(candidate, fontSize) <= maxWidth) { current = candidate; continue; } lines.push(current); current = word; } if (current) { lines.push(current); } if (lines.length > maxLines) { const kept = lines.slice(0, maxLines); let last = kept[maxLines - 1]; while ( last.length > 1 && estimateTextWidth(`${last}…`, fontSize) > maxWidth ) { last = last.slice(0, -1); } kept[maxLines - 1] = `${last.trimEnd()}…`; return kept; } return lines; } /** * One card per `highlight:` note, plus one release hero card. The screenshot * (when the note names one) is read from the published blog directory the * capture run writes to, in the requested theme. * * @param {object[]} notes parsed `.changes` notes * @param {{ version: string, releaseSlug: string, screenshotsDir: string, theme: string }} options * @returns {{ feature: object[], publicNoteCount: number, hero: object }} */ export function planHighlightCards(notes, options) { const { version, releaseSlug, screenshotsDir, theme } = options; const ordered = groupNotes(notes) .filter((group) => group.type !== 'internal') .flatMap((group) => group.notes); const highlights = ordered.filter((note) => note.highlight); const takenSlugs = new Set(); const feature = highlights.map((note) => { // Named after the note file, never the screenshot slug: filenames are // unique within `.changes/`, while two highlights may legitimately // point at the same manifest shot — naming cards after it would let // one silently overwrite the other. const base = cardSlug(path.basename(note.sourcePath, '.md')); // Normalizing can collapse two distinct names onto one, so uniqueness // is re-established here rather than assumed. let slug = base; for (let suffix = 2; takenSlugs.has(slug); suffix += 1) { slug = `${base}-${suffix}`; } takenSlugs.add(slug); return { slug, fileName: `card-${slug}.png`, headline: note.highlight, body: note.body, screenshotPath: note.screenshot ? path.join(screenshotsDir, `${note.screenshot}-${theme}.png`) : null, }; }); const counts = groupNotes(ordered) .map((group) => { const [singular, plural] = COUNT_LABELS[group.type]; return `${group.notes.length} ${group.notes.length === 1 ? singular : plural}`; }) .join(' · '); return { feature, // Public notes, not total: an internal-only release has nothing to put // on a card, which is a legal release shape rather than an error. publicNoteCount: ordered.length, hero: { fileName: 'hero.png', version, releaseSlug, headlines: highlights.map((note) => note.highlight), counts, }, }; } /** Files this generator owns in an output directory. */ export function isOwnedCardFile(fileName) { return ( /^card-[a-z0-9-]+\.png$/.test(fileName) || fileName === 'hero.png' || fileName === 'hero.jpg' ); } /** * Every emitted filename must satisfy isOwnedCardFile(), or a later run cannot * reclaim the card it wrote. Note filenames are conventionally lowercase slugs * but nothing enforces it, so normalize rather than trust: `player_new-ui.md` * would otherwise produce a card no cleanup pass can ever remove. * * @param {string} noteBaseName note filename without its `.md` extension * @returns {string} */ export function cardSlug(noteBaseName) { const normalized = noteBaseName .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return normalized || 'note'; } function backgroundDefs() { return [ '', ``, ``, ``, '', ``, ``, ``, '', '', ].join(''); } function backgroundRects() { return [ ``, ``, ].join(''); } function brandHeader(version) { const chipX = 262; return [ `IPTVnator`, ``, `v${escapeXml(version)}`, ].join(''); } /** * `maxWidth` is a hard backstop, not the wrap budget: the wrap already fits * every line by estimate, and this clamps anything the estimate got wrong so * a mis-measured glyph compresses instead of running off the canvas. */ function textLines(lines, { x, y, size, weight, fill, lineHeight, maxWidth }) { return lines .map((line, index) => { const clamp = maxWidth && estimateTextWidth(line, size) > maxWidth ? ` textLength="${maxWidth}" lengthAdjust="spacingAndGlyphs"` : ''; return `${escapeXml(line)}`; }) .join(''); } /** * Feature card: brand header, headline, muted body one-liner, and either a * framed screenshot strip along the bottom or (without a screenshot) an * accent rule under a larger, vertically centered headline. * * @param {object} job entry from planHighlightCards().feature * @param {string} version * @returns {string} SVG document; the screenshot itself is composited by the * renderer inside the frame this SVG draws */ export function buildFeatureCardSvg(job, version) { const parts = [ ``, backgroundDefs(), backgroundRects(), brandHeader(version), ]; if (job.screenshotPath) { const headline = wrapText(job.headline, { maxWidth: TEXT_MAX_WIDTH, fontSize: 52, maxLines: 2, }); // The body's line budget is derived from the space actually left above // the screenshot frame, never assumed: the frame is opaque and painted // after the text, so a fixed line count silently sliced the last line // in half whenever the headline wrapped to two lines. const bodyTop = HEADLINE_TOP + headline.length * HEADLINE_LINE_HEIGHT + BODY_GAP; const bodyLines = Math.max( 1, Math.min( 3, Math.floor((TEXT_BOTTOM - bodyTop) / BODY_LINE_HEIGHT) + 1 ) ); const body = wrapText(job.body, { maxWidth: TEXT_MAX_WIDTH, fontSize: 23, maxLines: bodyLines, }); parts.push( textLines(headline, { x: TEXT_LEFT, y: HEADLINE_TOP, size: 52, weight: 800, fill: BRAND.text, lineHeight: HEADLINE_LINE_HEIGHT, maxWidth: TEXT_MAX_WIDTH, }) ); parts.push( textLines(body, { x: TEXT_LEFT, y: bodyTop, size: 23, weight: 400, fill: BRAND.muted, lineHeight: BODY_LINE_HEIGHT, maxWidth: TEXT_MAX_WIDTH, }) ); // Frame stroke sits behind the composited screenshot; the strip is // bottom-cropped by the canvas, so only the top corners round. parts.push( `` ); } else { const headline = wrapText(job.headline, { maxWidth: TEXT_MAX_WIDTH, fontSize: 62, maxLines: 2, }); const body = wrapText(job.body, { maxWidth: TEXT_MAX_WIDTH, fontSize: 26, maxLines: 3, }); const headlineY = 250; parts.push( textLines(headline, { x: TEXT_LEFT, y: headlineY, size: 62, weight: 800, fill: BRAND.text, lineHeight: 74, maxWidth: TEXT_MAX_WIDTH, }) ); parts.push( `` ); parts.push( textLines(body, { x: TEXT_LEFT, y: headlineY + headline.length * 74 + 8, size: 26, weight: 400, fill: BRAND.muted, lineHeight: 38, maxWidth: TEXT_MAX_WIDTH, }) ); } parts.push(''); return parts.join(''); } /** * Hero card: big version, the highlight names as an accent-bulleted list, * and the per-type note counts along the bottom. * * @param {object} hero planHighlightCards().hero * @returns {string} */ export function buildHeroCardSvg(hero) { const listed = hero.headlines.slice(0, 4); const omitted = hero.headlines.length - listed.length; const parts = [ ``, backgroundDefs(), backgroundRects(), `IPTVnator`, `v${escapeXml(hero.version)}`, ``, ]; listed.forEach((headline, index) => { const y = 330 + index * 56; parts.push( `` ); // wrapText yields no lines for whitespace-only input; validation // rejects that upstream, but a card run must not die half-written. const [line = ''] = wrapText(headline, { maxWidth: HERO_BULLET_MAX_WIDTH, fontSize: 30, maxLines: 1, }); parts.push( textLines([line], { x: HERO_BULLET_LEFT, y, size: 30, weight: 600, fill: BRAND.text, lineHeight: 0, maxWidth: HERO_BULLET_MAX_WIDTH, }) ); }); if (omitted > 0) { parts.push( `…and ${omitted} more` ); } if (hero.counts) { parts.push( `${escapeXml(hero.counts)}` ); } parts.push(''); return parts.join(''); } /** * Rounded-corner alpha mask for the screenshot strip (dest-in composite). * * @param {number} width * @param {number} height * @returns {string} */ export function buildShotMaskSvg(width, height) { return ``; }