$ 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// ── Generic layout constants (inches, LAYOUT_WIDE: 13.33 x 7.5) ──6const MARGIN = 0.5;7const SLIDE_W = 13.33;8const SLIDE_H = 7.5;9const TITLE_Y = 0.4;10const TITLE_H = 1.0;11const CONTENT_Y = 1.6;12const CONTENT_W = SLIDE_W - MARGIN * 2;1314// ── Generic color palette ──15const COLORS = {16  title: '2D3748',17  body: '4A5568',18  accent: 'DD6B20',19  bulletDot: '3182CE',20  tableHeader: '2B6CB0',21  tableHeaderText: 'FFFFFF',22  tableBorder: 'CBD5E0',23  quoteBar: 'DD6B20',24  quoteText: '718096',25  tableCellBg: 'FFFFFF',26};2728// ── Reveal.js Theme Palettes ──29// Each theme defines a slide background + color map matching its Reveal.js CSS3031interface ThemeConfig {32  background: string;33  colors: typeof COLORS;34}3536const THEME_PALETTES: Record<string, ThemeConfig> = {37  // ── Light themes ──38  white: {39    background: 'FFFFFF',40    colors: { title: '222222', body: '222222', accent: '2A76DD', bulletDot: '2A76DD', tableHeader: '2A76DD', tableHeaderText: 'FFFFFF', tableBorder: 'CBD5E0', tableCellBg: 'FFFFFF', quoteBar: '2A76DD', quoteText: '666666' },41  },42  beige: {43    background: 'F7F3DE',44    colors: { title: '333333', body: '333333', accent: '8B743D', bulletDot: '8B743D', tableHeader: '8B743D', tableHeaderText: 'FFFFFF', tableBorder: 'C8C0A8', tableCellBg: 'F7F3DE', quoteBar: '8B743D', quoteText: '666666' },45  },46  sky: {47    background: 'F7FBFC',48    colors: { title: '333333', body: '333333', accent: '3B759E', bulletDot: '3B759E', tableHeader: '3B759E', tableHeaderText: 'FFFFFF', tableBorder: 'ADD9E4', tableCellBg: 'F7FBFC', quoteBar: '3B759E', quoteText: '666666' },49  },50  serif: {51    background: 'F0F1EB',52    colors: { title: '383D3D', body: '000000', accent: '51483D', bulletDot: '51483D', tableHeader: '51483D', tableHeaderText: 'FFFFFF', tableBorder: 'C0C0B8', tableCellBg: 'F0F1EB', quoteBar: '51483D', quoteText: '555555' },53  },54  simple: {55    background: 'FFFFFF',56    colors: { title: '000000', body: '000000', accent: '00008B', bulletDot: '00008B', tableHeader: '00008B', tableHeaderText: 'FFFFFF', tableBorder: 'CBD5E0', tableCellBg: 'FFFFFF', quoteBar: '00008B', quoteText: '444444' },57  },58  solarized: {59    background: 'FDF6E3',60    colors: { title: '586E75', body: '657B83', accent: '268BD2', bulletDot: '268BD2', tableHeader: '268BD2', tableHeaderText: 'FDF6E3', tableBorder: 'EEE8D5', tableCellBg: 'FDF6E3', quoteBar: '268BD2', quoteText: '93A1A1' },61  },62  // ── Dark themes ──63  black: {64    background: '191919',65    colors: { title: 'FFFFFF', body: 'FFFFFF', accent: '42AFFA', bulletDot: '42AFFA', tableHeader: '42AFFA', tableHeaderText: '191919', tableBorder: '444444', tableCellBg: '191919', quoteBar: '42AFFA', quoteText: 'CCCCCC' },66  },67  moon: {68    background: '002B36',69    colors: { title: 'EEE8D5', body: '93A1A1', accent: '268BD2', bulletDot: '268BD2', tableHeader: '268BD2', tableHeaderText: 'EEE8D5', tableBorder: '073642', tableCellBg: '002B36', quoteBar: '268BD2', quoteText: '839496' },70  },71  league: {72    background: '2B2B2B',73    colors: { title: 'EEEEEE', body: 'EEEEEE', accent: '13DAEC', bulletDot: '13DAEC', tableHeader: '13DAEC', tableHeaderText: '2B2B2B', tableBorder: '444444', tableCellBg: '2B2B2B', quoteBar: '13DAEC', quoteText: 'CCCCCC' },74  },75  night: {76    background: '111111',77    colors: { title: 'EEEEEE', body: 'EEEEEE', accent: 'E7AD52', bulletDot: 'E7AD52', tableHeader: 'E7AD52', tableHeaderText: '111111', tableBorder: '333333', tableCellBg: '111111', quoteBar: 'E7AD52', quoteText: 'CCCCCC' },78  },79  blood: {80    background: '222222',81    colors: { title: 'EEEEEE', body: 'EEEEEE', accent: 'AA2233', bulletDot: 'AA2233', tableHeader: 'AA2233', tableHeaderText: 'EEEEEE', tableBorder: '444444', tableCellBg: '222222', quoteBar: 'AA2233', quoteText: 'CCCCCC' },82  },83  dracula: {84    background: '282A36',85    colors: { title: 'BD93F9', body: 'F8F8F2', accent: 'FF79C6', bulletDot: '8BE9FD', tableHeader: '6272A4', tableHeaderText: 'F8F8F2', tableBorder: '44475A', tableCellBg: '282A36', quoteBar: 'FF79C6', quoteText: 'F8F8F2' },86  },87  // ── Marina Militare (custom Italian-government deck) ──88  marina: {89    background: '001A33',90    colors: { title: 'FFFFFF', body: 'D4E4F5', accent: '0072C6', bulletDot: 'FFCB28', tableHeader: '1B4F8C', tableHeaderText: 'FFCB28', tableBorder: '4A90D9', tableCellBg: '001A33', quoteBar: '0072C6', quoteText: 'D4E4F5' },91  },92};9394// ── Marina-specific extras (colors not in the generic ColorMap) ──95// These come from --mm-* CSS tokens; rgba() values are flattened to solids96// because PPTX shape fills don't reliably honor alpha.97interface MarinaExtras {98  cyan: string;99  gold: string;100  red: string;101  green: string;102  paleBlue: string;103  cardBg: string;104  cardBorder: string;105  cardHighlightBorder: string;106  cardAlertBorder: string;107  cardAlertBg: string;108  tricolorGreen: string;109  tricolorRed: string;110  watermarkColor: string;111}112113const MARINA_EXTRAS: MarinaExtras = {114  cyan: '2DD3FF',115  gold: 'FFCB28',116  red: 'ED0033',117  green: '288054',118  paleBlue: 'C0E4FF',119  cardBg: '1B4F8C',120  cardBorder: '4A90D9',121  cardHighlightBorder: 'FFCB28',122  cardAlertBorder: 'ED0033',123  cardAlertBg: '5C0A19',124  tricolorGreen: '00953E',125  tricolorRed: 'E21F1D',126  watermarkColor: '4A6680',127};128129// Marina theme fonts. PowerPoint will substitute if the viewing machine lacks130// them, so users get the closest visual match by installing Graduate and131// Montserrat (both free Google Fonts) on their system.132const MM_FONT_DISPLAY = 'Graduate';     // title-slide H1 — slab-serif uppercase133const MM_FONT_HEAD = 'Montserrat';      // section H2/H3, header bar, card text134const MM_FONT_BODY = 'Calibri';         // body text — Segoe UI substitute135136const DEFAULT_THEME: ThemeConfig = { background: 'FFFFFF', colors: COLORS };137138function getThemeConfig(theme: string | null): ThemeConfig {139  if (!theme) return DEFAULT_THEME;140  return THEME_PALETTES[theme] || DEFAULT_THEME;141}142143// ── Types ──144145type ColorMap = typeof COLORS;146type SlideType = 'intro' | 'content' | 'closing' | 'generic';147148interface TextRun {149  text: string;150  bold?: boolean;151  italic?: boolean;152  color?: string;153}154155interface SlideElement {156  tag: string;157  text: string;158  children?: { text: string; tag: string; runs?: TextRun[]; indent?: number }[];159  attrs?: Record<string, string>;160  runs?: TextRun[];161}162163interface ParsedCard {164  label: string;165  value: string;166  valueColor: 'gold' | 'cyan' | 'red' | 'green' | 'white';167  detail: string | null;168  variant: 'default' | 'highlight' | 'alert';169}170171interface ParsedSlide {172  slideType: SlideType;173  title: string | null;174  elements: SlideElement[];175  notes: string | null;176  bgColor: string | null;177  headerTitle: string | null;178  subtitles: string[];179  closingText: string | null;180  /** Base64 image data (without "data:" prefix) for image-left/right layouts */181  imageData: string | null;182  /** Raw image src for deferred fetch (http/https URLs or {{IMAGE}} placeholders) */183  imageSrcRaw: string | null;184  /** Image position: 'left' or 'right' */185  imagePosition: 'left' | 'right' | null;186  /** Marina: <p class="subtitle"> text on title slides */187  subtitle: string | null;188  /** Marina: <p class="meta"> text on title slides */189  meta: string | null;190  /** Marina: <p class="hashtag"> text on closing slides */191  hashtag: string | null;192  /** Marina: first <p class="section-label"> text in section */193  sectionLabel: string | null;194  /** Marina: parsed cards from <div class="card-grid"> */195  cards: ParsedCard[];196  /** Marina: card-grid columns (cols-N), default 1 */197  cardCols: number;198}199200// ── Theme Detection ──201202function detectTheme($: cheerio.CheerioAPI, themeInput?: string): string | null {203  if (themeInput && themeInput !== 'auto') {204    if (themeInput === 'marina') return 'marina';205    return themeInput;206  }207  const html = $.html();208209  // Marina Militare detection: CSS is inlined (no /dist/theme/marina.css link),210  // so look for the --mm-navy custom property, the watermark string, or the211  // data-header-title attribute marina sections always carry.212  if (html.includes('--mm-navy') || html.includes('MARINA MILITARE')) return 'marina';213  if ($('section[data-header-title]').length > 0) return 'marina';214215  // Standard Reveal.js themes: detect from <link href="...reveal.js.../theme/{name}.css">216  const themeMatch = html.match(/reveal\.js[^"]*\/dist\/theme\/([a-z-]+)\.css/);217  if (themeMatch && themeMatch[1] in THEME_PALETTES) return themeMatch[1];218219  return null;220}221222// ── Marina crest extraction ──223// Marina presentations embed a single large PNG (the crest) as a data URI in224// the inline CSS. Pick the longest base64 PNG match and return it stripped of225// the "data:" prefix; the texture is webp and won't match this regex.226function extractMarinaCrest(html: string): string | null {227  const re = /data:image\/png;base64,([A-Za-z0-9+/=]{1000,})/g;228  let m: RegExpExecArray | null;229  let best: string | null = null;230  while ((m = re.exec(html)) !== null) {231    const candidate = m[1];232    if (best === null || candidate.length > best.length) {233      best = candidate;234    }235  }236  return best;237}238239// ── Bold accent color (set per-theme in main()) ──240let accentStrongColor = '333333';241242// ── Inline Rich Text Parsing ──243244function parseInlineRuns($: cheerio.CheerioAPI, el: cheerio.Element, inherited?: { bold?: boolean; italic?: boolean; color?: string }): TextRun[] {245  const runs: TextRun[] = [];246  const inh = inherited || {};247248  $(el).contents().each((_i, node) => {249    if (node.type === 'text') {250      const t = (node as any).data || '';251      if (t) {252        runs.push({253          text: t,254          ...(inh.bold ? { bold: true } : {}),255          ...(inh.italic ? { italic: true } : {}),256          ...(inh.color ? { color: inh.color } : {}),257        });258      }259    } else if (node.type === 'tag') {260      const tag = (node as cheerio.TagElement).tagName?.toLowerCase() || '';261      // Skip nested lists and tables — they are handled separately262      if (tag === 'ul' || tag === 'ol' || tag === 'table') return;263      const next = { ...inh };264      if (tag === 'strong' || tag === 'b') { next.bold = true; next.color = next.color || accentStrongColor; }265      if (tag === 'em' || tag === 'i') { next.italic = true; }266      runs.push(...parseInlineRuns($, node as cheerio.Element, next));267    }268  });269270  return runs;271}272273/** Extract only direct text from a <li>, excluding nested <ul>/<ol> text. */274function directText($: cheerio.CheerioAPI, el: cheerio.Element): string {275  let text = '';276  $(el).contents().each((_i, node) => {277    if (node.type === 'text') {278      text += (node as any).data || '';279    } else if (node.type === 'tag') {280      const tag = (node as cheerio.TagElement).tagName?.toLowerCase() || '';281      if (tag !== 'ul' && tag !== 'ol' && tag !== 'table') {282        text += $(node).text();283      }284    }285  });286  return text.trim();287}288289// ── Section Parsing ──290291function parseSection($: cheerio.CheerioAPI, section: cheerio.Element): ParsedSlide {292  const $s = $(section);293  const notes = $s.find('aside.notes').text().trim() || null;294  const bgColor = ($s.attr('data-background-color') || '').replace('#', '') || null;295296  // Detect slide type — marina sections carry .title-slide / .closing-slide297  let slideType: SlideType = 'generic';298  if ($s.hasClass('title-slide')) slideType = 'intro';299  else if ($s.hasClass('closing-slide')) slideType = 'closing';300301  // Marina header bar text comes from the data-header-title attribute302  const headerTitle = $s.attr('data-header-title') || null;303  const subtitles: string[] = [];304  let closingText: string | null = null;305306  // Title source depends on slide type — intro/closing use h1, content uses h2.307  // For h1 we preserve <br> as \n so the renderer can honor explicit line breaks.308  let title: string | null = null;309  if (slideType === 'intro' || slideType === 'closing') {310    const $h1 = $s.find('h1').first();311    if ($h1.length > 0) {312      const cloned = $h1.clone();313      cloned.find('br').replaceWith('\n');314      title = cloned.text().trim() || null;315    }316  } else {317    title = $s.find('h2').first().text().trim() || null;318  }319320  // Marina-specific paragraph captures321  const subtitle = $s.find('p.subtitle').first().text().trim() || null;322  const meta = $s.find('p.meta').first().text().trim() || null;323  const hashtag = $s.find('p.hashtag').first().text().trim() || null;324  const sectionLabel = $s.find('p.section-label').first().text().trim() || null;325326  // Marina cards (populated below by the section walker when a card-grid is found)327  const cards: ParsedCard[] = [];328  let cardCols = 1;329330  // Detect image-left / image-right layout331  let imageData: string | null = null;332  let imageSrcRaw: string | null = null;333  let imagePosition: 'left' | 'right' | null = null;334335  // Parse content elements336  const elements: SlideElement[] = [];337338  /** Parse a list element, handling nesting and rich text */339  function parseList($list: cheerio.Cheerio<cheerio.Element>, listTag: string, indent: number = 0) {340    const items: { text: string; tag: string; runs?: TextRun[]; indent?: number }[] = [];341    $list.children('li').each((_k, li) => {342      const text = directText($, li);343      const runs = parseInlineRuns($, li);344      if (text) {345        items.push({ text, tag: listTag, runs: runs.length > 0 ? runs : undefined, indent });346      }347      // Handle nested lists inside this <li>348      $(li).children('ul, ol').each((_n, nested) => {349        const nestedTag = (nested as cheerio.TagElement).tagName?.toLowerCase() || 'ul';350        const nestedItems = parseList($(nested), nestedTag, indent + 1);351        items.push(...nestedItems);352      });353    });354    return items;355  }356357  function extractChildElements($container: cheerio.Cheerio<cheerio.Element>) {358    $container.find('p, ul, ol, h3, h2, blockquote, table').each((_j, child) => {359      const $child = $(child);360      const childTag = (child as cheerio.TagElement).tagName?.toLowerCase() || '';361      // Skip the main slide h2 title if already captured362      if (childTag === 'h2' && $child.text().trim() === title) return;363      // Skip nested lists (already handled by their parent <li>)364      if ((childTag === 'ul' || childTag === 'ol') && $(child).parent('li').length > 0) return;365      // Skip tables inside lists (extracted separately after list processing)366      if (childTag === 'table' && $(child).closest('ul, ol').length > 0) return;367      if (childTag === 'ul' || childTag === 'ol') {368        const items = parseList($child, childTag);369        elements.push({ tag: childTag, text: '', children: items });370      } else if (childTag === 'table') {371        const rows: { text: string; tag: string }[][] = [];372        $child.find('tr').each((_rj, tr) => {373          const cells: { text: string; tag: string }[] = [];374          $(tr).find('th, td').each((_k, cell) => {375            cells.push({376              text: $(cell).text().trim(),377              tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',378            });379          });380          if (cells.length > 0) rows.push(cells);381        });382        elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });383      } else if (childTag === 'p') {384        // Marina-only <p> classes are captured separately on ParsedSlide;385        // skip them so they don't render twice as generic body text.386        const cls = ($child.attr('class') || '').split(/\s+/);387        if (cls.includes('section-label') || cls.includes('subtitle') || cls.includes('meta') || cls.includes('hashtag')) return;388        const txt = $child.text().trim();389        if (txt) {390          const runs = parseInlineRuns($, child);391          elements.push({ tag: 'p', text: txt, runs: runs.length > 0 ? runs : undefined });392        }393      } else {394        const txt = $child.text().trim();395        if (txt) elements.push({ tag: childTag === 'h2' ? 'h3' : childTag, text: txt });396      }397    });398  }399400  $s.children().each((_i, el) => {401    const $el = $(el);402    const tag = (el as cheerio.TagElement).tagName?.toLowerCase() || '';403404    // Skip notes405    if (tag === 'aside' && $el.hasClass('notes')) return;406    // Skip only the first h2 (captured as slide title); render others as subheadings407    if (tag === 'h2') {408      if ($el.text().trim() === title) return;409      elements.push({ tag: 'h3', text: $el.text().trim() });410      return;411    }412413    if (tag === 'h3') {414      elements.push({ tag: 'h3', text: $el.text().trim() });415    } else if (tag === 'p') {416      // Marina-only <p> classes are captured separately on ParsedSlide;417      // skip them so they don't render twice as generic body text.418      const cls = ($el.attr('class') || '').split(/\s+/);419      if (cls.includes('section-label') || cls.includes('subtitle') || cls.includes('meta') || cls.includes('hashtag')) return;420      const txt = $el.text().trim();421      if (txt) {422        const runs = parseInlineRuns($, el);423        elements.push({ tag: 'p', text: txt, runs: runs.length > 0 ? runs : undefined });424      }425    } else if (tag === 'ul' || tag === 'ol') {426      const items = parseList($el, tag);427      elements.push({ tag, text: '', children: items });428      // Extract tables nested inside list items (e.g. table under a <li>)429      $el.find('table').each((_j, tbl) => {430        const $tbl = $(tbl);431        const rows: { text: string; tag: string }[][] = [];432        $tbl.find('tr').each((_rj, tr) => {433          const cells: { text: string; tag: string }[] = [];434          $(tr).find('th, td').each((_k, cell) => {435            cells.push({436              text: $(cell).text().trim(),437              tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',438            });439          });440          if (cells.length > 0) rows.push(cells);441        });442        if (rows.length > 0) {443          elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });444        }445      });446    } else if (tag === 'blockquote') {447      elements.push({ tag: 'blockquote', text: $el.text().trim() });448    } else if (tag === 'table') {449      const rows: { text: string; tag: string }[][] = [];450      $el.find('tr').each((_j, 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 (tag === 'div') {462      // Marina card-grid: parse cards directly into parsed.cards and SKIP the463      // generic flex/recursion fallback so card content doesn't also leak into464      // elements as bullet text.465      const className = $el.attr('class') || '';466      if (className.split(/\s+/).includes('card-grid')) {467        const colsMatch = className.match(/cols-(\d+)/);468        if (colsMatch) cardCols = parseInt(colsMatch[1], 10) || 1;469        $el.children('div.card').each((_j, cardEl) => {470          const $card = $(cardEl);471          const cardClass = ($card.attr('class') || '').split(/\s+/);472          const variant: 'default' | 'highlight' | 'alert' =473            cardClass.includes('alert') ? 'alert'474            : cardClass.includes('highlight') ? 'highlight'475            : 'default';476          const $value = $card.children('.value').first();477          const valueClass = ($value.attr('class') || '').split(/\s+/);478          let valueColor: 'gold' | 'cyan' | 'red' | 'green' | 'white' = 'white';479          if (valueClass.includes('gold')) valueColor = 'gold';480          else if (valueClass.includes('cyan')) valueColor = 'cyan';481          else if (valueClass.includes('red')) valueColor = 'red';482          else if (valueClass.includes('green')) valueColor = 'green';483          cards.push({484            label: $card.children('.label').first().text().trim(),485            value: $value.text().trim(),486            valueColor,487            detail: $card.children('.detail').first().text().trim() || null,488            variant,489          });490        });491        return;492      }493494      // Check for image-left / image-right flex layout495      const style = $el.attr('style') || '';496      const isFlex = style.includes('display:flex') || style.includes('display: flex');497      const childDivs = $el.children('div');498      const hasImg = $el.find('img').length > 0;499500      if (isFlex && hasImg && !imageData && !imageSrcRaw) {501        // Case 1: Both sides wrapped in <div> (expected structure)502        if (childDivs.length >= 2) {503          const firstChild = $(childDivs[0]);504          const secondChild = $(childDivs[1]);505          const firstImg = firstChild.find('img').first();506          const secondImg = secondChild.find('img').first();507508          let imgEl: cheerio.Cheerio<cheerio.Element> | null = null;509          let textContainer: cheerio.Cheerio<cheerio.Element> | null = null;510511          if (firstImg.length > 0) {512            imgEl = firstImg;513            textContainer = secondChild;514            imagePosition = 'left';515          } else if (secondImg.length > 0) {516            imgEl = secondImg;517            textContainer = firstChild;518            imagePosition = 'right';519          }520521          if (imgEl && textContainer) {522            const imgSrc = imgEl.attr('src') || '';523            if (imgSrc.startsWith('data:')) {524              imageData = imgSrc.slice(5);525            } else if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) {526              imageSrcRaw = imgSrc;527            } else {528              // {{IMAGE}} placeholder or empty src529              imageSrcRaw = imgSrc || '{{IMAGE}}';530            }531            extractChildElements(textContainer);532            return;533          }534        }535536        // Case 2: <img> is a direct child of flex container (LLM sometimes does this)537        const directImg = $el.children('img').first();538        if (directImg.length > 0 && childDivs.length >= 1) {539          const imgSrc = directImg.attr('src') || '';540          // Determine position: if img comes before the div, it's left541          const allChildren = $el.children().toArray();542          const imgIdx = allChildren.findIndex(c => c === directImg[0]);543          const divIdx = allChildren.findIndex(c => c === childDivs[0]);544          imagePosition = imgIdx < divIdx ? 'left' : 'right';545546          if (imgSrc.startsWith('data:')) {547            imageData = imgSrc.slice(5);548          } else if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) {549            imageSrcRaw = imgSrc;550          } else {551            imageSrcRaw = imgSrc || '{{IMAGE}}';552          }553          extractChildElements($(childDivs[0]));554          return;555        }556      }557558      // Fallback: recurse into layout divs (two-column layouts etc.)559      extractChildElements($el);560    }561  });562563  return {564    slideType, title, elements, notes, bgColor, headerTitle, subtitles, closingText,565    imageData, imageSrcRaw, imagePosition,566    subtitle, meta, hashtag, sectionLabel, cards, cardCols,567  };568}569570// ── Image Fetch Helper ──571572async function fetchImageAsBase64(url: string): Promise<string | null> {573  try {574    const controller = new AbortController();575    const timeout = setTimeout(() => controller.abort(), 10000);576    const resp = await fetch(url, { signal: controller.signal });577    clearTimeout(timeout);578    if (!resp.ok) return null;579    const buf = Buffer.from(await resp.arrayBuffer());580    const contentType = resp.headers.get('content-type') || 'image/png';581    const mimeType = contentType.split(';')[0].trim();582    return `${mimeType};base64,${buf.toString('base64')}`;583  } catch (e) {584    console.error(`Failed to fetch image from ${url}: ${e}`);585    return null;586  }587}588589// ── Content Height Estimation ──590591function estimateElementsHeight(elements: SlideElement[], contentW: number): number {592  let h = 0;593  for (const el of elements) {594    if (el.tag === 'h3') {595      h += 0.55;596    } else if (el.tag === 'p') {597      if (!el.text) continue;598      h += 0.5;599    } else if ((el.tag === 'ul' || el.tag === 'ol') && el.children) {600      el.children.forEach((item) => {601        const indent = item.indent || 0;602        const baseFontSize = indent > 0 ? 13 : 15;603        const indentOffset = indent * 0.35;604        const wPos = contentW - 0.2 - indentOffset;605        const textLen = item.text.length;606        const charW = baseFontSize * 0.0067;607        const lineH = baseFontSize * 0.019;608        const charsPerLine = Math.max(1, Math.floor(wPos / charW));609        const numLines = Math.max(1, Math.ceil(textLen / charsPerLine));610        h += Math.max(0.3, numLines * lineH + 0.06);611      });612      h += 0.1;613    } else if (el.tag === 'blockquote') {614      h += 0.75;615    } else if (el.tag === 'table' && el.attrs?._rows) {616      try {617        const rows: any[][] = JSON.parse(el.attrs._rows);618        h += rows.length * 0.4 + 0.15;619      } catch {}620    }621  }622  return h;623}624625// ── Shared Element Rendering ──626627function addElementsToSlide(628  slide: PptxGenJS.Slide,629  elements: SlideElement[],630  startY: number,631  colors: ColorMap,632  maxY: number,633  contentW: number,634  margin: number,635  bodyFontSize: number = 18,636  bulletBaseFontSize: number = 15,637) {638  let curY = startY;639640  for (const el of elements) {641    if (curY >= maxY) break;642643    if (el.tag === 'h3') {644      slide.addText(el.text, {645        x: margin, y: curY, w: contentW, h: 0.5,646        fontSize: 22, bold: true, color: colors.title, fontFace: 'Calibri',647      });648      curY += 0.55;649    } else if (el.tag === 'p') {650      if (!el.text) continue;651      if (el.runs && el.runs.length > 0) {652        const pRuns = el.runs.map(r => ({653          text: r.text,654          options: {655            fontSize: bodyFontSize, fontFace: 'Calibri', color: r.color || colors.body,656            ...(r.bold ? { bold: true } : {}),657            ...(r.italic ? { italic: true } : {}),658          },659        }));660        slide.addText(pRuns, { x: margin, y: curY, w: contentW, h: 0.45 });661      } else {662        slide.addText(el.text, {663          x: margin, y: curY, w: contentW, h: 0.45,664          fontSize: bodyFontSize, color: colors.body, fontFace: 'Calibri',665        });666      }667      curY += 0.5;668    } else if ((el.tag === 'ul' || el.tag === 'ol') && el.children) {669      // Render each bullet item with manual bullet/number characters for reliable display.670      const olCounters: Record<number, number> = {};671      let prevTag = '';672      let prevIndent = -1;673674      el.children.forEach((item) => {675        if (curY >= maxY) return;676        const indent = item.indent || 0;677        const baseFontSize = indent > 0 ? Math.max(11, bulletBaseFontSize - 2) : bulletBaseFontSize;678        const indentOffset = indent * 0.35;679        const xPos = margin + 0.2 + indentOffset;680        const wPos = contentW - 0.2 - indentOffset;681682        // Dynamic height: estimate wrapped lines based on text length683        const textLen = item.text.length;684        const charW = baseFontSize * 0.0067;685        const lineH = baseFontSize * 0.019;686        const charsPerLine = Math.max(1, Math.floor(wPos / charW));687        const numLines = Math.max(1, Math.ceil(textLen / charsPerLine));688        const itemH = Math.max(0.3, numLines * lineH + 0.06);689690        // Determine list type from item.tag (set by parseList to 'ul' or 'ol')691        const isOrdered = item.tag === 'ol';692693        // Manage ordered counters: reset when starting a new ordered sequence694        if (isOrdered) {695          if (prevTag !== 'ol' || prevIndent !== indent) {696            olCounters[indent] = 0;697          }698          olCounters[indent]++;699        }700        prevTag = item.tag || '';701        prevIndent = indent;702703        // Build prefix: bullet char or number704        const prefix = isOrdered705          ? `${olCounters[indent]}. `706          : (indent > 0 ? '\u25AA  ' : '\u2022  ');707        const prefixColor = isOrdered ? colors.body : colors.bulletDot;708709        // Always use text run array so bullet char gets its own color710        const textRuns: { text: string; options: any }[] = [711          { text: prefix, options: { fontSize: baseFontSize, fontFace: 'Calibri', color: prefixColor } },712        ];713714        if (item.runs && item.runs.length > 0) {715          for (const r of item.runs) {716            textRuns.push({717              text: r.text,718              options: {719                fontSize: baseFontSize, fontFace: 'Calibri',720                color: r.color || colors.body,721                ...(r.bold ? { bold: true } : {}),722                ...(r.italic ? { italic: true } : {}),723              },724            });725          }726        } else {727          textRuns.push({728            text: item.text,729            options: { fontSize: baseFontSize, fontFace: 'Calibri', color: colors.body },730          });731        }732733        slide.addText(textRuns, {734          x: xPos, y: curY, w: wPos, h: itemH, valign: 'top',735        });736        curY += itemH;737      });738      curY += 0.1;739    } else if (el.tag === 'blockquote') {740      slide.addShape('rect' as any, {741        x: margin, y: curY, w: 0.08, h: 0.6,742        fill: { color: colors.quoteBar },743      });744      slide.addText(el.text, {745        x: margin + 0.25, y: curY, w: contentW - 0.25, h: 0.6,746        fontSize: 16, italic: true, color: colors.quoteText, fontFace: 'Calibri', valign: 'middle',747      });748      curY += 0.75;749    } else if (el.tag === 'table' && el.attrs?._rows) {750      try {751        const rows: { text: string; tag: string }[][] = JSON.parse(el.attrs._rows);752        const tableRows: PptxGenJS.TableRow[] = rows.map((row, rowIdx) =>753          row.map((cell) => ({754            text: cell.text,755            options: {756              fontSize: 12,757              fontFace: 'Calibri',758              color: cell.tag === 'th' || rowIdx === 0 ? colors.tableHeaderText : colors.body,759              fill: { color: cell.tag === 'th' || rowIdx === 0 ? colors.tableHeader : colors.tableCellBg },760              border: { pt: 0.5, color: colors.tableBorder },761              valign: 'middle' as const,762              bold: cell.tag === 'th',763            },764          }))765        );766        const tableH = Math.min(rows.length * 0.4, maxY - curY);767        slide.addTable(tableRows, {768          x: margin, y: curY, w: contentW, h: tableH,769          colW: Array(rows[0]?.length || 1).fill(contentW / (rows[0]?.length || 1)),770        });771        curY += tableH + 0.15;772      } catch {773        // Skip malformed table774      }775    }776  }777}778779// ── Generic Slide Content ──780781function addImageLayoutSlide(782  slide: PptxGenJS.Slide,783  parsed: ParsedSlide,784  startY: number,785  maxY: number,786  colors: ColorMap,787  contentW: number,788  margin: number,789  textStartY?: number,790) {791  const gap = 0.5;792  const imgW = (contentW - gap) / 2;793  const imgH = maxY - startY;794  const textW = imgW;795796  const leftX = margin;797  const rightX = margin + imgW + gap;798799  const imgX = parsed.imagePosition === 'left' ? leftX : rightX;800  const textX = parsed.imagePosition === 'left' ? rightX : leftX;801802  // Image top-aligned with content (parent handles vertical centering)803  const estimatedH = Math.min(imgW * 0.75, imgH);804  const imgY = startY;805  if (parsed.imageData) {806    slide.addImage({807      data: parsed.imageData,808      x: imgX, y: imgY, w: imgW, h: estimatedH,809      sizing: { type: 'contain', w: imgW, h: estimatedH },810    });811  } else {812    // Placeholder: grey rectangle with dashed accent border and centered text813    slide.addShape('rect' as any, {814      x: imgX, y: imgY, w: imgW, h: estimatedH,815      fill: { color: 'F0F0F0' },816      line: { color: colors.accent, width: 1.5, dashType: 'dash' },817    });818    slide.addText('[ Image ]', {819      x: imgX, y: imgY, w: imgW, h: estimatedH,820      fontSize: 16, color: '999999', fontFace: 'Calibri',821      align: 'center', valign: 'middle',822    });823  }824825  // Add text elements on the other side826  addElementsToSlide(slide, parsed.elements, textStartY ?? startY, colors, maxY, textW, textX);827}828829// ── Marina Militare renderers ──830831function addMarinaChrome(832  slide: PptxGenJS.Slide,833  slideType: SlideType,834  headerTitle: string | null,835  crestB64: string | null,836  bgColor: string,837  extras: MarinaExtras,838) {839  // Navy background840  slide.background = { color: bgColor };841842  // Tricolor flag stripe at the bottom: green / white / red, no border line843  const stripeW = SLIDE_W / 3;844  const stripeY = SLIDE_H - 0.06;845  slide.addShape('rect' as any, {846    x: 0, y: stripeY, w: stripeW, h: 0.06,847    fill: { color: extras.tricolorGreen }, line: { color: extras.tricolorGreen, width: 0 },848  });849  slide.addShape('rect' as any, {850    x: stripeW, y: stripeY, w: stripeW, h: 0.06,851    fill: { color: 'FFFFFF' }, line: { color: 'FFFFFF', width: 0 },852  });853  slide.addShape('rect' as any, {854    x: stripeW * 2, y: stripeY, w: stripeW, h: 0.06,855    fill: { color: extras.tricolorRed }, line: { color: extras.tricolorRed, width: 0 },856  });857858  // Watermark at lower-left859  slide.addText('MARINA MILITARE • UNCLASSIFIED', {860    x: 0.25, y: SLIDE_H - 0.4, w: 4, h: 0.25,861    fontSize: 8, color: extras.watermarkColor, fontFace: MM_FONT_BODY, charSpacing: 2,862  });863864  // Header bar (only on regular content slides, not intro/closing)865  if (slideType !== 'intro' && slideType !== 'closing') {866    slide.addShape('rect' as any, {867      x: 0, y: 0, w: SLIDE_W, h: 0.5,868      fill: { color: bgColor }, line: { color: bgColor, width: 0 },869    });870    if (crestB64) {871      // Marina crest is ~2.67:1 (480x180 in CSS, 1830x685 native PNG).872      // pptxgenjs sizing.contain is a no-op when w/h match the box, so size873      // the box to the native aspect to avoid the image being stretched.874      slide.addImage({875        data: 'image/png;base64,' + crestB64,876        x: 0.2, y: 0.07, w: 0.96, h: 0.36,877      });878    }879    if (headerTitle) {880      slide.addText(headerTitle.toUpperCase(), {881        x: SLIDE_W - 6, y: 0, w: 5.7, h: 0.5,882        fontSize: 10, color: 'CCCCCC', fontFace: MM_FONT_HEAD,883        valign: 'middle', align: 'right', charSpacing: 2,884      });885    }886  }887}888889function addMarinaTitleSlide(890  slide: PptxGenJS.Slide,891  parsed: ParsedSlide,892  crestB64: string | null,893  _colors: ColorMap,894  _extras: MarinaExtras,895) {896  // Crest dimensions: matches the CSS .title-slide::before frame897  // (480x180 px on a 1920x1080 canvas → ~2.67:1, scaled smaller than full898  // width so the title block reads as the focal point).899  const crestW = 2.5;900  const crestH = 0.94;901  const crestX = (SLIDE_W - crestW) / 2;902  const crestY = 0.5;903  if (crestB64) {904    slide.addImage({905      data: 'image/png;base64,' + crestB64,906      x: crestX, y: crestY, w: crestW, h: crestH,907    });908  } else {909    slide.addText('MARINA MILITARE', {910      x: crestX, y: crestY, w: crestW, h: crestH,911      fontSize: 22, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,912      align: 'center', valign: 'middle', charSpacing: 4,913    });914  }915916  if (parsed.title) {917    // Title uses Graduate (slab-serif uppercase) per CSS .title-slide h1.918    // Respects explicit \n line breaks captured from <br> tags.919    slide.addText(parsed.title, {920      x: 0.5, y: 2.0, w: SLIDE_W - 1.0, h: 1.6,921      fontSize: 44, bold: true, color: 'FFFFFF', fontFace: MM_FONT_DISPLAY,922      align: 'center', valign: 'middle',923    });924  }925926  if (parsed.subtitle) {927    slide.addText(parsed.subtitle, {928      x: 0.5, y: 3.9, w: SLIDE_W - 1.0, h: 0.7,929      fontSize: 22, color: 'FFFFFF', fontFace: MM_FONT_HEAD,930      align: 'center', valign: 'middle',931    });932  }933934  if (parsed.meta) {935    slide.addText(parsed.meta, {936      x: 0.5, y: 5.4, w: SLIDE_W - 1.0, h: 0.4,937      fontSize: 11, color: 'B0BEC5', fontFace: MM_FONT_BODY,938      align: 'center', valign: 'middle', charSpacing: 1,939    });940  }941}942943function addMarinaClosingSlide(944  slide: PptxGenJS.Slide,945  parsed: ParsedSlide,946  crestB64: string | null,947  _colors: ColorMap,948  _extras: MarinaExtras,949) {950  const crestW = 2.5;951  const crestH = 0.94;952  const crestX = (SLIDE_W - crestW) / 2;953  const crestY = 0.5;954  if (crestB64) {955    slide.addImage({956      data: 'image/png;base64,' + crestB64,957      x: crestX, y: crestY, w: crestW, h: crestH,958    });959  } else {960    slide.addText('MARINA MILITARE', {961      x: crestX, y: crestY, w: crestW, h: crestH,962      fontSize: 22, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,963      align: 'center', valign: 'middle', charSpacing: 4,964    });965  }966967  if (parsed.title) {968    // Closing slide uses Montserrat (CSS .closing-slide h1), not Graduate.969    slide.addText(parsed.title, {970      x: 0.5, y: 2.6, w: SLIDE_W - 1.0, h: 1.4,971      fontSize: 44, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,972      align: 'center', valign: 'middle',973    });974  }975976  if (parsed.hashtag) {977    slide.addText(parsed.hashtag, {978      x: 0.5, y: 4.3, w: SLIDE_W - 1.0, h: 0.6,979      fontSize: 22, color: 'FFFFFF', fontFace: MM_FONT_HEAD,980      align: 'center', valign: 'middle',981    });982  }983}984985function addMarinaCardGrid(986  slide: PptxGenJS.Slide,987  cards: ParsedCard[],988  cardCols: number,989  x: number,990  y: number,991  w: number,992  maxH: number,993  extras: MarinaExtras,994): number {995  if (cards.length === 0) return 0;996  const cols = Math.max(1, cardCols);997  const rows = Math.ceil(cards.length / cols);998  const gap = 0.2;999  const cardWidth = (w - gap * (cols - 1)) / cols;1000  const cardHeight = Math.min(2.2, (maxH - gap * (rows - 1)) / rows);10011002  const valueColorMap: Record<string, string> = {1003    gold: extras.gold,1004    cyan: extras.cyan,1005    red: extras.red,1006    green: extras.green,1007    white: 'FFFFFF',1008  };10091010  cards.forEach((card, i) => {1011    const col = i % cols;1012    const row = Math.floor(i / cols);1013    const cx = x + col * (cardWidth + gap);1014    const cy = y + row * (cardHeight + gap);10151016    const fillColor = card.variant === 'alert' ? extras.cardAlertBg : extras.cardBg;1017    const borderColor =1018      card.variant === 'alert' ? extras.cardAlertBorder1019      : card.variant === 'highlight' ? extras.cardHighlightBorder1020      : extras.cardBorder;10211022    // PPTX shape: pptxgenjs supports 'roundRect' which gives subtle rounded1023    // corners; the corner radius is fixed (no curvature control), close enough1024    // to the CSS border-radius look.1025    slide.addShape('roundRect' as any, {1026      x: cx, y: cy, w: cardWidth, h: cardHeight,1027      fill: { color: fillColor },1028      line: { color: borderColor, width: 1 },1029      rectRadius: 0.05,1030    });10311032    const labelColor =1033      card.variant === 'highlight' ? extras.gold1034      : card.variant === 'alert' ? extras.red1035      : extras.paleBlue;10361037    if (card.label) {1038      slide.addText(card.label.toUpperCase(), {1039        x: cx + 0.15, y: cy + 0.12, w: cardWidth - 0.3, h: 0.3,1040        fontSize: 9, bold: true, color: labelColor, fontFace: MM_FONT_HEAD,1041        charSpacing: 2, valign: 'top',1042      });1043    }10441045    // Auto-shrink long card values so multi-line wrap doesn't bleed into1046    // the detail row. Threshold mirrors the typical narrow-card case.1047    const valueLen = card.value.length;1048    const valueFontSize = valueLen > 14 ? 16 : 22;1049    if (card.value) {1050      slide.addText(card.value, {1051        x: cx + 0.15, y: cy + 0.45, w: cardWidth - 0.3, h: 0.85,1052        fontSize: valueFontSize, bold: true,1053        color: valueColorMap[card.valueColor] || 'FFFFFF',1054        fontFace: MM_FONT_HEAD, valign: 'top',1055      });1056    }10571058    if (card.detail) {1059      const detailY = cy + 1.35;1060      const detailH = Math.max(0.3, cy + cardHeight - 0.15 - detailY);1061      slide.addText(card.detail, {1062        x: cx + 0.15, y: detailY, w: cardWidth - 0.3, h: detailH,1063        fontSize: 10, color: 'D4E4F5', fontFace: MM_FONT_BODY, valign: 'top',1064      });1065    }1066  });10671068  return rows * cardHeight + (rows - 1) * gap;1069}10701071function addMarinaSlideContent(1072  slide: PptxGenJS.Slide,1073  parsed: ParsedSlide,1074  bgColor: string,1075  colors: ColorMap,1076  extras: MarinaExtras,1077  crestB64: string | null,1078) {1079  addMarinaChrome(slide, parsed.slideType, parsed.headerTitle, crestB64, bgColor, extras);10801081  if (parsed.slideType === 'intro') {1082    addMarinaTitleSlide(slide, parsed, crestB64, colors, extras);1083    if (parsed.notes) slide.addNotes(parsed.notes);1084    return;1085  }1086  if (parsed.slideType === 'closing') {1087    addMarinaClosingSlide(slide, parsed, crestB64, colors, extras);1088    if (parsed.notes) slide.addNotes(parsed.notes);1089    return;1090  }10911092  // Generic content slide: header bar reserved at top 0.5"; content starts below.1093  let curY = 0.85;1094  const maxY = SLIDE_H - 0.55;10951096  if (parsed.title) {1097    slide.addText(parsed.title, {1098      x: MARGIN, y: 0.7, w: CONTENT_W, h: 0.55,1099      fontSize: 32, bold: true, color: colors.title, fontFace: MM_FONT_HEAD,1100      valign: 'top',1101    });1102    // Thin accent underline1103    slide.addShape('rect' as any, {1104      x: MARGIN, y: 1.3, w: CONTENT_W, h: 0.03,1105      fill: { color: colors.accent }, line: { color: colors.accent, width: 0 },1106    });1107    curY = 1.45;1108  }11091110  if (parsed.sectionLabel) {1111    slide.addText(parsed.sectionLabel.toUpperCase(), {1112      x: MARGIN, y: curY, w: CONTENT_W, h: 0.3,1113      fontSize: 10, color: extras.cyan, fontFace: MM_FONT_HEAD,1114      charSpacing: 2, valign: 'top',1115    });1116    curY += 0.4;1117  }11181119  // Card region: cap at ~3" so remaining elements still have room1120  if (parsed.cards.length > 0) {1121    const cardRegionMaxH = Math.min(3.0, maxY - curY);1122    const consumed = addMarinaCardGrid(1123      slide, parsed.cards, parsed.cardCols,1124      MARGIN, curY, CONTENT_W, cardRegionMaxH, extras,1125    );1126    curY += consumed + 0.25;1127  }11281129  // Image-left/right layout for marina is preserved when present1130  if (parsed.imagePosition && parsed.elements.length > 0) {1131    addImageLayoutSlide(slide, parsed, curY, maxY, colors, CONTENT_W, MARGIN, curY);1132  } else if (parsed.elements.length > 0) {1133    // Marina body is denser when alongside cards; pass smaller font sizes.1134    addElementsToSlide(slide, parsed.elements, curY, colors, maxY, CONTENT_W, MARGIN, 16, 14);1135  }11361137  if (parsed.notes) slide.addNotes(parsed.notes);1138}11391140function addSlideContent(1141  slide: PptxGenJS.Slide,1142  parsed: ParsedSlide,1143  bgColor: string,1144  colors: ColorMap,1145  theme: string | null = null,1146  crestB64: string | null = null,1147) {1148  if (theme === 'marina') {1149    addMarinaSlideContent(slide, parsed, parsed.bgColor || bgColor, colors, MARINA_EXTRAS, crestB64);1150    return;1151  }11521153  // Per-slide bgColor override, otherwise use theme background1154  slide.background = { color: parsed.bgColor || bgColor };11551156  // For image layouts, align title with text column1157  const gap = 0.5;1158  const halfW = (CONTENT_W - gap) / 2;1159  const titleX = parsed.imagePosition1160    ? (parsed.imagePosition === 'left' ? MARGIN + halfW + gap : MARGIN)1161    : MARGIN;1162  const titleW = parsed.imagePosition ? halfW : CONTENT_W;11631164  const maxY = SLIDE_H - MARGIN;1165  const titleH = parsed.title ? (CONTENT_Y - TITLE_Y) : 0;1166  const elementsH = estimateElementsHeight(parsed.elements, parsed.imagePosition ? halfW : CONTENT_W);1167  const textBlockH = titleH + elementsH;1168  const availableH = maxY - TITLE_Y;11691170  let offset: number;1171  if (parsed.imagePosition) {1172    const imgEstH = Math.min(halfW * 0.75, availableH);1173    const blockH = Math.max(textBlockH, imgEstH);1174    offset = Math.max(0, (availableH - blockH) / 2);1175  } else {1176    offset = Math.max(0, (availableH - textBlockH) / 2);1177  }11781179  const adjTitleY = TITLE_Y + offset;1180  const adjContentY = adjTitleY + titleH;11811182  if (parsed.title) {1183    slide.addText(parsed.title, {1184      x: titleX, y: adjTitleY, w: titleW, h: TITLE_H,1185      fontSize: 28, bold: true, color: colors.title, fontFace: 'Calibri', valign: 'bottom',1186    });1187  }11881189  const startY = parsed.title ? adjContentY : adjTitleY;11901191  // Image-left / image-right layout1192  if (parsed.imagePosition) {1193    addImageLayoutSlide(slide, parsed, adjTitleY, maxY, colors, CONTENT_W, MARGIN, startY);1194  } else {1195    addElementsToSlide(slide, parsed.elements, startY, colors, maxY, CONTENT_W, MARGIN);1196  }11971198  if (parsed.notes) {1199    slide.addNotes(parsed.notes);1200  }1201}12021203// ── Main ──12041205async function main() {1206  let inputData = '';1207  for await (const chunk of process.stdin) {1208    inputData += chunk;1209  }12101211  const { inputs } = JSON.parse(inputData);1212  const htmlContent: string = inputs.html_content || '';1213  const title: string = inputs.title || 'Presentation';1214  const themeInput: string = inputs.theme || '';12151216  if (!htmlContent) {1217    throw new Error("Required input 'html_content' not provided");1218  }12191220  // Parse HTML1221  const $ = cheerio.load(htmlContent);1222  const sections = $('div.slides > section');12231224  if (sections.length === 0) {1225    // Fallback: try top-level <section> elements1226    const fallbackSections = $('section');1227    if (fallbackSections.length === 0) {1228      throw new Error('No <section> elements found in the HTML content');1229    }1230  }12311232  const rawSections = sections.length > 0 ? sections : $('section');12331234  // Flatten Reveal.js vertical stacks: a <section> that wraps nested <section>1235  // children is a stack container, not a real slide — replace it with its children.1236  const flatSections: cheerio.Element[] = [];1237  rawSections.each((_i, section) => {1238    const nested = $(section).children('section');1239    if (nested.length > 0) {1240      nested.each((_j, child) => flatSections.push(child));1241    } else {1242      flatSections.push(section);1243    }1244  });12451246  console.error(`Parsing ${flatSections.length} slides from HTML`);12471248  // Theme detection1249  const theme = detectTheme($, themeInput);1250  const themeConfig = getThemeConfig(theme);1251  accentStrongColor = '333333';12521253  // Marina: extract embedded crest PNG (largest data URI in inline CSS)1254  let crestB64: string | null = null;1255  if (theme === 'marina') {1256    crestB64 = extractMarinaCrest(htmlContent);1257    if (crestB64) {1258      console.error(`Marina crest extracted (${crestB64.length} base64 chars)`);1259    } else {1260      console.error('Marina theme detected but no crest PNG found in HTML');1261    }1262  }12631264  // Create presentation1265  const pptx = new PptxGenJS();1266  pptx.title = title;1267  pptx.layout = 'LAYOUT_WIDE'; // 13.33 x 7.512681269  // Phase 1: Parse all sections into ParsedSlide array1270  const parsedSlides: ParsedSlide[] = [];1271  for (const section of flatSections) {1272    parsedSlides.push(parseSection($, section));1273  }12741275  // Phase 2: Resolve external image URLs in parallel1276  await Promise.all(parsedSlides.map(async (parsed) => {1277    if (parsed.imageSrcRaw && (parsed.imageSrcRaw.startsWith('http://') || parsed.imageSrcRaw.startsWith('https://'))) {1278      const data = await fetchImageAsBase64(parsed.imageSrcRaw);1279      if (data) {1280        parsed.imageData = data;1281        parsed.imageSrcRaw = null;1282      }1283    }1284  }));12851286  // Phase 3: Generate PPTX slides from resolved data1287  for (const parsed of parsedSlides) {1288    const slide = pptx.addSlide();1289    addSlideContent(slide, parsed, themeConfig.background, themeConfig.colors, theme, crestB64);1290  }12911292  console.error(`Generated ${flatSections.length} slides (theme: ${theme || 'generic'})`);12931294  // Write to output directory1295  const outputDir = '/data/output';1296  fs.mkdirSync(outputDir, { recursive: true });12971298  // Sanitize title for filename1299  const safeTitle = title.replace(/[^a-zA-Z0-9_\-\s]/g, '').trim().replace(/\s+/g, '_').slice(0, 50) || 'presentation';1300  const filename = `${safeTitle}.pptx`;1301  const outputPath = `${outputDir}/${filename}`;13021303  // PptxGenJS write to file1304  const buffer = await pptx.write({ outputType: 'nodebuffer' }) as Buffer;1305  fs.writeFileSync(outputPath, buffer);13061307  console.error(`PPTX written to ${outputPath} (${buffer.length} bytes)`);13081309  // Output result1310  const output = { pptx_file: filename };1311  console.log(JSON.stringify(output));1312}13131314main().catch((err) => {1315  console.error(JSON.stringify({1316    error: err.message || String(err),1317    errorType: err.name || 'Error',1318    traceback: err.stack || '',1319  }));1320  process.exit(1);1321});

$ git log --oneline

v1.2.2
HEAD
2026-05-07
v1.2.02026-04-28
v1.1.12026-04-23
v1.1.02026-04-07
v1.0.12026-03-29
v1.0.02026-03-20