@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.7 kB
JavaScript
/**
* 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,