/* * ============================================================================= * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform * ----------------------------------------------------------------------------- * File: server/services/pdfService.ts * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * Website: https://www.spboucher.ai * Demo: https://www.vquant.ai * License: MIT (see LICENSE) * * Copyright © 2026 Simon-Pierre Boucher. All rights reserved. * ============================================================================= */ import { exec } from 'child_process'; import { promisify } from 'util'; import fs from 'fs'; import path from 'path'; import puppeteer from 'puppeteer'; import { marked } from 'marked'; const execAsync = promisify(exec); // VQuant SVG logo as data URI for PDF embedding const VIBEQUANT_LOGO_SVG = ``; // Convert SVG to base64 data URI const LOGO_DATA_URI = `data:image/svg+xml;base64,${Buffer.from(VIBEQUANT_LOGO_SVG).toString('base64')}`; interface MarkdownPDFOptions { question: string; answer: string; } /** * Convert image paths to base64 data URLs for embedding in PDF */ function convertImagesToBase64(markdown: string): string { // Find all image references like ![Figure 1](/figures/fig-12345-0.png) const imageRegex = /!\[([^\]]*)\]\(\/figures\/([^)]+)\)/g; let result = markdown; let match; while ((match = imageRegex.exec(markdown)) !== null) { const altText = match[1]; const filename = match[2]; const fullMatch = match[0]; // Try to read the image file const imagePath = path.join(process.cwd(), 'dist', 'public', 'figures', filename); try { if (fs.existsSync(imagePath)) { // Read image and convert to base64 const imageBuffer = fs.readFileSync(imagePath); const base64Image = imageBuffer.toString('base64'); const mimeType = 'image/png'; // All our figures are PNG // Replace with base64 data URL const dataUrl = `data:${mimeType};base64,${base64Image}`; const replacement = `![${altText}](${dataUrl})`; result = result.replace(fullMatch, replacement); console.log(`✓ Converted image to base64: ${filename}`); } else { console.warn(`⚠ Image not found: ${imagePath}`); } } catch (error) { console.error(`✗ Error reading image ${filename}:`, error.message); } } return result; } /** * Convert markdown to HTML with styling */ function markdownToHTML(markdown: string, title: string): string { // Convert images to base64 first const markdownWithBase64Images = convertImagesToBase64(markdown); // Protect LaTeX math from markdown parser: // $$...$$ (display) and $...$ (inline) → placeholders, then restore after let protected_ = markdownWithBase64Images; const mathBlocks: string[] = []; // Display math $$...$$ protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, (_match, expr) => { const idx = mathBlocks.length; mathBlocks.push(expr); return `%%DISPLAYMATH${idx}%%`; }); // Inline math $...$ (not $$) protected_ = protected_.replace(/\$([^\$\n]+?)\$/g, (_match, expr) => { const idx = mathBlocks.length; mathBlocks.push(expr); return `%%INLINEMATH${idx}%%`; }); let htmlBody = marked.parse(protected_) as string; // Restore math with KaTeX spans htmlBody = htmlBody.replace(/%%DISPLAYMATH(\d+)%%/g, (_m, idx) => { return `\\[${mathBlocks[parseInt(idx)]}\\]`; }); htmlBody = htmlBody.replace(/%%INLINEMATH(\d+)%%/g, (_m, idx) => { return `\\(${mathBlocks[parseInt(idx)]}\\)`; }); return ` ${title}
VQuant
Financial Intelligence Platform

${new Date().toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
${htmlBody}
`; } /** * Generate a PDF Buffer from markdown content using Puppeteer */ export async function generatePDFFromMarkdown(options: MarkdownPDFOptions): Promise { const { question, answer } = options; // Use answer only — no question in PDF const markdown = answer; const htmlContent = markdownToHTML(markdown, question); let browser; try { // Launch Puppeteer browser = await puppeteer.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu' ] }); const page = await browser.newPage(); // Set content and wait for KaTeX to render math await page.setContent(htmlContent, { waitUntil: 'networkidle0' }); // Extra wait for KaTeX auto-render to finish await page.waitForFunction(() => { return !document.querySelector('.math-inline, .math-display') || document.querySelector('.katex') !== null; }, { timeout: 5000 }).catch(() => {}); // Generate PDF const pdfData = await page.pdf({ format: 'A4', margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }, printBackground: true, displayHeaderFooter: true, headerTemplate: `
`, footerTemplate: `
VQuant | Page / | ${new Date().toLocaleDateString('fr-FR')}
` }); await browser.close(); // Ensure we return a proper Buffer const pdfBuffer = Buffer.isBuffer(pdfData) ? pdfData : Buffer.from(pdfData); return pdfBuffer; } catch (error) { if (browser) { await browser.close(); } console.error('PDF generation error:', error); throw new Error(`Failed to generate PDF: ${error.message}`); } } /** * Generate a DOCX Buffer from markdown content using Pandoc */ export async function generateDOCXFromMarkdown(options: MarkdownPDFOptions): Promise { const { question, answer } = options; // Create temp directory if it doesn't exist const tempDir = path.join(process.cwd(), 'temp'); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true }); } // Generate unique filenames const timestamp = Date.now(); const mdPath = path.join(tempDir, `report-${timestamp}.md`); const docxPath = path.join(tempDir, `report-${timestamp}.docx`); try { // Use answer only — no question in DOCX const markdown = answer; // Write markdown to temp file fs.writeFileSync(mdPath, markdown, 'utf-8'); // Generate DOCX using Pandoc await execAsync(`pandoc "${mdPath}" -f markdown -t docx -o "${docxPath}"`); // Read the generated DOCX file const docxBuffer = fs.readFileSync(docxPath); // Clean up temp files fs.unlinkSync(mdPath); fs.unlinkSync(docxPath); return docxBuffer; } catch (error) { // Clean up files on error if (fs.existsSync(mdPath)) fs.unlinkSync(mdPath); if (fs.existsSync(docxPath)) fs.unlinkSync(docxPath); throw new Error(`Failed to generate DOCX: ${error.message}`); } }