"""
Professional Chat Message Log PDF Generator - Soft Slate Blue Theme
Generates beautifully formatted PDF reports from chat conversations
Uses WeasyPrint for superior CSS support and rendering
Supports file attachments (CSV and Image files)
"""
import markdown
from weasyprint import HTML, CSS
import requests
import os
import tempfile
import re
from datetime import datetime
from typing import List, Dict, Optional
import json
import random
import string
import logging
# ---------------------------------------------------------
# LOGGING CONFIGURATION
# ---------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------
# SOFT SLATE BLUE THEME - WEASYPRINT OPTIMIZED
# ---------------------------------------------------------
CSS_STYLE = """
@page {
size: letter;
margin: 0.75in 0.75in 1in 0.75in;
@top-center {
content: element(header);
}
@bottom-center {
content: element(footer);
}
}
body {
font-family: 'Helvetica', Arial, sans-serif;
color: #2d3748;
line-height: 1.6;
font-size: 11pt;
}
.header {
position: running(header);
font-size: 9pt;
color: #718096;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 4px;
margin-bottom: 20px;
}
.footer {
position: running(footer);
font-size: 9pt;
color: #718096;
border-top: 1px solid #e2e8f0;
padding-top: 4px;
text-align: center;
}
.cover-page {
text-align: center;
padding-top: 3in;
page-break-after: always;
}
.cover-title {
font-size: 32pt;
font-weight: bold;
color: #4a5568;
margin-bottom: 0.5in;
letter-spacing: 1px;
}
.cover-subtitle {
font-size: 18pt;
color: #5b7c99;
margin-bottom: 1in;
}
.cover-meta {
font-size: 14pt;
color: #718096;
margin-bottom: 0.2in;
}
.cover-date {
font-size: 11pt;
color: #a0aec0;
}
.message-container {
margin-bottom: 20px;
page-break-inside: avoid;
}
.message-user {
background-color: #ffffff;
border-left: 0.5px solid #5b7c99;
padding: 16px 20px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.message-assistant {
background-color: #ffffff;
border-left: 0.5px solid #4a5568;
padding: 16px 20px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.message-header {
font-weight: bold;
font-size: 10pt;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
}
.role-user {
color: #4a5568;
font-size: 11pt;
}
.role-assistant {
color: #4a5568;
font-size: 11pt;
}
.timestamp {
font-size: 9pt;
color: #a0aec0;
font-weight: normal;
}
.message-content {
color: #4a5568;
font-size: 10pt;
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
}
a {
color: #5b7c99;
text-decoration: none;
word-wrap: break-word;
overflow-wrap: break-word;
}
a:hover {
text-decoration: underline;
}
.message-content a {
font-weight: 500;
}
.message-divider {
border-top: 1px solid #e2e8f0;
margin: 16px 0;
}
h1, h2, h3, h4 {
color: #4a5568;
margin-top: 12px;
margin-bottom: 8px;
}
h1 { font-size: 16pt; }
h2 { font-size: 14pt; color: #5b7c99; }
h3 { font-size: 12pt; }
h4 { font-size: 11pt; }
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 9pt;
table-layout: fixed;
}
/* For tables with many columns, reduce font size */
table.many-columns {
font-size: 7pt;
}
table.many-columns th,
table.many-columns td {
padding: 4px 6px;
}
th {
background-color: #5b7c99;
color: white;
padding: 8px;
text-align: left;
font-weight: bold;
border: 1px solid #4a6b85;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
td {
padding: 6px 8px;
border: 1px solid #e2e8f0;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 0;
overflow: hidden;
hyphens: auto;
font-size: inherit;
}
tr:nth-child(even) {
background-color: #f7fafc;
}
code {
background-color: #edf2f7;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 9pt;
color: #2d3748;
}
pre {
background-color: #edf2f7;
border: 1px solid #cbd5e0;
border-left: none;
padding: 12px;
font-size: 8pt;
line-height: 1.4;
margin: 10px 0;
border-radius: 6px;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
pre code {
background-color: transparent;
padding: 0;
color: #2d3748;
}
blockquote {
border-left: 3px solid #5b7c99;
margin-left: 0;
padding-left: 12px;
color: #4a5568;
font-style: italic;
background-color: #f7fafc;
padding: 8px 8px 8px 12px;
margin: 10px 0;
}
ul, ol {
margin: 8px 0;
padding-left: 20px;
list-style-type: none;
}
ul li:before {
content: "• ";
color: #5b7c99;
font-weight: bold;
display: inline-block;
width: 1em;
margin-left: -1em;
}
li {
margin: 4px 0;
}
.figure-container {
text-align: center;
margin: 16px 0;
page-break-inside: avoid;
}
.figure-container img {
max-width: 100%;
max-height: 400px;
height: auto;
border: 1px solid #e2e8f0;
padding: 4px;
background: white;
}
.figure-caption {
font-size: 9pt;
color: #718096;
margin-top: 6px;
font-style: italic;
}
.file-item {
margin-bottom: 12px;
padding: 12px;
background-color: #f7fafc;
border-left: 3px solid #5b7c99;
page-break-inside: avoid;
}
.file-label {
color: #4a5568;
font-weight: bold;
font-size: 9pt;
}
.file-value {
color: #4a5568;
font-size: 9pt;
word-wrap: break-word;
overflow-wrap: break-word;
}
.file-url {
color: #5b7c99;
font-size: 7pt;
font-family: 'Courier New', monospace;
display: block;
padding: 6px 8px;
background-color: #edf2f7;
border-radius: 3px;
margin-top: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
line-height: 1.5;
}
.file-url a {
color: #5b7c99;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 7pt;
font-family: 'Courier New', monospace;
}
.stats-summary {
margin: 30px 0 20px 0;
}
.stats-summary p {
margin: 0;
padding: 0;
font-size: 10pt;
color: #718096;
}
.stats-summary strong {
color: #4a5568;
}
.section-divider {
border: none;
border-top: 2px solid #5b7c99;
margin: 20px 0 30px 0;
}
"""
# ---------------------------------------------------------
# CHAT LOG GENERATOR CLASS
# ---------------------------------------------------------
class ChatLogGenerator:
"""
Generates professional PDF reports from chat message logs.
Supports multiple JSON formats (createdAt, timestamp, chatId, chat_id, etc.)
"""
def __init__(self,
title: str = "Chat Conversation Log",
chat_id: str = None,
participants: List[str] = None,
date_range: str = None,
confidential: bool = False,
output_dir: str = "chat_pdfs"):
"""
Initialize the chat log generator.
Args:
title: Title of the chat log report
chat_id: Unique identifier for the chat session
participants: List of participant names
date_range: Date range of the conversation
confidential: Whether to mark as confidential
output_dir: Directory to save PDF files
"""
self.title = title
self.chat_id = chat_id
self.participants = participants or ["User", "Assistant"]
self.date_range = date_range
self.confidential = confidential
self.output_dir = output_dir
# Create output directory if it doesn't exist
os.makedirs(self.output_dir, exist_ok=True)
logger.info(f"ChatLogGenerator initialized - output_dir: {self.output_dir}")
def _get_timestamp_from_message(self, message: Dict) -> str:
"""
Extract timestamp from message, checking multiple possible field names.
Supports: timestamp, createdAt, created_at
"""
# Try different field names
for field in ['timestamp', 'createdAt', 'created_at']:
if field in message and message[field]:
return message[field]
# Return empty string if no timestamp found
return ''
def _generate_unique_filename(self, base_name: str = "chat_log") -> str:
"""
Generate a unique filename with random number.
Args:
base_name: Base name for the file
Returns:
Full path to the PDF file
"""
# Generate random 8-digit number
random_num = ''.join(random.choices(string.digits, k=8))
# Get current timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Create filename
filename = f"{base_name}_{timestamp}_{random_num}.pdf"
# Return full path
filepath = os.path.join(self.output_dir, filename)
logger.debug(f"Generated unique filename: {filepath}")
return filepath
def _format_timestamp(self, timestamp: str) -> str:
"""
Format ISO timestamp to readable format.
Handles both with and without timezone info.
"""
if not timestamp or timestamp.strip() == '':
return "No timestamp"
try:
# Try parsing with timezone first
if '+' in timestamp or timestamp.endswith('Z'):
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
else:
# Try parsing without timezone
dt = datetime.fromisoformat(timestamp)
return dt.strftime("%B %d, %Y at %I:%M:%S %p")
except Exception as e:
logger.warning(f"Failed to parse timestamp: {timestamp} - {e}")
return timestamp if timestamp else "No timestamp"
def _convert_markdown_with_inline_images(self, content: str) -> str:
"""
Convert markdown to HTML while preserving inline image positions.
Images will appear exactly where they're referenced.
Standalone image URLs are hidden from display.
Auto-converts URLs to "Click to View" links.
"""
# Pattern to match markdown images: 
image_pattern = r'!\[([^\]]*)\]\(([^\)]+)\)'
# Find all image URLs that will be converted to tags
image_urls = set()
for match in re.finditer(image_pattern, content):
image_urls.add(match.group(2))
# Split content by images while keeping the images
parts = re.split(f'({image_pattern})', content)
html_output = ""
i = 0
while i < len(parts):
part = parts[i]
# Check if this part is an image (starts with ![)
if part.startswith('!['):
# Extract alt text and URL from the matched groups
match = re.match(image_pattern, part)
if match:
alt_text = match.group(1) or "Figure"
url = match.group(2)
# Add the image HTML inline
html_output += f"""
Total Messages: {total} | User: {user_count} | Assistant: {assistant_count} | Period: {date_info}