$ 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,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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 // ── 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