marp2pptx
Version:
Convert Marp markdown presentations to theme-compatible, editable PowerPoint files. Single command, zero Python dependencies, fully editable output.
348 lines (306 loc) • 8.2 kB
JavaScript
/**
* PowerPoint Generator
* Creates theme-compatible, editable PPTX files with placeholders
*/
const pptxgen = require('pptxgenjs');
const { JSDOM } = require('jsdom');
// Color scheme
const COLORS = {
primary: 'E67E22', // Orange for headings
accent: '16A085', // Teal for accents
text: '2C3E50', // Dark gray for body text
muted: '7F8C8D', // Muted gray
codeBg: 'F5F5F5' // Code background
};
/**
* Parse HTML slide to extract elements
* @param {string} html - HTML content
* @returns {Object} Object with elements array and isLead boolean
*/
function parseHTMLSlide(html) {
const dom = new JSDOM(html);
const doc = dom.window.document;
const body = doc.querySelector('body');
// Determine if it's a lead/centered slide
const isLead = body.className.includes('center');
// Extract all content elements
const elements = [];
const contentDiv = doc.querySelector('.fill-height') || body;
for (const child of contentDiv.children) {
const tagName = child.tagName.toLowerCase();
const text = child.textContent.trim();
if (!text) continue;
switch (tagName) {
case 'h1':
elements.push({ type: 'h1', text });
break;
case 'h2':
elements.push({ type: 'h2', text });
break;
case 'h3':
elements.push({ type: 'h3', text });
break;
case 'p':
elements.push({ type: 'p', text });
break;
case 'ul':
const ulItems = Array.from(child.querySelectorAll('li')).map(li => li.textContent.trim());
elements.push({ type: 'ul', items: ulItems });
break;
case 'ol':
const olItems = Array.from(child.querySelectorAll('li')).map(li => li.textContent.trim());
elements.push({ type: 'ol', items: olItems });
break;
case 'pre':
const code = child.querySelector('code');
elements.push({ type: 'code', text: code ? code.textContent : child.textContent });
break;
case 'blockquote':
elements.push({ type: 'blockquote', text });
break;
}
}
return { elements, isLead };
}
/**
* Detect slide type
*/
function detectSlideType(elements, isLead) {
const hasH1 = elements.some(e => e.type === 'h1');
const hasH2 = elements.some(e => e.type === 'h2');
if (isLead && hasH1) {
return 'title';
}
if (hasH1 || hasH2) {
return 'content';
}
return 'content';
}
/**
* Create title slide
*/
function createTitleSlide(slide, elements) {
const h1 = elements.find(e => e.type === 'h1');
const h2 = elements.find(e => e.type === 'h2');
const otherContent = elements.filter(e => e.type !== 'h1' && e.type !== 'h2');
// Title placeholder
if (h1) {
slide.addText(h1.text, {
placeholder: 'title',
fontSize: 44,
bold: true,
color: COLORS.primary,
align: 'center'
});
}
// Subtitle placeholder
let subtitleText = '';
if (h2) {
subtitleText = h2.text;
}
if (otherContent.length > 0) {
const additionalText = otherContent
.map(e => e.type === 'p' ? e.text : '')
.filter(t => t)
.join('\n');
subtitleText += (subtitleText ? '\n' : '') + additionalText;
}
if (subtitleText) {
slide.addText(subtitleText, {
placeholder: 'body',
fontSize: 20,
color: COLORS.text,
align: 'center'
});
}
}
/**
* Create content slide
*/
function createContentSlide(slide, elements) {
// Extract title (first h1, h2, or h3)
const titleElem = elements.find(e => ['h1', 'h2', 'h3'].includes(e.type));
const contentElements = elements.filter(e => e !== titleElem);
// Add title to placeholder
if (titleElem) {
const fontSize = titleElem.type === 'h1' ? 40 : (titleElem.type === 'h2' ? 36 : 32);
slide.addText(titleElem.text, {
placeholder: 'title',
fontSize: fontSize,
bold: true,
color: COLORS.primary
});
}
// Build content for body placeholder
if (contentElements.length > 0) {
const bodyContent = buildBodyContent(contentElements);
if (bodyContent.length > 0) {
slide.addText(bodyContent, {
placeholder: 'body',
fontSize: 18,
color: COLORS.text,
valign: 'top'
});
}
}
}
/**
* Build body content array
*/
function buildBodyContent(elements) {
const content = [];
for (const elem of elements) {
switch (elem.type) {
case 'p':
content.push({
text: elem.text,
options: { breakLine: true }
});
break;
case 'ul':
elem.items.forEach((item, idx) => {
content.push({
text: item,
options: {
bullet: { type: 'bullet' },
indentLevel: 0,
breakLine: idx < elem.items.length - 1
}
});
});
break;
case 'ol':
elem.items.forEach((item, idx) => {
content.push({
text: item,
options: {
bullet: { type: 'number' },
indentLevel: 0,
breakLine: idx < elem.items.length - 1
}
});
});
break;
case 'code':
content.push({
text: elem.text,
options: {
fontFace: 'Courier New',
fontSize: 14,
color: COLORS.text,
breakLine: true
}
});
break;
case 'blockquote':
content.push({
text: elem.text,
options: {
italic: true,
color: COLORS.muted,
breakLine: true
}
});
break;
case 'h3':
content.push({
text: elem.text,
options: {
fontSize: 24,
bold: true,
color: COLORS.text,
breakLine: true
}
});
break;
}
}
return content;
}
/**
* Generate PPTX from HTML slides
* @param {Array} htmlSlides - Array of HTML strings
* @param {string} outputPath - Output file path
* @returns {Promise} Promise that resolves when PPTX is created
*/
async function generatePPTX(htmlSlides, outputPath) {
const pptx = new pptxgen();
pptx.layout = 'LAYOUT_16x9';
pptx.author = 'Marp to PPTX Converter';
// Define custom master slide layouts with placeholders
pptx.defineSlideMaster({
title: 'CUSTOM_LAYOUT',
background: { color: 'FFFFFF' },
objects: [
// Title placeholder
{
placeholder: {
options: {
name: 'title',
type: 'title',
x: 0.5,
y: 0.5,
w: 9.0,
h: 1.0
}
}
},
// Body/Content placeholder
{
placeholder: {
options: {
name: 'body',
type: 'body',
x: 0.5,
y: 1.7,
w: 9.0,
h: 4.0
}
}
}
]
});
let successCount = 0;
let errorCount = 0;
const errors = [];
for (let i = 0; i < htmlSlides.length; i++) {
const html = htmlSlides[i];
try {
const { elements, isLead } = parseHTMLSlide(html);
const slideType = detectSlideType(elements, isLead);
const slide = pptx.addSlide({ masterName: 'CUSTOM_LAYOUT' });
if (slideType === 'title') {
createTitleSlide(slide, elements);
} else {
createContentSlide(slide, elements);
}
successCount++;
} catch (error) {
errors.push({ slide: i + 1, error: error.message });
errorCount++;
// Add error slide
try {
const slide = pptx.addSlide();
slide.addText(`Error loading slide ${i + 1}`, {
x: 1, y: 2, w: 8, h: 1,
fontSize: 18,
color: 'FF0000'
});
slide.addText(error.message, {
x: 1, y: 3, w: 8, h: 2,
fontSize: 12,
color: '666666'
});
} catch (e) {
// Failed to add error slide, just record the error
}
}
}
await pptx.writeFile({ fileName: outputPath });
return { successCount, errorCount, errors };
}
module.exports = {
generatePPTX,
parseHTMLSlide,
detectSlideType
};