create_pdf_from_markdown
Convert markdown content to PDF with support for headers, lists, tables, code blocks, images, and Mermaid diagrams. Specify paper format, orientation, border, and optional watermark. Output saved to a defined filename and directory.
Instructions
Convert markdown content to PDF. Supports basic markdown elements like headers, lists, tables, code blocks, blockquotes, images (both local and external URLs), and Mermaid diagrams. Note: Cannot handle LaTeX math equations. Mermaid syntax errors will be displayed directly in the PDF output.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| markdown | Yes | Markdown content to convert to PDF | |
| outputFilename | No | Create a filename for the PDF file to be saved (default: "final-output.pdf"). The environmental variable M2P_OUTPUT_DIR sets the output path directory. If directory is not provided, it will default to user's HOME directory. | |
| paperBorder | No | Border margin for the PDF (default: 2cm). Use CSS units (cm, mm, in, px) | 20mm |
| paperFormat | No | Paper format for the PDF (default: letter) | letter |
| paperOrientation | No | Paper orientation for the PDF (default: portrait) | portrait |
| watermark | No | Optional watermark text (max 15 characters, uppercase), e.g. "DRAFT", "PRELIMINARY", "CONFIDENTIAL", "FOR REVIEW", etc |
Implementation Reference
- src/index.ts:98-150 (schema)Defines the input schema and description for the 'create_pdf_from_markdown' tool, including parameters for markdown content, output filename, paper format, orientation, borders, watermark, and page numbers.{ name: 'create_pdf_from_markdown', description: 'Convert markdown content to PDF. Supports basic markdown elements like headers, lists, tables, code blocks, blockquotes, images (both local and external URLs), and Mermaid diagrams. Note: Cannot handle LaTeX math equations. Mermaid syntax errors will be displayed directly in the PDF output.', inputSchema: { type: 'object', properties: { markdown: { type: 'string', description: 'Markdown content to convert to PDF', }, outputFilename: { type: 'string', description: 'Create a filename for the PDF file to be saved (default: "final-output.pdf"). The environmental variable M2P_OUTPUT_DIR sets the output path directory. If directory is not provided, it will default to user\'s HOME directory.' }, paperFormat: { type: 'string', description: 'Paper format for the PDF (default: letter)', enum: ['letter', 'a4', 'a3', 'a5', 'legal', 'tabloid'], default: 'letter' }, paperOrientation: { type: 'string', description: 'Paper orientation for the PDF (default: portrait)', enum: ['portrait', 'landscape'], default: 'portrait' }, paperBorder: { type: 'string', description: 'Border margin for the PDF (default: 2cm). Use CSS units (cm, mm, in, px)', pattern: '^[0-9]+(\.[0-9]+)?(cm|mm|in|px)$', default: '20mm' }, watermark: { type: 'string', description: 'Optional watermark text (max 15 characters, uppercase), e.g. "DRAFT", "PRELIMINARY", "CONFIDENTIAL", "FOR REVIEW", etc', maxLength: 15, pattern: '^[A-Z0-9\\s-]+$' }, watermarkScope: { type: 'string', description: 'Control watermark visibility: "all-pages" repeats on every page, "first-page" displays on the first page only (default: all-pages)', enum: ['all-pages', 'first-page'], default: 'all-pages' }, showPageNumbers: { type: 'boolean', description: 'Include page numbers in the PDF footer (default: false)', default: false } }, required: ['markdown'] }, },
- src/index.ts:38-40 (registration)Registers the 'create_pdf_from_markdown' tool capability in the MCP server constructor.tools: { create_pdf_from_markdown: true },
- src/index.ts:154-338 (handler)MCP 'call_tool' request handler that processes requests for 'create_pdf_from_markdown', validates inputs, computes output path, and delegates to convertToPdf method.this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== 'create_pdf_from_markdown') { throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } if (!request.params.arguments) { throw new McpError( ErrorCode.InvalidParams, 'No arguments provided' ); } const args = request.params.arguments as { markdown: string; outputFilename?: string; paperFormat?: string; paperOrientation?: string; paperBorder?: string; watermark?: string; watermarkScope?: 'all-pages' | 'first-page'; showPageNumbers?: boolean; }; // Get output directory from environment variable, outputFilename path, or default to user's home const outputDir = (() => { if (process.env.M2P_OUTPUT_DIR) { return path.resolve(process.env.M2P_OUTPUT_DIR); } if (args.outputFilename && typeof args.outputFilename === 'string') { const hasExplicitDirectory = path.isAbsolute(args.outputFilename) || path.dirname(args.outputFilename) !== '.'; if (hasExplicitDirectory) { return path.dirname(path.resolve(args.outputFilename)); } } return path.resolve(os.homedir()); })(); // Ensure output directory exists if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } const { markdown, outputFilename = 'output.pdf', paperFormat = 'letter', paperOrientation = 'portrait', paperBorder = '2cm', watermark = '', watermarkScope = 'all-pages', showPageNumbers = false } = args; if (typeof markdown !== 'string' || markdown.trim().length === 0) { throw new McpError( ErrorCode.InvalidParams, 'Missing required argument: markdown' ); } // Calculate content size and validate const contentSize = markdown.length; const lineCount = markdown.split('\n').length; // Hard limit check - prevent extremely large files that will definitely fail const MAX_SIZE = 10 * 1024 * 1024; // 10MB if (contentSize > MAX_SIZE) { throw new McpError( ErrorCode.InvalidParams, `Markdown content too large (${Math.round(contentSize / 1024 / 1024)}MB). Maximum supported size is ${MAX_SIZE / 1024 / 1024}MB. Consider splitting the content into smaller documents.`, { details: { contentSize, maxSize: MAX_SIZE, lineCount } } ); } // Calculate dynamic timeouts based on content size // Base: 60s load, 7s render. Add 1s per 10KB and 1s per 100 lines const baseLoadTimeout = 60000; const baseRenderDelay = 7000; const loadTimeout = Math.min( baseLoadTimeout + Math.floor(contentSize / 10000) * 1000, 300000 // Max 5 minutes ); const renderDelay = Math.min( baseRenderDelay + Math.floor(lineCount / 100) * 1000, 30000 // Max 30 seconds ); if (contentSize > 500000) { // ~500KB console.error(`[markdown2pdf] Warning: Large markdown content detected (${Math.round(contentSize / 1024)}KB, ${lineCount} lines). Processing may take longer than usual.`); console.error(`[markdown2pdf] Using extended timeouts: load=${loadTimeout/1000}s, render=${renderDelay/1000}s`); } // Ensure output filename has .pdf extension const filename = outputFilename.toLowerCase().endsWith('.pdf') ? outputFilename : `${outputFilename}.pdf`; // Combine output directory with filename const outputPath = path.join(outputDir, filename); try { // Track operation progress through response content const progressUpdates: string[] = []; progressUpdates.push(`Starting PDF conversion (format: ${paperFormat}, orientation: ${paperOrientation})`); progressUpdates.push(`Content size: ${Math.round(contentSize / 1024)}KB (${lineCount} lines)`); progressUpdates.push(`Using output path: ${outputPath}`); await this.convertToPdf( markdown, outputPath, paperFormat, paperOrientation, paperBorder, watermark, watermarkScope, showPageNumbers, renderDelay, loadTimeout ); // Verify file was created if (!fs.existsSync(outputPath)) { throw new McpError( ErrorCode.InternalError, 'PDF file was not created', { details: { outputPath, paperFormat, paperOrientation } } ); } // Ensure absolute path is returned const absolutePath = path.resolve(outputPath); progressUpdates.push(`PDF file created successfully at: ${absolutePath}`); progressUpdates.push(`File exists: ${fs.existsSync(absolutePath)}`); return { content: [ { type: 'text', text: progressUpdates.join('\n') }, ], }; } catch (error: unknown) { if (error instanceof Error) { throw new McpError( ErrorCode.InternalError, `PDF generation failed: ${error.message}`, { details: { name: error.name, stack: error.stack, outputPath, paperFormat, paperOrientation } } ); } throw new McpError( ErrorCode.InternalError, `PDF generation failed: ${String(error)}` ); } });
- src/index.ts:356-543 (handler)Converts Markdown to HTML using Remarkable with syntax highlighting and Mermaid support, embeds in styled HTML template with watermark, creates temporary HTML file, and invokes Puppeteer renderer.private async convertToPdf( markdown: string, outputPath: string, paperFormat: string = 'letter', paperOrientation: string = 'portrait', paperBorder: string = '2cm', watermark: string = '', watermarkScope: 'all-pages' | 'first-page' = 'all-pages', showPageNumbers: boolean = false, renderDelay: number = 7000, loadTimeout: number = 60000 ): Promise<void> { return new Promise<void>(async (resolve, reject) => { try { // Ensure output directory exists const outputDir = path.dirname(outputPath); await fs.promises.mkdir(outputDir, { recursive: true }); // Get incremental path and ensure absolute outputPath = this.getIncrementalPath(outputPath); outputPath = path.resolve(outputPath); // Setup markdown parser with syntax highlighting const mdParser = new Remarkable({ breaks: true, preset: 'default', html: true, // Enable HTML tags highlight: (str: string, language: string) => { if (language && language === 'mermaid') { return `<div class="mermaid">${str}</div>`; } if (language && hljs.getLanguage(language)) { try { return hljs.highlight(str, { language }).value; } catch (err) { } } try { return hljs.highlightAuto(str).value; } catch (err) { } return ''; } }); const watermarkClassName = watermarkScope === 'first-page' ? 'watermark watermark--first-page' : 'watermark watermark--all-pages'; const headerOffset = '12.5mm'; const showWatermarkAllPages = Boolean( watermark && watermarkScope === 'all-pages' ); // Create HTML content const html = ` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script> <style> @page { margin: ${paperBorder}; ${showWatermarkAllPages ? `margin-top: calc(${paperBorder} + ${headerOffset});` : ''} size: ${paperFormat} ${paperOrientation}; } ${ showWatermarkAllPages ? `@page:first { margin-top: ${paperBorder}; }` : '' } html, body { margin: 0; padding: 0; width: 100%; height: 100%; } .page { position: relative; width: ${paperFormat === 'letter' ? '8.5in' : '210mm'}; height: ${paperFormat === 'letter' ? '11in' : '297mm'}; margin: 0; padding: 20px; box-sizing: border-box; } .content { position: relative; z-index: 1; } .watermark { left: 0; top: 0; right: 0; bottom: 0; display: flex; justify-content: center; align-items: center; font-size: calc(${paperFormat === 'letter' ? '8.5in' : '210mm'} * 0.14); color: rgba(0, 0, 0, 0.15); font-family: Arial, sans-serif; white-space: nowrap; pointer-events: none; z-index: 0; transform: rotate(-45deg); } .watermark--all-pages { position: fixed; } .watermark--first-page { position: absolute; } </style> </head> <body> <div class="page"> <div id="mermaid-error" style="display: none; color: red;"></div> <div class="content"> ${mdParser.render(markdown)} </div> ${watermark ? `<div class="${watermarkClassName}" data-scope="${watermarkScope}">${watermark}</div>` : ''} </div> <script> document.addEventListener('DOMContentLoaded', function () { mermaid.initialize({ startOnLoad: false }); try { mermaid.run({ nodes: document.querySelectorAll('.mermaid') }); } catch (e) { const errorDiv = document.getElementById('mermaid-error'); if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.innerText = e.message; } } }); </script> </body> </html>`; // Create temporary HTML file const tmpFile = await new Promise<{ path: string, fd: number }>((resolve, reject) => { tmp.file({ postfix: '.html' }, (err: Error | null, path: string, fd: number) => { if (err) reject(err); else resolve({ path, fd }); }); }); // Close file descriptor immediately fs.closeSync(tmpFile.fd); // Write HTML content await fs.promises.writeFile(tmpFile.path, html); // Import and use Puppeteer renderer const renderPDF = (await import('./puppeteer/render.js')).default; await renderPDF({ htmlPath: tmpFile.path, pdfPath: outputPath, runningsPath: path.resolve(__dirname, 'runnings.js'), cssPath: path.resolve(__dirname, 'css', 'pdf.css'), highlightCssPath: '', paperFormat, paperOrientation, paperBorder, watermarkScope, showPageNumbers, renderDelay, loadTimeout }); resolve(); } catch (error) { reject(new McpError( ErrorCode.InternalError, `PDF generation failed: ${error instanceof Error ? error.message : String(error)}`, { details: { phase: error instanceof Error && error.message.includes('renderPDF') ? 'renderPDF' : 'setup', outputPath, paperFormat, paperOrientation, ...(error instanceof Error ? { name: error.name, stack: error.stack } : {}) } } )); } }); }
- src/puppeteer/render.js:27-224 (handler)Puppeteer-based PDF renderer: launches browser, loads generated HTML, handles Mermaid rendering, applies custom headers/footers with page numbers and watermark, and saves PDF file.async function renderPDF({ htmlPath, pdfPath, runningsPath, cssPath, highlightCssPath, paperFormat, paperOrientation, paperBorder, watermarkScope, showPageNumbers, renderDelay, loadTimeout }) { let browser; const verbose = process.env.M2P_VERBOSE === 'true'; if (verbose) { console.error(`[markdown2pdf] Starting PDF rendering`); console.error(`[markdown2pdf] Timeouts - load: ${loadTimeout}ms, render: ${renderDelay}ms`); } try { // Try with our specific Chrome version first const chromePath = path.join( os.homedir(), '.cache', 'puppeteer', 'chrome', getPlatformPath() ); if (!fs.existsSync(chromePath)) { if (verbose) { console.error(`[markdown2pdf] Chrome not found at: ${chromePath}, using fallback`); } throw new Error(`Chrome executable not found at: ${chromePath}`); } browser = await puppeteer.launch({ headless: true, executablePath: chromePath, product: 'chrome', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', // Prevent shared memory issues '--disable-accelerated-2d-canvas', '--disable-gpu', '--max-old-space-size=4096' // Increase memory limit to 4GB ] }); } catch (err) { // Fall back to default Puppeteer-installed Chrome if (verbose) { console.error('[markdown2pdf] Falling back to default Chrome installation'); } browser = await puppeteer.launch({ headless: true, product: 'chrome', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--disable-gpu', '--max-old-space-size=4096' ] }); } try { const page = await browser.newPage(); // Monitor browser crashes browser.on('disconnected', () => { throw new Error('Browser disconnected unexpectedly. This may indicate an out-of-memory issue or browser crash. Try reducing content size or increasing system resources.'); }); page.on('error', err => { throw new Error(`Page crashed: ${err.message}`); }); page.on('pageerror', err => { if (verbose) { console.error(`[markdown2pdf] Page error:`, err); } }); // Set viewport await page.setViewport({ width: 1200, height: 1600 }); if (verbose) { console.error(`[markdown2pdf] Loading HTML from: ${htmlPath}`); } // Load the HTML file with timeout const htmlFileUrl = pathToFileURL(htmlPath).href; await page.goto(htmlFileUrl, { waitUntil: 'networkidle0', timeout: loadTimeout }).catch(err => { if (err.message.includes('timeout')) { throw new Error(`Failed to load HTML content within ${loadTimeout/1000}s timeout. The content may be too large or complex. Error: ${err.message}`); } throw new Error(`Failed to load HTML content: ${err.message}`); }); if (verbose) { console.error(`[markdown2pdf] HTML loaded successfully`); } // Import runnings (header/footer) const runningsUrl = pathToFileURL(runningsPath).href; const runningsModule = await import(runningsUrl).catch(err => { throw new Error(`Failed to import runnings.js: ${err.message}`); }); // Add CSS if provided if (cssPath && fs.existsSync(cssPath)) { await page.addStyleTag({ path: cssPath }).catch(err => { throw new Error(`Failed to add CSS: ${err.message}`); }); } if (highlightCssPath && fs.existsSync(highlightCssPath)) { await page.addStyleTag({ path: highlightCssPath }).catch(err => { throw new Error(`Failed to add highlight CSS: ${err.message}`); }); } // Wait for specified delay await new Promise(resolve => setTimeout(resolve, renderDelay)); // Check for mermaid errors const mermaidError = await page.evaluate(() => { const errorDiv = document.getElementById('mermaid-error'); return errorDiv ? errorDiv.innerText : null; }); if (mermaidError) { throw new Error(`Mermaid diagram rendering failed: ${mermaidError}`); } // Force repaint to ensure proper rendering await page.evaluate(() => { document.body.style.transform = 'scale(1)'; return document.body.offsetHeight; }); // Get watermark text if present const watermarkText = await page.evaluate(() => { const watermark = document.querySelector('.watermark'); return watermark ? watermark.textContent : ''; }); const templatesFactory = runningsModule?.default; if (typeof templatesFactory !== 'function') { throw new Error('Invalid runnings export: expected default function'); } const templates = templatesFactory({ watermarkText, watermarkScope, showPageNumbers }); const shouldDisplayHeaderFooter = Boolean( showPageNumbers || (watermarkText && watermarkScope === 'all-pages') ); await page.pdf({ path: pdfPath, format: paperFormat, landscape: paperOrientation === 'landscape', margin: { top: paperBorder, right: paperBorder, bottom: paperBorder, left: paperBorder }, printBackground: true, displayHeaderFooter: shouldDisplayHeaderFooter, headerTemplate: shouldDisplayHeaderFooter ? templates.header : '', footerTemplate: shouldDisplayHeaderFooter ? templates.footer : '', preferCSSPageSize: true }); return pdfPath; } finally { if (browser) { await browser.close(); } } }