$ cat node-template.ts

PPTX Converter

// Converts a Reveal.js HTML presentation into a downloadable PowerPoint (.pptx) file.

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,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';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};125126const DEFAULT_THEME: ThemeConfig = { background: 'FFFFFF', colors: COLORS };127128function getThemeConfig(theme: string | null): ThemeConfig {129  if (!theme || theme === 'polizia-postale') return DEFAULT_THEME;130  return THEME_PALETTES[theme] || DEFAULT_THEME;131}132133// ── Types ──134135type ColorMap = typeof COLORS;136type SlideType = 'intro' | 'content' | 'closing' | 'generic';137138interface TextRun {139  text: string;140  bold?: boolean;141  italic?: boolean;142  color?: string;143}144145interface SlideElement {146  tag: string;147  text: string;148  children?: { text: string; tag: string; runs?: TextRun[]; indent?: number }[];149  attrs?: Record<string, string>;150  runs?: TextRun[];151}152153interface ParsedSlide {154  slideType: SlideType;155  title: string | null;156  elements: SlideElement[];157  notes: string | null;158  bgColor: string | null;159  headerTitle: string | null;160  subtitles: string[];161  closingText: string | null;162  stemmaData: string | null;163  /** Base64 image data (without "data:" prefix) for image-left/right layouts */164  imageData: string | null;165  /** Raw image src for deferred fetch (http/https URLs or {{IMAGE}} placeholders) */166  imageSrcRaw: string | null;167  /** Image position: 'left' or 'right' */168  imagePosition: 'left' | 'right' | null;169}170171interface PPAssets {172  headerLeftData: string | null;173  logoData: string | null;174  stemmaData: string | null;175}176177// ── Theme Detection ──178179function detectTheme($: cheerio.CheerioAPI, themeInput?: string): string | null {180  if (themeInput && themeInput !== 'auto') return themeInput;181  const html = $.html();182183  // Branded: Polizia Postale184  if (html.includes('--pp-navy') || html.includes('--pp-blue')) return 'polizia-postale';185  if ($('section.pp-cover').length > 0 || $('section.pp-closing').length > 0) return 'polizia-postale';186  if ($('.pp-header-title').length > 0) return 'polizia-postale';187188  // Standard Reveal.js themes: detect from <link href="...reveal.js.../theme/{name}.css">189  const themeMatch = html.match(/reveal\.js[^"]*\/dist\/theme\/([a-z-]+)\.css/);190  if (themeMatch && themeMatch[1] in THEME_PALETTES) return themeMatch[1];191192  return null;193}194195function extractPPAssets($: cheerio.CheerioAPI): PPAssets {196  const html = $.html();197198  // Extract all data:image URLs from CSS (split header: logo + left bar)199  let headerLeftData: string | null = null;200  let logoData: string | null = null;201  const urlMatches = [...html.matchAll(/url\(data:(image\/png;base64,[^)]+)\)/g)];202  if (urlMatches.length >= 2) {203    // First url() is the logo (right), second is the left header bar204    logoData = urlMatches[0][1];205    headerLeftData = urlMatches[1][1];206  } else if (urlMatches.length === 1) {207    // Fallback: single header image (old format)208    headerLeftData = urlMatches[0][1];209  }210211  // Stemma from first <img class="pp-emblem" src="data:...">212  let stemmaData: string | null = null;213  const emblem = $('img.pp-emblem').first();214  if (emblem.length > 0) {215    const src = emblem.attr('src') || '';216    if (src.startsWith('data:')) {217      stemmaData = src.slice(5); // strip "data:" prefix for PptxGenJS218    }219  }220221  return { headerLeftData, logoData, stemmaData };222}223224// ── Slide Masters ──225226function definePPMasters(pptx: PptxGenJS, assets: PPAssets) {227  const footerObjects: any[] = [{228    image: {229      x: 0, y: PP_FOOTER_Y,230      w: PP_W, h: PP_FOOTER_H,231      data: PP_TRICOLOR_IMG,232    },233  }];234235  const headerObjects: any[] = [];236  if (assets.headerLeftData) {237    // Left header bar (blue + red line) stretched across most of the width238    headerObjects.push({ image: { x: 0, y: 0, w: PP_W - 1.0, h: PP_HEADER_H, data: assets.headerLeftData } });239  }240  if (assets.logoData) {241    // Logo (fixed size, right-aligned, overlaps blue bar + red line)242    const logoW = 1.8;243    const logoH = PP_HEADER_H;244    headerObjects.push({ image: { x: PP_W - logoW, y: 0, w: logoW, h: logoH, data: assets.logoData } });245  }246247  const commonObjects = [...headerObjects, ...footerObjects];248249  pptx.defineSlideMaster({ title: 'PP_INTRO', background: { color: 'FFFFFF' }, objects: [...commonObjects] });250  pptx.defineSlideMaster({ title: 'PP_CONTENT', background: { color: 'FFFFFF' }, objects: [...commonObjects] });251  pptx.defineSlideMaster({ title: 'PP_CLOSING', background: { color: 'FFFFFF' }, objects: [...commonObjects] });252}253254// ── Inline Rich Text Parsing ──255256function parseInlineRuns($: cheerio.CheerioAPI, el: cheerio.Element, inherited?: { bold?: boolean; italic?: boolean; color?: string }): TextRun[] {257  const runs: TextRun[] = [];258  const inh = inherited || {};259260  $(el).contents().each((_i, node) => {261    if (node.type === 'text') {262      const t = (node as any).data || '';263      if (t) {264        runs.push({265          text: t,266          ...(inh.bold ? { bold: true } : {}),267          ...(inh.italic ? { italic: true } : {}),268          ...(inh.color ? { color: inh.color } : {}),269        });270      }271    } else if (node.type === 'tag') {272      const tag = (node as cheerio.TagElement).tagName?.toLowerCase() || '';273      // Skip nested lists and tables — they are handled separately274      if (tag === 'ul' || tag === 'ol' || tag === 'table') return;275      const next = { ...inh };276      if (tag === 'strong' || tag === 'b') { next.bold = true; next.color = next.color || 'B91C3E'; }277      if (tag === 'em' || tag === 'i') { next.italic = true; }278      runs.push(...parseInlineRuns($, node as cheerio.Element, next));279    }280  });281282  return runs;283}284285/** Extract only direct text from a <li>, excluding nested <ul>/<ol> text. */286function directText($: cheerio.CheerioAPI, el: cheerio.Element): string {287  let text = '';288  $(el).contents().each((_i, node) => {289    if (node.type === 'text') {290      text += (node as any).data || '';291    } else if (node.type === 'tag') {292      const tag = (node as cheerio.TagElement).tagName?.toLowerCase() || '';293      if (tag !== 'ul' && tag !== 'ol' && tag !== 'table') {294        text += $(node).text();295      }296    }297  });298  return text.trim();299}300301// ── Section Parsing ──302303function parseSection($: cheerio.CheerioAPI, section: cheerio.Element): ParsedSlide {304  const $s = $(section);305  const notes = $s.find('aside.notes').text().trim() || null;306  const bgColor = ($s.attr('data-background-color') || '').replace('#', '') || null;307308  // Detect slide type309  let slideType: SlideType = 'generic';310  if ($s.hasClass('pp-cover')) slideType = 'intro';311  else if ($s.hasClass('pp-closing')) slideType = 'closing';312  else if ($s.find('.pp-header-title').length > 0) slideType = 'content';313314  // PP-specific extractions315  const headerTitle = $s.find('.pp-header-title').first().text().trim() || null;316  const subtitles: string[] = [];317  $s.find('.pp-subtitle').each((_i, el) => {318    const text = $(el).text().trim();319    if (text) subtitles.push(text);320  });321  const closingText = $s.find('.pp-closing-text').first().text().trim() || null;322323  let stemmaData: string | null = null;324  const emblem = $s.find('img.pp-emblem').first();325  if (emblem.length > 0) {326    const src = emblem.attr('src') || '';327    if (src.startsWith('data:')) stemmaData = src.slice(5);328  }329330  // Title from h2331  const title = $s.find('h2').first().text().trim() || null;332333  // Detect image-left / image-right layout334  let imageData: string | null = null;335  let imageSrcRaw: string | null = null;336  let imagePosition: 'left' | 'right' | null = null;337338  // Parse content elements (skip PP-specific markup)339  const elements: SlideElement[] = [];340341  /** Parse a list element, handling nesting and rich text */342  function parseList($list: cheerio.Cheerio<cheerio.Element>, listTag: string, indent: number = 0) {343    const items: { text: string; tag: string; runs?: TextRun[]; indent?: number }[] = [];344    $list.children('li').each((_k, li) => {345      const text = directText($, li);346      const runs = parseInlineRuns($, li);347      if (text) {348        items.push({ text, tag: listTag, runs: runs.length > 0 ? runs : undefined, indent });349      }350      // Handle nested lists inside this <li>351      $(li).children('ul, ol').each((_n, nested) => {352        const nestedTag = (nested as cheerio.TagElement).tagName?.toLowerCase() || 'ul';353        const nestedItems = parseList($(nested), nestedTag, indent + 1);354        items.push(...nestedItems);355      });356    });357    return items;358  }359360  function extractChildElements($container: cheerio.Cheerio<cheerio.Element>) {361    $container.find('p, ul, ol, h3, h2, blockquote, table').each((_j, child) => {362      const $child = $(child);363      const childTag = (child as cheerio.TagElement).tagName?.toLowerCase() || '';364      // Skip the main slide h2 title if already captured365      if (childTag === 'h2' && $child.text().trim() === title) return;366      // Skip nested lists (already handled by their parent <li>)367      if ((childTag === 'ul' || childTag === 'ol') && $(child).parent('li').length > 0) return;368      // Skip tables inside lists (extracted separately after list processing)369      if (childTag === 'table' && $(child).closest('ul, ol').length > 0) return;370      if (childTag === 'ul' || childTag === 'ol') {371        const items = parseList($child, childTag);372        elements.push({ tag: childTag, text: '', children: items });373      } else if (childTag === 'table') {374        const rows: { text: string; tag: string }[][] = [];375        $child.find('tr').each((_rj, tr) => {376          const cells: { text: string; tag: string }[] = [];377          $(tr).find('th, td').each((_k, cell) => {378            cells.push({379              text: $(cell).text().trim(),380              tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',381            });382          });383          if (cells.length > 0) rows.push(cells);384        });385        elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });386      } else if (childTag === 'p') {387        const txt = $child.text().trim();388        if (txt) {389          const runs = parseInlineRuns($, child);390          elements.push({ tag: 'p', text: txt, runs: runs.length > 0 ? runs : undefined });391        }392      } else {393        const txt = $child.text().trim();394        if (txt) elements.push({ tag: childTag === 'h2' ? 'h3' : childTag, text: txt });395      }396    });397  }398399  $s.children().each((_i, el) => {400    const $el = $(el);401    const tag = (el as cheerio.TagElement).tagName?.toLowerCase() || '';402403    // Skip notes and PP-specific elements404    if (tag === 'aside' && $el.hasClass('notes')) return;405    // Skip only the first h2 (captured as slide title); render others as subheadings406    if (tag === 'h2') {407      if ($el.text().trim() === title) return;408      elements.push({ tag: 'h3', text: $el.text().trim() });409      return;410    }411    if ($el.hasClass('pp-header-title') || $el.hasClass('pp-intro-layout') ||412        $el.hasClass('pp-closing-text') || (tag === 'img' && $el.hasClass('pp-emblem'))) return;413414    if (tag === 'h3') {415      elements.push({ tag: 'h3', text: $el.text().trim() });416    } else if (tag === 'p') {417      const txt = $el.text().trim();418      if (txt) {419        const runs = parseInlineRuns($, el);420        elements.push({ tag: 'p', text: txt, runs: runs.length > 0 ? runs : undefined });421      }422    } else if (tag === 'ul' || tag === 'ol') {423      const items = parseList($el, tag);424      elements.push({ tag, text: '', children: items });425      // Extract tables nested inside list items (e.g. table under a <li>)426      $el.find('table').each((_j, tbl) => {427        const $tbl = $(tbl);428        const rows: { text: string; tag: string }[][] = [];429        $tbl.find('tr').each((_rj, tr) => {430          const cells: { text: string; tag: string }[] = [];431          $(tr).find('th, td').each((_k, cell) => {432            cells.push({433              text: $(cell).text().trim(),434              tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',435            });436          });437          if (cells.length > 0) rows.push(cells);438        });439        if (rows.length > 0) {440          elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });441        }442      });443    } else if (tag === 'blockquote') {444      elements.push({ tag: 'blockquote', text: $el.text().trim() });445    } else if (tag === 'table') {446      const rows: { text: string; tag: string }[][] = [];447      $el.find('tr').each((_j, tr) => {448        const cells: { text: string; tag: string }[] = [];449        $(tr).find('th, td').each((_k, cell) => {450          cells.push({451            text: $(cell).text().trim(),452            tag: (cell as cheerio.TagElement).tagName?.toLowerCase() || 'td',453          });454        });455        if (cells.length > 0) rows.push(cells);456      });457      elements.push({ tag: 'table', text: '', children: rows.flat(), attrs: { _rows: JSON.stringify(rows) } });458    } else if (tag === 'div') {459      // Check for image-left / image-right flex layout460      const style = $el.attr('style') || '';461      const isFlex = style.includes('display:flex') || style.includes('display: flex');462      const childDivs = $el.children('div');463      const hasImg = $el.find('img').length > 0;464465      if (isFlex && hasImg && !imageData && !imageSrcRaw) {466        // Case 1: Both sides wrapped in <div> (expected structure)467        if (childDivs.length >= 2) {468          const firstChild = $(childDivs[0]);469          const secondChild = $(childDivs[1]);470          const firstImg = firstChild.find('img').first();471          const secondImg = secondChild.find('img').first();472473          let imgEl: cheerio.Cheerio<cheerio.Element> | null = null;474          let textContainer: cheerio.Cheerio<cheerio.Element> | null = null;475476          if (firstImg.length > 0) {477            imgEl = firstImg;478            textContainer = secondChild;479            imagePosition = 'left';480          } else if (secondImg.length > 0) {481            imgEl = secondImg;482            textContainer = firstChild;483            imagePosition = 'right';484          }485486          if (imgEl && textContainer) {487            const imgSrc = imgEl.attr('src') || '';488            if (imgSrc.startsWith('data:')) {489              imageData = imgSrc.slice(5);490            } else if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) {491              imageSrcRaw = imgSrc;492            } else {493              // {{IMAGE}} placeholder or empty src494              imageSrcRaw = imgSrc || '{{IMAGE}}';495            }496            extractChildElements(textContainer);497            return;498          }499        }500501        // Case 2: <img> is a direct child of flex container (LLM sometimes does this)502        const directImg = $el.children('img').first();503        if (directImg.length > 0 && childDivs.length >= 1) {504          const imgSrc = directImg.attr('src') || '';505          // Determine position: if img comes before the div, it's left506          const allChildren = $el.children().toArray();507          const imgIdx = allChildren.findIndex(c => c === directImg[0]);508          const divIdx = allChildren.findIndex(c => c === childDivs[0]);509          imagePosition = imgIdx < divIdx ? 'left' : 'right';510511          if (imgSrc.startsWith('data:')) {512            imageData = imgSrc.slice(5);513          } else if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) {514            imageSrcRaw = imgSrc;515          } else {516            imageSrcRaw = imgSrc || '{{IMAGE}}';517          }518          extractChildElements($(childDivs[0]));519          return;520        }521      }522523      // Fallback: recurse into layout divs (two-column layouts etc.)524      extractChildElements($el);525    }526  });527528  return { slideType, title, elements, notes, bgColor, headerTitle, subtitles, closingText, stemmaData, imageData, imageSrcRaw, imagePosition };529}530531// ── Image Fetch Helper ──532533async function fetchImageAsBase64(url: string): Promise<string | null> {534  try {535    const controller = new AbortController();536    const timeout = setTimeout(() => controller.abort(), 10000);537    const resp = await fetch(url, { signal: controller.signal });538    clearTimeout(timeout);539    if (!resp.ok) return null;540    const buf = Buffer.from(await resp.arrayBuffer());541    const contentType = resp.headers.get('content-type') || 'image/png';542    const mimeType = contentType.split(';')[0].trim();543    return `${mimeType};base64,${buf.toString('base64')}`;544  } catch (e) {545    console.error(`Failed to fetch image from ${url}: ${e}`);546    return null;547  }548}549550// ── Content Height Estimation ──551552function estimateElementsHeight(elements: SlideElement[], contentW: number): number {553  let h = 0;554  for (const el of elements) {555    if (el.tag === 'h3') {556      h += 0.55;557    } else if (el.tag === 'p') {558      if (!el.text) continue;559      h += 0.5;560    } else if ((el.tag === 'ul' || el.tag === 'ol') && el.children) {561      el.children.forEach((item) => {562        const indent = item.indent || 0;563        const baseFontSize = indent > 0 ? 13 : 15;564        const indentOffset = indent * 0.35;565        const wPos = contentW - 0.2 - indentOffset;566        const textLen = item.text.length;567        const charW = baseFontSize * 0.0067;568        const lineH = baseFontSize * 0.019;569        const charsPerLine = Math.max(1, Math.floor(wPos / charW));570        const numLines = Math.max(1, Math.ceil(textLen / charsPerLine));571        h += Math.max(0.3, numLines * lineH + 0.06);572      });573      h += 0.1;574    } else if (el.tag === 'blockquote') {575      h += 0.75;576    } else if (el.tag === 'table' && el.attrs?._rows) {577      try {578        const rows: any[][] = JSON.parse(el.attrs._rows);579        h += rows.length * 0.4 + 0.15;580      } catch {}581    }582  }583  return h;584}585586// ── Shared Element Rendering ──587588function addElementsToSlide(589  slide: PptxGenJS.Slide,590  elements: SlideElement[],591  startY: number,592  colors: ColorMap,593  maxY: number,594  contentW: number,595  margin: number,596) {597  let curY = startY;598599  for (const el of elements) {600    if (curY >= maxY) break;601602    if (el.tag === 'h3') {603      slide.addText(el.text, {604        x: margin, y: curY, w: contentW, h: 0.5,605        fontSize: 22, bold: true, color: colors.title, fontFace: 'Calibri',606      });607      curY += 0.55;608    } else if (el.tag === 'p') {609      if (!el.text) continue;610      if (el.runs && el.runs.length > 0) {611        const pRuns = el.runs.map(r => ({612          text: r.text,613          options: {614            fontSize: 18, fontFace: 'Calibri', color: r.color || colors.body,615            ...(r.bold ? { bold: true } : {}),616            ...(r.italic ? { italic: true } : {}),617          },618        }));619        slide.addText(pRuns, { x: margin, y: curY, w: contentW, h: 0.45 });620      } else {621        slide.addText(el.text, {622          x: margin, y: curY, w: contentW, h: 0.45,623          fontSize: 18, color: colors.body, fontFace: 'Calibri',624        });625      }626      curY += 0.5;627    } else if ((el.tag === 'ul' || el.tag === 'ol') && el.children) {628      // Render each bullet item with manual bullet/number characters for reliable display.629      const olCounters: Record<number, number> = {};630      let prevTag = '';631      let prevIndent = -1;632633      el.children.forEach((item) => {634        if (curY >= maxY) return;635        const indent = item.indent || 0;636        const baseFontSize = indent > 0 ? 13 : 15;637        const indentOffset = indent * 0.35;638        const xPos = margin + 0.2 + indentOffset;639        const wPos = contentW - 0.2 - indentOffset;640641        // Dynamic height: estimate wrapped lines based on text length642        const textLen = item.text.length;643        const charW = baseFontSize * 0.0067;644        const lineH = baseFontSize * 0.019;645        const charsPerLine = Math.max(1, Math.floor(wPos / charW));646        const numLines = Math.max(1, Math.ceil(textLen / charsPerLine));647        const itemH = Math.max(0.3, numLines * lineH + 0.06);648649        // Determine list type from item.tag (set by parseList to 'ul' or 'ol')650        const isOrdered = item.tag === 'ol';651652        // Manage ordered counters: reset when starting a new ordered sequence653        if (isOrdered) {654          if (prevTag !== 'ol' || prevIndent !== indent) {655            olCounters[indent] = 0;656          }657          olCounters[indent]++;658        }659        prevTag = item.tag || '';660        prevIndent = indent;661662        // Build prefix: bullet char or number663        const prefix = isOrdered664          ? `${olCounters[indent]}. `665          : (indent > 0 ? '\u25AA  ' : '\u2022  ');666        const prefixColor = isOrdered ? colors.body : colors.bulletDot;667668        // Always use text run array so bullet char gets its own color669        const textRuns: { text: string; options: any }[] = [670          { text: prefix, options: { fontSize: baseFontSize, fontFace: 'Calibri', color: prefixColor } },671        ];672673        if (item.runs && item.runs.length > 0) {674          for (const r of item.runs) {675            textRuns.push({676              text: r.text,677              options: {678                fontSize: baseFontSize, fontFace: 'Calibri',679                color: r.color || colors.body,680                ...(r.bold ? { bold: true } : {}),681                ...(r.italic ? { italic: true } : {}),682              },683            });684          }685        } else {686          textRuns.push({687            text: item.text,688            options: { fontSize: baseFontSize, fontFace: 'Calibri', color: colors.body },689          });690        }691692        slide.addText(textRuns, {693          x: xPos, y: curY, w: wPos, h: itemH, valign: 'top',694        });695        curY += itemH;696      });697      curY += 0.1;698    } else if (el.tag === 'blockquote') {699      slide.addShape('rect' as any, {700        x: margin, y: curY, w: 0.08, h: 0.6,701        fill: { color: colors.quoteBar },702      });703      slide.addText(el.text, {704        x: margin + 0.25, y: curY, w: contentW - 0.25, h: 0.6,705        fontSize: 16, italic: true, color: colors.quoteText, fontFace: 'Calibri', valign: 'middle',706      });707      curY += 0.75;708    } else if (el.tag === 'table' && el.attrs?._rows) {709      try {710        const rows: { text: string; tag: string }[][] = JSON.parse(el.attrs._rows);711        const tableRows: PptxGenJS.TableRow[] = rows.map((row, rowIdx) =>712          row.map((cell) => ({713            text: cell.text,714            options: {715              fontSize: 12,716              fontFace: 'Calibri',717              color: cell.tag === 'th' || rowIdx === 0 ? colors.tableHeaderText : colors.body,718              fill: { color: cell.tag === 'th' || rowIdx === 0 ? colors.tableHeader : colors.tableCellBg },719              border: { pt: 0.5, color: colors.tableBorder },720              valign: 'middle' as const,721              bold: cell.tag === 'th',722            },723          }))724        );725        const tableH = Math.min(rows.length * 0.4, maxY - curY);726        slide.addTable(tableRows, {727          x: margin, y: curY, w: contentW, h: tableH,728          colW: Array(rows[0]?.length || 1).fill(contentW / (rows[0]?.length || 1)),729        });730        curY += tableH + 0.15;731      } catch {732        // Skip malformed table733      }734    }735  }736}737738// ── Generic Slide Content ──739740function addImageLayoutSlide(741  slide: PptxGenJS.Slide,742  parsed: ParsedSlide,743  startY: number,744  maxY: number,745  colors: ColorMap,746  contentW: number,747  margin: number,748  textStartY?: number,749) {750  const gap = 0.5;751  const imgW = (contentW - gap) / 2;752  const imgH = maxY - startY;753  const textW = imgW;754755  const leftX = margin;756  const rightX = margin + imgW + gap;757758  const imgX = parsed.imagePosition === 'left' ? leftX : rightX;759  const textX = parsed.imagePosition === 'left' ? rightX : leftX;760761  // Image top-aligned with content (parent handles vertical centering)762  const estimatedH = Math.min(imgW * 0.75, imgH);763  const imgY = startY;764  if (parsed.imageData) {765    slide.addImage({766      data: parsed.imageData,767      x: imgX, y: imgY, w: imgW, h: estimatedH,768      sizing: { type: 'contain', w: imgW, h: estimatedH },769    });770  } else {771    // Placeholder: grey rectangle with dashed accent border and centered text772    slide.addShape('rect' as any, {773      x: imgX, y: imgY, w: imgW, h: estimatedH,774      fill: { color: 'F0F0F0' },775      line: { color: colors.accent, width: 1.5, dashType: 'dash' },776    });777    slide.addText('[ Image ]', {778      x: imgX, y: imgY, w: imgW, h: estimatedH,779      fontSize: 16, color: '999999', fontFace: 'Calibri',780      align: 'center', valign: 'middle',781    });782  }783784  // Add text elements on the other side785  addElementsToSlide(slide, parsed.elements, textStartY ?? startY, colors, maxY, textW, textX);786}787788function addSlideContent(slide: PptxGenJS.Slide, parsed: ParsedSlide, bgColor: string, colors: ColorMap) {789  // Per-slide bgColor override, otherwise use theme background790  slide.background = { color: parsed.bgColor || bgColor };791792  // For image layouts, align title with text column793  const gap = 0.5;794  const halfW = (CONTENT_W - gap) / 2;795  const titleX = parsed.imagePosition796    ? (parsed.imagePosition === 'left' ? MARGIN + halfW + gap : MARGIN)797    : MARGIN;798  const titleW = parsed.imagePosition ? halfW : CONTENT_W;799800  const maxY = SLIDE_H - MARGIN;801  const titleH = parsed.title ? (CONTENT_Y - TITLE_Y) : 0;802  const elementsH = estimateElementsHeight(parsed.elements, parsed.imagePosition ? halfW : CONTENT_W);803  const textBlockH = titleH + elementsH;804  const availableH = maxY - TITLE_Y;805806  let offset: number;807  if (parsed.imagePosition) {808    const imgEstH = Math.min(halfW * 0.75, availableH);809    const blockH = Math.max(textBlockH, imgEstH);810    offset = Math.max(0, (availableH - blockH) / 2);811  } else {812    offset = Math.max(0, (availableH - textBlockH) / 2);813  }814815  const adjTitleY = TITLE_Y + offset;816  const adjContentY = adjTitleY + titleH;817818  if (parsed.title) {819    slide.addText(parsed.title, {820      x: titleX, y: adjTitleY, w: titleW, h: TITLE_H,821      fontSize: 28, bold: true, color: colors.title, fontFace: 'Calibri', valign: 'bottom',822    });823  }824825  const startY = parsed.title ? adjContentY : adjTitleY;826827  // Image-left / image-right layout828  if (parsed.imagePosition) {829    addImageLayoutSlide(slide, parsed, adjTitleY, maxY, colors, CONTENT_W, MARGIN, startY);830  } else {831    addElementsToSlide(slide, parsed.elements, startY, colors, maxY, CONTENT_W, MARGIN);832  }833834  if (parsed.notes) {835    slide.addNotes(parsed.notes);836  }837}838839// ── PP Branded Slides ──840841function addPPIntroContent(slide: PptxGenJS.Slide, parsed: ParsedSlide, globalStemma: string | null) {842  const stemma = parsed.stemmaData || globalStemma;843844  // Stemma — exact from original PPTX: x=1.475 y=2.253 w=2.573 h=3.273845  if (stemma) {846    slide.addImage({847      data: stemma,848      x: 1.475, y: 2.253, w: 2.573, h: 3.273,849      sizing: { type: 'contain', w: 2.573, h: 3.273 },850    });851  }852853  // Text box — original: x=3.934 y=3.206 w=9.044 h=1.851854  // We split into title (top half) and subtitles (bottom half) of that box855  const textX = stemma ? 3.934 : 2.0;856  const textW = stemma ? 9.044 : 10.0;857858  if (parsed.title) {859    slide.addText(parsed.title.toUpperCase(), {860      x: textX, y: 3.206, w: textW, h: 0.8,861      fontSize: 32, bold: true, italic: true,862      color: PP.navy, fontFace: 'Calibri', valign: 'bottom', align: 'center',863    });864  }865866  if (parsed.subtitles.length > 0) {867    const parts = parsed.subtitles.map((s, i) => ({868      text: s.toUpperCase() + (i < parsed.subtitles.length - 1 ? '\n' : ''),869      options: {870        fontSize: 24, bold: true, italic: true,871        color: PP.accent, fontFace: 'Calibri',872      },873    }));874    slide.addText(parts, {875      x: textX, y: 4.1, w: textW, h: 1.0, valign: 'top', align: 'center',876    });877  }878879  if (parsed.notes) slide.addNotes(parsed.notes);880}881882function addPPContentSlide(slide: PptxGenJS.Slide, parsed: ParsedSlide) {883  // White title in header bar area — original: x=0.620 y=0.258 w=7.281 h=0.351, 20pt884  if (parsed.headerTitle) {885    slide.addText(parsed.headerTitle.toUpperCase(), {886      x: 0.62, y: 0.258, w: 7.281, h: 0.351,887      fontSize: 20, bold: true, color: 'FFFFFF', fontFace: 'Calibri',888    });889  }890891  // For image layouts, align h2 title with text column892  const gap = 0.5;893  const halfW = (PP_CONTENT_W - gap) / 2;894  const textX = parsed.imagePosition895    ? (parsed.imagePosition === 'left' ? PP_MARGIN + halfW + gap : PP_MARGIN)896    : PP_MARGIN;897  const titleW = parsed.imagePosition ? halfW : PP_CONTENT_W;898899  const maxY = PP_FOOTER_Y - 0.2;900  const minTopY = 1.05;901  const titleBoxH = 0.7;902  const titleGap = 0.15;903  const titleBlockH = parsed.title ? titleBoxH + titleGap : 0;904  const elementsH = estimateElementsHeight(parsed.elements, parsed.imagePosition ? halfW : PP_CONTENT_W);905  const textBlockH = titleBlockH + elementsH;906  const availableH = maxY - minTopY;907908  // For image layouts, center based on the taller of text block vs image909  let offset: number;910  if (parsed.imagePosition) {911    const imgEstH = Math.min(halfW * 0.75, availableH);912    const blockH = Math.max(textBlockH, imgEstH);913    offset = Math.max(0, (availableH - blockH) / 2);914  } else {915    offset = Math.max(0, (availableH - textBlockH) / 2);916  }917918  // Navy h2 heading below header919  const titleY = minTopY + offset;920  let contentStartY = minTopY + offset;921  if (parsed.title) {922    slide.addText(parsed.title, {923      x: textX, y: titleY, w: titleW, h: titleBoxH,924      fontSize: 26, bold: true, color: PP.navy, fontFace: 'Calibri', valign: 'bottom',925    });926    contentStartY = titleY + titleBlockH;927  }928929  // Image-left / image-right layout930  if (parsed.imagePosition) {931    addImageLayoutSlide(slide, parsed, titleY, maxY, PP_COLORS, PP_CONTENT_W, PP_MARGIN, contentStartY);932  } else {933    addElementsToSlide(slide, parsed.elements, contentStartY, PP_COLORS, maxY, PP_CONTENT_W, PP_MARGIN);934  }935936  if (parsed.notes) slide.addNotes(parsed.notes);937}938939function addPPClosingContent(slide: PptxGenJS.Slide, parsed: ParsedSlide, globalStemma: string | null) {940  const stemma = parsed.stemmaData || globalStemma;941942  // Stemma — exact from original PPTX: x=3.242 y=2.227 w=2.573 h=3.273943  if (stemma) {944    slide.addImage({945      data: stemma,946      x: 3.242, y: 2.227, w: 2.573, h: 3.273,947      sizing: { type: 'contain', w: 2.573, h: 3.273 },948    });949  }950951  // Text — original: x=6.343 y=3.520 w=2.251 h=0.687, 40pt bold navy952  const text = parsed.closingText || parsed.title || 'GRAZIE';953  const textX = stemma ? 6.343 : 4.0;954955  slide.addText(text, {956    x: textX, y: 3.52, w: 3.5, h: 0.7,957    fontSize: 40, bold: true, color: PP.navy, fontFace: 'Calibri', valign: 'middle',958  });959960  if (parsed.notes) slide.addNotes(parsed.notes);961}962963// ── Main ──964965async function main() {966  let inputData = '';967  for await (const chunk of process.stdin) {968    inputData += chunk;969  }970971  const { inputs } = JSON.parse(inputData);972  const htmlContent: string = inputs.html_content || '';973  const title: string = inputs.title || 'Presentation';974  const themeInput: string = inputs.theme || '';975976  if (!htmlContent) {977    throw new Error("Required input 'html_content' not provided");978  }979980  // Parse HTML981  const $ = cheerio.load(htmlContent);982  const sections = $('div.slides > section');983984  if (sections.length === 0) {985    // Fallback: try top-level <section> elements986    const fallbackSections = $('section');987    if (fallbackSections.length === 0) {988      throw new Error('No <section> elements found in the HTML content');989    }990  }991992  const sectionElements = sections.length > 0 ? sections : $('section');993  console.error(`Parsing ${sectionElements.length} slides from HTML`);994995  // Theme detection and asset extraction996  const theme = detectTheme($, themeInput);997  const isPP = theme === 'polizia-postale';998  const themeConfig = getThemeConfig(theme);999  let ppAssets: PPAssets = { headerLeftData: null, logoData: null, stemmaData: null };10001001  // Create presentation1002  const pptx = new PptxGenJS();1003  pptx.title = title;1004  pptx.author = 'Emblema';1005  pptx.layout = 'LAYOUT_WIDE'; // 13.33 x 7.510061007  if (isPP) {1008    ppAssets = extractPPAssets($);1009    definePPMasters(pptx, ppAssets);1010    console.error(`PP theme — headerLeft: ${ppAssets.headerLeftData ? 'yes' : 'no'}, logo: ${ppAssets.logoData ? 'yes' : 'no'}, stemma: ${ppAssets.stemmaData ? 'yes' : 'no'}`);1011  }10121013  // Phase 1: Parse all sections into ParsedSlide array1014  const parsedSlides: ParsedSlide[] = [];1015  sectionElements.each((_i, section) => {1016    parsedSlides.push(parseSection($, section));1017  });10181019  // Phase 2: Resolve external image URLs in parallel1020  await Promise.all(parsedSlides.map(async (parsed) => {1021    if (parsed.imageSrcRaw && (parsed.imageSrcRaw.startsWith('http://') || parsed.imageSrcRaw.startsWith('https://'))) {1022      const data = await fetchImageAsBase64(parsed.imageSrcRaw);1023      if (data) {1024        parsed.imageData = data;1025        parsed.imageSrcRaw = null;1026      }1027    }1028  }));10291030  // Phase 3: Generate PPTX slides from resolved data1031  for (const parsed of parsedSlides) {1032    if (isPP) {1033      const masterMap: Record<SlideType, string> = {1034        intro: 'PP_INTRO',1035        content: 'PP_CONTENT',1036        closing: 'PP_CLOSING',1037        generic: 'PP_CONTENT',1038      };1039      const slide = pptx.addSlide({ masterName: masterMap[parsed.slideType] });10401041      switch (parsed.slideType) {1042        case 'intro':1043          addPPIntroContent(slide, parsed, ppAssets.stemmaData);1044          break;1045        case 'closing':1046          addPPClosingContent(slide, parsed, ppAssets.stemmaData);1047          break;1048        default:1049          addPPContentSlide(slide, parsed);1050          break;1051      }1052    } else {1053      const slide = pptx.addSlide();1054      addSlideContent(slide, parsed, themeConfig.background, themeConfig.colors);1055    }1056  }10571058  console.error(`Generated ${sectionElements.length} slides (theme: ${theme || 'generic'})`);10591060  // Write to output directory1061  const outputDir = '/data/output';1062  fs.mkdirSync(outputDir, { recursive: true });10631064  // Sanitize title for filename1065  const safeTitle = title.replace(/[^a-zA-Z0-9_\-\s]/g, '').trim().replace(/\s+/g, '_').slice(0, 50) || 'presentation';1066  const filename = `${safeTitle}.pptx`;1067  const outputPath = `${outputDir}/${filename}`;10681069  // PptxGenJS write to file1070  const buffer = await pptx.write({ outputType: 'nodebuffer' }) as Buffer;1071  fs.writeFileSync(outputPath, buffer);10721073  console.error(`PPTX written to ${outputPath} (${buffer.length} bytes)`);10741075  // Output result1076  const output = { pptx_file: filename };1077  console.log(JSON.stringify(output));1078}10791080main().catch((err) => {1081  console.error(JSON.stringify({1082    error: err.message || String(err),1083    errorType: err.name || 'Error',1084    traceback: err.stack || '',1085  }));1086  process.exit(1);1087});