const express = require('express');
const router = express.Router();
const puppeteer = require('puppeteer');
const path = require('path');
// POST /api/export/pdf - Generate PDF from markdown content
router.post('/pdf', async (req, res) => {
const { content, title = 'Design Journal' } = req.body;
if (!content || content.trim() === '') {
return res.status(400).json({
success: false,
error: 'Content required to generate PDF'
});
}
let browser;
try {
// Convert markdown to HTML with styles
const htmlContent = generateStyledHTML(content, title);
// Launch Puppeteer
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
// Set HTML content
await page.setContent(htmlContent, {
waitUntil: 'domcontentloaded',
timeout: parseInt(process.env.PDF_TIMEOUT) || 30000
});
// Wait for Mermaid to load and diagrams to render
await new Promise(resolve => setTimeout(resolve, 2000));
// Check if Mermaid diagrams exist and wait for them to load
try {
await page.waitForFunction(() => {
const mermaidElements = document.querySelectorAll('.mermaid');
if (mermaidElements.length === 0) return true;
return Array.from(mermaidElements).every(el =>
el.querySelector('svg') || el.textContent.trim() === '' || el.classList.contains('error')
);
}, { timeout: 10000 });
} catch (e) {
console.log('Timeout waiting for Mermaid, proceeding...');
}
// Generate PDF
const pdfBuffer = await page.pdf({
format: 'A4',
margin: {
top: '2cm',
right: '2cm',
bottom: '2cm',
left: '2cm'
},
printBackground: true,
displayHeaderFooter: true,
headerTemplate: `
${title}
`,
footerTemplate: `
Page of - Generated on ${new Date().toLocaleDateString('en-US')}
`
});
await browser.close();
// Check PDF size
const maxSize = parseInt(process.env.PDF_MAX_SIZE) || 10485760; // 10MB default
if (pdfBuffer.length > maxSize) {
return res.status(413).json({
success: false,
error: 'Generated PDF exceeds maximum allowed size'
});
}
// Send PDF
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(title)}.pdf"`);
res.setHeader('Content-Length', pdfBuffer.length);
res.send(pdfBuffer);
} catch (error) {
console.error('PDF generation error:', error);
if (browser) {
await browser.close();
}
res.status(500).json({
success: false,
error: 'Error during PDF generation'
});
}
});
// Utility function to escape HTML
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
}
// Function to convert markdown to styled HTML with Mermaid support
function generateStyledHTML(content, title) {
let html = content;
// Process Mermaid diagrams BEFORE code blocks
html = html.replace(/```mermaid\n([\s\S]*?)```/g, '$1
');
// Process code blocks with language
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
const language = lang ? ` class="language-${lang}"` : '';
return `${escapeHtml(code)}
`;
});
// Inline code
html = html.replace(/`([^`]+)`/g, '$1');
// Enhanced table processing
html = html.replace(/(\|.*\|\s*\n)+/g, (match) => {
const lines = match.trim().split('\n').filter(line => line.trim());
let tableHtml = '\n';
let inHeader = true;
lines.forEach((line, index) => {
// Ignore separator lines
if (line.match(/^\s*\|[\s\-:]*\|\s*$/)) {
inHeader = false;
return;
}
const cells = line.split('|')
.map(cell => cell.trim())
.filter((cell, idx, arr) => idx > 0 && idx < arr.length - 1);
if (cells.length === 0) return;
const tag = inHeader ? 'th' : 'td';
tableHtml += ' \n';
cells.forEach(cell => {
tableHtml += ` <${tag}>${cell}${tag}>\n`;
});
tableHtml += '
\n';
if (inHeader && index === 0) inHeader = false;
});
tableHtml += '
\n';
return tableHtml;
});
// Markdown to HTML conversion
html = html
// Headings (in descending order)
.replace(/^#{6}\s+(.*$)/gm, '$1
')
.replace(/^#{5}\s+(.*$)/gm, '$1
')
.replace(/^#{4}\s+(.*$)/gm, '$1
')
.replace(/^#{3}\s+(.*$)/gm, '$1
')
.replace(/^#{2}\s+(.*$)/gm, '$1
')
.replace(/^#{1}\s+(.*$)/gm, '$1
')
// Blockquotes
.replace(/^>\s+(.*$)/gm, '$1
')
// Bold and italic (order matters)
.replace(/\*\*\*(.*?)\*\*\*/g, '$1')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
// Strikethrough
.replace(/~~(.*?)~~/g, '$1')
// Numbered lists
.replace(/^(\s*)(\d+)\. (.*$)/gm, '$3')
// Bullet lists and tasks
.replace(/^(\s*)- \[x\] (.*$)/gm, '$2')
.replace(/^(\s*)- \[ \] (.*$)/gm, '$2')
.replace(/^(\s*)[*\-+] (.*$)/gm, '$2')
// Links
.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '$1')
// Horizontal rule
.replace(/^---+$/gm, '
');
// Group consecutive lists
html = html.replace(/(]*>.*?<\/li>\s*)+/gs, (match) => {
if (match.includes('class="numbered"')) {
return '' + match.replace(/ class="numbered"/g, '') + '
';
} else {
return '';
}
});
// Clean up consecutive multiple lists
html = html.replace(/(<\/[uo]l>\s*<[uo]l>)/g, '');
// Group consecutive blockquotes
html = html.replace(/(.*?<\/blockquote>\s*)+/gs, (match) => {
const content = match.replace(/<\/?blockquote>/g, '');
return '' + content + '
';
});
// Process paragraphs
html = html.split('\n\n').map(paragraph => {
paragraph = paragraph.trim();
if (!paragraph) return '';
// Don't wrap block elements in paragraphs
if (paragraph.match(/^<(h[1-6]|div|table|ul|ol|blockquote|hr|pre)/)) {
return paragraph;
}
return '' + paragraph.replace(/\n/g, '
') + '
';
}).join('\n\n');
const styledHTML = `
${title}
${html}
`;
return styledHTML;
}
// Function to sanitize filename
function sanitizeFilename(filename) {
return filename
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with dashes
.toLowerCase() // Lowercase
.substring(0, 100); // Limit length
}
module.exports = router;