/** * Chat PDF Export Utilities * Modular functions for exporting chat history to PDF with visual UI preservation */ import { Message, Memori } from '@memori.ai/memori-api-client/dist/types'; import { renderMsg } from './message'; import { stripOutputTags } from './utils'; export interface ChatPDFOptions { fontSize?: string; fontFamily?: string; lineHeight?: string; color?: string; backgroundColor?: string; primaryColorRgb?: string; } export interface ChatPDFExportParams { messages: Message[]; memori: Memori; conversationStartedLabel: string; language?: string; } /** * Generate PDF-optimized CSS for chat export */ export const generateChatPDFCSS = (options: ChatPDFOptions = {}): string => { const { fontSize = '12pt', fontFamily = 'system-ui, -apple-system, sans-serif', lineHeight = '1.6', color = '#333', backgroundColor = '#fff', } = options; const baseStyles = ` @page { margin: 1in; size: A4; } @media print { * { -webkit-print-color-adjust: exact !important; color-adjust: exact !important; } body { font-family: ${fontFamily}; font-size: ${fontSize}; line-height: ${lineHeight}; color: ${color}; background-color: ${backgroundColor}; margin: 0; padding: 0; max-width: none; word-wrap: break-word; overflow-wrap: break-word; } h1, h2, h3, h4, h5, h6 { page-break-after: avoid; margin-top: 1.5em; margin-bottom: 0.5em; font-weight: 600; } h1 { font-size: 1.8em; } h2 { font-size: 1.5em; } h3 { font-size: 1.3em; } p { margin: 0 0 1em 0; orphans: 3; widows: 3; } pre, code { font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.9em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; padding: 0.2em 0.4em; page-break-inside: avoid; } pre { padding: 1em; overflow-x: auto; white-space: pre; margin: 1em 0; } pre code { background: none; border: none; padding: 0; } blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid #ddd; font-style: italic; page-break-inside: avoid; } ul, ol { margin: 1em 0; padding-left: 2em; } li { margin: 0.25em 0; page-break-inside: avoid; } table { border-collapse: collapse; width: 100%; margin: 1em 0; page-break-inside: avoid; } th, td { border: 1px solid #ddd; padding: 0.5em; text-align: left; } th { background-color: #f5f5f5; font-weight: 600; } a { color: #0066cc; text-decoration: none; } a:hover { text-decoration: underline; } img { max-width: 100%; height: auto; page-break-inside: avoid; } hr { margin: 2em 0; border: none; border-top: 1px solid #ddd; } .page-break { page-break-before: always; } .no-print { display: none !important; } } @media screen { body { font-family: ${fontFamily}; font-size: ${fontSize}; line-height: ${lineHeight}; color: ${color}; background-color: ${backgroundColor}; margin: 20px; padding: 20px; max-width: 800px; margin: 0 auto; } } `; const chatBubbleStyles = generateChatBubbleStyles(options.primaryColorRgb); return ``; }; /** * Generate CSS styles for chat bubbles */ const generateChatBubbleStyles = (primaryColorRgb?: string): string => { // Default fallback if not provided const rgbValue = primaryColorRgb || '130, 70, 175'; return ` :root { --memori-primary-rgb: ${rgbValue}; } .chat-export-header { margin-bottom: 2em; padding-bottom: 1em; border-bottom: 2px solid #e5e7eb; } .chat-export-date { margin-top: 0.5em; color: #6b7280; font-size: 0.9em; } .chat-bubble-container { display: flex; align-items: flex-end; margin-bottom: 1em; page-break-inside: avoid; } .chat-bubble { display: inline-flex; max-width: 75%; flex-direction: column; padding: 10px 16px; border-radius: 12px; margin-bottom: 5px; box-shadow: 0 3px 6px 0 rgba(0, 0, 0, 0.1), 0 1px 3px 0 rgba(0, 0, 0, 0.08); font-size: 0.9em; line-height: 1.5; word-wrap: break-word; overflow-wrap: break-word; } .chat-bubble-assistant { border-radius: 12px 12px 12px 0; margin-left: 0; margin-right: auto; background: #ffffff; color: #141515; border: 1px solid #e5e7eb; } .chat-bubble-user { border-radius: 12px 12px 0 12px; margin-left: auto; margin-right: 0; background: rgb(var(--memori-primary-rgb)); color: #ffffff; } .chat-bubble-content { margin: 0; } .chat-bubble-content p { margin: 0 0 0.5em 0; } .chat-bubble-content p:last-child { margin-bottom: 0; } .chat-bubble-content ul, .chat-bubble-content ol { padding-left: 1.5em; margin: 0.5em 0; } .chat-bubble-content a { color: inherit; text-decoration: underline; } .chat-bubble-user .chat-bubble-content a { color: #ffffff; text-decoration: underline; } .chat-bubble-assistant .chat-bubble-content a { color:rgb(var(--memori-primary-rgb)); text-decoration: underline; } .chat-bubble-timestamp { margin-top: 0.25em; font-size: 0.75em; opacity: 0.7; text-align: right; } .chat-bubble-assistant .chat-bubble-timestamp { text-align: left; } .chat-bubble-media { margin: 0.5em 0 0 0; padding-left: 1.5em; list-style-type: disc; } .chat-bubble-media li { margin: 0.25em 0; } .chat-bubble-content code { background-color: rgba(0, 0, 0, 0.1); padding: 0.2em 0.4em; border-radius: 3px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.9em; } .chat-bubble-user .chat-bubble-content code { background-color: rgba(255, 255, 255, 0.2); } .chat-bubble-content pre { background-color: rgba(0, 0, 0, 0.05); padding: 1em; border-radius: 5px; overflow-x: auto; margin: 0.5em 0; border: 1px solid rgba(0, 0, 0, 0.1); } .chat-bubble-user .chat-bubble-content pre { background-color: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.2); } .chat-bubble-content pre code { background: none; padding: 0; } .chat-bubble-content table { width: 100%; border-collapse: collapse; margin: 0.5em 0; } .chat-bubble-content table th, .chat-bubble-content table td { border: 1px solid rgba(0, 0, 0, 0.1); padding: 0.5em; text-align: left; } .chat-bubble-user .chat-bubble-content table th, .chat-bubble-user .chat-bubble-content table td { border-color: rgba(255, 255, 255, 0.2); } .chat-bubble-content table th { background-color: rgba(0, 0, 0, 0.05); font-weight: 600; } .chat-bubble-user .chat-bubble-content table th { background-color: rgba(255, 255, 255, 0.1); } .chat-bubble-content blockquote { margin: 0.5em 0; padding-left: 1em; border-left: 3px solid rgba(0, 0, 0, 0.2); font-style: italic; } .chat-bubble-user .chat-bubble-content blockquote { border-left-color: rgba(255, 255, 255, 0.3); } .chat-bubble-content img { max-width: 100%; height: auto; border-radius: 5px; margin: 0.5em 0; } .chat-bubble-content h1, .chat-bubble-content h2, .chat-bubble-content h3, .chat-bubble-content h4, .chat-bubble-content h5, .chat-bubble-content h6 { margin: 0.5em 0 0.25em 0; font-weight: 600; } .chat-bubble-content h1 { font-size: 1.5em; } .chat-bubble-content h2 { font-size: 1.3em; } .chat-bubble-content h3 { font-size: 1.1em; } `; }; /** * Format a single message as a chat bubble HTML */ const formatMessageBubble = ( message: Message, language: string = 'en' ): string => { const timestamp = message.timestamp ? new Intl.DateTimeFormat(language, { dateStyle: 'short', timeStyle: 'short', }).format( new Date( message.timestamp.endsWith('Z') ? message.timestamp : `${message.timestamp}Z` ) ) : ''; // Clean and render message text const cleanText = (message.text || '').replace( /([\s\S]*?)<\/document_attachment>/g, '' ); // Use renderMsg to convert markdown to HTML (same as ChatBubble does) const { text: renderedText } = renderMsg( cleanText, false, // useMathFormatting 'Reasoning...', // reasoningText false // showReasoning - hide reasoning in PDF ); // For non-user messages, remove output tags but keep the rendered HTML const messageText = message.fromUser ? renderedText : stripOutputTags(renderedText); // Format message as chat bubble const bubbleClass = message.fromUser ? 'chat-bubble chat-bubble-user' : 'chat-bubble chat-bubble-assistant'; let html = `
`; html += `
`; // Add message text if (messageText.trim()) { html += `
${messageText}
`; } // Add media attachments if present if (message.media && message.media.length > 0) { html += `
    `; message.media.forEach(media => { if (media.title) { html += `
  • ${media.title}
  • `; } else if (media.url) { html += `
  • ${media.url}
  • `; } }); html += `
`; } // Add timestamp if (timestamp) { html += `
${timestamp}
`; } html += `
`; html += `
`; return html; }; /** * Format chat history header */ const formatChatHeader = ( memoriName: string, exportDate: string, conversationStartedLabel: string ): string => { return `

${memoriName} - Chat Export

${conversationStartedLabel}: ${exportDate}

`; }; /** * Format chat history into HTML for PDF export */ export const formatChatHistoryForPDF = ( params: ChatPDFExportParams ): string => { const { messages, memori, conversationStartedLabel, language = 'en' } = params; if (!messages || messages.length === 0) { return ''; } const memoriName = memori?.name || 'Assistant'; const exportDate = new Intl.DateTimeFormat(language, { dateStyle: 'long', timeStyle: 'short', }).format(new Date()); // Build HTML let html = formatChatHeader(memoriName, exportDate, conversationStartedLabel); // Add each message as a bubble messages.forEach(message => { html += formatMessageBubble(message, language); }); return html; }; /** * Create the complete HTML document for PDF export */ export const createChatPDFDocument = ( htmlContent: string, title: string, options: ChatPDFOptions = {} ): string => { const css = generateChatPDFCSS(options); return ` ${title} ${css}
${htmlContent}
`; };