$ 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// ── Polizia Postale (PP) Theme ──29const PP = {30  navy: '1F3863',31  blue: '6692C2',32  accent: '5B9BD5',33  crimson: 'B91C3E',34  green: '00B050',35  red: 'FF0000',36  white: 'FFFFFF',37  lightGrey: 'F5F6F8',38};3940const PP_COLORS = {41  title: PP.navy,42  body: PP.navy,43  accent: PP.crimson,44  bulletDot: PP.blue,45  tableHeader: PP.blue,46  tableHeaderText: 'FFFFFF',47  tableBorder: 'CBD5E0',48  quoteBar: PP.crimson,49  quoteText: PP.navy,50  tableCellBg: 'FFFFFF',51};5253// PP slide dimensions (LAYOUT_WIDE: 13.33 x 7.5 inches)54const PP_W = 13.33;55const PP_H = 7.5;56const PP_MARGIN = 0.6;57const PP_CONTENT_W = PP_W - PP_MARGIN * 2;58const PP_HEADER_H = 0.9;59const PP_FOOTER_H = 0.209;60const PP_FOOTER_Y = 7.045;6162// Tricolor gradient bar — pre-rendered PNG (generated in Python, same approach as header image)63const PP_TRICOLOR_IMG = 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB9AAAAAOCAIAAAD4yRD/AAAHE0lEQVR4nO3Y+1/NhwPH8Uwzxoy5TzPm0oy5TzPmMuY+SXR3qSg1JLdccy9yidP9XqfLqdM93e+lpJQUKZcIRVKKSkm89+kv+P72/X4/O+/34/lPvF9KJpJsIiIiIiL6P7dFkrVFkrlZkmksyTCWpBs5pBs6pBk6pG5ySNnYJXmDY5JgvWOigWOCgWO8vlO8nlOcnlOsrlOsjtMVHecYbedogZZz1DrnSMFalwhNl3BNl7A1LmEaLqEarvLVrnJ11xB11+BVrrJVbrK/3IJWugUKVrgFLHfzX+4uXeYuXerut9Tdd4nAw2exh/efXbwWeXgu9PQQ/OHpvsDTTTDf03W+l8s8L+e5XZx+93Kc4y1wmO0t+c37smCWz6VZPva/+tir+VxU87kw0+f8L76CczN87aZ3OTvN78w0P9upfrZT/Gym+J2eLD09SXpqkvTkz9ITE7scn+B/TPCT/9Hx/tbj/Y/8GHBENeCwasChcQGHxgYcHBt4YEzg/tFdrH4I3CcYFbR3ZNCekUG7vw/aPSJo1wiZ5XcySxXZThWZxXDZjuHBO74N3j4seJtgaPDfQ4LNh4SYDQ4xGxSydVCI6UCB3GSAfMs38s2C/nLjfqFGgq9DDfuGbuobuvErQdiGPmHre3cx+DJMv1e4nqBnuO4X4TqCHhHaPSK0Po/QUo5YpxyxtnuE5meRgjXdIjWUuqxWiqL/hm5Rqz+L0lCOXvN5tOYXMet6xmh9eUW7zxWdr2J1v47V6xen/02cwcD49YPiNwxJ2DQ0wfDbRCOVROPvkjZ/n7RlZLLJD8mmY1K2jk0xU001/zF1209p2yem7fg53WJy+s4pGZbTMnbNyNz9S+Yetay9v2ZZ/Za9f072gd+vHpx39dD8nMN/5BxZmGv9Z+7RJdeOLb12fHneyRV5p/66flr9us3qfNs1+Wc0C86uK7DTLjinc+O83o0L+oX26wsvbSy6vKlIYnTTwfim45ZiJ9Ni5623XMxvuf5d4r69xMOi1HNnqdeu2967b/vsveNrdcdvf5n0YJn/obsBR+4GWZfLjpUHn6gIOVkhP30v1OZe2Jn74Xb3I849iLzwIOriw5hLD69IKmMdKuOcHsU7P0pwfZzo/jjJoyrZqyrF+0ma75N06dMM/6eZgc+ygp5lB1dflVfnhNbkhtdci3ieF/U8P+ZFwZUXN+JqC+NrixJf3kx6WZxSdyutriT9VWnmqztZ9WVX6+/mNpRfa6i4/vpe/uv7NxofFDU+vNlUeavpUcmbqttvnpS9fXr37bOK5up7zTUPmp9Xtrx41FJb1frySWvds3f1Ne8anre9rm1rfNne9Kr9TcP7t6/fNzd1tLzpaG3+0Nbyof1d5/v2zo73Hz98+NjZ+enjp0+fwHH/pn38iM5OdHSgvR1tbWhtRUsL3r5FUxMaG9HQgPp61NWhthYvXqCmBtXVePoUVVV4/BiVlXj4EPfvo6IC5eUoK8OdOygtRUkJiotRVITCQhQUID8feXnIzUVODrKzkZWFjAykpSE1FcnJSEpCQgLi4hAbi5gYREcjMhLh4QgLg1yOkBDIZAgMREAApFL4+cHHB15e8PSEuzvc3ODiAmdnODpCIsHly7C3x8WLOH8ednY4exa2trCxwalTOHECx4/j6FFYW+PwYRw8iAMHYGWFffuwZw927YKlJSwssGMHtm2DuTnMzGBqChMTbN4MIyMYGmLjRmzYAAMD6OtDVxfa2tDSwtq10NSEhgbU1bFqFVauxIoVWLYMS5Zg8WIsWoSFC7FgAebNw9y5mDMHs2dj1iyoqWHmTMyYgenTMXUqJk/GpEmYOBETJmD8eKiqYtw4jBmD0aMxahRGjsSIEVBRwfDhGDYMQ4di8GAMHIgBA9C/P/r1Q9++6NMHvXujVy/07IkePaCsjO7d0a0blJTo303pf/4ciIiIiIjoP2JwZ3AnBncGdwZ3ToHG4M7gTqLF4E5EREREJAIM7gzuxODO4M7gzinQGNwZ3Em0GNyJiIiIiESAwZ3BnRjcGdwZ3DkFGoM7gzuJFoM7EREREZEIMLgzuBODO4M7gzunQGNwZ3An0WJwJyIiIiISAQZ3BndicGdwZ3DnFGgM7gzuJFoM7kREREREIsDgzuBODO4M7gzunAKNwZ3BnUSLwZ2IiIiISAQY3BncicGdwZ3BnVOgMbgzuJNoMbgTEREREYkAgzuDOzG4M7gzuHMKNAZ3BncSLQZ3IiIiIiIRYHBncCcGdwZ3BndOgcbgzuBOosXgTkREREQkAgzuDO7E4M7gzuDOKdAY3BncSbQY3ImIiIiIRIDBncGdGNwZ3BncOQUagzuDO4kWgzsRERERkQgwuDO4E4M7gzuDO6dAY3BncCfRYnAnIiIiIhIBBncGd2JwZ3BncOcUaAzuDO4kWv8AZUadPJx3KUkAAAAASUVORK5CYII=';6465// ── Reveal.js Theme Palettes ──66// Each theme defines a slide background + color map matching its Reveal.js CSS6768interface ThemeConfig {69  background: string;70  colors: typeof COLORS;71}7273const THEME_PALETTES: Record<string, ThemeConfig> = {74  // ── Light themes ──75  white: {76    background: 'FFFFFF',77    colors: { title: '222222', body: '222222', accent: '2A76DD', bulletDot: '2A76DD', tableHeader: '2A76DD', tableHeaderText: 'FFFFFF', tableBorder: 'CBD5E0', tableCellBg: 'FFFFFF', quoteBar: '2A76DD', quoteText: '666666' },78  },79  beige: {80    background: 'F7F3DE',81    colors: { title: '333333', body: '333333', accent: '8B743D', bulletDot: '8B743D', tableHeader: '8B743D', tableHeaderText: 'FFFFFF', tableBorder: 'C8C0A8', tableCellBg: 'F7F3DE', quoteBar: '8B743D', quoteText: '666666' },82  },83  sky: {84    background: 'F7FBFC',85    colors: { title: '333333', body: '333333', accent: '3B759E', bulletDot: '3B759E', tableHeader: '3B759E', tableHeaderText: 'FFFFFF', tableBorder: 'ADD9E4', tableCellBg: 'F7FBFC', quoteBar: '3B759E', quoteText: '666666' },86  },87  serif: {88    background: 'F0F1EB',89    colors: { title: '383D3D', body: '000000', accent: '51483D', bulletDot: '51483D', tableHeader: '51483D', tableHeaderText: 'FFFFFF', tableBorder: 'C0C0B8', tableCellBg: 'F0F1EB', quoteBar: '51483D', quoteText: '555555' },90  },91  simple: {92    background: 'FFFFFF',93    colors: { title: '000000', body: '000000', accent: '00008B', bulletDot: '00008B', tableHeader: '00008B', tableHeaderText: 'FFFFFF', tableBorder: 'CBD5E0', tableCellBg: 'FFFFFF', quoteBar: '00008B', quoteText: '444444' },94  },95  solarized: {96    background: 'FDF6E3',97    colors: { title: '586E75', body: '657B83', accent: '268BD2', bulletDot: '268BD2', tableHeader: '268BD2', tableHeaderText: 'FDF6E3', tableBorder: 'EEE8D5', tableCellBg: 'FDF6E3', quoteBar: '268BD2', quoteText: '93A1A1' },98  },99  // ── Dark themes ──100  black: {101    background: '191919',102    colors: { title: 'FFFFFF', body: 'FFFFFF', accent: '42AFFA', bulletDot: '42AFFA', tableHeader: '42AFFA', tableHeaderText: '191919', tableBorder: '444444', tableCellBg: '191919', quoteBar: '42AFFA', quoteText: 'CCCCCC' },103  },104  moon: {105    background: '002B36',106    colors: { title: 'EEE8D5', body: '93A1A1', accent: '268BD2', bulletDot: '268BD2', tableHeader: '268BD2', tableHeaderText: 'EEE8D5', tableBorder: '073642', tableCellBg: '002B36', quoteBar: '268BD2', quoteText: '839496' },107  },108  league: {109    background: '2B2B2B',110    colors: { title: 'EEEEEE', body: 'EEEEEE', accent: '13DAEC', bulletDot: '13DAEC', tableHeader: '13DAEC', tableHeaderText: '2B2B2B', tableBorder: '444444', tableCellBg: '2B2B2B', quoteBar: '13DAEC', quoteText: 'CCCCCC' },111  },112  night: {113    background: '111111',114    colors: { title: 'EEEEEE', body: 'EEEEEE', accent: 'E7AD52', bulletDot: 'E7AD52', tableHeader: 'E7AD52', tableHeaderText: '111111', tableBorder: '333333', tableCellBg: '111111', quoteBar: 'E7AD52', quoteText: 'CCCCCC' },115  },116  blood: {117    background: '222222',118    colors: { title: 'EEEEEE', body: 'EEEEEE', accent: 'AA2233', bulletDot: 'AA2233', tableHeader: 'AA2233', tableHeaderText: 'EEEEEE', tableBorder: '444444', tableCellBg: '222222', quoteBar: 'AA2233', quoteText: 'CCCCCC' },119  },120  dracula: {121    background: '282A36',122    colors: { title: 'BD93F9', body: 'F8F8F2', accent: 'FF79C6', bulletDot: '8BE9FD', tableHeader: '6272A4', tableHeaderText: 'F8F8F2', tableBorder: '44475A', tableCellBg: '282A36', quoteBar: 'FF79C6', quoteText: 'F8F8F2' },123  },124  // ── Marina Militare (custom Italian-government deck) ──125  marina: {126    background: '001A33',127    colors: { title: 'FFFFFF', body: 'D4E4F5', accent: '0072C6', bulletDot: 'FFCB28', tableHeader: '1B4F8C', tableHeaderText: 'FFCB28', tableBorder: '4A90D9', tableCellBg: '001A33', quoteBar: '0072C6', quoteText: 'D4E4F5' },128  },129};130131// ── Marina-specific extras (colors not in the generic ColorMap) ──132// These come from --mm-* CSS tokens; rgba() values are flattened to solids133// because PPTX shape fills don't reliably honor alpha.134interface MarinaExtras {135  cyan: string;136  gold: string;137  red: string;138  green: string;139  paleBlue: string;140  cardBg: string;141  cardBorder: string;142  cardHighlightBorder: string;143  cardAlertBorder: string;144  cardAlertBg: string;145  tricolorGreen: string;146  tricolorRed: string;147  watermarkColor: string;148}149150const MARINA_EXTRAS: MarinaExtras = {151  cyan: '2DD3FF',152  gold: 'FFCB28',153  red: 'ED0033',154  green: '288054',155  paleBlue: 'C0E4FF',156  cardBg: '1B4F8C',157  cardBorder: '4A90D9',158  cardHighlightBorder: 'FFCB28',159  cardAlertBorder: 'ED0033',160  cardAlertBg: '5C0A19',161  tricolorGreen: '00953E',162  tricolorRed: 'E21F1D',163  watermarkColor: '4A6680',164};165166// Marina theme fonts. PowerPoint will substitute if the viewing machine lacks167// them, so users get the closest visual match by installing Graduate and168// Montserrat (both free Google Fonts) on their system.169const MM_FONT_DISPLAY = 'Graduate';     // title-slide H1 — slab-serif uppercase170const MM_FONT_HEAD = 'Montserrat';      // section H2/H3, header bar, card text171const MM_FONT_BODY = 'Calibri';         // body text — Segoe UI substitute172173const DEFAULT_THEME: ThemeConfig = { background: 'FFFFFF', colors: COLORS };174175function getThemeConfig(theme: string | null): ThemeConfig {176  if (!theme) return DEFAULT_THEME;177  return THEME_PALETTES[theme] || DEFAULT_THEME;178}179180// ── Types ──181182type ColorMap = typeof COLORS;183type SlideType = 'intro' | 'content' | 'closing' | 'generic';184185interface TextRun {186  text: string;187  bold?: boolean;188  italic?: boolean;189  color?: string;190}191192interface SlideElement {193  tag: string;194  text: string;195  children?: { text: string; tag: string; runs?: TextRun[]; indent?: number }[];196  attrs?: Record<string, string>;197  runs?: TextRun[];198}199200interface ParsedCard {201  label: string;202  value: string;203  valueColor: 'gold' | 'cyan' | 'red' | 'green' | 'white';204  detail: string | null;205  variant: 'default' | 'highlight' | 'alert';206}207208interface ParsedSlide {209  slideType: SlideType;210  title: string | null;211  elements: SlideElement[];212  notes: string | null;213  bgColor: string | null;214  headerTitle: string | null;215  subtitles: string[];216  closingText: string | null;217  /** PP: per-slide stemma data URI (without "data:" prefix); falls back to global */218  stemmaData: string | null;219  /** Base64 image data (without "data:" prefix) for image-left/right layouts */220  imageData: string | null;221  /** Raw image src for deferred fetch (http/https URLs or {{IMAGE}} placeholders) */222  imageSrcRaw: string | null;223  /** Image position: 'left' or 'right' */224  imagePosition: 'left' | 'right' | null;225  /** Marina: <p class="subtitle"> text on title slides */226  subtitle: string | null;227  /** Marina: <p class="meta"> text on title slides */228  meta: string | null;229  /** Marina: <p class="hashtag"> text on closing slides */230  hashtag: string | null;231  /** Marina: first <p class="section-label"> text in section */232  sectionLabel: string | null;233  /** Marina: parsed cards from <div class="card-grid"> */234  cards: ParsedCard[];235  /** Marina: card-grid columns (cols-N), default 1 */236  cardCols: number;237}238239interface PPAssets {240  headerLeftData: string | null;241  logoData: string | null;242  stemmaData: string | null;243}244245// ── Theme Detection ──246247function detectTheme($: cheerio.CheerioAPI, themeInput?: string): string | null {248  if (themeInput && themeInput !== 'auto') {249    if (themeInput === 'marina') return 'marina';250    if (themeInput === 'polizia-postale') return 'polizia-postale';251    return themeInput;252  }253  const html = $.html();254255  // Branded: Polizia Postale256  if (html.includes('--pp-navy') || html.includes('--pp-blue')) return 'polizia-postale';257  if ($('section.pp-cover').length > 0 || $('section.pp-closing').length > 0) return 'polizia-postale';258  if ($('.pp-header-title').length > 0) return 'polizia-postale';259260  // Marina Militare detection: CSS is inlined (no /dist/theme/marina.css link),261  // so look for the --mm-navy custom property, the watermark string, or the262  // data-header-title attribute marina sections always carry.263  if (html.includes('--mm-navy') || html.includes('MARINA MILITARE')) return 'marina';264  if ($('section[data-header-title]').length > 0) return 'marina';265266  // Standard Reveal.js themes: detect from <link href="...reveal.js.../theme/{name}.css">267  const themeMatch = html.match(/reveal\.js[^"]*\/dist\/theme\/([a-z-]+)\.css/);268  if (themeMatch && themeMatch[1] in THEME_PALETTES) return themeMatch[1];269270  return null;271}272273// ── Marina crest extraction ──274// Marina presentations embed a single large PNG (the crest) as a data URI in275// the inline CSS. Pick the longest base64 PNG match and return it stripped of276// the "data:" prefix; the texture is webp and won't match this regex.277function extractMarinaCrest(html: string): string | null {278  const re = /data:image\/png;base64,([A-Za-z0-9+/=]{1000,})/g;279  let m: RegExpExecArray | null;280  let best: string | null = null;281  while ((m = re.exec(html)) !== null) {282    const candidate = m[1];283    if (best === null || candidate.length > best.length) {284      best = candidate;285    }286  }287  return best;288}289290// ── Polizia Postale asset extraction ──291function extractPPAssets($: cheerio.CheerioAPI): PPAssets {292  const html = $.html();293294  // Extract all data:image URLs from CSS (split header: logo + left bar)295  let headerLeftData: string | null = null;296  let logoData: string | null = null;297  const urlMatches = [...html.matchAll(/url\(data:(image\/png;base64,[^)]+)\)/g)];298  if (urlMatches.length >= 2) {299    // First url() is the logo (right), second is the left header bar300    logoData = urlMatches[0][1];301    headerLeftData = urlMatches[1][1];302  } else if (urlMatches.length === 1) {303    // Fallback: single header image (old format)304    headerLeftData = urlMatches[0][1];305  }306307  // Stemma from first <img class="pp-emblem" src="data:...">308  let stemmaData: string | null = null;309  const emblem = $('img.pp-emblem').first();310  if (emblem.length > 0) {311    const src = emblem.attr('src') || '';312    if (src.startsWith('data:')) {313      stemmaData = src.slice(5); // strip "data:" prefix for PptxGenJS314    }315  }316317  return { headerLeftData, logoData, stemmaData };318}319320// ── PP Slide Masters ──321function definePPMasters(pptx: PptxGenJS, assets: PPAssets) {322  const footerObjects: any[] = [{323    image: {324      x: 0, y: PP_FOOTER_Y,325      w: PP_W, h: PP_FOOTER_H,326      data: PP_TRICOLOR_IMG,327    },328  }];329330  const headerObjects: any[] = [];331  if (assets.headerLeftData) {332    // Left header bar (blue + red line) stretched across most of the width333    headerObjects.push({ image: { x: 0, y: 0, w: PP_W - 1.0, h: PP_HEADER_H, data: assets.headerLeftData } });334  }335  if (assets.logoData) {336    // Logo (fixed size, right-aligned, overlaps blue bar + red line)337    const logoW = 1.8;338    const logoH = PP_HEADER_H;339    headerObjects.push({ image: { x: PP_W - logoW, y: 0, w: logoW, h: logoH, data: assets.logoData } });340  }341342  const commonObjects = [...headerObjects, ...footerObjects];343344  pptx.defineSlideMaster({ title: 'PP_INTRO', background: { color: 'FFFFFF' }, objects: [...commonObjects] });345  pptx.defineSlideMaster({ title: 'PP_CONTENT', background: { color: 'FFFFFF' }, objects: [...commonObjects] });346  pptx.defineSlideMaster({ title: 'PP_CLOSING', background: { color: 'FFFFFF' }, objects: [...commonObjects] });347}348349// ── Bold accent color (set per-theme in main()) ──350let accentStrongColor = '333333';351352// ── Inline Rich Text Parsing ──353354function parseInlineRuns($: cheerio.CheerioAPI, el: cheerio.Element, inherited?: { bold?: boolean; italic?: boolean; color?: string }): TextRun[] {355  const runs: TextRun[] = [];356  const inh = inherited || {};357358  $(el).contents().each((_i, node) => {359    if (node.type === 'text') {360      const raw = (node as any).data || '';361      // HTML whitespace rules: collapse any run of whitespace (newlines, tabs,362      // multiple spaces) to a single space and drop pure-whitespace nodes.363      // Without this, indentation between block siblings (e.g. <strong>X</strong>364      // \n            <ul>...) leaks into the run array and renders as visible365      // padding inside bullet/paragraph text frames.366      const t = raw.replace(/\s+/g, ' ');367      if (t.trim()) {368        runs.push({369          text: t,370          ...(inh.bold ? { bold: true } : {}),371          ...(inh.italic ? { italic: true } : {}),372          ...(inh.color ? { color: inh.color } : {}),373        });374      }375    } else if (node.type === 'tag') {376      const tag = (node as cheerio.TagElement).tagName?.toLowerCase() || '';377      // Skip nested lists and tables — they are handled separately378      if (tag === 'ul' || tag === 'ol' || tag === 'table') return;379      const next = { ...inh };380      if (tag === 'strong' || tag === 'b') { next.bold = true; next.color = next.color || accentStrongColor; }381      if (tag === 'em' || tag === 'i') { next.italic = true; }382      runs.push(...parseInlineRuns($, node as cheerio.Element, next));383    }384  });385386  return runs;387}388389/** Extract only direct text from a <li>, excluding nested <ul>/<ol> text. */390function directText($: cheerio.CheerioAPI, el: cheerio.Element): string {391  let text = '';392  $(el).contents().each((_i, node) => {393    if (node.type === 'text') {394      text += (node as any).data || '';395    } else if (node.type === 'tag') {396      const tag = (node as cheerio.TagElement).tagName?.toLowerCase() || '';397      if (tag !== 'ul' && tag !== 'ol' && tag !== 'table') {398        text += $(node).text();399      }400    }401  });402  return text.trim();403}404405// ── Section Parsing ──406407function parseSection($: cheerio.CheerioAPI, section: cheerio.Element): ParsedSlide {408  const $s = $(section);409  const notes = $s.find('aside.notes').text().trim() || null;410  const bgColor = ($s.attr('data-background-color') || '').replace('#', '') || null;411412  // Detect slide type — marina sections carry .title-slide / .closing-slide,413  // PP sections carry .pp-cover / .pp-closing / .pp-header-title.414  let slideType: SlideType = 'generic';415  if ($s.hasClass('title-slide')) slideType = 'intro';416  else if ($s.hasClass('closing-slide')) slideType = 'closing';417  else if ($s.hasClass('pp-cover')) slideType = 'intro';418  else if ($s.hasClass('pp-closing')) slideType = 'closing';419  else if ($s.find('.pp-header-title').length > 0) slideType = 'content';420421  // Marina header bar text comes from the data-header-title attribute;422  // PP uses a .pp-header-title element inside the section.423  let headerTitle: string | null = $s.attr('data-header-title') || null;424  if (!headerTitle) {425    const ppHeader = $s.find('.pp-header-title').first().text().trim();426    if (ppHeader) headerTitle = ppHeader;427  }428  const subtitles: string[] = [];429  // PP intro subtitles430  $s.find('.pp-subtitle').each((_i, el) => {431    const text = $(el).text().trim();432    if (text) subtitles.push(text);433  });434  let closingText: string | null = $s.find('.pp-closing-text').first().text().trim() || null;435436  // PP per-slide stemma override (first <img class="pp-emblem">)437  let stemmaData: string | null = null;438  const ppEmblem = $s.find('img.pp-emblem').first();439  if (ppEmblem.length > 0) {440    const src = ppEmblem.attr('src') || '';441    if (src.startsWith('data:')) stemmaData = src.slice(5);442  }443444  // Title source depends on slide type — intro/closing prefer h1 (Marina445  // convention), falling back to h2 (Polizia Postale puts the cover title446  // inside <div class="pp-intro-text"><h2>...). For h1 we preserve <br> as \n447  // so the renderer can honor explicit line breaks.448  let title: string | null = null;449  if (slideType === 'intro' || slideType === 'closing') {450    let $heading = $s.find('h1').first();451    if ($heading.length === 0) $heading = $s.find('h2').first();452    if ($heading.length > 0) {453      const cloned = $heading.clone();454      cloned.find('br').replaceWith('\n');455      title = cloned.text().trim() || null;456    }457  } else {458    title = $s.find('h2').first().text().trim() || null;459  }460461  // Marina-specific paragraph captures462  const subtitle = $s.find('p.subtitle').first().text().trim() || null;463  const meta = $s.find('p.meta').first().text().trim() || null;464  const hashtag = $s.find('p.hashtag').first().text().trim() || null;465  const sectionLabel = $s.find('p.section-label').first().text().trim() || null;466467  // Marina cards (populated below by the section walker when a card-grid is found)468  const cards: ParsedCard[] = [];469  let cardCols = 1;470471  // Detect image-left / image-right layout472  let imageData: string | null = null;473  let imageSrcRaw: string | null = null;474  let imagePosition: 'left' | 'right' | null = null;475476  // Parse content elements477  const elements: SlideElement[] = [];478479  /** Parse a list element, handling nesting and rich text */480  function parseList($list: cheerio.Cheerio<cheerio.Element>, listTag: string, indent: number = 0) {481    const items: { text: string; tag: string; runs?: TextRun[]; indent?: number }[] = [];482    $list.children('li').each((_k, li) => {483      const text = directText($, li);484      const runs = parseInlineRuns($, li);485      if (text) {486        items.push({ text, tag: listTag, runs: runs.length > 0 ? runs : undefined, indent });487      }488      // Handle nested lists inside this <li>489      $(li).children('ul, ol').each((_n, nested) => {490        const nestedTag = (nested as cheerio.TagElement).tagName?.toLowerCase() || 'ul';491        const nestedItems = parseList($(nested), nestedTag, indent + 1);492        items.push(...nestedItems);493      });494    });495    return items;496  }497498  function extractChildElements($container: cheerio.Cheerio<cheerio.Element>) {499    $container.find('p, ul, ol, h3, h2, blockquote, table').each((_j, child) => {500      const $child = $(child);501      const childTag = (child as cheerio.TagElement).tagName?.toLowerCase() || '';502      // Skip the main slide h2 title if already captured503      if (childTag === 'h2' && $child.text().trim() === title) return;504      // Skip nested lists (already handled by their parent <li>)505      if ((childTag === 'ul' || childTag === 'ol') && $(child).parent('li').length > 0) return;506      // Skip tables inside lists (extracted separately after list processing)507      if (childTag === 'table' && $(child).closest('ul, ol').length > 0) return;508      if (childTag === 'ul' || childTag === 'ol') {509        const items = parseList($child, childTag);510        elements.push({ tag: childTag, text: '', children: items });511      } else if (childTag === 'table') {512        const rows: { text: string; tag: string }[][] = [];513        $child.find('tr').each((_rj, tr) => {514          const cells: { text: string; tag: string }[] = [];515          $(tr).find('th, td').each((_k, cell) => {516            cells.push({517              text: $(cell).text().trim(),518              tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',519            });520          });521          if (cells.length > 0) rows.push(cells);522        });523        elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });524      } else if (childTag === 'p') {525        // Marina-only <p> classes are captured separately on ParsedSlide;526        // skip them so they don't render twice as generic body text.527        const cls = ($child.attr('class') || '').split(/\s+/);528        if (cls.includes('section-label') || cls.includes('subtitle') || cls.includes('meta') || cls.includes('hashtag')) return;529        const txt = $child.text().trim();530        if (txt) {531          const runs = parseInlineRuns($, child);532          elements.push({ tag: 'p', text: txt, runs: runs.length > 0 ? runs : undefined });533        }534      } else {535        const txt = $child.text().trim();536        if (txt) elements.push({ tag: childTag === 'h2' ? 'h3' : childTag, text: txt });537      }538    });539  }540541  $s.children().each((_i, el) => {542    const $el = $(el);543    const tag = (el as cheerio.TagElement).tagName?.toLowerCase() || '';544545    // Skip notes546    if (tag === 'aside' && $el.hasClass('notes')) return;547    // Skip only the first h2 (captured as slide title); render others as subheadings548    if (tag === 'h2') {549      if ($el.text().trim() === title) return;550      elements.push({ tag: 'h3', text: $el.text().trim() });551      return;552    }553    // Skip PP-specific markup already captured as discrete fields554    if ($el.hasClass('pp-header-title') || $el.hasClass('pp-intro-layout') ||555        $el.hasClass('pp-closing-text') || $el.hasClass('pp-subtitle') ||556        (tag === 'img' && $el.hasClass('pp-emblem'))) return;557558    if (tag === 'h3') {559      elements.push({ tag: 'h3', text: $el.text().trim() });560    } else if (tag === 'p') {561      // Marina-only <p> classes are captured separately on ParsedSlide;562      // skip them so they don't render twice as generic body text.563      const cls = ($el.attr('class') || '').split(/\s+/);564      if (cls.includes('section-label') || cls.includes('subtitle') || cls.includes('meta') || cls.includes('hashtag')) return;565      const txt = $el.text().trim();566      if (txt) {567        const runs = parseInlineRuns($, el);568        elements.push({ tag: 'p', text: txt, runs: runs.length > 0 ? runs : undefined });569      }570    } else if (tag === 'ul' || tag === 'ol') {571      const items = parseList($el, tag);572      elements.push({ tag, text: '', children: items });573      // Extract tables nested inside list items (e.g. table under a <li>)574      $el.find('table').each((_j, tbl) => {575        const $tbl = $(tbl);576        const rows: { text: string; tag: string }[][] = [];577        $tbl.find('tr').each((_rj, tr) => {578          const cells: { text: string; tag: string }[] = [];579          $(tr).find('th, td').each((_k, cell) => {580            cells.push({581              text: $(cell).text().trim(),582              tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',583            });584          });585          if (cells.length > 0) rows.push(cells);586        });587        if (rows.length > 0) {588          elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });589        }590      });591    } else if (tag === 'blockquote') {592      elements.push({ tag: 'blockquote', text: $el.text().trim() });593    } else if (tag === 'table') {594      const rows: { text: string; tag: string }[][] = [];595      $el.find('tr').each((_j, tr) => {596        const cells: { text: string; tag: string }[] = [];597        $(tr).find('th, td').each((_k, cell) => {598          cells.push({599            text: $(cell).text().trim(),600            tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',601          });602        });603        if (cells.length > 0) rows.push(cells);604      });605      elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });606    } else if (tag === 'div') {607      // Marina card-grid: parse cards directly into parsed.cards and SKIP the608      // generic flex/recursion fallback so card content doesn't also leak into609      // elements as bullet text.610      const className = $el.attr('class') || '';611      if (className.split(/\s+/).includes('card-grid')) {612        const colsMatch = className.match(/cols-(\d+)/);613        if (colsMatch) cardCols = parseInt(colsMatch[1], 10) || 1;614        $el.children('div.card').each((_j, cardEl) => {615          const $card = $(cardEl);616          const cardClass = ($card.attr('class') || '').split(/\s+/);617          const variant: 'default' | 'highlight' | 'alert' =618            cardClass.includes('alert') ? 'alert'619            : cardClass.includes('highlight') ? 'highlight'620            : 'default';621          const $value = $card.children('.value').first();622          const valueClass = ($value.attr('class') || '').split(/\s+/);623          let valueColor: 'gold' | 'cyan' | 'red' | 'green' | 'white' = 'white';624          if (valueClass.includes('gold')) valueColor = 'gold';625          else if (valueClass.includes('cyan')) valueColor = 'cyan';626          else if (valueClass.includes('red')) valueColor = 'red';627          else if (valueClass.includes('green')) valueColor = 'green';628          cards.push({629            label: $card.children('.label').first().text().trim(),630            value: $value.text().trim(),631            valueColor,632            detail: $card.children('.detail').first().text().trim() || null,633            variant,634          });635        });636        return;637      }638639      // Check for image-left / image-right flex layout640      const style = $el.attr('style') || '';641      const isFlex = style.includes('display:flex') || style.includes('display: flex');642      const childDivs = $el.children('div');643      const hasImg = $el.find('img').length > 0;644645      if (isFlex && hasImg && !imageData && !imageSrcRaw) {646        // Case 1: Both sides wrapped in <div> (expected structure)647        if (childDivs.length >= 2) {648          const firstChild = $(childDivs[0]);649          const secondChild = $(childDivs[1]);650          const firstImg = firstChild.find('img').first();651          const secondImg = secondChild.find('img').first();652653          let imgEl: cheerio.Cheerio<cheerio.Element> | null = null;654          let textContainer: cheerio.Cheerio<cheerio.Element> | null = null;655656          if (firstImg.length > 0) {657            imgEl = firstImg;658            textContainer = secondChild;659            imagePosition = 'left';660          } else if (secondImg.length > 0) {661            imgEl = secondImg;662            textContainer = firstChild;663            imagePosition = 'right';664          }665666          if (imgEl && textContainer) {667            const imgSrc = imgEl.attr('src') || '';668            if (imgSrc.startsWith('data:')) {669              imageData = imgSrc.slice(5);670            } else if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) {671              imageSrcRaw = imgSrc;672            } else {673              // {{IMAGE}} placeholder or empty src674              imageSrcRaw = imgSrc || '{{IMAGE}}';675            }676            extractChildElements(textContainer);677            return;678          }679        }680681        // Case 2: <img> is a direct child of flex container (LLM sometimes does this)682        const directImg = $el.children('img').first();683        if (directImg.length > 0 && childDivs.length >= 1) {684          const imgSrc = directImg.attr('src') || '';685          // Determine position: if img comes before the div, it's left686          const allChildren = $el.children().toArray();687          const imgIdx = allChildren.findIndex(c => c === directImg[0]);688          const divIdx = allChildren.findIndex(c => c === childDivs[0]);689          imagePosition = imgIdx < divIdx ? 'left' : 'right';690691          if (imgSrc.startsWith('data:')) {692            imageData = imgSrc.slice(5);693          } else if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) {694            imageSrcRaw = imgSrc;695          } else {696            imageSrcRaw = imgSrc || '{{IMAGE}}';697          }698          extractChildElements($(childDivs[0]));699          return;700        }701      }702703      // Fallback: recurse into layout divs (two-column layouts etc.)704      extractChildElements($el);705    }706  });707708  return {709    slideType, title, elements, notes, bgColor, headerTitle, subtitles, closingText,710    stemmaData,711    imageData, imageSrcRaw, imagePosition,712    subtitle, meta, hashtag, sectionLabel, cards, cardCols,713  };714}715716// ── Image Fetch Helper ──717718async function fetchImageAsBase64(url: string): Promise<string | null> {719  try {720    const controller = new AbortController();721    const timeout = setTimeout(() => controller.abort(), 10000);722    const resp = await fetch(url, { signal: controller.signal });723    clearTimeout(timeout);724    if (!resp.ok) return null;725    const buf = Buffer.from(await resp.arrayBuffer());726    const contentType = resp.headers.get('content-type') || 'image/png';727    const mimeType = contentType.split(';')[0].trim();728    return `${mimeType};base64,${buf.toString('base64')}`;729  } catch (e) {730    console.error(`Failed to fetch image from ${url}: ${e}`);731    return null;732  }733}734735// ── Content Height Estimation ──736737function estimateElementsHeight(elements: SlideElement[], contentW: number): number {738  let h = 0;739  for (const el of elements) {740    if (el.tag === 'h3') {741      h += 0.55;742    } else if (el.tag === 'p') {743      if (!el.text) continue;744      h += 0.5;745    } else if ((el.tag === 'ul' || el.tag === 'ol') && el.children) {746      el.children.forEach((item) => {747        const indent = item.indent || 0;748        const baseFontSize = indent > 0 ? 13 : 15;749        const indentOffset = indent * 0.35;750        const wPos = contentW - 0.2 - indentOffset;751        const textLen = item.text.length;752        const charW = baseFontSize * 0.0067;753        const lineH = baseFontSize * 0.019;754        const charsPerLine = Math.max(1, Math.floor(wPos / charW));755        const numLines = Math.max(1, Math.ceil(textLen / charsPerLine));756        h += Math.max(0.3, numLines * lineH + 0.06);757      });758      h += 0.1;759    } else if (el.tag === 'blockquote') {760      h += 0.75;761    } else if (el.tag === 'table' && el.attrs?._rows) {762      try {763        const rows: any[][] = JSON.parse(el.attrs._rows);764        h += rows.length * 0.4 + 0.15;765      } catch {}766    }767  }768  return h;769}770771// ── Shared Element Rendering ──772773function addElementsToSlide(774  slide: PptxGenJS.Slide,775  elements: SlideElement[],776  startY: number,777  colors: ColorMap,778  maxY: number,779  contentW: number,780  margin: number,781  bodyFontSize: number = 18,782  bulletBaseFontSize: number = 15,783) {784  let curY = startY;785786  for (const el of elements) {787    if (curY >= maxY) break;788789    if (el.tag === 'h3') {790      slide.addText(el.text, {791        x: margin, y: curY, w: contentW, h: 0.5,792        fontSize: 22, bold: true, color: colors.title, fontFace: 'Calibri',793      });794      curY += 0.55;795    } else if (el.tag === 'p') {796      if (!el.text) continue;797      if (el.runs && el.runs.length > 0) {798        const pRuns = el.runs.map(r => ({799          text: r.text,800          options: {801            fontSize: bodyFontSize, fontFace: 'Calibri', color: r.color || colors.body,802            ...(r.bold ? { bold: true } : {}),803            ...(r.italic ? { italic: true } : {}),804          },805        }));806        slide.addText(pRuns, { x: margin, y: curY, w: contentW, h: 0.45 });807      } else {808        slide.addText(el.text, {809          x: margin, y: curY, w: contentW, h: 0.45,810          fontSize: bodyFontSize, color: colors.body, fontFace: 'Calibri',811        });812      }813      curY += 0.5;814    } else if ((el.tag === 'ul' || el.tag === 'ol') && el.children) {815      // Render each bullet item with manual bullet/number characters for reliable display.816      const olCounters: Record<number, number> = {};817      let prevTag = '';818      let prevIndent = -1;819820      el.children.forEach((item) => {821        if (curY >= maxY) return;822        const indent = item.indent || 0;823        const baseFontSize = indent > 0 ? Math.max(11, bulletBaseFontSize - 2) : bulletBaseFontSize;824        const indentOffset = indent * 0.35;825        const xPos = margin + 0.2 + indentOffset;826        const wPos = contentW - 0.2 - indentOffset;827828        // Dynamic height: estimate wrapped lines based on text length829        const textLen = item.text.length;830        const charW = baseFontSize * 0.0067;831        const lineH = baseFontSize * 0.019;832        const charsPerLine = Math.max(1, Math.floor(wPos / charW));833        const numLines = Math.max(1, Math.ceil(textLen / charsPerLine));834        const itemH = Math.max(0.3, numLines * lineH + 0.06);835836        // Determine list type from item.tag (set by parseList to 'ul' or 'ol')837        const isOrdered = item.tag === 'ol';838839        // Manage ordered counters: reset when starting a new ordered sequence840        if (isOrdered) {841          if (prevTag !== 'ol' || prevIndent !== indent) {842            olCounters[indent] = 0;843          }844          olCounters[indent]++;845        }846        prevTag = item.tag || '';847        prevIndent = indent;848849        // Build prefix: bullet char or number850        const prefix = isOrdered851          ? `${olCounters[indent]}. `852          : (indent > 0 ? '\u25AA  ' : '\u2022  ');853        const prefixColor = isOrdered ? colors.body : colors.bulletDot;854855        // Always use text run array so bullet char gets its own color856        const textRuns: { text: string; options: any }[] = [857          { text: prefix, options: { fontSize: baseFontSize, fontFace: 'Calibri', color: prefixColor } },858        ];859860        if (item.runs && item.runs.length > 0) {861          for (const r of item.runs) {862            textRuns.push({863              text: r.text,864              options: {865                fontSize: baseFontSize, fontFace: 'Calibri',866                color: r.color || colors.body,867                ...(r.bold ? { bold: true } : {}),868                ...(r.italic ? { italic: true } : {}),869              },870            });871          }872        } else {873          textRuns.push({874            text: item.text,875            options: { fontSize: baseFontSize, fontFace: 'Calibri', color: colors.body },876          });877        }878879        slide.addText(textRuns, {880          x: xPos, y: curY, w: wPos, h: itemH, valign: 'top',881        });882        curY += itemH;883      });884      curY += 0.1;885    } else if (el.tag === 'blockquote') {886      slide.addShape('rect' as any, {887        x: margin, y: curY, w: 0.08, h: 0.6,888        fill: { color: colors.quoteBar },889      });890      slide.addText(el.text, {891        x: margin + 0.25, y: curY, w: contentW - 0.25, h: 0.6,892        fontSize: 16, italic: true, color: colors.quoteText, fontFace: 'Calibri', valign: 'middle',893      });894      curY += 0.75;895    } else if (el.tag === 'table' && el.attrs?._rows) {896      try {897        const rows: { text: string; tag: string }[][] = JSON.parse(el.attrs._rows);898        const tableRows: PptxGenJS.TableRow[] = rows.map((row, rowIdx) =>899          row.map((cell) => ({900            text: cell.text,901            options: {902              fontSize: 12,903              fontFace: 'Calibri',904              color: cell.tag === 'th' || rowIdx === 0 ? colors.tableHeaderText : colors.body,905              fill: { color: cell.tag === 'th' || rowIdx === 0 ? colors.tableHeader : colors.tableCellBg },906              border: { pt: 0.5, color: colors.tableBorder },907              valign: 'middle' as const,908              bold: cell.tag === 'th',909            },910          }))911        );912        const tableH = Math.min(rows.length * 0.4, maxY - curY);913        slide.addTable(tableRows, {914          x: margin, y: curY, w: contentW, h: tableH,915          colW: Array(rows[0]?.length || 1).fill(contentW / (rows[0]?.length || 1)),916        });917        curY += tableH + 0.15;918      } catch {919        // Skip malformed table920      }921    }922  }923}924925// ── Generic Slide Content ──926927function addImageLayoutSlide(928  slide: PptxGenJS.Slide,929  parsed: ParsedSlide,930  startY: number,931  maxY: number,932  colors: ColorMap,933  contentW: number,934  margin: number,935  textStartY?: number,936) {937  const gap = 0.5;938  const imgW = (contentW - gap) / 2;939  const imgH = maxY - startY;940  const textW = imgW;941942  const leftX = margin;943  const rightX = margin + imgW + gap;944945  const imgX = parsed.imagePosition === 'left' ? leftX : rightX;946  const textX = parsed.imagePosition === 'left' ? rightX : leftX;947948  // Image top-aligned with content (parent handles vertical centering)949  const estimatedH = Math.min(imgW * 0.75, imgH);950  const imgY = startY;951  if (parsed.imageData) {952    slide.addImage({953      data: parsed.imageData,954      x: imgX, y: imgY, w: imgW, h: estimatedH,955      sizing: { type: 'contain', w: imgW, h: estimatedH },956    });957  } else {958    // Placeholder: grey rectangle with dashed accent border and centered text959    slide.addShape('rect' as any, {960      x: imgX, y: imgY, w: imgW, h: estimatedH,961      fill: { color: 'F0F0F0' },962      line: { color: colors.accent, width: 1.5, dashType: 'dash' },963    });964    slide.addText('[ Image ]', {965      x: imgX, y: imgY, w: imgW, h: estimatedH,966      fontSize: 16, color: '999999', fontFace: 'Calibri',967      align: 'center', valign: 'middle',968    });969  }970971  // Add text elements on the other side972  addElementsToSlide(slide, parsed.elements, textStartY ?? startY, colors, maxY, textW, textX);973}974975// ── PP Branded Slides ──976977function addPPIntroContent(slide: PptxGenJS.Slide, parsed: ParsedSlide, globalStemma: string | null) {978  const stemma = parsed.stemmaData || globalStemma;979980  // Stemma — exact from original PPTX: x=1.475 y=2.253 w=2.573 h=3.273981  if (stemma) {982    slide.addImage({983      data: stemma,984      x: 1.475, y: 2.253, w: 2.573, h: 3.273,985      sizing: { type: 'contain', w: 2.573, h: 3.273 },986    });987  }988989  // Text box — original: x=3.934 y=3.206 w=9.044 h=1.851990  // We split into title (top half) and subtitles (bottom half) of that box991  const textX = stemma ? 3.934 : 2.0;992  const textW = stemma ? 9.044 : 10.0;993994  if (parsed.title) {995    slide.addText(parsed.title.toUpperCase(), {996      x: textX, y: 3.206, w: textW, h: 0.8,997      fontSize: 32, bold: true, italic: true,998      color: PP.navy, fontFace: 'Calibri', valign: 'bottom', align: 'center',999    });1000  }10011002  if (parsed.subtitles.length > 0) {1003    const parts = parsed.subtitles.map((s, i) => ({1004      text: s.toUpperCase() + (i < parsed.subtitles.length - 1 ? '\n' : ''),1005      options: {1006        fontSize: 24, bold: true, italic: true,1007        color: PP.accent, fontFace: 'Calibri',1008      },1009    }));1010    slide.addText(parts, {1011      x: textX, y: 4.1, w: textW, h: 1.0, valign: 'top', align: 'center',1012    });1013  }10141015  if (parsed.notes) slide.addNotes(parsed.notes);1016}10171018function addPPContentSlide(slide: PptxGenJS.Slide, parsed: ParsedSlide) {1019  // White title in header bar area — original: x=0.620 y=0.258 w=7.281 h=0.351, 20pt1020  if (parsed.headerTitle) {1021    slide.addText(parsed.headerTitle.toUpperCase(), {1022      x: 0.62, y: 0.258, w: 7.281, h: 0.351,1023      fontSize: 20, bold: true, color: 'FFFFFF', fontFace: 'Calibri',1024    });1025  }10261027  // For image layouts, align h2 title with text column1028  const gap = 0.5;1029  const halfW = (PP_CONTENT_W - gap) / 2;1030  const textX = parsed.imagePosition1031    ? (parsed.imagePosition === 'left' ? PP_MARGIN + halfW + gap : PP_MARGIN)1032    : PP_MARGIN;1033  const titleW = parsed.imagePosition ? halfW : PP_CONTENT_W;10341035  const maxY = PP_FOOTER_Y - 0.2;1036  const minTopY = 1.05;1037  const titleBoxH = 0.7;1038  const titleGap = 0.15;1039  const titleBlockH = parsed.title ? titleBoxH + titleGap : 0;1040  const elementsH = estimateElementsHeight(parsed.elements, parsed.imagePosition ? halfW : PP_CONTENT_W);1041  const textBlockH = titleBlockH + elementsH;1042  const availableH = maxY - minTopY;10431044  // For image layouts, center based on the taller of text block vs image1045  let offset: number;1046  if (parsed.imagePosition) {1047    const imgEstH = Math.min(halfW * 0.75, availableH);1048    const blockH = Math.max(textBlockH, imgEstH);1049    offset = Math.max(0, (availableH - blockH) / 2);1050  } else {1051    offset = Math.max(0, (availableH - textBlockH) / 2);1052  }10531054  // Navy h2 heading below header1055  const titleY = minTopY + offset;1056  let contentStartY = minTopY + offset;1057  if (parsed.title) {1058    slide.addText(parsed.title, {1059      x: textX, y: titleY, w: titleW, h: titleBoxH,1060      fontSize: 26, bold: true, color: PP.navy, fontFace: 'Calibri', valign: 'bottom',1061    });1062    contentStartY = titleY + titleBlockH;1063  }10641065  // Image-left / image-right layout1066  if (parsed.imagePosition) {1067    addImageLayoutSlide(slide, parsed, titleY, maxY, PP_COLORS, PP_CONTENT_W, PP_MARGIN, contentStartY);1068  } else {1069    addElementsToSlide(slide, parsed.elements, contentStartY, PP_COLORS, maxY, PP_CONTENT_W, PP_MARGIN);1070  }10711072  if (parsed.notes) slide.addNotes(parsed.notes);1073}10741075function addPPClosingContent(slide: PptxGenJS.Slide, parsed: ParsedSlide, globalStemma: string | null) {1076  const stemma = parsed.stemmaData || globalStemma;10771078  // Stemma — exact from original PPTX: x=3.242 y=2.227 w=2.573 h=3.2731079  if (stemma) {1080    slide.addImage({1081      data: stemma,1082      x: 3.242, y: 2.227, w: 2.573, h: 3.273,1083      sizing: { type: 'contain', w: 2.573, h: 3.273 },1084    });1085  }10861087  // Text — original: x=6.343 y=3.520 w=2.251 h=0.687, 40pt bold navy1088  const text = parsed.closingText || parsed.title || 'GRAZIE';1089  const textX = stemma ? 6.343 : 4.0;10901091  slide.addText(text, {1092    x: textX, y: 3.52, w: 3.5, h: 0.7,1093    fontSize: 40, bold: true, color: PP.navy, fontFace: 'Calibri', valign: 'middle',1094  });10951096  if (parsed.notes) slide.addNotes(parsed.notes);1097}10981099// ── Marina Militare renderers ──11001101function addMarinaChrome(1102  slide: PptxGenJS.Slide,1103  slideType: SlideType,1104  headerTitle: string | null,1105  crestB64: string | null,1106  bgColor: string,1107  extras: MarinaExtras,1108) {1109  // Navy background1110  slide.background = { color: bgColor };11111112  // Tricolor flag stripe at the bottom: green / white / red, no border line1113  const stripeW = SLIDE_W / 3;1114  const stripeY = SLIDE_H - 0.06;1115  slide.addShape('rect' as any, {1116    x: 0, y: stripeY, w: stripeW, h: 0.06,1117    fill: { color: extras.tricolorGreen }, line: { color: extras.tricolorGreen, width: 0 },1118  });1119  slide.addShape('rect' as any, {1120    x: stripeW, y: stripeY, w: stripeW, h: 0.06,1121    fill: { color: 'FFFFFF' }, line: { color: 'FFFFFF', width: 0 },1122  });1123  slide.addShape('rect' as any, {1124    x: stripeW * 2, y: stripeY, w: stripeW, h: 0.06,1125    fill: { color: extras.tricolorRed }, line: { color: extras.tricolorRed, width: 0 },1126  });11271128  // Watermark at lower-left1129  slide.addText('MARINA MILITARE • UNCLASSIFIED', {1130    x: 0.25, y: SLIDE_H - 0.4, w: 4, h: 0.25,1131    fontSize: 8, color: extras.watermarkColor, fontFace: MM_FONT_BODY, charSpacing: 2,1132  });11331134  // Header bar (only on regular content slides, not intro/closing)1135  if (slideType !== 'intro' && slideType !== 'closing') {1136    slide.addShape('rect' as any, {1137      x: 0, y: 0, w: SLIDE_W, h: 0.5,1138      fill: { color: bgColor }, line: { color: bgColor, width: 0 },1139    });1140    if (crestB64) {1141      // Marina crest is ~2.67:1 (480x180 in CSS, 1830x685 native PNG).1142      // pptxgenjs sizing.contain is a no-op when w/h match the box, so size1143      // the box to the native aspect to avoid the image being stretched.1144      slide.addImage({1145        data: 'image/png;base64,' + crestB64,1146        x: 0.2, y: 0.07, w: 0.96, h: 0.36,1147      });1148    }1149    if (headerTitle) {1150      slide.addText(headerTitle.toUpperCase(), {1151        x: SLIDE_W - 6, y: 0, w: 5.7, h: 0.5,1152        fontSize: 10, color: 'CCCCCC', fontFace: MM_FONT_HEAD,1153        valign: 'middle', align: 'right', charSpacing: 2,1154      });1155    }1156  }1157}11581159function addMarinaTitleSlide(1160  slide: PptxGenJS.Slide,1161  parsed: ParsedSlide,1162  crestB64: string | null,1163  _colors: ColorMap,1164  _extras: MarinaExtras,1165) {1166  // Crest dimensions: matches the CSS .title-slide::before frame1167  // (480x180 px on a 1920x1080 canvas → ~2.67:1, scaled smaller than full1168  // width so the title block reads as the focal point).1169  const crestW = 2.5;1170  const crestH = 0.94;1171  const crestX = (SLIDE_W - crestW) / 2;1172  const crestY = 0.5;1173  if (crestB64) {1174    slide.addImage({1175      data: 'image/png;base64,' + crestB64,1176      x: crestX, y: crestY, w: crestW, h: crestH,1177    });1178  } else {1179    slide.addText('MARINA MILITARE', {1180      x: crestX, y: crestY, w: crestW, h: crestH,1181      fontSize: 22, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1182      align: 'center', valign: 'middle', charSpacing: 4,1183    });1184  }11851186  if (parsed.title) {1187    // Title uses Graduate (slab-serif uppercase) per CSS .title-slide h1.1188    // Respects explicit \n line breaks captured from <br> tags.1189    slide.addText(parsed.title, {1190      x: 0.5, y: 2.0, w: SLIDE_W - 1.0, h: 1.6,1191      fontSize: 44, bold: true, color: 'FFFFFF', fontFace: MM_FONT_DISPLAY,1192      align: 'center', valign: 'middle',1193    });1194  }11951196  if (parsed.subtitle) {1197    slide.addText(parsed.subtitle, {1198      x: 0.5, y: 3.9, w: SLIDE_W - 1.0, h: 0.7,1199      fontSize: 22, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1200      align: 'center', valign: 'middle',1201    });1202  }12031204  if (parsed.meta) {1205    slide.addText(parsed.meta, {1206      x: 0.5, y: 5.4, w: SLIDE_W - 1.0, h: 0.4,1207      fontSize: 11, color: 'B0BEC5', fontFace: MM_FONT_BODY,1208      align: 'center', valign: 'middle', charSpacing: 1,1209    });1210  }1211}12121213function addMarinaClosingSlide(1214  slide: PptxGenJS.Slide,1215  parsed: ParsedSlide,1216  crestB64: string | null,1217  _colors: ColorMap,1218  _extras: MarinaExtras,1219) {1220  const crestW = 2.5;1221  const crestH = 0.94;1222  const crestX = (SLIDE_W - crestW) / 2;1223  const crestY = 0.5;1224  if (crestB64) {1225    slide.addImage({1226      data: 'image/png;base64,' + crestB64,1227      x: crestX, y: crestY, w: crestW, h: crestH,1228    });1229  } else {1230    slide.addText('MARINA MILITARE', {1231      x: crestX, y: crestY, w: crestW, h: crestH,1232      fontSize: 22, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1233      align: 'center', valign: 'middle', charSpacing: 4,1234    });1235  }12361237  if (parsed.title) {1238    // Closing slide uses Montserrat (CSS .closing-slide h1), not Graduate.1239    slide.addText(parsed.title, {1240      x: 0.5, y: 2.6, w: SLIDE_W - 1.0, h: 1.4,1241      fontSize: 44, bold: true, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1242      align: 'center', valign: 'middle',1243    });1244  }12451246  if (parsed.hashtag) {1247    slide.addText(parsed.hashtag, {1248      x: 0.5, y: 4.3, w: SLIDE_W - 1.0, h: 0.6,1249      fontSize: 22, color: 'FFFFFF', fontFace: MM_FONT_HEAD,1250      align: 'center', valign: 'middle',1251    });1252  }1253}12541255function addMarinaCardGrid(1256  slide: PptxGenJS.Slide,1257  cards: ParsedCard[],1258  cardCols: number,1259  x: number,1260  y: number,1261  w: number,1262  maxH: number,1263  extras: MarinaExtras,1264): number {1265  if (cards.length === 0) return 0;1266  const cols = Math.max(1, cardCols);1267  const rows = Math.ceil(cards.length / cols);1268  const gap = 0.2;1269  const cardWidth = (w - gap * (cols - 1)) / cols;1270  const cardHeight = Math.min(2.2, (maxH - gap * (rows - 1)) / rows);12711272  const valueColorMap: Record<string, string> = {1273    gold: extras.gold,1274    cyan: extras.cyan,1275    red: extras.red,1276    green: extras.green,1277    white: 'FFFFFF',1278  };12791280  cards.forEach((card, i) => {1281    const col = i % cols;1282    const row = Math.floor(i / cols);1283    const cx = x + col * (cardWidth + gap);1284    const cy = y + row * (cardHeight + gap);12851286    const fillColor = card.variant === 'alert' ? extras.cardAlertBg : extras.cardBg;1287    const borderColor =1288      card.variant === 'alert' ? extras.cardAlertBorder1289      : card.variant === 'highlight' ? extras.cardHighlightBorder1290      : extras.cardBorder;12911292    // PPTX shape: pptxgenjs supports 'roundRect' which gives subtle rounded1293    // corners; the corner radius is fixed (no curvature control), close enough1294    // to the CSS border-radius look.1295    slide.addShape('roundRect' as any, {1296      x: cx, y: cy, w: cardWidth, h: cardHeight,1297      fill: { color: fillColor },1298      line: { color: borderColor, width: 1 },1299      rectRadius: 0.05,1300    });13011302    const labelColor =1303      card.variant === 'highlight' ? extras.gold1304      : card.variant === 'alert' ? extras.red1305      : extras.paleBlue;13061307    if (card.label) {1308      slide.addText(card.label.toUpperCase(), {1309        x: cx + 0.15, y: cy + 0.12, w: cardWidth - 0.3, h: 0.3,1310        fontSize: 9, bold: true, color: labelColor, fontFace: MM_FONT_HEAD,1311        charSpacing: 2, valign: 'top',1312      });1313    }13141315    // Auto-shrink long card values so multi-line wrap doesn't bleed into1316    // the detail row. Threshold mirrors the typical narrow-card case.1317    const valueLen = card.value.length;1318    const valueFontSize = valueLen > 14 ? 16 : 22;1319    if (card.value) {1320      slide.addText(card.value, {1321        x: cx + 0.15, y: cy + 0.45, w: cardWidth - 0.3, h: 0.85,1322        fontSize: valueFontSize, bold: true,1323        color: valueColorMap[card.valueColor] || 'FFFFFF',1324        fontFace: MM_FONT_HEAD, valign: 'top',1325      });1326    }13271328    if (card.detail) {1329      const detailY = cy + 1.35;1330      const detailH = Math.max(0.3, cy + cardHeight - 0.15 - detailY);1331      slide.addText(card.detail, {1332        x: cx + 0.15, y: detailY, w: cardWidth - 0.3, h: detailH,1333        fontSize: 10, color: 'D4E4F5', fontFace: MM_FONT_BODY, valign: 'top',1334      });1335    }1336  });13371338  return rows * cardHeight + (rows - 1) * gap;1339}13401341function addMarinaSlideContent(1342  slide: PptxGenJS.Slide,1343  parsed: ParsedSlide,1344  bgColor: string,1345  colors: ColorMap,1346  extras: MarinaExtras,1347  crestB64: string | null,1348) {1349  addMarinaChrome(slide, parsed.slideType, parsed.headerTitle, crestB64, bgColor, extras);13501351  if (parsed.slideType === 'intro') {1352    addMarinaTitleSlide(slide, parsed, crestB64, colors, extras);1353    if (parsed.notes) slide.addNotes(parsed.notes);1354    return;1355  }1356  if (parsed.slideType === 'closing') {1357    addMarinaClosingSlide(slide, parsed, crestB64, colors, extras);1358    if (parsed.notes) slide.addNotes(parsed.notes);1359    return;1360  }13611362  // Generic content slide: header bar reserved at top 0.5"; content starts below.1363  let curY = 0.85;1364  const maxY = SLIDE_H - 0.55;13651366  if (parsed.title) {1367    slide.addText(parsed.title, {1368      x: MARGIN, y: 0.7, w: CONTENT_W, h: 0.55,1369      fontSize: 32, bold: true, color: colors.title, fontFace: MM_FONT_HEAD,1370      valign: 'top',1371    });1372    // Thin accent underline1373    slide.addShape('rect' as any, {1374      x: MARGIN, y: 1.3, w: CONTENT_W, h: 0.03,1375      fill: { color: colors.accent }, line: { color: colors.accent, width: 0 },1376    });1377    curY = 1.45;1378  }13791380  if (parsed.sectionLabel) {1381    slide.addText(parsed.sectionLabel.toUpperCase(), {1382      x: MARGIN, y: curY, w: CONTENT_W, h: 0.3,1383      fontSize: 10, color: extras.cyan, fontFace: MM_FONT_HEAD,1384      charSpacing: 2, valign: 'top',1385    });1386    curY += 0.4;1387  }13881389  // Card region: cap at ~3" so remaining elements still have room1390  if (parsed.cards.length > 0) {1391    const cardRegionMaxH = Math.min(3.0, maxY - curY);1392    const consumed = addMarinaCardGrid(1393      slide, parsed.cards, parsed.cardCols,1394      MARGIN, curY, CONTENT_W, cardRegionMaxH, extras,1395    );1396    curY += consumed + 0.25;1397  }13981399  // Image-left/right layout for marina is preserved when present1400  if (parsed.imagePosition && parsed.elements.length > 0) {1401    addImageLayoutSlide(slide, parsed, curY, maxY, colors, CONTENT_W, MARGIN, curY);1402  } else if (parsed.elements.length > 0) {1403    // Marina body is denser when alongside cards; pass smaller font sizes.1404    addElementsToSlide(slide, parsed.elements, curY, colors, maxY, CONTENT_W, MARGIN, 16, 14);1405  }14061407  if (parsed.notes) slide.addNotes(parsed.notes);1408}14091410function addSlideContent(1411  slide: PptxGenJS.Slide,1412  parsed: ParsedSlide,1413  bgColor: string,1414  colors: ColorMap,1415  theme: string | null = null,1416  crestB64: string | null = null,1417) {1418  if (theme === 'marina') {1419    addMarinaSlideContent(slide, parsed, parsed.bgColor || bgColor, colors, MARINA_EXTRAS, crestB64);1420    return;1421  }14221423  // Per-slide bgColor override, otherwise use theme background1424  slide.background = { color: parsed.bgColor || bgColor };14251426  // For image layouts, align title with text column1427  const gap = 0.5;1428  const halfW = (CONTENT_W - gap) / 2;1429  const titleX = parsed.imagePosition1430    ? (parsed.imagePosition === 'left' ? MARGIN + halfW + gap : MARGIN)1431    : MARGIN;1432  const titleW = parsed.imagePosition ? halfW : CONTENT_W;14331434  const maxY = SLIDE_H - MARGIN;1435  const titleH = parsed.title ? (CONTENT_Y - TITLE_Y) : 0;1436  const elementsH = estimateElementsHeight(parsed.elements, parsed.imagePosition ? halfW : CONTENT_W);1437  const textBlockH = titleH + elementsH;1438  const availableH = maxY - TITLE_Y;14391440  let offset: number;1441  if (parsed.imagePosition) {1442    const imgEstH = Math.min(halfW * 0.75, availableH);1443    const blockH = Math.max(textBlockH, imgEstH);1444    offset = Math.max(0, (availableH - blockH) / 2);1445  } else {1446    offset = Math.max(0, (availableH - textBlockH) / 2);1447  }14481449  const adjTitleY = TITLE_Y + offset;1450  const adjContentY = adjTitleY + titleH;14511452  if (parsed.title) {1453    slide.addText(parsed.title, {1454      x: titleX, y: adjTitleY, w: titleW, h: TITLE_H,1455      fontSize: 28, bold: true, color: colors.title, fontFace: 'Calibri', valign: 'bottom',1456    });1457  }14581459  const startY = parsed.title ? adjContentY : adjTitleY;14601461  // Image-left / image-right layout1462  if (parsed.imagePosition) {1463    addImageLayoutSlide(slide, parsed, adjTitleY, maxY, colors, CONTENT_W, MARGIN, startY);1464  } else {1465    addElementsToSlide(slide, parsed.elements, startY, colors, maxY, CONTENT_W, MARGIN);1466  }14671468  if (parsed.notes) {1469    slide.addNotes(parsed.notes);1470  }1471}14721473// ── Main ──14741475async function main() {1476  let inputData = '';1477  for await (const chunk of process.stdin) {1478    inputData += chunk;1479  }14801481  const { inputs } = JSON.parse(inputData);1482  const htmlContent: string = inputs.html_content || '';1483  const title: string = inputs.title || 'Presentation';1484  const themeInput: string = inputs.theme || '';14851486  if (!htmlContent) {1487    throw new Error("Required input 'html_content' not provided");1488  }14891490  // Parse HTML1491  const $ = cheerio.load(htmlContent);1492  const sections = $('div.slides > section');14931494  if (sections.length === 0) {1495    // Fallback: try top-level <section> elements1496    const fallbackSections = $('section');1497    if (fallbackSections.length === 0) {1498      throw new Error('No <section> elements found in the HTML content');1499    }1500  }15011502  const rawSections = sections.length > 0 ? sections : $('section');15031504  // Flatten Reveal.js vertical stacks: a <section> that wraps nested <section>1505  // children is a stack container, not a real slide — replace it with its children.1506  const flatSections: cheerio.Element[] = [];1507  rawSections.each((_i, section) => {1508    const nested = $(section).children('section');1509    if (nested.length > 0) {1510      nested.each((_j, child) => flatSections.push(child));1511    } else {1512      flatSections.push(section);1513    }1514  });15151516  console.error(`Parsing ${flatSections.length} slides from HTML`);15171518  // Theme detection1519  const theme = detectTheme($, themeInput);1520  const isPP = theme === 'polizia-postale';1521  const themeConfig = getThemeConfig(theme);15221523  // Set accent strong color based on theme1524  if (isPP) accentStrongColor = PP.crimson;1525  else accentStrongColor = '333333';15261527  // Marina: extract embedded crest PNG (largest data URI in inline CSS)1528  let crestB64: string | null = null;1529  if (theme === 'marina') {1530    crestB64 = extractMarinaCrest(htmlContent);1531    if (crestB64) {1532      console.error(`Marina crest extracted (${crestB64.length} base64 chars)`);1533    } else {1534      console.error('Marina theme detected but no crest PNG found in HTML');1535    }1536  }15371538  // Create presentation1539  const pptx = new PptxGenJS();1540  pptx.title = title;1541  pptx.layout = 'LAYOUT_WIDE'; // 13.33 x 7.515421543  // PP: extract assets + define slide masters before slides are added1544  let ppAssets: PPAssets = { headerLeftData: null, logoData: null, stemmaData: null };1545  if (isPP) {1546    ppAssets = extractPPAssets($);1547    definePPMasters(pptx, ppAssets);1548    console.error(`PP theme — headerLeft: ${ppAssets.headerLeftData ? 'yes' : 'no'}, logo: ${ppAssets.logoData ? 'yes' : 'no'}, stemma: ${ppAssets.stemmaData ? 'yes' : 'no'}`);1549  }15501551  // Phase 1: Parse all sections into ParsedSlide array1552  const parsedSlides: ParsedSlide[] = [];1553  for (const section of flatSections) {1554    parsedSlides.push(parseSection($, section));1555  }15561557  // Phase 2: Resolve external image URLs in parallel1558  await Promise.all(parsedSlides.map(async (parsed) => {1559    if (parsed.imageSrcRaw && (parsed.imageSrcRaw.startsWith('http://') || parsed.imageSrcRaw.startsWith('https://'))) {1560      const data = await fetchImageAsBase64(parsed.imageSrcRaw);1561      if (data) {1562        parsed.imageData = data;1563        parsed.imageSrcRaw = null;1564      }1565    }1566  }));15671568  // Phase 3: Generate PPTX slides from resolved data1569  for (const parsed of parsedSlides) {1570    if (isPP) {1571      const masterMap: Record<SlideType, string> = {1572        intro: 'PP_INTRO',1573        content: 'PP_CONTENT',1574        closing: 'PP_CLOSING',1575        generic: 'PP_CONTENT',1576      };1577      const slide = pptx.addSlide({ masterName: masterMap[parsed.slideType] });15781579      switch (parsed.slideType) {1580        case 'intro':1581          addPPIntroContent(slide, parsed, ppAssets.stemmaData);1582          break;1583        case 'closing':1584          addPPClosingContent(slide, parsed, ppAssets.stemmaData);1585          break;1586        default:1587          addPPContentSlide(slide, parsed);1588          break;1589      }1590    } else {1591      const slide = pptx.addSlide();1592      addSlideContent(slide, parsed, themeConfig.background, themeConfig.colors, theme, crestB64);1593    }1594  }15951596  console.error(`Generated ${flatSections.length} slides (theme: ${theme || 'generic'})`);15971598  // Write to output directory1599  const outputDir = '/data/output';1600  fs.mkdirSync(outputDir, { recursive: true });16011602  // Sanitize title for filename1603  const safeTitle = title.replace(/[^a-zA-Z0-9_\-\s]/g, '').trim().replace(/\s+/g, '_').slice(0, 50) || 'presentation';1604  const filename = `${safeTitle}.pptx`;1605  const outputPath = `${outputDir}/${filename}`;16061607  // PptxGenJS write to file1608  const buffer = await pptx.write({ outputType: 'nodebuffer' }) as Buffer;1609  fs.writeFileSync(outputPath, buffer);16101611  console.error(`PPTX written to ${outputPath} (${buffer.length} bytes)`);16121613  // Output result1614  const output = { pptx_file: filename };1615  console.log(JSON.stringify(output));1616}16171618main().catch((err) => {1619  console.error(JSON.stringify({1620    error: err.message || String(err),1621    errorType: err.name || 'Error',1622    traceback: err.stack || '',1623  }));1624  process.exit(1);1625});

$ git log --oneline

v1.2.4
HEAD
2026-10-08