$ cat node-template.ts
P
PPTX Converter
// Converts an HTML presentation into a downloadable PowerPoint (.pptx) file. Connect to a Presentation Composer output.
Output
Document
template.ts
1import * as fs from 'fs';2import * as cheerio from 'cheerio';3import PptxGenJS from 'pptxgenjs';45// ── Render service (document-render) ──6// Chromium in document-render lays the deck out and measures every slide: its7// look becomes the slide background, its pictures and text become native8// pictures and editable text boxes. Any theme exports the way it looks.9// Everything below the service call is the fallback for when the route cannot10// apply (FALLBACK_STATUSES): it redraws the slides from the bare tags and only11// knows the stock palettes and Marina. A service that is down or fails makes12// the node fail instead: falling back there would quietly produce, and the13// executor would cache, the very output this route exists to replace.14// The executor does not pass DOCUMENT_RENDER_URL into node containers; the15// override exists for the template's tests and local runs.16const RENDER_URL = `${(process.env.DOCUMENT_RENDER_URL || 'http://document-render:8094').replace(/\/+$/, '')}/v1/render/html-to-pptx`;17// The route gives up at 120 s by default (RENDER_HTML_PPTX_TIMEOUT) and answers18// 504; wait a little longer so that answer arrives instead of racing it.19const RENDER_TIMEOUT_MS = 130000;2021// 404/405: a document-render image built before the route. 413: a deck over22// its size limit. 400/422: a deck the route will not convert (not a Reveal23// deck, too many slides, a canvas or picture load over its caps).24const FALLBACK_STATUSES = new Set([400, 404, 405, 413, 422]);2526/**27 * The .pptx, or null when the route cannot apply and the fallback should run.28 * Throws when the service fails. The thrown message is what the workspace user29 * (and the chat agent) sees, so the service's own error text and its internal30 * address go to the log only.31 */32async function renderWithService(html: string, title: string): Promise<Buffer | null> {33 const form = new FormData();34 form.append('file', new Blob([html], { type: 'text/html; charset=utf-8' }), 'deck.html');35 form.append('title', title);36 const failed = (what: string) => new Error(`PPTX export failed: ${what}. The deck was not converted; run the node again.`);37 let resp: Response;38 let buffer: Buffer;39 try {40 resp = await fetch(RENDER_URL, { method: 'POST', body: form, signal: AbortSignal.timeout(RENDER_TIMEOUT_MS) });41 if (!resp.ok) {42 const detail = (await resp.text().catch(() => '')).slice(0, 300);43 if (FALLBACK_STATUSES.has(resp.status)) {44 console.error(`Render service cannot convert this deck (${resp.status}: ${detail})`);45 return null;46 }47 console.error(`Render service answered ${resp.status}: ${detail}`);48 throw failed(`the render service answered ${resp.status}`);49 }50 buffer = Buffer.from(await resp.arrayBuffer());51 } catch (e: any) {52 if (e instanceof Error && e.message.startsWith('PPTX export failed')) throw e;53 console.error(`Render service request to ${RENDER_URL} failed: ${e}`);54 if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) {55 throw failed(`the render service did not answer within ${RENDER_TIMEOUT_MS / 1000} s`);56 }57 throw failed('the render service could not be reached');58 }59 if (buffer.length < 4 || buffer.readUInt32LE(0) !== 0x04034b50) {60 throw failed('the render service answered something that is not a .pptx');61 }62 console.error(`Render service converted ${resp.headers.get('x-slide-count') || '?'} slides`);63 return buffer;64}6566function writeOutput(buffer: Buffer, title: string) {67 const outputDir = '/data/output';68 fs.mkdirSync(outputDir, { recursive: true });6970 // Sanitize title for filename71 const safeTitle = title.replace(/[^a-zA-Z0-9_\-\s]/g, '').trim().replace(/\s+/g, '_').slice(0, 50) || 'presentation';72 const filename = `${safeTitle}.pptx`;73 const outputPath = `${outputDir}/${filename}`;74 fs.writeFileSync(outputPath, buffer);7576 console.error(`PPTX written to ${outputPath} (${buffer.length} bytes)`);77 console.log(JSON.stringify({ pptx_file: filename }));78}7980// ── Generic layout constants (inches, LAYOUT_WIDE: 13.33 x 7.5) ──81const MARGIN = 0.5;82const SLIDE_W = 13.33;83const SLIDE_H = 7.5;84const TITLE_Y = 0.4;85const TITLE_H = 1.0;86const CONTENT_Y = 1.6;87const CONTENT_W = SLIDE_W - MARGIN * 2;8889// ── Generic color palette ──90const COLORS = {91 title: '2D3748',92 body: '4A5568',93 accent: 'DD6B20',94 bulletDot: '3182CE',95 tableHeader: '2B6CB0',96 tableHeaderText: 'FFFFFF',97 tableBorder: 'CBD5E0',98 quoteBar: 'DD6B20',99 quoteText: '718096',100 tableCellBg: 'FFFFFF',101};102103// ── Reveal.js Theme Palettes ──104// Each theme defines a slide background + color map matching its Reveal.js CSS105106interface ThemeConfig {107 background: string;108 colors: typeof COLORS;109}110111const THEME_PALETTES: Record<string, ThemeConfig> = {112 // ── Light themes ──113 white: {114 background: 'FFFFFF',115 colors: { title: '222222', body: '222222', accent: '2A76DD', bulletDot: '2A76DD', tableHeader: '2A76DD', tableHeaderText: 'FFFFFF', tableBorder: 'CBD5E0', tableCellBg: 'FFFFFF', quoteBar: '2A76DD', quoteText: '666666' },116 },117 beige: {118 background: 'F7F3DE',119 colors: { title: '333333', body: '333333', accent: '8B743D', bulletDot: '8B743D', tableHeader: '8B743D', tableHeaderText: 'FFFFFF', tableBorder: 'C8C0A8', tableCellBg: 'F7F3DE', quoteBar: '8B743D', quoteText: '666666' },120 },121 sky: {122 background: 'F7FBFC',123 colors: { title: '333333', body: '333333', accent: '3B759E', bulletDot: '3B759E', tableHeader: '3B759E', tableHeaderText: 'FFFFFF', tableBorder: 'ADD9E4', tableCellBg: 'F7FBFC', quoteBar: '3B759E', quoteText: '666666' },124 },125 serif: {126 background: 'F0F1EB',127 colors: { title: '383D3D', body: '000000', accent: '51483D', bulletDot: '51483D', tableHeader: '51483D', tableHeaderText: 'FFFFFF', tableBorder: 'C0C0B8', tableCellBg: 'F0F1EB', quoteBar: '51483D', quoteText: '555555' },128 },129 simple: {130 background: 'FFFFFF',131 colors: { title: '000000', body: '000000', accent: '00008B', bulletDot: '00008B', tableHeader: '00008B', tableHeaderText: 'FFFFFF', tableBorder: 'CBD5E0', tableCellBg: 'FFFFFF', quoteBar: '00008B', quoteText: '444444' },132 },133 solarized: {134 background: 'FDF6E3',135 colors: { title: '586E75', body: '657B83', accent: '268BD2', bulletDot: '268BD2', tableHeader: '268BD2', tableHeaderText: 'FDF6E3', tableBorder: 'EEE8D5', tableCellBg: 'FDF6E3', quoteBar: '268BD2', quoteText: '93A1A1' },136 },137 // ── Dark themes ──138 black: {139 background: '191919',140 colors: { title: 'FFFFFF', body: 'FFFFFF', accent: '42AFFA', bulletDot: '42AFFA', tableHeader: '42AFFA', tableHeaderText: '191919', tableBorder: '444444', tableCellBg: '191919', quoteBar: '42AFFA', quoteText: 'CCCCCC' },141 },142 moon: {143 background: '002B36',144 colors: { title: 'EEE8D5', body: '93A1A1', accent: '268BD2', bulletDot: '268BD2', tableHeader: '268BD2', tableHeaderText: 'EEE8D5', tableBorder: '073642', tableCellBg: '002B36', quoteBar: '268BD2', quoteText: '839496' },145 },146 league: {147 background: '2B2B2B',148 colors: { title: 'EEEEEE', body: 'EEEEEE', accent: '13DAEC', bulletDot: '13DAEC', tableHeader: '13DAEC', tableHeaderText: '2B2B2B', tableBorder: '444444', tableCellBg: '2B2B2B', quoteBar: '13DAEC', quoteText: 'CCCCCC' },149 },150 night: {151 background: '111111',152 colors: { title: 'EEEEEE', body: 'EEEEEE', accent: 'E7AD52', bulletDot: 'E7AD52', tableHeader: 'E7AD52', tableHeaderText: '111111', tableBorder: '333333', tableCellBg: '111111', quoteBar: 'E7AD52', quoteText: 'CCCCCC' },153 },154 blood: {155 background: '222222',156 colors: { title: 'EEEEEE', body: 'EEEEEE', accent: 'AA2233', bulletDot: 'AA2233', tableHeader: 'AA2233', tableHeaderText: 'EEEEEE', tableBorder: '444444', tableCellBg: '222222', quoteBar: 'AA2233', quoteText: 'CCCCCC' },157 },158 dracula: {159 background: '282A36',160 colors: { title: 'BD93F9', body: 'F8F8F2', accent: 'FF79C6', bulletDot: '8BE9FD', tableHeader: '6272A4', tableHeaderText: 'F8F8F2', tableBorder: '44475A', tableCellBg: '282A36', quoteBar: 'FF79C6', quoteText: 'F8F8F2' },161 },162 // ── Marina Militare (custom Italian-government deck) ──163 marina: {164 background: '001A33',165 colors: { title: 'FFFFFF', body: 'D4E4F5', accent: '0072C6', bulletDot: 'FFCB28', tableHeader: '1B4F8C', tableHeaderText: 'FFCB28', tableBorder: '4A90D9', tableCellBg: '001A33', quoteBar: '0072C6', quoteText: 'D4E4F5' },166 },167};168169// ── Marina-specific extras (colors not in the generic ColorMap) ──170// These come from --mm-* CSS tokens; rgba() values are flattened to solids171// because PPTX shape fills don't reliably honor alpha.172interface MarinaExtras {173 cyan: string;174 gold: string;175 red: string;176 green: string;177 paleBlue: string;178 cardBg: string;179 cardBorder: string;180 cardHighlightBorder: string;181 cardAlertBorder: string;182 cardAlertBg: string;183 tricolorGreen: string;184 tricolorRed: string;185 watermarkColor: string;186}187188const MARINA_EXTRAS: MarinaExtras = {189 cyan: '2DD3FF',190 gold: 'FFCB28',191 red: 'ED0033',192 green: '288054',193 paleBlue: 'C0E4FF',194 cardBg: '1B4F8C',195 cardBorder: '4A90D9',196 cardHighlightBorder: 'FFCB28',197 cardAlertBorder: 'ED0033',198 cardAlertBg: '5C0A19',199 tricolorGreen: '00953E',200 tricolorRed: 'E21F1D',201 watermarkColor: '4A6680',202};203204// Marina theme fonts. PowerPoint will substitute if the viewing machine lacks205// them, so users get the closest visual match by installing Graduate and206// Montserrat (both free Google Fonts) on their system.207const MM_FONT_DISPLAY = 'Graduate'; // title-slide H1 — slab-serif uppercase208const MM_FONT_HEAD = 'Montserrat'; // section H2/H3, header bar, card text209const MM_FONT_BODY = 'Calibri'; // body text — Segoe UI substitute210211const DEFAULT_THEME: ThemeConfig = { background: 'FFFFFF', colors: COLORS };212213function getThemeConfig(theme: string | null): ThemeConfig {214 if (!theme) return DEFAULT_THEME;215 return THEME_PALETTES[theme] || DEFAULT_THEME;216}217218// ── Types ──219220type ColorMap = typeof COLORS;221type SlideType = 'intro' | 'content' | 'closing' | 'generic';222223interface TextRun {224 text: string;225 bold?: boolean;226 italic?: boolean;227 color?: string;228}229230interface SlideElement {231 tag: string;232 text: string;233 children?: { text: string; tag: string; runs?: TextRun[]; indent?: number }[];234 attrs?: Record<string, string>;235 runs?: TextRun[];236}237238interface ParsedCard {239 label: string;240 value: string;241 valueColor: 'gold' | 'cyan' | 'red' | 'green' | 'white';242 detail: string | null;243 variant: 'default' | 'highlight' | 'alert';244}245246interface ParsedSlide {247 slideType: SlideType;248 title: string | null;249 elements: SlideElement[];250 notes: string | null;251 bgColor: string | null;252 headerTitle: string | null;253 /** Base64 image data (without "data:" prefix) for image-left/right layouts */254 imageData: string | null;255 /** Raw image src for deferred fetch (http/https URLs or {{IMAGE}} placeholders) */256 imageSrcRaw: string | null;257 /** Image position: 'left' or 'right' */258 imagePosition: 'left' | 'right' | null;259 /** Marina: <p class="subtitle"> text on title slides */260 subtitle: string | null;261 /** Marina: <p class="meta"> text on title slides */262 meta: string | null;263 /** Marina: <p class="hashtag"> text on closing slides */264 hashtag: string | null;265 /** Marina: first <p class="section-label"> text in section */266 sectionLabel: string | null;267 /** Marina: parsed cards from <div class="card-grid"> */268 cards: ParsedCard[];269 /** Marina: card-grid columns (cols-N), default 1 */270 cardCols: number;271}272273// ── Theme Detection ──274275function detectTheme($: cheerio.CheerioAPI, themeInput?: string): string | null {276 if (themeInput && themeInput !== 'auto') {277 return themeInput;278 }279 const html = $.html();280281 // Marina Militare detection: CSS is inlined (no /dist/theme/marina.css link),282 // so look for the --mm-navy custom property, the watermark string, or the283 // data-header-title attribute marina sections always carry.284 if (html.includes('--mm-navy') || html.includes('MARINA MILITARE')) return 'marina';285 if ($('section[data-header-title]').length > 0) return 'marina';286287 // Standard Reveal.js themes: detect from <link href="...reveal.js.../theme/{name}.css">288 const themeMatch = html.match(/reveal\.js[^"]*\/dist\/theme\/([a-z-]+)\.css/);289 if (themeMatch && themeMatch[1] in THEME_PALETTES) return themeMatch[1];290291 return null;292}293294// ── Marina crest extraction ──295// Marina presentations embed a single large PNG (the crest) as a data URI in296// the inline CSS. Pick the longest base64 PNG match and return it stripped of297// the "data:" prefix; the texture is webp and won't match this regex.298function extractMarinaCrest(html: string): string | null {299 const re = /data:image\/png;base64,([A-Za-z0-9+/=]{1000,})/g;300 let m: RegExpExecArray | null;301 let best: string | null = null;302 while ((m = re.exec(html)) !== null) {303 const candidate = m[1];304 if (best === null || candidate.length > best.length) {305 best = candidate;306 }307 }308 return best;309}310311// ── Bold accent color ──312const accentStrongColor = '333333';313314// ── Inline Rich Text Parsing ──315316function parseInlineRuns($: cheerio.CheerioAPI, el: cheerio.Element, inherited?: { bold?: boolean; italic?: boolean; color?: string }): TextRun[] {317 const runs: TextRun[] = [];318 const inh = inherited || {};319320 $(el).contents().each((_i, node) => {321 if (node.type === 'text') {322 const raw = (node as any).data || '';323 // HTML whitespace rules: collapse any run of whitespace (newlines, tabs,324 // multiple spaces) to a single space and drop pure-whitespace nodes.325 // Without this, indentation between block siblings (e.g. <strong>X</strong>326 // \n <ul>...) leaks into the run array and renders as visible327 // padding inside bullet/paragraph text frames.328 const t = raw.replace(/\s+/g, ' ');329 if (t.trim()) {330 runs.push({331 text: t,332 ...(inh.bold ? { bold: true } : {}),333 ...(inh.italic ? { italic: true } : {}),334 ...(inh.color ? { color: inh.color } : {}),335 });336 }337 } else if (node.type === 'tag') {338 const tag = (node as cheerio.TagElement).tagName?.toLowerCase() || '';339 // Skip nested lists and tables — they are handled separately340 if (tag === 'ul' || tag === 'ol' || tag === 'table') return;341 const next = { ...inh };342 if (tag === 'strong' || tag === 'b') { next.bold = true; next.color = next.color || accentStrongColor; }343 if (tag === 'em' || tag === 'i') { next.italic = true; }344 runs.push(...parseInlineRuns($, node as cheerio.Element, next));345 }346 });347348 return runs;349}350351/** Extract only direct text from a <li>, excluding nested <ul>/<ol> text. */352function directText($: cheerio.CheerioAPI, el: cheerio.Element): string {353 let text = '';354 $(el).contents().each((_i, node) => {355 if (node.type === 'text') {356 text += (node as any).data || '';357 } else if (node.type === 'tag') {358 const tag = (node as cheerio.TagElement).tagName?.toLowerCase() || '';359 if (tag !== 'ul' && tag !== 'ol' && tag !== 'table') {360 text += $(node).text();361 }362 }363 });364 return text.trim();365}366367// ── Section Parsing ──368369function parseSection($: cheerio.CheerioAPI, section: cheerio.Element): ParsedSlide {370 const $s = $(section);371 const notes = $s.find('aside.notes').text().trim() || null;372 const bgColor = ($s.attr('data-background-color') || '').replace('#', '') || null;373374 // Detect slide type — marina sections carry .title-slide / .closing-slide.375 let slideType: SlideType = 'generic';376 if ($s.hasClass('title-slide')) slideType = 'intro';377 else if ($s.hasClass('closing-slide')) slideType = 'closing';378379 // Marina header bar text comes from the data-header-title attribute.380 const headerTitle: string | null = $s.attr('data-header-title') || null;381382 // Title source depends on slide type — intro/closing prefer h1 (Marina383 // convention), falling back to h2. For h1 we preserve <br> as \n384 // so the renderer can honor explicit line breaks.385 let title: string | null = null;386 if (slideType === 'intro' || slideType === 'closing') {387 let $heading = $s.find('h1').first();388 if ($heading.length === 0) $heading = $s.find('h2').first();389 if ($heading.length > 0) {390 const cloned = $heading.clone();391 cloned.find('br').replaceWith('\n');392 title = cloned.text().trim() || null;393 }394 } else {395 title = $s.find('h2').first().text().trim() || null;396 }397398 // Marina-specific paragraph captures399 const subtitle = $s.find('p.subtitle').first().text().trim() || null;400 const meta = $s.find('p.meta').first().text().trim() || null;401 const hashtag = $s.find('p.hashtag').first().text().trim() || null;402 const sectionLabel = $s.find('p.section-label').first().text().trim() || null;403404 // Marina cards (populated below by the section walker when a card-grid is found)405 const cards: ParsedCard[] = [];406 let cardCols = 1;407408 // Detect image-left / image-right layout409 let imageData: string | null = null;410 let imageSrcRaw: string | null = null;411 let imagePosition: 'left' | 'right' | null = null;412413 // Parse content elements414 const elements: SlideElement[] = [];415416 /** Parse a list element, handling nesting and rich text */417 function parseList($list: cheerio.Cheerio<cheerio.Element>, listTag: string, indent: number = 0) {418 const items: { text: string; tag: string; runs?: TextRun[]; indent?: number }[] = [];419 $list.children('li').each((_k, li) => {420 const text = directText($, li);421 const runs = parseInlineRuns($, li);422 if (text) {423 items.push({ text, tag: listTag, runs: runs.length > 0 ? runs : undefined, indent });424 }425 // Handle nested lists inside this <li>426 $(li).children('ul, ol').each((_n, nested) => {427 const nestedTag = (nested as cheerio.TagElement).tagName?.toLowerCase() || 'ul';428 const nestedItems = parseList($(nested), nestedTag, indent + 1);429 items.push(...nestedItems);430 });431 });432 return items;433 }434435 function extractChildElements($container: cheerio.Cheerio<cheerio.Element>) {436 $container.find('p, ul, ol, h3, h2, blockquote, table').each((_j, child) => {437 const $child = $(child);438 const childTag = (child as cheerio.TagElement).tagName?.toLowerCase() || '';439 // Skip the main slide h2 title if already captured440 if (childTag === 'h2' && $child.text().trim() === title) return;441 // Skip nested lists (already handled by their parent <li>)442 if ((childTag === 'ul' || childTag === 'ol') && $(child).parent('li').length > 0) return;443 // Skip tables inside lists (extracted separately after list processing)444 if (childTag === 'table' && $(child).closest('ul, ol').length > 0) return;445 if (childTag === 'ul' || childTag === 'ol') {446 const items = parseList($child, childTag);447 elements.push({ tag: childTag, text: '', children: items });448 } else if (childTag === 'table') {449 const rows: { text: string; tag: string }[][] = [];450 $child.find('tr').each((_rj, tr) => {451 const cells: { text: string; tag: string }[] = [];452 $(tr).find('th, td').each((_k, cell) => {453 cells.push({454 text: $(cell).text().trim(),455 tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',456 });457 });458 if (cells.length > 0) rows.push(cells);459 });460 elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });461 } else if (childTag === 'p') {462 // Marina-only <p> classes are captured separately on ParsedSlide;463 // skip them so they don't render twice as generic body text.464 const cls = ($child.attr('class') || '').split(/\s+/);465 if (cls.includes('section-label') || cls.includes('subtitle') || cls.includes('meta') || cls.includes('hashtag')) return;466 const txt = $child.text().trim();467 if (txt) {468 const runs = parseInlineRuns($, child);469 elements.push({ tag: 'p', text: txt, runs: runs.length > 0 ? runs : undefined });470 }471 } else {472 const txt = $child.text().trim();473 if (txt) elements.push({ tag: childTag === 'h2' ? 'h3' : childTag, text: txt });474 }475 });476 }477478 $s.children().each((_i, el) => {479 const $el = $(el);480 const tag = (el as cheerio.TagElement).tagName?.toLowerCase() || '';481482 // Skip notes483 if (tag === 'aside' && $el.hasClass('notes')) return;484 // Skip only the first h2 (captured as slide title); render others as subheadings485 if (tag === 'h2') {486 if ($el.text().trim() === title) return;487 elements.push({ tag: 'h3', text: $el.text().trim() });488 return;489 }490 if (tag === 'h3') {491 elements.push({ tag: 'h3', text: $el.text().trim() });492 } else if (tag === 'p') {493 // Marina-only <p> classes are captured separately on ParsedSlide;494 // skip them so they don't render twice as generic body text.495 const cls = ($el.attr('class') || '').split(/\s+/);496 if (cls.includes('section-label') || cls.includes('subtitle') || cls.includes('meta') || cls.includes('hashtag')) return;497 const txt = $el.text().trim();498 if (txt) {499 const runs = parseInlineRuns($, el);500 elements.push({ tag: 'p', text: txt, runs: runs.length > 0 ? runs : undefined });501 }502 } else if (tag === 'ul' || tag === 'ol') {503 const items = parseList($el, tag);504 elements.push({ tag, text: '', children: items });505 // Extract tables nested inside list items (e.g. table under a <li>)506 $el.find('table').each((_j, tbl) => {507 const $tbl = $(tbl);508 const rows: { text: string; tag: string }[][] = [];509 $tbl.find('tr').each((_rj, tr) => {510 const cells: { text: string; tag: string }[] = [];511 $(tr).find('th, td').each((_k, cell) => {512 cells.push({513 text: $(cell).text().trim(),514 tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',515 });516 });517 if (cells.length > 0) rows.push(cells);518 });519 if (rows.length > 0) {520 elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });521 }522 });523 } else if (tag === 'blockquote') {524 elements.push({ tag: 'blockquote', text: $el.text().trim() });525 } else if (tag === 'table') {526 const rows: { text: string; tag: string }[][] = [];527 $el.find('tr').each((_j, tr) => {528 const cells: { text: string; tag: string }[] = [];529 $(tr).find('th, td').each((_k, cell) => {530 cells.push({531 text: $(cell).text().trim(),532 tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',533 });534 });535 if (cells.length > 0) rows.push(cells);536 });537 elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });538 } else if (tag === 'div') {539 // Marina card-grid: parse cards directly into parsed.cards and SKIP the540 // generic flex/recursion fallback so card content doesn't also leak into541 // elements as bullet text.542 const className = $el.attr('class') || '';543 if (className.split(/\s+/).includes('card-grid')) {544 const colsMatch = className.match(/cols-(\d+)/);545 if (colsMatch) cardCols = parseInt(colsMatch[1], 10) || 1;546 $el.children('div.card').each((_j, cardEl) => {547 const $card = $(cardEl);548 const cardClass = ($card.attr('class') || '').split(/\s+/);549 const variant: 'default' | 'highlight' | 'alert' =550 cardClass.includes('alert') ? 'alert'551 : cardClass.includes('highlight') ? 'highlight'552 : 'default';553 const $value = $card.children('.value').first();554 const valueClass = ($value.attr('class') || '').split(/\s+/);555 let valueColor: 'gold' | 'cyan' | 'red' | 'green' | 'white' = 'white';556 if (valueClass.includes('gold')) valueColor = 'gold';557 else if (valueClass.includes('cyan')) valueColor = 'cyan';558 else if (valueClass.includes('red')) valueColor = 'red';559 else if (valueClass.includes('green')) valueColor = 'green';560 cards.push({561 label: $card.children('.label').first().text().trim(),562 value: $value.text().trim(),563 valueColor,564 detail: $card.children('.detail').first().text().trim() || null,565 variant,566 });567 });568 return;569 }570571 // Check for image-left / image-right flex layout572 const style = $el.attr('style') || '';573 const isFlex = style.includes('display:flex') || style.includes('display: flex');574 const childDivs = $el.children('div');575 const hasImg = $el.find('img').length > 0;576577 if (isFlex && hasImg && !imageData && !imageSrcRaw) {578 // Case 1: Both sides wrapped in <div> (expected structure)579 if (childDivs.length >= 2) {580 const firstChild = $(childDivs[0]);581 const secondChild = $(childDivs[1]);582 const firstImg = firstChild.find('img').first();583 const secondImg = secondChild.find('img').first();584585 let imgEl: cheerio.Cheerio<cheerio.Element> | null = null;586 let textContainer: cheerio.Cheerio<cheerio.Element> | null = null;587588 if (firstImg.length > 0) {589 imgEl = firstImg;590 textContainer = secondChild;591 imagePosition = 'left';592 } else if (secondImg.length > 0) {593 imgEl = secondImg;594 textContainer = firstChild;595 imagePosition = 'right';596 }597598 if (imgEl && textContainer) {599 const imgSrc = imgEl.attr('src') || '';600 if (imgSrc.startsWith('data:')) {601 imageData = imgSrc.slice(5);602 } else if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) {603 imageSrcRaw = imgSrc;604 } else {605 // {{IMAGE}} placeholder or empty src606 imageSrcRaw = imgSrc || '{{IMAGE}}';607 }608 extractChildElements(textContainer);609 return;610 }611 }612613 // Case 2: <img> is a direct child of flex container (LLM sometimes does this)614 const directImg = $el.children('img').first();615 if (directImg.length > 0 && childDivs.length >= 1) {616 const imgSrc = directImg.attr('src') || '';617 // Determine position: if img comes before the div, it's left618 const allChildren = $el.children().toArray();619 const imgIdx = allChildren.findIndex(c => c === directImg[0]);620 const divIdx = allChildren.findIndex(c => c === childDivs[0]);621 imagePosition = imgIdx < divIdx ? 'left' : 'right';622623 if (imgSrc.startsWith('data:')) {624 imageData = imgSrc.slice(5);625 } else if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) {626 imageSrcRaw = imgSrc;627 } else {628 imageSrcRaw = imgSrc || '{{IMAGE}}';629 }630 extractChildElements($(childDivs[0]));631 return;632 }633 }634635 // Fallback: recurse into layout divs (two-column layouts etc.)636 extractChildElements($el);637 }638 });639640 return {641 slideType, title, elements, notes, bgColor, headerTitle,642 imageData, imageSrcRaw, imagePosition,643 subtitle, meta, hashtag, sectionLabel, cards, cardCols,644 };645}646647// ── Image Fetch Helper ──648649async function fetchImageAsBase64(url: string): Promise<string | null> {650 try {651 const controller = new AbortController();652 const timeout = setTimeout(() => controller.abort(), 10000);653 const resp = await fetch(url, { signal: controller.signal });654 clearTimeout(timeout);655 if (!resp.ok) return null;656 const buf = Buffer.from(await resp.arrayBuffer());657 const contentType = resp.headers.get('content-type') || 'image/png';658 const mimeType = contentType.split(';')[0].trim();659 return `${mimeType};base64,${buf.toString('base64')}`;660 } catch (e) {661 console.error(`Failed to fetch image from ${url}: ${e}`);662 return null;663 }664}665666// ── Content Height Estimation ──667668function estimateElementsHeight(elements: SlideElement[], contentW: number): number {669 let h = 0;670 for (const el of elements) {671 if (el.tag === 'h3') {672 h += 0.55;673 } else if (el.tag === 'p') {674 if (!el.text) continue;675 h += 0.5;676 } else if ((el.tag === 'ul' || el.tag === 'ol') && el.children) {677 el.children.forEach((item) => {678 const indent = item.indent || 0;679 const baseFontSize = indent > 0 ? 13 : 15;680 const indentOffset = indent * 0.35;681 const wPos = contentW - 0.2 - indentOffset;682 const textLen = item.text.length;683 const charW = baseFontSize * 0.0067;684 const lineH = baseFontSize * 0.019;685 const charsPerLine = Math.max(1, Math.floor(wPos / charW));686 const numLines = Math.max(1, Math.ceil(textLen / charsPerLine));687 h += Math.max(0.3, numLines * lineH + 0.06);688 });689 h += 0.1;690 } else if (el.tag === 'blockquote') {691 h += 0.75;692 } else if (el.tag === 'table' && el.attrs?._rows) {693 try {694 const rows: any[][] = JSON.parse(el.attrs._rows);695 h += rows.length * 0.4 + 0.15;696 } catch {}697 }698 }699 return h;700}701702// ── Shared Element Rendering ──703704function addElementsToSlide(705 slide: PptxGenJS.Slide,706 elements: SlideElement[],707 startY: number,708 colors: ColorMap,709 maxY: number,710 contentW: number,711 margin: number,712 bodyFontSize: number = 18,713 bulletBaseFontSize: number = 15,714) {715 let curY = startY;716717 for (const el of elements) {718 if (curY >= maxY) break;719720 if (el.tag === 'h3') {721 slide.addText(el.text, {722 x: margin, y: curY, w: contentW, h: 0.5,723 fontSize: 22, bold: true, color: colors.title, fontFace: 'Calibri',724 });725 curY += 0.55;726 } else if (el.tag === 'p') {727 if (!el.text) continue;728 if (el.runs && el.runs.length > 0) {729 const pRuns = el.runs.map(r => ({730 text: r.text,731 options: {732 fontSize: bodyFontSize, fontFace: 'Calibri', color: r.color || colors.body,733 ...(r.bold ? { bold: true } : {}),734 ...(r.italic ? { italic: true } : {}),735 },736 }));737 slide.addText(pRuns, { x: margin, y: curY, w: contentW, h: 0.45 });738 } else {739 slide.addText(el.text, {740 x: margin, y: curY, w: contentW, h: 0.45,741 fontSize: bodyFontSize, color: colors.body, fontFace: 'Calibri',742 });743 }744 curY += 0.5;745 } else if ((el.tag === 'ul' || el.tag === 'ol') && el.children) {746 // Render each bullet item with manual bullet/number characters for reliable display.747 const olCounters: Record<number, number> = {};748 let prevTag = '';749 let prevIndent = -1;750751 el.children.forEach((item) => {752 if (curY >= maxY) return;753 const indent = item.indent || 0;754 const baseFontSize = indent > 0 ? Math.max(11, bulletBaseFontSize - 2) : bulletBaseFontSize;755 const indentOffset = indent * 0.35;756 const xPos = margin + 0.2 + indentOffset;757 const wPos = contentW - 0.2 - indentOffset;758759 // Dynamic height: estimate wrapped lines based on text length760 const textLen = item.text.length;761 const charW = baseFontSize * 0.0067;762 const lineH = baseFontSize * 0.019;763 const charsPerLine = Math.max(1, Math.floor(wPos / charW));764 const numLines = Math.max(1, Math.ceil(textLen / charsPerLine));765 const itemH = Math.max(0.3, numLines * lineH + 0.06);766767 // Determine list type from item.tag (set by parseList to 'ul' or 'ol')768 const isOrdered = item.tag === 'ol';769770 // Manage ordered counters: reset when starting a new ordered sequence771 if (isOrdered) {772 if (prevTag !== 'ol' || prevIndent !== indent) {773 olCounters[indent] = 0;774 }775 olCounters[indent]++;776 }777 prevTag = item.tag || '';778 prevIndent = indent;779780 // Build prefix: bullet char or number781 const prefix = isOrdered782 ? `${olCounters[indent]}. `783 : (indent > 0 ? '\u25AA ' : '\u2022 ');784 const prefixColor = isOrdered ? colors.body : colors.bulletDot;785786 // Always use text run array so bullet char gets its own color787 const textRuns: { text: string; options: any }[] = [788 { text: prefix, options: { fontSize: baseFontSize, fontFace: 'Calibri', color: prefixColor } },789 ];790791 if (item.runs && item.runs.length > 0) {792 for (const r of item.runs) {793 textRuns.push({794 text: r.text,795 options: {796 fontSize: baseFontSize, fontFace: 'Calibri',797 color: r.color || colors.body,798 ...(r.bold ? { bold: true } : {}),799 ...(r.italic ? { italic: true } : {}),800 },801 });802 }803 } else {804 textRuns.push({805 text: item.text,806 options: { fontSize: baseFontSize, fontFace: 'Calibri', color: colors.body },807 });808 }809810 slide.addText(textRuns, {811 x: xPos, y: curY, w: wPos, h: itemH, valign: 'top',812 });813 curY += itemH;814 });815 curY += 0.1;816 } else if (el.tag === 'blockquote') {817 slide.addShape('rect' as any, {818 x: margin, y: curY, w: 0.08, h: 0.6,819 fill: { color: colors.quoteBar },820 });821 slide.addText(el.text, {822 x: margin + 0.25, y: curY, w: contentW - 0.25, h: 0.6,823 fontSize: 16, italic: true, color: colors.quoteText, fontFace: 'Calibri', valign: 'middle',824 });825 curY += 0.75;826 } else if (el.tag === 'table' && el.attrs?._rows) {827 try {828 const rows: { text: string; tag: string }[][] = JSON.parse(el.attrs._rows);829 const tableRows: PptxGenJS.TableRow[] = rows.map((row, rowIdx) =>830 row.map((cell) => ({831 text: cell.text,832 options: {833 fontSize: 12,834 fontFace: 'Calibri',835 color: cell.tag === 'th' || rowIdx === 0 ? colors.tableHeaderText : colors.body,836 fill: { color: cell.tag === 'th' || rowIdx === 0 ? colors.tableHeader : colors.tableCellBg },837 border: { pt: 0.5, color: colors.tableBorder },838 valign: 'middle' as const,839 bold: cell.tag === 'th',840 },841 }))842 );843 const tableH = Math.min(rows.length * 0.4, maxY - curY);844 slide.addTable(tableRows, {845 x: margin, y: curY, w: contentW, h: tableH,846 colW: Array(rows[0]?.length || 1).fill(contentW / (rows[0]?.length || 1)),847 });848 curY += tableH + 0.15;849 } catch {850 // Skip malformed table851 }852 }853 }854}855856// ── Generic Slide Content ──857858function addImageLayoutSlide(859 slide: PptxGenJS.Slide,860 parsed: ParsedSlide,861 startY: number,862 maxY: number,863 colors: ColorMap,864 contentW: number,865 margin: number,866 textStartY?: number,867) {868 const gap = 0.5;869 const imgW = (contentW - gap) / 2;870 const imgH = maxY - startY;871 const textW = imgW;872873 const leftX = margin;874 const rightX = margin + imgW + gap;875876 const imgX = parsed.imagePosition === 'left' ? leftX : rightX;877 const textX = parsed.imagePosition === 'left' ? rightX : leftX;878879 // Image top-aligned with content (parent handles vertical centering)880 const estimatedH = Math.min(imgW * 0.75, imgH);881 const imgY = startY;882 if (parsed.imageData) {883 slide.addImage({884 data: parsed.imageData,885 x: imgX, y: imgY, w: imgW, h: estimatedH,886 sizing: { type: 'contain', w: imgW, h: estimatedH },887 });888 } else {889 // Placeholder: grey rectangle with dashed accent border and centered text890 slide.addShape('rect' as any, {891 x: imgX, y: imgY, w: imgW, h: estimatedH,892 fill: { color: 'F0F0F0' },893 line: { color: colors.accent, width: 1.5, dashType: 'dash' },894 });895 slide.addText('[ Image ]', {896 x: imgX, y: imgY, w: imgW, h: estimatedH,897 fontSize: 16, color: '999999', fontFace: 'Calibri',898 align: 'center', valign: 'middle',899 });900 }901902 // Add text elements on the other side903 addElementsToSlide(slide, parsed.elements, textStartY ?? startY, colors, maxY, textW, textX);904}905906// ── Marina Militare renderers ──907908function addMarinaChrome(909 slide: PptxGenJS.Slide,910 slideType: SlideType,911 headerTitle: string | null,912 crestB64: string | null,913 bgColor: string,914 extras: MarinaExtras,915) {916 // Navy background917 slide.background = { color: bgColor };918919 // Tricolor flag stripe at the bottom: green / white / red, no border line920 const stripeW = SLIDE_W / 3;921 const stripeY = SLIDE_H - 0.06;922 slide.addShape('rect' as any, {923 x: 0, y: stripeY, w: stripeW, h: 0.06,924 fill: { color: extras.tricolorGreen }, line: { color: extras.tricolorGreen, width: 0 },925 });926 slide.addShape('rect' as any, {927 x: stripeW, y: stripeY, w: stripeW, h: 0.06,928 fill: { color: 'FFFFFF' }, line: { color: 'FFFFFF', width: 0 },929 });930 slide.addShape('rect' as any, {931 x: stripeW * 2, y: stripeY, w: stripeW, h: 0.06,932 fill: { color: extras.tricolorRed }, line: { color: extras.tricolorRed, width: 0 },933 });934935 // Watermark at lower-left936 slide.addText('MARINA MILITARE • UNCLASSIFIED', {937 x: 0.25, y: SLIDE_H - 0.4, w: 4, h: 0.25,938 fontSize: 8, color: extras.watermarkColor, fontFace: MM_FONT_BODY, charSpacing: 2,939 });940941 // Header bar (only on regular content slides, not intro/closing)942 if (slideType !== 'intro' && slideType !== 'closing') {943 slide.addShape('rect' as any, {944 x: 0, y: 0, w: SLIDE_W, h: 0.5,945 fill: { color: bgColor }, line: { color: bgColor, width: 0 },946 });947 if (crestB64) {948 // Marina crest is ~2.67:1 (480x180 in CSS, 1830x685 native PNG).949 // pptxgenjs sizing.contain is a no-op when w/h match the box, so size950 // the box to the native aspect to avoid the image being stretched.951 slide.addImage({952 data: 'image/png;base64,' + crestB64,953 x: 0.2, y: 0.07, w: 0.96, h: 0.36,954 });955 }956 if (headerTitle) {957 slide.addText(headerTitle.toUpperCase(), {958 x: SLIDE_W - 6, y: 0, w: 5.7, h: 0.5,959 fontSize: 10, color: 'CCCCCC', fontFace: MM_FONT_HEAD,960 valign: 'middle', align: 'right', charSpacing: 2,961 });962 }963 }964}965966function addMarinaTitleSlide(967 slide: PptxGenJS.Slide,968 parsed: ParsedSlide,969 crestB64: string | null,970 _colors: ColorMap,971 _extras: MarinaExtras,972) {973 // Crest dimensions: matches the CSS .title-slide::before frame974 // (480x180 px on a 1920x1080 canvas → ~2.67:1, scaled smaller than full975 // width so the title block reads as the focal point).976 const crestW = 2.5;977 const crestH = 0.94;978 const crestX = (SLIDE_W - crestW) / 2;979 const crestY = 0.5;980 if (crestB64) {981 slide.addImage({982 data: 'image/png;base64,' + crestB64,983 x: crestX, y: crestY, w: crestW, h: crestH,984 });985 } else {986 slide.addText('MARINA MILITARE', {987 x: crestX, y: crestY, w: crestW, h: crestH,988 fontSize: 22, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,989 align: 'center', valign: 'middle', charSpacing: 4,990 });991 }992993 if (parsed.title) {994 // Title uses Graduate (slab-serif uppercase) per CSS .title-slide h1.995 // Respects explicit \n line breaks captured from <br> tags.996 slide.addText(parsed.title, {997 x: 0.5, y: 2.0, w: SLIDE_W - 1.0, h: 1.6,998 fontSize: 44, bold: true, color: 'FFFFFF', fontFace: MM_FONT_DISPLAY,999 align: 'center', valign: 'middle',1000 });1001 }10021003 if (parsed.subtitle) {1004 slide.addText(parsed.subtitle, {1005 x: 0.5, y: 3.9, w: SLIDE_W - 1.0, h: 0.7,1006 fontSize: 22, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1007 align: 'center', valign: 'middle',1008 });1009 }10101011 if (parsed.meta) {1012 slide.addText(parsed.meta, {1013 x: 0.5, y: 5.4, w: SLIDE_W - 1.0, h: 0.4,1014 fontSize: 11, color: 'B0BEC5', fontFace: MM_FONT_BODY,1015 align: 'center', valign: 'middle', charSpacing: 1,1016 });1017 }1018}10191020function addMarinaClosingSlide(1021 slide: PptxGenJS.Slide,1022 parsed: ParsedSlide,1023 crestB64: string | null,1024 _colors: ColorMap,1025 _extras: MarinaExtras,1026) {1027 const crestW = 2.5;1028 const crestH = 0.94;1029 const crestX = (SLIDE_W - crestW) / 2;1030 const crestY = 0.5;1031 if (crestB64) {1032 slide.addImage({1033 data: 'image/png;base64,' + crestB64,1034 x: crestX, y: crestY, w: crestW, h: crestH,1035 });1036 } else {1037 slide.addText('MARINA MILITARE', {1038 x: crestX, y: crestY, w: crestW, h: crestH,1039 fontSize: 22, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1040 align: 'center', valign: 'middle', charSpacing: 4,1041 });1042 }10431044 if (parsed.title) {1045 // Closing slide uses Montserrat (CSS .closing-slide h1), not Graduate.1046 slide.addText(parsed.title, {1047 x: 0.5, y: 2.6, w: SLIDE_W - 1.0, h: 1.4,1048 fontSize: 44, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1049 align: 'center', valign: 'middle',1050 });1051 }10521053 if (parsed.hashtag) {1054 slide.addText(parsed.hashtag, {1055 x: 0.5, y: 4.3, w: SLIDE_W - 1.0, h: 0.6,1056 fontSize: 22, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1057 align: 'center', valign: 'middle',1058 });1059 }1060}10611062function addMarinaCardGrid(1063 slide: PptxGenJS.Slide,1064 cards: ParsedCard[],1065 cardCols: number,1066 x: number,1067 y: number,1068 w: number,1069 maxH: number,1070 extras: MarinaExtras,1071): number {1072 if (cards.length === 0) return 0;1073 const cols = Math.max(1, cardCols);1074 const rows = Math.ceil(cards.length / cols);1075 const gap = 0.2;1076 const cardWidth = (w - gap * (cols - 1)) / cols;1077 const cardHeight = Math.min(2.2, (maxH - gap * (rows - 1)) / rows);10781079 const valueColorMap: Record<string, string> = {1080 gold: extras.gold,1081 cyan: extras.cyan,1082 red: extras.red,1083 green: extras.green,1084 white: 'FFFFFF',1085 };10861087 cards.forEach((card, i) => {1088 const col = i % cols;1089 const row = Math.floor(i / cols);1090 const cx = x + col * (cardWidth + gap);1091 const cy = y + row * (cardHeight + gap);10921093 const fillColor = card.variant === 'alert' ? extras.cardAlertBg : extras.cardBg;1094 const borderColor =1095 card.variant === 'alert' ? extras.cardAlertBorder1096 : card.variant === 'highlight' ? extras.cardHighlightBorder1097 : extras.cardBorder;10981099 // PPTX shape: pptxgenjs supports 'roundRect' which gives subtle rounded1100 // corners; the corner radius is fixed (no curvature control), close enough1101 // to the CSS border-radius look.1102 slide.addShape('roundRect' as any, {1103 x: cx, y: cy, w: cardWidth, h: cardHeight,1104 fill: { color: fillColor },1105 line: { color: borderColor, width: 1 },1106 rectRadius: 0.05,1107 });11081109 const labelColor =1110 card.variant === 'highlight' ? extras.gold1111 : card.variant === 'alert' ? extras.red1112 : extras.paleBlue;11131114 if (card.label) {1115 slide.addText(card.label.toUpperCase(), {1116 x: cx + 0.15, y: cy + 0.12, w: cardWidth - 0.3, h: 0.3,1117 fontSize: 9, bold: true, color: labelColor, fontFace: MM_FONT_HEAD,1118 charSpacing: 2, valign: 'top',1119 });1120 }11211122 // Auto-shrink long card values so multi-line wrap doesn't bleed into1123 // the detail row. Threshold mirrors the typical narrow-card case.1124 const valueLen = card.value.length;1125 const valueFontSize = valueLen > 14 ? 16 : 22;1126 if (card.value) {1127 slide.addText(card.value, {1128 x: cx + 0.15, y: cy + 0.45, w: cardWidth - 0.3, h: 0.85,1129 fontSize: valueFontSize, bold: true,1130 color: valueColorMap[card.valueColor] || 'FFFFFF',1131 fontFace: MM_FONT_HEAD, valign: 'top',1132 });1133 }11341135 if (card.detail) {1136 const detailY = cy + 1.35;1137 const detailH = Math.max(0.3, cy + cardHeight - 0.15 - detailY);1138 slide.addText(card.detail, {1139 x: cx + 0.15, y: detailY, w: cardWidth - 0.3, h: detailH,1140 fontSize: 10, color: 'D4E4F5', fontFace: MM_FONT_BODY, valign: 'top',1141 });1142 }1143 });11441145 return rows * cardHeight + (rows - 1) * gap;1146}11471148function addMarinaSlideContent(1149 slide: PptxGenJS.Slide,1150 parsed: ParsedSlide,1151 bgColor: string,1152 colors: ColorMap,1153 extras: MarinaExtras,1154 crestB64: string | null,1155) {1156 addMarinaChrome(slide, parsed.slideType, parsed.headerTitle, crestB64, bgColor, extras);11571158 if (parsed.slideType === 'intro') {1159 addMarinaTitleSlide(slide, parsed, crestB64, colors, extras);1160 if (parsed.notes) slide.addNotes(parsed.notes);1161 return;1162 }1163 if (parsed.slideType === 'closing') {1164 addMarinaClosingSlide(slide, parsed, crestB64, colors, extras);1165 if (parsed.notes) slide.addNotes(parsed.notes);1166 return;1167 }11681169 // Generic content slide: header bar reserved at top 0.5"; content starts below.1170 let curY = 0.85;1171 const maxY = SLIDE_H - 0.55;11721173 if (parsed.title) {1174 slide.addText(parsed.title, {1175 x: MARGIN, y: 0.7, w: CONTENT_W, h: 0.55,1176 fontSize: 32, bold: true, color: colors.title, fontFace: MM_FONT_HEAD,1177 valign: 'top',1178 });1179 // Thin accent underline1180 slide.addShape('rect' as any, {1181 x: MARGIN, y: 1.3, w: CONTENT_W, h: 0.03,1182 fill: { color: colors.accent }, line: { color: colors.accent, width: 0 },1183 });1184 curY = 1.45;1185 }11861187 if (parsed.sectionLabel) {1188 slide.addText(parsed.sectionLabel.toUpperCase(), {1189 x: MARGIN, y: curY, w: CONTENT_W, h: 0.3,1190 fontSize: 10, color: extras.cyan, fontFace: MM_FONT_HEAD,1191 charSpacing: 2, valign: 'top',1192 });1193 curY += 0.4;1194 }11951196 // Card region: cap at ~3" so remaining elements still have room1197 if (parsed.cards.length > 0) {1198 const cardRegionMaxH = Math.min(3.0, maxY - curY);1199 const consumed = addMarinaCardGrid(1200 slide, parsed.cards, parsed.cardCols,1201 MARGIN, curY, CONTENT_W, cardRegionMaxH, extras,1202 );1203 curY += consumed + 0.25;1204 }12051206 // Image-left/right layout for marina is preserved when present1207 if (parsed.imagePosition && parsed.elements.length > 0) {1208 addImageLayoutSlide(slide, parsed, curY, maxY, colors, CONTENT_W, MARGIN, curY);1209 } else if (parsed.elements.length > 0) {1210 // Marina body is denser when alongside cards; pass smaller font sizes.1211 addElementsToSlide(slide, parsed.elements, curY, colors, maxY, CONTENT_W, MARGIN, 16, 14);1212 }12131214 if (parsed.notes) slide.addNotes(parsed.notes);1215}12161217function addSlideContent(1218 slide: PptxGenJS.Slide,1219 parsed: ParsedSlide,1220 bgColor: string,1221 colors: ColorMap,1222 theme: string | null = null,1223 crestB64: string | null = null,1224) {1225 if (theme === 'marina') {1226 addMarinaSlideContent(slide, parsed, parsed.bgColor || bgColor, colors, MARINA_EXTRAS, crestB64);1227 return;1228 }12291230 // Per-slide bgColor override, otherwise use theme background1231 slide.background = { color: parsed.bgColor || bgColor };12321233 // For image layouts, align title with text column1234 const gap = 0.5;1235 const halfW = (CONTENT_W - gap) / 2;1236 const titleX = parsed.imagePosition1237 ? (parsed.imagePosition === 'left' ? MARGIN + halfW + gap : MARGIN)1238 : MARGIN;1239 const titleW = parsed.imagePosition ? halfW : CONTENT_W;12401241 const maxY = SLIDE_H - MARGIN;1242 const titleH = parsed.title ? (CONTENT_Y - TITLE_Y) : 0;1243 const elementsH = estimateElementsHeight(parsed.elements, parsed.imagePosition ? halfW : CONTENT_W);1244 const textBlockH = titleH + elementsH;1245 const availableH = maxY - TITLE_Y;12461247 let offset: number;1248 if (parsed.imagePosition) {1249 const imgEstH = Math.min(halfW * 0.75, availableH);1250 const blockH = Math.max(textBlockH, imgEstH);1251 offset = Math.max(0, (availableH - blockH) / 2);1252 } else {1253 offset = Math.max(0, (availableH - textBlockH) / 2);1254 }12551256 const adjTitleY = TITLE_Y + offset;1257 const adjContentY = adjTitleY + titleH;12581259 if (parsed.title) {1260 slide.addText(parsed.title, {1261 x: titleX, y: adjTitleY, w: titleW, h: TITLE_H,1262 fontSize: 28, bold: true, color: colors.title, fontFace: 'Calibri', valign: 'bottom',1263 });1264 }12651266 const startY = parsed.title ? adjContentY : adjTitleY;12671268 // Image-left / image-right layout1269 if (parsed.imagePosition) {1270 addImageLayoutSlide(slide, parsed, adjTitleY, maxY, colors, CONTENT_W, MARGIN, startY);1271 } else {1272 addElementsToSlide(slide, parsed.elements, startY, colors, maxY, CONTENT_W, MARGIN);1273 }12741275 if (parsed.notes) {1276 slide.addNotes(parsed.notes);1277 }1278}12791280// ── Main ──12811282async function main() {1283 let inputData = '';1284 for await (const chunk of process.stdin) {1285 inputData += chunk;1286 }12871288 const { inputs } = JSON.parse(inputData);1289 const htmlContent: string = inputs.html_content || '';1290 const title: string = inputs.title || 'Presentation';1291 const themeInput: string = inputs.theme || '';12921293 if (!htmlContent) {1294 throw new Error("Required input 'html_content' not provided");1295 }12961297 const rendered = await renderWithService(htmlContent, title);1298 if (rendered) {1299 writeOutput(rendered, title);1300 return;1301 }1302 console.error("Falling back to the built-in converter: the deck's own styles and layout will not carry over");13031304 // Parse HTML1305 const $ = cheerio.load(htmlContent);1306 const sections = $('div.slides > section');13071308 if (sections.length === 0) {1309 // Fallback: try top-level <section> elements1310 const fallbackSections = $('section');1311 if (fallbackSections.length === 0) {1312 throw new Error('No <section> elements found in the HTML content');1313 }1314 }13151316 const rawSections = sections.length > 0 ? sections : $('section');13171318 // Flatten Reveal.js vertical stacks: a <section> that wraps nested <section>1319 // children is a stack container, not a real slide — replace it with its children.1320 const flatSections: cheerio.Element[] = [];1321 rawSections.each((_i, section) => {1322 const nested = $(section).children('section');1323 if (nested.length > 0) {1324 nested.each((_j, child) => flatSections.push(child));1325 } else {1326 flatSections.push(section);1327 }1328 });13291330 console.error(`Parsing ${flatSections.length} slides from HTML`);13311332 // Theme detection1333 const theme = detectTheme($, themeInput);1334 const themeConfig = getThemeConfig(theme);13351336 // Marina: extract embedded crest PNG (largest data URI in inline CSS)1337 let crestB64: string | null = null;1338 if (theme === 'marina') {1339 crestB64 = extractMarinaCrest(htmlContent);1340 if (crestB64) {1341 console.error(`Marina crest extracted (${crestB64.length} base64 chars)`);1342 } else {1343 console.error('Marina theme detected but no crest PNG found in HTML');1344 }1345 }13461347 // Create presentation1348 const pptx = new PptxGenJS();1349 pptx.title = title;1350 pptx.layout = 'LAYOUT_WIDE'; // 13.33 x 7.513511352 // Phase 1: Parse all sections into ParsedSlide array1353 const parsedSlides: ParsedSlide[] = [];1354 for (const section of flatSections) {1355 parsedSlides.push(parseSection($, section));1356 }13571358 // Phase 2: Resolve external image URLs in parallel1359 await Promise.all(parsedSlides.map(async (parsed) => {1360 if (parsed.imageSrcRaw && (parsed.imageSrcRaw.startsWith('http://') || parsed.imageSrcRaw.startsWith('https://'))) {1361 const data = await fetchImageAsBase64(parsed.imageSrcRaw);1362 if (data) {1363 parsed.imageData = data;1364 parsed.imageSrcRaw = null;1365 }1366 }1367 }));13681369 // Phase 3: Generate PPTX slides from resolved data1370 for (const parsed of parsedSlides) {1371 const slide = pptx.addSlide();1372 addSlideContent(slide, parsed, themeConfig.background, themeConfig.colors, theme, crestB64);1373 }13741375 console.error(`Generated ${flatSections.length} slides (theme: ${theme || 'generic'})`);13761377 const buffer = await pptx.write({ outputType: 'nodebuffer' }) as Buffer;1378 writeOutput(buffer, title);1379}13801381main().catch((err) => {1382 console.error(JSON.stringify({1383 error: err.message || String(err),1384 errorType: err.name || 'Error',1385 traceback: err.stack || '',1386 }));1387 process.exit(1);1388});$ git log --oneline
v1.4.1
HEAD
2026-10-26fall back to PptxGenJS only when the route cannot apply (404/405 old image, 413, 400/422 a deck it refuses); on 5xx, 504, a network error, a timeout or an answer that is not a .pptx the node fails with a readable error, so a transient outage no longer leaves a cached white-slide export.
v1.4.02026-10-25
The deck is converted by document-render (Chromium measures it), so slides keep the deck's look, pictures and editable text; the PptxGenJS converter remains the fallback when the service does not answer.
v1.3.02026-10-22
v1.2.52026-10-21
v1.2.42026-05-22