class PDFGenerator
A class that generates PDF documents optimized for e-ink displays, converting LLM responses and images into formatted, high-contrast PDFs with custom styling.
/tf/active/vicechatdev/e-ink-llm/pdf_generator.py
69 - 430
complex
Purpose
PDFGenerator creates PDF documents specifically optimized for e-ink display devices by using high-contrast black text, grayscale images, and custom typography. It formats LLM responses with markdown-like syntax support (headers, code blocks, lists, inline formatting), embeds original input images, and includes metadata tracking for conversation sessions. The class handles both successful responses and error cases, producing readable documents suitable for e-ink readers.
Source Code
class PDFGenerator:
"""Generates PDF responses optimized for e-ink displays"""
def __init__(self):
self.styles = getSampleStyleSheet()
self.setup_eink_styles()
def setup_eink_styles(self):
"""Setup custom styles optimized for e-ink displays"""
# Main title style
self.styles.add(ParagraphStyle(
name='EInkTitle',
parent=self.styles['Title'],
fontSize=18,
leading=24,
alignment=TA_CENTER,
spaceAfter=20,
textColor=colors.black,
fontName='Helvetica-Bold'
))
# Section header style
self.styles.add(ParagraphStyle(
name='EInkHeader',
parent=self.styles['Heading1'],
fontSize=14,
leading=18,
spaceAfter=12,
spaceBefore=16,
textColor=colors.black,
fontName='Helvetica-Bold'
))
# Sub-header style
self.styles.add(ParagraphStyle(
name='EInkSubHeader',
parent=self.styles['Heading2'],
fontSize=12,
leading=16,
spaceAfter=8,
spaceBefore=12,
textColor=colors.black,
fontName='Helvetica-Bold'
))
# Body text optimized for e-ink
self.styles.add(ParagraphStyle(
name='EInkBody',
parent=self.styles['Normal'],
fontSize=11,
leading=15,
alignment=TA_JUSTIFY,
spaceAfter=8,
textColor=colors.black,
fontName='Helvetica'
))
# Code or technical text
self.styles.add(ParagraphStyle(
name='EInkCode',
parent=self.styles['Code'],
fontSize=10,
leading=13,
spaceAfter=8,
spaceBefore=4,
textColor=colors.black,
fontName='Courier',
backColor=colors.lightgrey,
borderWidth=1,
borderColor=colors.black,
leftIndent=20,
rightIndent=20
))
# Metadata style
self.styles.add(ParagraphStyle(
name='EInkMeta',
parent=self.styles['Normal'],
fontSize=9,
leading=12,
alignment=TA_LEFT,
spaceAfter=4,
textColor=colors.grey,
fontName='Helvetica-Oblique'
))
def create_response_pdf(self,
llm_response: str,
original_image_b64: str,
metadata: Dict[str, Any],
output_path: str,
conversation_id: Optional[str] = None,
exchange_number: Optional[int] = None) -> str:
"""
Generate PDF with original prompt and LLM response
Args:
llm_response: The AI-generated response
original_image_b64: Base64 encoded original image
metadata: Image metadata
output_path: Path for output PDF
Returns:
Path to generated PDF
"""
print(f"📄 Generating PDF response: {output_path}")
# Use custom document template with session footer
doc = SessionDocTemplate(
output_path,
conversation_id=conversation_id,
exchange_number=exchange_number,
pagesize=letter,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=72
)
story = []
# Add title
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
title = f"AI Response - {timestamp}"
story.append(Paragraph(title, self.styles['EInkTitle']))
story.append(Spacer(1, 20))
# Add metadata section
story.append(Paragraph("Document Information", self.styles['EInkHeader']))
source_info = f"Source: {metadata.get('source_file', 'Unknown')}"
story.append(Paragraph(source_info, self.styles['EInkMeta']))
source_type = f"Type: {metadata.get('source_type', 'Unknown').upper()}"
story.append(Paragraph(source_type, self.styles['EInkMeta']))
if metadata.get('dimensions'):
dims = f"Dimensions: {metadata['dimensions'][0]} x {metadata['dimensions'][1]} pixels"
story.append(Paragraph(dims, self.styles['EInkMeta']))
# Add session information if available
if conversation_id:
session_info = f"Conversation: {conversation_id}"
story.append(Paragraph(session_info, self.styles['EInkMeta']))
if exchange_number:
exchange_info = f"Exchange: #{exchange_number}"
story.append(Paragraph(exchange_info, self.styles['EInkMeta']))
# Add compact mode indicator if present
if metadata.get('compact_mode'):
format_info = "Format: Compact (E-ink optimized)"
story.append(Paragraph(format_info, self.styles['EInkMeta']))
story.append(Spacer(1, 16))
# Add original prompt image
story.append(Paragraph("Original Input", self.styles['EInkHeader']))
original_img = self._b64_to_image(original_image_b64, max_width=450)
if original_img:
story.append(original_img)
else:
story.append(Paragraph("*Original image could not be displayed*", self.styles['EInkMeta']))
story.append(Spacer(1, 20))
# Add AI response section
story.append(Paragraph("AI Analysis and Response", self.styles['EInkHeader']))
story.append(Spacer(1, 12))
# Process the response text with markdown-like formatting
formatted_response = self._format_response_text(llm_response)
story.extend(formatted_response)
# Add footer with generation info
story.append(Spacer(1, 30))
story.append(Paragraph("---", self.styles['EInkMeta']))
# Footer line 1: Basic generation info
footer_text = f"Generated by E-Ink LLM Assistant on {timestamp}"
story.append(Paragraph(footer_text, self.styles['EInkMeta']))
# Footer line 2: Session tracking info
if conversation_id and exchange_number:
session_footer = f"Session: {conversation_id} | Exchange: #{exchange_number}"
story.append(Paragraph(session_footer, self.styles['EInkMeta']))
elif conversation_id:
session_footer = f"Session: {conversation_id}"
story.append(Paragraph(session_footer, self.styles['EInkMeta']))
# Build the PDF
doc.build(story)
print(f"✅ PDF generated successfully: {output_path}")
return output_path
def _format_response_text(self, response_text: str) -> list:
"""
Format response text with basic markdown-like styling for PDF
Args:
response_text: Raw response text from LLM
Returns:
List of ReportLab flowables
"""
story = []
lines = response_text.split('\n')
i = 0
while i < len(lines):
line = lines[i].strip()
if not line:
# Empty line - add small spacer
story.append(Spacer(1, 6))
i += 1
continue
# Handle headers (## or ###)
if line.startswith('###'):
header_text = line[3:].strip()
story.append(Paragraph(header_text, self.styles['EInkSubHeader']))
elif line.startswith('##'):
header_text = line[2:].strip()
story.append(Paragraph(header_text, self.styles['EInkHeader']))
elif line.startswith('#'):
header_text = line[1:].strip()
story.append(Paragraph(header_text, self.styles['EInkTitle']))
# Handle code blocks (```)
elif line.startswith('```'):
i += 1
code_lines = []
while i < len(lines) and not lines[i].strip().startswith('```'):
code_lines.append(lines[i])
i += 1
if code_lines:
code_text = '\n'.join(code_lines)
# Split long code lines for e-ink display
wrapped_code = []
for code_line in code_lines:
if len(code_line) > 80:
wrapped_code.extend(textwrap.wrap(code_line, width=80))
else:
wrapped_code.append(code_line)
code_text = '\n'.join(wrapped_code)
story.append(Paragraph(code_text, self.styles['EInkCode']))
# Handle bullet points
elif line.startswith(('- ', '* ', '+ ')):
bullet_text = line[2:].strip()
# Process bold text within bullets
bullet_text = self._process_inline_formatting(bullet_text)
story.append(Paragraph(f"• {bullet_text}", self.styles['EInkBody']))
# Handle numbered lists
elif line and line[0].isdigit() and '. ' in line:
story.append(Paragraph(line, self.styles['EInkBody']))
# Regular paragraph
else:
if line:
# Process inline formatting (bold, italic)
formatted_line = self._process_inline_formatting(line)
story.append(Paragraph(formatted_line, self.styles['EInkBody']))
i += 1
return story
def _process_inline_formatting(self, text: str) -> str:
"""Process basic inline formatting for ReportLab"""
# Handle bold (**text**)
import re
# Bold formatting
text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text)
# Italic formatting (*text*)
text = re.sub(r'\*(.*?)\*', r'<i>\1</i>', text)
# Code formatting (`text`)
text = re.sub(r'`(.*?)`', r'<font name="Courier">\1</font>', text)
return text
def _b64_to_image(self, image_b64: str, max_width: int = 450) -> Optional[Image]:
"""
Convert base64 to ReportLab Image optimized for e-ink
Args:
image_b64: Base64 encoded image
max_width: Maximum width in points
Returns:
ReportLab Image object or None if conversion fails
"""
try:
img_data = base64.b64decode(image_b64)
img = PILImage.open(io.BytesIO(img_data))
# Convert to grayscale for better e-ink display
if img.mode != 'L':
img = img.convert('L')
# Resize for e-ink display constraints
ratio = min(max_width / img.width, max_width / img.height)
if ratio < 1:
new_size = (int(img.width * ratio), int(img.height * ratio))
img = img.resize(new_size, PILImage.Resampling.LANCZOS)
# Enhance contrast for e-ink
from PIL import ImageEnhance
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(1.2) # Slightly increase contrast
# Save to BytesIO
img_buffer = io.BytesIO()
img.save(img_buffer, format='PNG')
img_buffer.seek(0)
return Image(img_buffer, width=img.width, height=img.height)
except Exception as e:
print(f"⚠️ Error converting image: {e}")
return None
def generate_error_pdf(self, error_message: str, original_file: str, output_path: str,
conversation_id: str = None, exchange_number: int = None) -> str:
"""Generate a PDF for error cases"""
doc = SessionDocTemplate(output_path, pagesize=letter,
rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=72,
conversation_id=conversation_id, exchange_number=exchange_number)
story = []
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Title
story.append(Paragraph("Processing Error", self.styles['EInkTitle']))
story.append(Spacer(1, 20))
# Error details
story.append(Paragraph("Error Information", self.styles['EInkHeader']))
story.append(Paragraph(f"File: {original_file}", self.styles['EInkMeta']))
story.append(Paragraph(f"Time: {timestamp}", self.styles['EInkMeta']))
if conversation_id:
story.append(Paragraph(f"Session: {conversation_id}", self.styles['EInkMeta']))
if exchange_number:
story.append(Paragraph(f"Exchange: #{exchange_number}", self.styles['EInkMeta']))
story.append(Spacer(1, 16))
story.append(Paragraph("Error Message:", self.styles['EInkSubHeader']))
story.append(Paragraph(error_message, self.styles['EInkBody']))
story.append(Spacer(1, 20))
story.append(Paragraph("Please check the input file and try again.", self.styles['EInkBody']))
doc.build(story)
return output_path
Parameters
| Name | Type | Default | Kind |
|---|---|---|---|
bases |
- | - |
Parameter Details
__init__: No parameters required. Initializes the PDF generator with ReportLab's sample stylesheet and sets up custom e-ink optimized styles automatically.
Return Value
Instantiation returns a PDFGenerator object ready to generate PDFs. The main methods (create_response_pdf, generate_error_pdf) return strings containing the file path to the generated PDF document.
Class Interface
Methods
__init__(self)
Purpose: Initializes the PDFGenerator with ReportLab styles and sets up custom e-ink optimized paragraph styles
Returns: None
setup_eink_styles(self)
Purpose: Creates and registers custom paragraph styles optimized for e-ink displays (EInkTitle, EInkHeader, EInkSubHeader, EInkBody, EInkCode, EInkMeta)
Returns: None (modifies self.styles in place)
create_response_pdf(self, llm_response: str, original_image_b64: str, metadata: Dict[str, Any], output_path: str, conversation_id: Optional[str] = None, exchange_number: Optional[int] = None) -> str
Purpose: Generates a complete PDF document with the original input image, LLM response with formatted text, and metadata information
Parameters:
llm_response: The AI-generated text response to format and include in the PDForiginal_image_b64: Base64 encoded string of the original input imagemetadata: Dictionary containing source_file, source_type, dimensions, and optional compact_modeoutput_path: File system path where the PDF should be savedconversation_id: Optional unique identifier for the conversation sessionexchange_number: Optional sequential number for this exchange in the conversation
Returns: String containing the path to the generated PDF file
_format_response_text(self, response_text: str) -> list
Purpose: Parses LLM response text and converts markdown-like syntax into ReportLab flowable objects with appropriate styling
Parameters:
response_text: Raw text response from LLM containing markdown-like formatting
Returns: List of ReportLab flowable objects (Paragraph, Spacer) ready for PDF rendering
_process_inline_formatting(self, text: str) -> str
Purpose: Converts markdown inline formatting (**bold**, *italic*, `code`) to ReportLab XML tags
Parameters:
text: Text string containing markdown inline formatting
Returns: String with markdown syntax replaced by ReportLab XML tags (<b>, <i>, <font>)
_b64_to_image(self, image_b64: str, max_width: int = 450) -> Optional[Image]
Purpose: Converts base64 encoded image to ReportLab Image object, optimized for e-ink with grayscale conversion, resizing, and contrast enhancement
Parameters:
image_b64: Base64 encoded image stringmax_width: Maximum width in points for the image (default 450)
Returns: ReportLab Image object ready for PDF inclusion, or None if conversion fails
generate_error_pdf(self, error_message: str, original_file: str, output_path: str, conversation_id: str = None, exchange_number: int = None) -> str
Purpose: Creates a formatted PDF document for error cases with error details and session tracking information
Parameters:
error_message: Description of the error that occurredoriginal_file: Name or path of the file that caused the erroroutput_path: File system path where the error PDF should be savedconversation_id: Optional unique identifier for the conversation sessionexchange_number: Optional sequential number for this exchange
Returns: String containing the path to the generated error PDF file
Attributes
| Name | Type | Description | Scope |
|---|---|---|---|
styles |
reportlab.lib.styles.StyleSheet1 | ReportLab stylesheet containing both default and custom e-ink optimized paragraph styles | instance |
Dependencies
reportlabPILPillowiobase64pathlibdatetimetypingtextwrapre
Required Imports
import io
import base64
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_JUSTIFY, TA_CENTER
from reportlab.platypus import Paragraph, Spacer, Image
from reportlab.lib import colors
from PIL import Image as PILImage
import textwrap
import re
Conditional/Optional Imports
These imports are only needed under specific conditions:
from PIL import ImageEnhance
Condition: only when processing images for contrast enhancement in _b64_to_image method
Required (conditional)Usage Example
from pdf_generator import PDFGenerator
import base64
# Instantiate the generator
pdf_gen = PDFGenerator()
# Prepare data
llm_response = "## Analysis\n\nThis is a **bold** response with `code`.\n\n- Bullet point 1\n- Bullet point 2"
with open('image.png', 'rb') as f:
image_b64 = base64.b64encode(f.read()).decode('utf-8')
metadata = {
'source_file': 'input.png',
'source_type': 'image',
'dimensions': (800, 600),
'compact_mode': True
}
# Generate PDF
output_path = pdf_gen.create_response_pdf(
llm_response=llm_response,
original_image_b64=image_b64,
metadata=metadata,
output_path='output.pdf',
conversation_id='conv_123',
exchange_number=1
)
print(f'PDF created at: {output_path}')
# Generate error PDF if needed
error_path = pdf_gen.generate_error_pdf(
error_message='File format not supported',
original_file='bad_input.txt',
output_path='error.pdf',
conversation_id='conv_123',
exchange_number=2
)
Best Practices
- Always instantiate PDFGenerator once and reuse it for multiple PDF generations to avoid redundant style setup
- Ensure output_path directory exists before calling create_response_pdf or generate_error_pdf
- Provide base64-encoded images as strings; the class handles decoding and conversion automatically
- Include conversation_id and exchange_number for session tracking in multi-turn conversations
- The class automatically converts images to grayscale and enhances contrast for e-ink displays
- Metadata dictionary should include 'source_file', 'source_type', and optionally 'dimensions' and 'compact_mode'
- LLM responses support markdown-like syntax: ## for headers, for code blocks, - for bullets, **bold**, *italic*, `code`
- Long code lines are automatically wrapped at 80 characters for e-ink readability
- Error handling is built-in for image conversion failures; check console output for warnings
- The SessionDocTemplate dependency must be available in the environment for proper PDF generation with footers
Tags
Similar Components
AI-powered semantic similarity - components with related functionality:
-
class HybridPDFGenerator 76.8% similar
-
class HybridResponseHandler 64.9% similar
-
class CompactResponseFormatter 63.6% similar
-
function demo_hybrid_response 62.3% similar
-
class PDFGenerator_v1 60.6% similar