$ 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-26

fall 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