UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

1,517 lines (1,516 loc) 78.8 kB
/** * Slide Renderers * * Standalone render functions for each slide type. * Extracted from SlideGenerator to keep functions under 300 lines. * * @module presentation/slideRenderers */ import { logger } from "../../utils/logger.js"; import { getBulletOptions, getSlideTypeFormatting } from "./constants.js"; import { bufferToDataUrl, calculateFontSize, createFormattedTextProps, hasMarkdownFormatting, parseMarkdownText, validateImageBuffer, } from "./utils.js"; // ============================================================================ // LAYOUT POSITIONS // ============================================================================ /** * Minimum gap between elements (in inches) */ const MIN_GAP = 0.1; /** * Default text fit option for pptxgenjs * 'shrink' = Shrink text to fit within the container */ const DEFAULT_TEXT_FIT = "shrink"; export const LAYOUT_POSITIONS = { margin: { x: 0.5, y: 0.4 }, title: { x: 0.5, y: 0.4, w: 9, h: 0.8 }, subtitle: { x: 0.5, y: 1.4, w: 9, h: 0.5 }, content: { x: 0.5, y: 1.4, w: 9, h: 3.8 }, contentFull: { x: 0.5, y: 1.4, w: 9, h: 3.8 }, contentLeft: { x: 0.5, y: 1.4, w: 4.2, h: 3.8 }, contentRight: { x: 5.3, y: 1.4, w: 4.2, h: 3.8 }, imageRight: { x: 5.3, y: 1.4, w: 4.2, h: 3.8 }, imageLeft: { x: 0.5, y: 1.4, w: 4.2, h: 3.8 }, imageFull: { x: 0, y: 0, w: 10, h: 5.625 }, imageCentered: { x: 2, y: 1.2, w: 6, h: 3.6 }, columnLeft: { x: 0.5, y: 1.4, w: 4.2, h: 3.8 }, columnRight: { x: 5.3, y: 1.4, w: 4.2, h: 3.8 }, col1: { x: 0.5, y: 1.4, w: 2.8, h: 3.8 }, col2: { x: 3.5, y: 1.4, w: 2.8, h: 3.8 }, col3: { x: 6.5, y: 1.4, w: 2.8, h: 3.8 }, chart: { x: 0.5, y: 1.4, w: 9, h: 3.8 }, statRow: { y: 2.2, h: 2.5 }, footer: { x: 0.5, y: 5.2, w: 9, h: 0.3 }, logo: { "top-left": { x: 0.3, y: 0.2 }, "top-right": { x: 8.5, y: 0.2 }, "bottom-left": { x: 0.3, y: 5.0 }, "bottom-right": { x: 8.5, y: 5.0 }, }, quote: { x: 1, y: 1.5, w: 8, h: 2.5 }, quoteAuthor: { x: 1, y: 4.2, w: 8, h: 0.5 }, }; /** * Map legend position from SlideContent values to pptxgenjs values */ const LEGEND_POS_MAP = { top: "t", bottom: "b", left: "l", right: "r", }; // ============================================================================ // HELPER FUNCTIONS - ENHANCED BACKGROUNDS // ============================================================================ /** Extract theme colors for background (strips # prefix) */ function extractBackgroundColors(theme) { return { primary: theme.colors.primary.replace("#", ""), secondary: theme.colors.secondary.replace("#", ""), accent: theme.colors.accent.replace("#", ""), background: theme.colors.background.replace("#", ""), }; } /** Blue to purple diagonal gradient effect */ function addGradientBlueBackground(slide, colors) { const { primary, secondary } = colors; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "E8F4FD" }, }); slide.addShape("rect", { x: 0, y: 0, w: 6, h: 3, fill: { color: primary, transparency: 85 }, }); slide.addShape("rect", { x: 0, y: 0, w: 4, h: 2, fill: { color: primary, transparency: 75 }, }); slide.addShape("rect", { x: 5, y: 3, w: 5, h: 2.625, fill: { color: secondary, transparency: 85 }, }); slide.addShape("rect", { x: 7, y: 4, w: 3, h: 1.625, fill: { color: secondary, transparency: 75 }, }); } /** Professional dark blue to teal gradient */ function addGradientCorporateBackground(slide, colors) { const { primary } = colors; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "F0F9FF" }, }); slide.addShape("rect", { x: 0, y: 0, w: 3, h: "100%", fill: { color: "1E3A5F", transparency: 92 }, }); slide.addShape("rect", { x: 0, y: 0, w: 1.5, h: "100%", fill: { color: "1E3A5F", transparency: 85 }, }); slide.addShape("rect", { x: 7, y: 0, w: 3, h: "100%", fill: { color: "2E7D32", transparency: 92 }, }); slide.addShape("rect", { x: 0, y: 0, w: "100%", h: 0.03, fill: { color: primary }, }); } /** Warm orange to pink gradient */ function addGradientWarmBackground(slide) { slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "FFF7ED" }, }); slide.addShape("rect", { x: 0, y: 0, w: "100%", h: 2.5, fill: { color: "EA580C", transparency: 90 }, }); slide.addShape("rect", { x: 0, y: 0, w: "100%", h: 1, fill: { color: "EA580C", transparency: 80 }, }); slide.addShape("rect", { x: 0, y: 4.5, w: "100%", h: 1.125, fill: { color: "DB2777", transparency: 88 }, }); } /** Dark theme with accent glow */ function addGradientDarkBackground(slide) { slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "0F172A" }, }); slide.addShape("ellipse", { x: -2, y: -1, w: 6, h: 4, fill: { color: "06B6D4", transparency: 85 }, }); slide.addShape("ellipse", { x: 7, y: 3, w: 5, h: 4, fill: { color: "A855F7", transparency: 85 }, }); } /** Very subtle professional gradient */ function addGradientSubtleBackground(slide, colors) { const { primary, secondary } = colors; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "FAFBFC" }, }); slide.addShape("rect", { x: 0, y: 0, w: "100%", h: 2, fill: { color: primary, transparency: 95 }, }); slide.addShape("rect", { x: 0, y: 4, w: "100%", h: 1.625, fill: { color: secondary, transparency: 96 }, }); slide.addShape("rect", { x: 0, y: 0, w: "100%", h: 0.02, fill: { color: primary }, }); } /** Geometric shapes pattern */ function addGeometricBackground(slide, colors) { const { primary, secondary, accent, background } = colors; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: background }, }); slide.addShape("rtTriangle", { x: 6, y: 3, w: 4, h: 2.625, fill: { color: primary, transparency: 90 }, rotate: 180, }); slide.addShape("rtTriangle", { x: 0, y: 0, w: 2.5, h: 2, fill: { color: secondary, transparency: 92 }, }); slide.addShape("ellipse", { x: 8.5, y: 0.2, w: 1, h: 1, fill: { color: accent, transparency: 85 }, }); slide.addShape("ellipse", { x: 0.5, y: 4.5, w: 0.6, h: 0.6, fill: { color: primary, transparency: 80 }, }); } /** Large corner accent shapes */ function addCornerAccentBackground(slide, colors) { const { primary, secondary, background } = colors; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: background }, }); slide.addShape("rect", { x: 7, y: 0, w: 3, h: 1.8, fill: { color: primary, transparency: 88 }, }); slide.addShape("rect", { x: 8.5, y: 0, w: 1.5, h: 1, fill: { color: primary, transparency: 70 }, }); slide.addShape("rect", { x: 0, y: 4, w: 2.5, h: 1.625, fill: { color: secondary, transparency: 88 }, }); slide.addShape("rect", { x: 0, y: 4.8, w: 1.2, h: 0.825, fill: { color: secondary, transparency: 70 }, }); } /** Curved wave pattern effect */ function addWaveBackground(slide, colors) { const { primary, secondary, accent } = colors; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "F8FAFC" }, }); slide.addShape("ellipse", { x: -3, y: 4, w: 8, h: 3, fill: { color: primary, transparency: 92 }, }); slide.addShape("ellipse", { x: 2, y: 4.5, w: 7, h: 2.5, fill: { color: secondary, transparency: 93 }, }); slide.addShape("ellipse", { x: 6, y: 4.2, w: 6, h: 3, fill: { color: accent, transparency: 94 }, }); slide.addShape("rect", { x: 0, y: 0, w: "100%", h: 0.03, fill: { color: primary }, }); } /** Split diagonal background */ function addSplitBackground(slide, colors) { const { primary, secondary, background } = colors; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: background }, }); slide.addShape("rect", { x: 5, y: -1, w: 6, h: 8, fill: { color: primary, transparency: 94 }, rotate: 15, }); slide.addShape("rect", { x: 7, y: -1, w: 5, h: 8, fill: { color: secondary, transparency: 92 }, rotate: 15, }); } /** Simple solid with subtle accent */ function addSolidBackground(slide, colors) { const { primary, background } = colors; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: background }, }); slide.addShape("rect", { x: 0, y: 0, w: "100%", h: 0.02, fill: { color: primary }, }); } /** * Add enhanced background with gradient or multi-color designs * Creates visually appealing slides with sophisticated styling */ export function addEnhancedBackground(slide, theme, style = "gradient-subtle") { const colors = extractBackgroundColors(theme); switch (style) { case "gradient-blue": addGradientBlueBackground(slide, colors); break; case "gradient-corporate": addGradientCorporateBackground(slide, colors); break; case "gradient-warm": addGradientWarmBackground(slide); break; case "gradient-dark": addGradientDarkBackground(slide); break; case "gradient-subtle": addGradientSubtleBackground(slide, colors); break; case "geometric": addGeometricBackground(slide, colors); break; case "corner-accent": addCornerAccentBackground(slide, colors); break; case "wave": addWaveBackground(slide, colors); break; case "split": addSplitBackground(slide, colors); break; case "solid": default: addSolidBackground(slide, colors); break; } } /** * Add subtle colored background to slides (legacy - use addEnhancedBackground for more options) */ export function addColoredBackground(slide, theme, opacity = 0.05) { const primaryColor = theme.colors.primary.replace("#", ""); slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: primaryColor, transparency: (1 - opacity) * 100, // pptx transparency is 0-100 where 100 is fully transparent }, }); } /** * Add modern card-style container with border */ export function addCardContainer(slide, pos, theme, borderWidth = 2) { // Card background with subtle border slide.addShape("rect", { x: pos.x, y: pos.y, w: pos.w, h: pos.h, fill: { color: theme.colors.background.replace("#", ""), transparency: 0, // Fully opaque }, line: { color: theme.colors.muted.replace("#", ""), width: borderWidth, }, }); } /** * Add accent bar for visual hierarchy */ export function addAccentBar(slide, pos, theme, position = "left") { const barConfig = { left: { x: pos.x, y: pos.y, w: 0.1, h: pos.h }, top: { x: pos.x, y: pos.y, w: pos.w, h: 0.1 }, bottom: { x: pos.x, y: pos.y + pos.h - 0.1, w: pos.w, h: 0.1 }, }; slide.addShape("rect", { ...barConfig[position], fill: { color: theme.colors.primary.replace("#", "") }, }); } /** * Calculate text width in inches based on font size and character count. * Uses typographic metrics for Arial font family. * * @param text - The text string to measure * @param fontSize - Font size in points * @param isBold - Whether the text is bold (adds ~10% width) * @returns Width in inches */ export function calculateTextWidth(text, fontSize, isBold = false) { // For proportional fonts like Arial: // - Average character width is approximately 0.42 * em-height for normal text // - 1 point = 1/72 inch, so em-height = fontSize / 72 inches // - Bold text is approximately 10% wider const emHeight = fontSize / 72; // em-height in inches const avgCharWidthRatio = 0.42; // Average char width as ratio of em-height (reduced for tighter fit) const boldMultiplier = isBold ? 1.1 : 1.0; const charWidth = emHeight * avgCharWidthRatio * boldMultiplier; return text.length * charWidth; } export function addTitle(slide, title, theme, showUnderline = true) { slide.addText(title, { x: LAYOUT_POSITIONS.title.x, y: LAYOUT_POSITIONS.title.y, w: LAYOUT_POSITIONS.title.w, h: LAYOUT_POSITIONS.title.h, fontSize: theme.fonts.sizes.heading, fontFace: theme.fonts.heading, color: theme.colors.text.replace("#", ""), bold: true, fit: DEFAULT_TEXT_FIT, }); if (showUnderline) { // Calculate dynamic underline width based on actual title text and font size const textWidth = calculateTextWidth(title, theme.fonts.sizes.heading, true); // Constrain to reasonable bounds (min 1.5", max fits within slide margins) const maxWidth = LAYOUT_POSITIONS.title.w; // 9 inches - same as title container const minWidth = 1.0; const calculatedWidth = Math.min(maxWidth, Math.max(minWidth, textWidth)); slide.addShape("rect", { x: LAYOUT_POSITIONS.title.x, y: LAYOUT_POSITIONS.title.y + LAYOUT_POSITIONS.title.h + 0.1, w: calculatedWidth, h: 0.03, fill: { color: theme.colors.primary.replace("#", "") }, }); } } /** * Add individual bullet items (each as separate text element) * This creates cleaner spacing and no bounding box around bullets * Useful for column layouts and when you want more control over spacing */ export function addIndividualBullets(options) { const { slide, bullets, startX, startY, width, theme, itemSpacing = 0.45, } = options; if (!bullets || bullets.length === 0) { return; } // Ensure minimum gap between bullets const effectiveSpacing = Math.max(itemSpacing, MIN_GAP * 2); bullets.forEach((bullet, index) => { // Skip invalid bullets if (!bullet || !bullet.text) { return; } const isBold = bullet.bold || bullet.emphasis || false; const color = bullet.color?.replace("#", "") || theme.colors.text.replace("#", ""); // Check if bullet text contains markdown formatting if (hasMarkdownFormatting(bullet.text)) { // Parse markdown and create rich text runs with bullet prefix const segments = parseMarkdownText(bullet.text); const textRuns = [ { text: "• ", options: { fontSize: theme.fonts.sizes.body, fontFace: theme.fonts.body, color, }, }, ...createFormattedTextProps(segments, { fontSize: theme.fonts.sizes.body, fontFace: theme.fonts.body, color, baseBold: isBold, }), ]; slide.addText(textRuns, { x: startX, y: startY + index * effectiveSpacing, w: width, h: Math.max(0.4, effectiveSpacing - MIN_GAP), fit: DEFAULT_TEXT_FIT, }); } else { // No markdown - use simple text const bulletText = `• ${bullet.text}`; slide.addText(bulletText, { x: startX, y: startY + index * effectiveSpacing, w: width, h: Math.max(0.4, effectiveSpacing - MIN_GAP), fontSize: theme.fonts.sizes.body, fontFace: theme.fonts.body, color, bold: isBold, fit: DEFAULT_TEXT_FIT, }); } }); } /** * Add bullets to a slide with hybrid formatting * * Priority: bullet-level > slide-level > type-defaults > theme-defaults * * @param slide - The pptxgenjs slide * @param bullets - Array of bullet points (normalized) * @param pos - Position and dimensions * @param theme - Presentation theme * @param slideType - Slide type for default formatting (optional) */ export function addBullets(slide, bullets, pos, theme, slideType = "content", options) { if (!bullets || bullets.length === 0) { return; } // Use individual bullets for cleaner spacing (no bounding box) if (options?.useIndividualBullets) { addIndividualBullets({ slide, bullets, startX: pos.x, startY: pos.y, width: pos.w, theme, itemSpacing: options.itemSpacing ?? 0.45, }); return; } // Get hardcoded defaults for this slide type const typeDefaults = getSlideTypeFormatting(slideType); // Calculate base font size based on bullet count (if not overridden) const calculatedFontSize = calculateFontSize(bullets.length, typeDefaults.baseFontSize || theme.fonts.sizes.body); const textLines = []; bullets.forEach((bullet) => { // Skip invalid bullets if (!bullet || !bullet.text) { return; } // Priority: bullet-level > type-defaults const bulletStyle = bullet.bulletStyle || typeDefaults.bulletStyle || "disc"; const fontSize = bullet.fontSize || calculatedFontSize; const color = bullet.color?.replace("#", "") || theme.colors.text.replace("#", ""); const isBold = bullet.bold ?? bullet.emphasis ?? false; // Get pptxgenjs bullet options based on style // If bullet has custom icon, use that instead of style let bulletOptions; if (bullet.icon) { bulletOptions = { code: bullet.icon }; } else { bulletOptions = getBulletOptions(bulletStyle); } // Check if bullet text contains markdown formatting (**bold**, *italic*) if (hasMarkdownFormatting(bullet.text)) { // Parse markdown and create rich text runs const segments = parseMarkdownText(bullet.text); const textRuns = createFormattedTextProps(segments, { fontSize, fontFace: theme.fonts.body, color, baseBold: isBold, }); textLines.push({ text: textRuns, options: { bullet: bulletOptions, indentLevel: 0, paraSpaceBefore: 6, paraSpaceAfter: 6, }, }); } else { // No markdown - use simple text textLines.push({ text: bullet.text, options: { bullet: bulletOptions, fontSize, fontFace: theme.fonts.body, color, bold: isBold, indentLevel: 0, paraSpaceBefore: 6, // Add space before each paragraph (in points) paraSpaceAfter: 6, // Add space after each paragraph (in points) }, }); } // Handle sub-bullets (2pt smaller than main bullet) if (bullet.subBullets && bullet.subBullets.length > 0) { bullet.subBullets.forEach((subBullet) => { // Check for markdown in sub-bullets too if (hasMarkdownFormatting(subBullet)) { const segments = parseMarkdownText(subBullet); const textRuns = createFormattedTextProps(segments, { fontSize: Math.max(10, fontSize - 2), fontFace: theme.fonts.body, color: theme.colors.muted.replace("#", ""), }); textLines.push({ text: textRuns, options: { bullet: true, indentLevel: 1, paraSpaceBefore: 3, paraSpaceAfter: 3, }, }); } else { textLines.push({ text: subBullet, options: { bullet: true, fontSize: Math.max(10, fontSize - 2), fontFace: theme.fonts.body, color: theme.colors.muted.replace("#", ""), indentLevel: 1, paraSpaceBefore: 3, paraSpaceAfter: 3, }, }); } }); } }); slide.addText(textLines, { x: pos.x, y: pos.y, w: pos.w, h: pos.h, valign: "top", fit: DEFAULT_TEXT_FIT, }); } export function addImage(slide, imageBuffer, pos) { // Validate and get data URL const validation = validateImageBuffer(imageBuffer); if (!validation.isValid && validation.format === "") { logger.warn("[addImage] Invalid image buffer", { error: validation.error }); return; } if (!validation.isValid) { logger.warn("[addImage] Image validation warning, attempting to add anyway", { error: validation.error, mimeType: validation.mimeType, }); } const dataUrl = bufferToDataUrl(imageBuffer); if (!dataUrl) { logger.warn("[addImage] Failed to convert buffer to data URL"); return; } try { slide.addImage({ data: dataUrl, x: pos.x, y: pos.y, w: pos.w, h: pos.h, sizing: { type: "cover", w: pos.w, h: pos.h }, }); } catch (error) { logger.error("[addImage] Failed to add image to slide", { error: error instanceof Error ? error.message : String(error), }); } } export function getPptxChartType(slideType) { switch (slideType) { case "chart-bar": return "bar"; case "chart-line": return "line"; case "chart-pie": return "pie"; case "chart-area": return "area"; default: return "bar"; } } // ============================================================================ // SLIDE RENDERERS - OPENING/CLOSING // ============================================================================ export function renderTitleSlide(slide, title, content, theme, imageBuffer) { const layout = content.layoutOptions || {}; const titleOpts = layout.title || {}; const subtitleOpts = layout.subtitle || {}; const bgOpts = layout.background || {}; // Background: image > custom color > theme default (white) const imageDataUrl = imageBuffer ? bufferToDataUrl(imageBuffer) : null; if (imageDataUrl) { slide.background = { data: imageDataUrl, }; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "000000", transparency: 40 }, }); } else if (bgOpts.color || bgOpts.useThemePrimary || bgOpts.useThemeSecondary) { const bgColor = bgOpts.useThemePrimary ? theme.colors.primary : bgOpts.useThemeSecondary ? theme.colors.secondary : bgOpts.color || theme.colors.background; slide.addShape("rect", { x: 0, y: 0, w: 10, h: 5.625, fill: { color: bgColor.replace("#", "") }, }); } // Determine text colors based on background const hasColoredBg = imageBuffer || bgOpts.color || bgOpts.useThemePrimary || bgOpts.useThemeSecondary; const defaultTitleColor = hasColoredBg ? "FFFFFF" : theme.colors.text.replace("#", ""); const defaultSubtitleColor = hasColoredBg ? "FFFFFF" : theme.colors.muted.replace("#", ""); // Title slide.addText(title, { x: titleOpts.x ?? 0.5, y: titleOpts.y ?? 2, w: titleOpts.w ?? 9, h: titleOpts.h ?? 1.2, fontSize: titleOpts.fontSize ?? theme.fonts.sizes.title, fontFace: theme.fonts.heading, color: titleOpts.color?.replace("#", "") ?? defaultTitleColor, align: titleOpts.align ?? "center", bold: true, fit: DEFAULT_TEXT_FIT, }); // Subtitle if (content.subtitle) { slide.addText(content.subtitle, { x: subtitleOpts.x ?? 0.5, y: subtitleOpts.y ?? 3.3, w: subtitleOpts.w ?? 9, h: subtitleOpts.h ?? 0.6, fontSize: subtitleOpts.fontSize ?? theme.fonts.sizes.subtitle, fontFace: theme.fonts.body, color: subtitleOpts.color?.replace("#", "") ?? defaultSubtitleColor, align: subtitleOpts.align ?? "center", fit: DEFAULT_TEXT_FIT, }); } } export function renderSectionHeaderSlide(slide, title, content, theme) { const layout = content.layoutOptions || {}; const sectionNumOpts = layout.sectionNumber || {}; const titleOpts = layout.title || {}; const subtitleOpts = layout.subtitle || {}; const bgOpts = layout.background || {}; // Apply background if specified if (bgOpts.color) { slide.addShape("rect", { x: 0, y: 0, w: 10, h: 5.625, fill: { color: bgOpts.color.replace("#", "") }, }); } // Section number if (content.sectionNumber) { const isWatermark = sectionNumOpts.style === "watermark"; const isSmall = sectionNumOpts.style === "small"; slide.addText(String(content.sectionNumber).padStart(2, "0"), { x: sectionNumOpts.x ?? (isWatermark ? 5.5 : 0.5), y: sectionNumOpts.y ?? (isWatermark ? 0.5 : 1.5), w: isWatermark ? 5 : 2, h: isWatermark ? 4 : 1, fontSize: sectionNumOpts.fontSize ?? (isWatermark ? 200 : isSmall ? 32 : 72), fontFace: theme.fonts.heading, color: theme.colors.primary.replace("#", ""), bold: true, align: isWatermark ? "right" : "left", transparency: isWatermark ? 70 : 0, }); } // Title slide.addText(title, { x: titleOpts.x ?? 0.5, y: titleOpts.y ?? 2.5, w: titleOpts.w ?? 9, h: titleOpts.h ?? 1, fontSize: titleOpts.fontSize ?? theme.fonts.sizes.title, fontFace: theme.fonts.heading, color: theme.colors.text.replace("#", ""), bold: true, align: titleOpts.align ?? "left", }); // Subtitle (if provided) if (content.subtitle) { slide.addText(content.subtitle, { x: subtitleOpts.x ?? 0.5, y: subtitleOpts.y ?? 3.6, w: subtitleOpts.w ?? 8, h: subtitleOpts.h ?? 0.6, fontSize: subtitleOpts.fontSize ?? theme.fonts.sizes.subtitle, fontFace: theme.fonts.body, color: theme.colors.muted.replace("#", ""), align: subtitleOpts.align ?? "left", }); } } export function renderThankYouSlide(slide, title, content, theme, imageBuffer) { if (imageBuffer) { const dataUrl = bufferToDataUrl(imageBuffer); if (dataUrl) { slide.background = { data: dataUrl, }; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "000000", transparency: 40 }, }); } } const textColor = imageBuffer ? "FFFFFF" : theme.colors.text.replace("#", ""); slide.addText(title || "Thank You!", { x: 0.5, y: 1.8, w: 9, h: 1, fontSize: theme.fonts.sizes.title, fontFace: theme.fonts.heading, color: textColor, align: "center", bold: true, fit: DEFAULT_TEXT_FIT, }); if (content.cta) { slide.addText(content.cta, { x: 0.5, y: 2.9, w: 9, h: 0.5, fontSize: theme.fonts.sizes.subtitle, fontFace: theme.fonts.body, color: imageBuffer ? "FFFFFF" : theme.colors.muted.replace("#", ""), align: "center", fit: DEFAULT_TEXT_FIT, }); } if (content.contactInfo) { const contactLines = []; if (content.contactInfo.email) { contactLines.push(`Email: ${content.contactInfo.email}`); } if (content.contactInfo.website) { contactLines.push(`Web: ${content.contactInfo.website}`); } if (content.contactInfo.phone) { contactLines.push(`Phone: ${content.contactInfo.phone}`); } if (contactLines.length > 0) { slide.addText(contactLines.join(" • "), { x: 0.5, y: 4.2, w: 9, h: 0.4, fontSize: theme.fonts.sizes.body, fontFace: theme.fonts.body, color: textColor, align: "center", fit: DEFAULT_TEXT_FIT, }); } if (content.contactInfo.social && Array.isArray(content.contactInfo.social) && content.contactInfo.social.length > 0) { const socialText = content.contactInfo.social .map((s) => `${s.platform || ""}: ${s.handle || ""}`) .join(" • "); slide.addText(socialText, { x: 0.5, y: 4.7, w: 9, h: 0.3, fontSize: theme.fonts.sizes.caption, fontFace: theme.fonts.body, color: imageBuffer ? "CCCCCC" : theme.colors.muted.replace("#", ""), align: "center", }); } } } // ============================================================================ // SLIDE RENDERERS - CONTENT // ============================================================================ export function renderContentSlide(options) { const { slide, title, content, layout, theme, imageBuffer, slideType = "content", } = options; addTitle(slide, title, theme); const hasImage = imageBuffer && layout.includes("image"); const contentPos = hasImage ? layout.includes("left") ? LAYOUT_POSITIONS.contentRight : LAYOUT_POSITIONS.contentLeft : LAYOUT_POSITIONS.contentFull; if (content.bullets && content.bullets.length > 0) { addBullets(slide, content.bullets, contentPos, theme, slideType); } else if (content.body) { slide.addText(content.body, { x: contentPos.x, y: contentPos.y, w: contentPos.w, h: contentPos.h, fontSize: theme.fonts.sizes.body, fontFace: theme.fonts.body, color: theme.colors.text.replace("#", ""), valign: "top", }); } if (imageBuffer && hasImage) { const imagePos = layout.includes("left") ? LAYOUT_POSITIONS.imageLeft : LAYOUT_POSITIONS.imageRight; addImage(slide, imageBuffer, imagePos); } } export function renderImageSlide(slide, title, content, layout, theme, imageBuffer) { if (layout === "image-full-overlay" && imageBuffer) { const dataUrl = bufferToDataUrl(imageBuffer); if (dataUrl) { slide.background = { data: dataUrl, }; slide.addShape("rect", { x: 0, y: 0, w: "100%", h: "100%", fill: { color: "000000", transparency: 50 }, }); slide.addText(title, { x: 0.5, y: 4.2, w: 9, h: 1, fontSize: theme.fonts.sizes.heading, fontFace: theme.fonts.heading, color: "FFFFFF", bold: true, fit: DEFAULT_TEXT_FIT, }); } else { // Fallback to standard layout if image is invalid addTitle(slide, title, theme); } } else if (layout === "image-centered" || layout === "image-full-overlay") { addTitle(slide, title, theme); if (imageBuffer) { addImage(slide, imageBuffer, LAYOUT_POSITIONS.imageCentered); } else { // Fallback when no image is available - show placeholder with content slide.addShape("rect", { x: 1.5, y: 1.8, w: 7, h: 3.5, fill: { color: theme.colors.muted.replace("#", ""), transparency: 90 }, line: { color: theme.colors.muted.replace("#", ""), width: 1, dashType: "dash", }, }); slide.addText("📷", { x: 1.5, y: 2.8, w: 7, h: 1, fontSize: 48, align: "center", valign: "middle", fit: DEFAULT_TEXT_FIT, }); slide.addText("Image not available", { x: 1.5, y: 3.8, w: 7, h: 0.5, fontSize: 12, fontFace: theme.fonts.body, color: theme.colors.muted.replace("#", ""), align: "center", fit: DEFAULT_TEXT_FIT, }); } if (content.caption) { slide.addText(content.caption, { x: 0.5, y: 5, w: 9, h: 0.4, fontSize: theme.fonts.sizes.caption, fontFace: theme.fonts.body, color: theme.colors.muted.replace("#", ""), align: "center", fit: DEFAULT_TEXT_FIT, }); } } else { renderContentSlide({ slide, title, content, layout, theme, imageBuffer }); } } // ============================================================================ // SLIDE RENDERERS - COLUMNS // ============================================================================ // ============================================================================ // SLIDE RENDERERS - COLUMNS (Generic) // ============================================================================ /** * Generic column slide renderer * Handles 2, 3, or more columns dynamically * Renders each bullet as separate element (comparison-style) */ export function renderColumnSlide(slide, title, columns, theme, options) { addTitle(slide, title, theme, false); const opts = { headerY: options?.headerY ?? 1.3, headerHeight: options?.headerHeight ?? 0.5, bulletsStartY: options?.bulletsStartY ?? 2.0, columnGap: options?.columnGap ?? 0.3, highlightFirstColumn: options?.highlightFirstColumn ?? true, }; const numColumns = columns.length; if (numColumns === 0) { return; } // Calculate dynamic column widths const totalWidth = 9; // Available width (10 - 0.5 margin on each side) const totalGaps = (numColumns - 1) * opts.columnGap; const columnWidth = (totalWidth - totalGaps) / numColumns; const startX = 0.5; columns.forEach((col, index) => { if (!col) { return; } const x = startX + index * (columnWidth + opts.columnGap); const isPrimary = opts.highlightFirstColumn && index === 0; // Render header box if title exists if (col.title) { slide.addShape("roundRect", { x, y: opts.headerY, w: columnWidth, h: opts.headerHeight, fill: { color: isPrimary ? theme.colors.primary.replace("#", "") : theme.colors.muted.replace("#", ""), }, rectRadius: 0.05, }); // Adjust font size based on column count const headerFontSize = numColumns <= 2 ? theme.fonts.sizes.body : theme.fonts.sizes.body - 2; slide.addText(col.title, { x, y: opts.headerY, w: columnWidth, h: opts.headerHeight, fontSize: headerFontSize, fontFace: theme.fonts.heading, color: isPrimary ? theme.colors.textOnPrimary.replace("#", "") : theme.colors.text.replace("#", ""), align: "center", bold: true, valign: "middle", fit: DEFAULT_TEXT_FIT, }); } // Render bullets individually (like comparison slide) if (col.bullets && col.bullets.length > 0) { addIndividualBullets({ slide, bullets: col.bullets, startX: x, startY: opts.bulletsStartY, width: columnWidth, theme, }); } }); } export function renderTwoColumnSlide(slide, title, content, layout, theme, imageBuffer) { const columns = []; if (content.leftColumn) { columns.push(content.leftColumn); } if (content.rightColumn) { columns.push(content.rightColumn); } if (columns.length > 0) { renderColumnSlide(slide, title, columns, theme, { highlightFirstColumn: true, }); } // Handle image if right column has no bullets if (imageBuffer && !content.rightColumn?.bullets) { addImage(slide, imageBuffer, LAYOUT_POSITIONS.columnRight); } } export function renderThreeColumnSlide(slide, title, content, theme) { const columns = []; if (content.leftColumn) { columns.push(content.leftColumn); } if (content.centerColumn) { columns.push(content.centerColumn); } if (content.rightColumn) { columns.push(content.rightColumn); } if (columns.length > 0) { renderColumnSlide(slide, title, columns, theme, { highlightFirstColumn: true, }); } } // ============================================================================ // SLIDE RENDERERS - DATA VISUALIZATION // ============================================================================ export function renderQuoteSlide(slide, title, content, theme) { slide.addText("\u201C", { x: 0.5, y: 1, w: 1, h: 1, fontSize: 120, fontFace: "Georgia", color: theme.colors.primary.replace("#", ""), fit: DEFAULT_TEXT_FIT, }); if (content.quote) { slide.addText(content.quote, { x: LAYOUT_POSITIONS.quote.x, y: LAYOUT_POSITIONS.quote.y, w: LAYOUT_POSITIONS.quote.w, h: LAYOUT_POSITIONS.quote.h, fontSize: theme.fonts.sizes.heading, fontFace: "Georgia", color: theme.colors.text.replace("#", ""), italic: true, valign: "middle", fit: DEFAULT_TEXT_FIT, }); } if (content.quoteAuthor) { let authorText = `— ${content.quoteAuthor}`; if (content.quoteAuthorTitle) { authorText += `, ${content.quoteAuthorTitle}`; } slide.addText(authorText, { x: LAYOUT_POSITIONS.quoteAuthor.x, y: LAYOUT_POSITIONS.quoteAuthor.y, w: LAYOUT_POSITIONS.quoteAuthor.w, h: LAYOUT_POSITIONS.quoteAuthor.h, fontSize: theme.fonts.sizes.body, fontFace: theme.fonts.body, color: theme.colors.muted.replace("#", ""), align: "right", fit: DEFAULT_TEXT_FIT, }); } } export function renderStatisticsSlide(slide, title, content, theme) { addTitle(slide, title, theme); if (!content.statistics || content.statistics.length === 0) { return; } const stats = content.statistics.slice(0, 4); const statWidth = 9 / stats.length; stats.forEach((stat, index) => { const x = 0.5 + index * statWidth; slide.addText(stat.value, { x, y: LAYOUT_POSITIONS.statRow.y, w: statWidth - 0.2, h: 1.2, fontSize: 48, fontFace: theme.fonts.heading, color: theme.colors.primary.replace("#", ""), bold: true, align: "center", fit: DEFAULT_TEXT_FIT, }); slide.addText(stat.label, { x, y: LAYOUT_POSITIONS.statRow.y + 1.3, w: statWidth - 0.2, h: 0.5, fontSize: theme.fonts.sizes.body, fontFace: theme.fonts.body, color: theme.colors.text.replace("#", ""), align: "center", fit: DEFAULT_TEXT_FIT, }); if (stat.change || stat.trend) { const trendColor = stat.trend === "up" ? "22C55E" : stat.trend === "down" ? "EF4444" : theme.colors.muted.replace("#", ""); // Use simple text indicators instead of Unicode arrows for better compatibility const trendSymbol = stat.trend === "up" ? "(+)" : stat.trend === "down" ? "(-)" : ""; slide.addText(`${trendSymbol} ${stat.change || ""}`, { x, y: LAYOUT_POSITIONS.statRow.y + 1.8, w: statWidth - 0.2, h: 0.4, fontSize: theme.fonts.sizes.caption, fontFace: theme.fonts.body, color: trendColor, align: "center", fit: DEFAULT_TEXT_FIT, }); } }); } export function renderChartSlide(slide, title, content, chartType, theme) { addTitle(slide, title, theme); // Validate chartData exists with valid series if (!content.chartData || !content.chartData.series || content.chartData.series.length === 0) { // Add placeholder text for empty chart slide.addText("No chart data available", { x: LAYOUT_POSITIONS.chart.x, y: LAYOUT_POSITIONS.chart.y + 1, w: LAYOUT_POSITIONS.chart.w, h: 1, fontSize: 18, color: theme.colors.muted.replace("#", ""), align: "center", fit: DEFAULT_TEXT_FIT, }); return; } // Filter out invalid series (must have name, labels array, and values array) const validSeries = content.chartData.series.filter((series) => series.name && Array.isArray(series.labels) && series.labels.length > 0 && Array.isArray(series.values) && series.values.length > 0); if (validSeries.length === 0) { slide.addText("Invalid chart data format", { x: LAYOUT_POSITIONS.chart.x, y: LAYOUT_POSITIONS.chart.y + 1, w: LAYOUT_POSITIONS.chart.w, h: 1, fontSize: 18, color: theme.colors.muted.replace("#", ""), align: "center", fit: DEFAULT_TEXT_FIT, }); return; } const pptxChartType = getPptxChartType(chartType); const chartData = validSeries.map((series) => ({ name: series.name, labels: series.labels, values: series.values, })); slide.addChart(pptxChartType, chartData, { x: LAYOUT_POSITIONS.chart.x, y: LAYOUT_POSITIONS.chart.y, w: LAYOUT_POSITIONS.chart.w, h: LAYOUT_POSITIONS.chart.h, showTitle: !!content.chartData.title, title: content.chartData.title, showLegend: content.chartData.legendPosition !== "none", legendPos: LEGEND_POS_MAP[content.chartData.legendPosition || "bottom"] || "b", showValue: content.chartData.showLabels, chartColors: [ theme.colors.primary.replace("#", ""), theme.colors.secondary.replace("#", ""), theme.colors.accent.replace("#", ""), ], }); } export function renderTableSlide(slide, title, content, theme) { addTitle(slide, title, theme); if (!content.tableData) { return; } const { headers, rows, hasHeader } = content.tableData; const tableRows = []; if (hasHeader && headers) { tableRows.push(headers.map((header) => ({ text: header, options: { bold: true, fill: { color: theme.colors.primary.replace("#", "") }, color: theme.colors.textOnPrimary.replace("#", ""), align: "center", }, }))); } rows.forEach((row, rowIndex) => { tableRows.push(row.map((cell) => ({ text: cell.text, options: { fill: { color: rowIndex % 2 === 0 ? "F8FAFC" : "FFFFFF" }, color: theme.colors.text.replace("#", ""), align: (cell.align || "left"), }, }))); }); slide.addTable(tableRows, { x: LAYOUT_POSITIONS.chart.x, y: LAYOUT_POSITIONS.chart.y, w: LAYOUT_POSITIONS.chart.w, colW: Array(headers?.length || rows[0]?.length || 1).fill(LAYOUT_POSITIONS.chart.w / (headers?.length || rows[0]?.length || 1)), fontSize: theme.fonts.sizes.body - 2, fontFace: theme.fonts.body, border: { pt: 0.5, color: "E2E8F0" }, autoPage: true, }); if (content.tableData.caption) { slide.addText(content.tableData.caption, { x: 0.5, y: 5.1, w: 9, h: 0.3, fontSize: theme.fonts.sizes.caption, fontFace: theme.fonts.body, color: theme.colors.muted.replace("#", ""), align: "center", }); } } // ============================================================================ // SLIDE RENDERERS - PROCESS & TIMELINE // ============================================================================ export function renderTimelineSlide(slide, title, content, theme) { addTitle(slide, title, theme); if (!content.timeline?.items) { return; } const items = content.timeline.items.slice(0, 5); const isHorizontal = content.timeline.orientation !== "vertical"; if (isHorizontal) { renderHorizontalTimeline(slide, items, theme); } else { renderVerticalTimeline(slide, items, theme); } } function renderHorizontalTimeline(slide, items, theme) { const itemWidth = 8 / items.length; const lineY = 2.8; slide.addShape("rect", { x: 1, y: lineY, w: 8, h: 0.02, fill: { color: theme.colors.primary.replace("#", "") }, }); items.forEach((item, index) => { const x = 1 + index * itemWidth + itemWidth / 2 - 0.15; slide.addShape("ellipse", { x, y: lineY - 0.15, w: 0.3, h: 0.3, fill: { color: theme.colors.primary.replace("#", "") }, }); slide.addText(item.date, { x: x - itemWidth / 2 + 0.15, y: lineY - 0.8, w: itemWidth, h: 0.4, fontSize: theme.fonts.sizes.caption, fontFace: theme.fonts.body, color: theme.colors.primary.replace("#", ""), align: "center", bold: true, fit: DEFAULT_TEXT_FIT, }); slide.addText(item.title, { x: x - itemWidth / 2 + 0.15, y: lineY + 0.4, w: itemWidth, h: 0.4, fontSize: theme.fonts.sizes.body, fontFace: theme.fonts.heading, color: theme.colors.text.replace("#", ""), align: "center", bold: true,