🔍 Code Extractor

class PDFGenerator

Maturity: 46

A class that generates PDF documents optimized for e-ink displays, converting LLM responses and images into formatted, high-contrast PDFs with custom styling.

File:
/tf/active/vicechatdev/e-ink-llm/pdf_generator.py
Lines:
69 - 430
Complexity:
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 PDF
  • original_image_b64: Base64 encoded string of the original input image
  • metadata: Dictionary containing source_file, source_type, dimensions, and optional compact_mode
  • output_path: File system path where the PDF should be saved
  • conversation_id: Optional unique identifier for the conversation session
  • exchange_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 string
  • max_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 occurred
  • original_file: Name or path of the file that caused the error
  • output_path: File system path where the error PDF should be saved
  • conversation_id: Optional unique identifier for the conversation session
  • exchange_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

  • reportlab
  • PIL
  • Pillow
  • io
  • base64
  • pathlib
  • datetime
  • typing
  • textwrap
  • re

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

Similar Components

AI-powered semantic similarity - components with related functionality:

  • class HybridPDFGenerator 76.8% similar

    A class that generates hybrid PDF documents combining formatted text content with embedded graphics, optimized for e-ink displays.

    From: /tf/active/vicechatdev/e-ink-llm/hybrid_pdf_generator.py
  • class HybridResponseHandler 64.9% similar

    Orchestrates the complete workflow for generating hybrid PDF documents that combine LLM text responses with dynamically generated graphics (charts, diagrams, illustrations).

    From: /tf/active/vicechatdev/e-ink-llm/hybrid_response_handler.py
  • class CompactResponseFormatter 63.6% similar

    A formatter class that converts verbose LLM responses into compact, symbol-rich text optimized for e-ink displays by using Unicode symbols, mathematical notation, and abbreviated formatting.

    From: /tf/active/vicechatdev/e-ink-llm/compact_formatter.py
  • function demo_hybrid_response 62.3% similar

    Demonstrates end-to-end hybrid response processing by converting an LLM response containing text and graphics placeholders into a formatted PDF document.

    From: /tf/active/vicechatdev/e-ink-llm/demo_hybrid_mode.py
  • class PDFGenerator_v1 60.6% similar

    PDF document generation for reports and controlled documents This class provides methods to generate PDF documents from scratch, including audit reports, document covers, and certificate pages.

    From: /tf/active/vicechatdev/CDocs/utils/pdf_utils.py
← Back to Browse