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