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