""" PDF renderer for report generation using reportlab. """ from .rendererBaseTemplate import BaseRenderer from typing import Dict, Any, Tuple, List import io import base64 from datetime import datetime, UTC try: from reportlab.lib.pagesizes import letter, A4 from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY REPORTLAB_AVAILABLE = True except ImportError: REPORTLAB_AVAILABLE = False class RendererPdf(BaseRenderer): """Renders content to PDF format using reportlab.""" @classmethod def get_supported_formats(cls) -> List[str]: """Return supported PDF formats.""" return ['pdf'] @classmethod def get_format_aliases(cls) -> List[str]: """Return format aliases.""" return ['document', 'print'] @classmethod def get_priority(cls) -> int: """Return priority for PDF renderer.""" return 120 async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: """Render extracted JSON content to PDF format using AI-analyzed styling.""" try: if not REPORTLAB_AVAILABLE: # Fallback to HTML if reportlab not available from .rendererHtml import RendererHtml html_renderer = RendererHtml() html_content, _ = await html_renderer.render(extracted_content, title, user_prompt, ai_service) return html_content, "text/html" # Generate PDF using AI-analyzed styling pdf_content = await self._generate_pdf_from_json(extracted_content, title, user_prompt, ai_service) return pdf_content, "application/pdf" except Exception as e: self.logger.error(f"Error rendering PDF: {str(e)}") # Return minimal fallback return f"PDF Generation Error: {str(e)}", "text/plain" async def _generate_pdf_from_json(self, json_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> str: """Generate PDF content from structured JSON document using AI-generated styling.""" try: # Get AI-generated styling definitions styles = await self._get_pdf_styles(user_prompt, ai_service) # Validate JSON structure if not isinstance(json_content, dict): raise ValueError("JSON content must be a dictionary") if "sections" not in json_content: raise ValueError("JSON content must contain 'sections' field") # Use title from JSON metadata if available, otherwise use provided title document_title = json_content.get("metadata", {}).get("title", title) # Create a buffer to hold the PDF buffer = io.BytesIO() # Create PDF document doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18 ) # Build PDF content story = [] # Title page title_style = self._create_title_style(styles) story.append(Paragraph(document_title, title_style)) story.append(Spacer(1, 20)) story.append(Paragraph(f"Generated: {self._format_timestamp()}", self._create_normal_style(styles))) story.append(PageBreak()) # Process each section sections = json_content.get("sections", []) for section in sections: section_elements = self._render_json_section(section, styles) story.extend(section_elements) # Build PDF doc.build(story) # Get PDF content as base64 buffer.seek(0) pdf_bytes = buffer.getvalue() pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') return pdf_base64 except Exception as e: self.logger.error(f"Error generating PDF from JSON: {str(e)}") raise Exception(f"PDF generation failed: {str(e)}") async def _get_pdf_styles(self, user_prompt: str, ai_service=None) -> Dict[str, Any]: """Get PDF styling definitions using base template AI styling.""" style_schema = { "title": {"font_size": 24, "color": "#1F4E79", "bold": True, "align": "center", "space_after": 30}, "heading1": {"font_size": 18, "color": "#2F2F2F", "bold": True, "align": "left", "space_after": 12, "space_before": 12}, "heading2": {"font_size": 14, "color": "#4F4F4F", "bold": True, "align": "left", "space_after": 8, "space_before": 8}, "paragraph": {"font_size": 11, "color": "#2F2F2F", "bold": False, "align": "left", "space_after": 6, "line_height": 1.2}, "table_header": {"background": "#4F4F4F", "text_color": "#FFFFFF", "bold": True, "align": "center", "font_size": 12}, "table_cell": {"background": "#FFFFFF", "text_color": "#2F2F2F", "bold": False, "align": "left", "font_size": 10}, "bullet_list": {"font_size": 11, "color": "#2F2F2F", "space_after": 3}, "code_block": {"font": "Courier", "font_size": 9, "color": "#2F2F2F", "background": "#F5F5F5", "space_after": 6} } style_template = self._create_ai_style_template("pdf", user_prompt, style_schema) styles = await self._get_ai_styles(ai_service, style_template, self._get_default_pdf_styles()) # Validate and fix contrast issues return self._validate_pdf_styles_contrast(styles) def _validate_pdf_styles_contrast(self, styles: Dict[str, Any]) -> Dict[str, Any]: """Validate and fix contrast issues in AI-generated styles.""" try: # Fix table header contrast if "table_header" in styles: header = styles["table_header"] bg_color = header.get("background", "#FFFFFF") text_color = header.get("text_color", "#000000") # If both are white or both are dark, fix it if bg_color.upper() == "#FFFFFF" and text_color.upper() == "#FFFFFF": header["background"] = "#4F4F4F" header["text_color"] = "#FFFFFF" elif bg_color.upper() == "#000000" and text_color.upper() == "#000000": header["background"] = "#4F4F4F" header["text_color"] = "#FFFFFF" # Fix table cell contrast if "table_cell" in styles: cell = styles["table_cell"] bg_color = cell.get("background", "#FFFFFF") text_color = cell.get("text_color", "#000000") # If both are white or both are dark, fix it if bg_color.upper() == "#FFFFFF" and text_color.upper() == "#FFFFFF": cell["background"] = "#FFFFFF" cell["text_color"] = "#2F2F2F" elif bg_color.upper() == "#000000" and text_color.upper() == "#000000": cell["background"] = "#FFFFFF" cell["text_color"] = "#2F2F2F" return styles except Exception as e: self.logger.warning(f"Style validation failed: {str(e)}") return self._get_default_pdf_styles() def _get_default_pdf_styles(self) -> Dict[str, Any]: """Default PDF styles.""" return { "title": {"font_size": 24, "color": "#1F4E79", "bold": True, "align": "center", "space_after": 30}, "heading1": {"font_size": 18, "color": "#2F2F2F", "bold": True, "align": "left", "space_after": 12, "space_before": 12}, "heading2": {"font_size": 14, "color": "#4F4F4F", "bold": True, "align": "left", "space_after": 8, "space_before": 8}, "paragraph": {"font_size": 11, "color": "#2F2F2F", "bold": False, "align": "left", "space_after": 6, "line_height": 1.2}, "table_header": {"background": "#4F4F4F", "text_color": "#FFFFFF", "bold": True, "align": "center", "font_size": 12}, "table_cell": {"background": "#FFFFFF", "text_color": "#2F2F2F", "bold": False, "align": "left", "font_size": 10}, "bullet_list": {"font_size": 11, "color": "#2F2F2F", "space_after": 3}, "code_block": {"font": "Courier", "font_size": 9, "color": "#2F2F2F", "background": "#F5F5F5", "space_after": 6} } def _create_title_style(self, styles: Dict[str, Any]) -> ParagraphStyle: """Create title style from style definitions.""" title_style_def = styles.get("title", {}) return ParagraphStyle( 'CustomTitle', fontSize=title_style_def.get("font_size", 24), spaceAfter=title_style_def.get("space_after", 30), alignment=self._get_alignment(title_style_def.get("align", "center")), textColor=self._hex_to_color(title_style_def.get("color", "#1F4E79")) ) def _create_heading_style(self, styles: Dict[str, Any], level: int) -> ParagraphStyle: """Create heading style from style definitions.""" heading_key = f"heading{level}" heading_style_def = styles.get(heading_key, styles.get("heading1", {})) return ParagraphStyle( f'CustomHeading{level}', fontSize=heading_style_def.get("font_size", 18 - level * 2), spaceAfter=heading_style_def.get("space_after", 12), spaceBefore=heading_style_def.get("space_before", 12), alignment=self._get_alignment(heading_style_def.get("align", "left")), textColor=self._hex_to_color(heading_style_def.get("color", "#2F2F2F")) ) def _create_normal_style(self, styles: Dict[str, Any]) -> ParagraphStyle: """Create normal paragraph style from style definitions.""" paragraph_style_def = styles.get("paragraph", {}) return ParagraphStyle( 'CustomNormal', fontSize=paragraph_style_def.get("font_size", 11), spaceAfter=paragraph_style_def.get("space_after", 6), alignment=self._get_alignment(paragraph_style_def.get("align", "left")), textColor=self._hex_to_color(paragraph_style_def.get("color", "#2F2F2F")), leading=paragraph_style_def.get("line_height", 1.2) * paragraph_style_def.get("font_size", 11) ) def _get_alignment(self, align: str) -> int: """Convert alignment string to reportlab alignment constant.""" align_map = { "center": TA_CENTER, "left": TA_LEFT, "justify": TA_JUSTIFY } return align_map.get(align.lower(), TA_LEFT) def _hex_to_color(self, hex_color: str) -> colors.Color: """Convert hex color to reportlab color.""" try: hex_color = hex_color.lstrip('#') r = int(hex_color[0:2], 16) / 255.0 g = int(hex_color[2:4], 16) / 255.0 b = int(hex_color[4:6], 16) / 255.0 return colors.Color(r, g, b) except: return colors.black def _render_json_section(self, section: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a single JSON section to PDF elements using AI-generated styles.""" try: section_type = self._get_section_type(section) section_data = self._get_section_data(section) if section_type == "table": return self._render_json_table(section_data, styles) elif section_type == "bullet_list": return self._render_json_bullet_list(section_data, styles) elif section_type == "heading": return self._render_json_heading(section_data, styles) elif section_type == "paragraph": return self._render_json_paragraph(section_data, styles) elif section_type == "code_block": return self._render_json_code_block(section_data, styles) elif section_type == "image": return self._render_json_image(section_data, styles) else: # Fallback to paragraph for unknown types return self._render_json_paragraph(section_data, styles) except Exception as e: self.logger.warning(f"Error rendering section {self._get_section_id(section)}: {str(e)}") return [Paragraph(f"[Error rendering section: {str(e)}]", self._create_normal_style(styles))] def _render_json_table(self, table_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON table to PDF elements using AI-generated styles.""" try: headers = table_data.get("headers", []) rows = table_data.get("rows", []) if not headers or not rows: return [] # Prepare table data table_data_list = [headers] + rows # Create table table = Table(table_data_list) # Apply styling table_header_style = styles.get("table_header", {}) table_cell_style = styles.get("table_cell", {}) table_style = [ ('BACKGROUND', (0, 0), (-1, 0), self._hex_to_color(table_header_style.get("background", "#4F4F4F"))), ('TEXTCOLOR', (0, 0), (-1, 0), self._hex_to_color(table_header_style.get("text_color", "#FFFFFF"))), ('ALIGN', (0, 0), (-1, -1), self._get_alignment(table_cell_style.get("align", "left"))), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold' if table_header_style.get("bold", True) else 'Helvetica'), ('FONTSIZE', (0, 0), (-1, 0), table_header_style.get("font_size", 12)), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('BACKGROUND', (0, 1), (-1, -1), self._hex_to_color(table_cell_style.get("background", "#FFFFFF"))), ('FONTSIZE', (0, 1), (-1, -1), table_cell_style.get("font_size", 10)), ('GRID', (0, 0), (-1, -1), 1, colors.black) ] table.setStyle(TableStyle(table_style)) return [table, Spacer(1, 12)] except Exception as e: self.logger.warning(f"Error rendering table: {str(e)}") return [] def _render_json_bullet_list(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON bullet list to PDF elements using AI-generated styles.""" try: items = list_data.get("items", []) bullet_style_def = styles.get("bullet_list", {}) elements = [] for item in items: if isinstance(item, str): elements.append(Paragraph(f"• {item}", self._create_normal_style(styles))) elif isinstance(item, dict) and "text" in item: elements.append(Paragraph(f"• {item['text']}", self._create_normal_style(styles))) if elements: elements.append(Spacer(1, bullet_style_def.get("space_after", 3))) return elements except Exception as e: self.logger.warning(f"Error rendering bullet list: {str(e)}") return [] def _render_json_heading(self, heading_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON heading to PDF elements using AI-generated styles.""" try: level = heading_data.get("level", 1) text = heading_data.get("text", "") if text: level = max(1, min(6, level)) heading_style = self._create_heading_style(styles, level) return [Paragraph(text, heading_style)] return [] except Exception as e: self.logger.warning(f"Error rendering heading: {str(e)}") return [] def _render_json_paragraph(self, paragraph_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON paragraph to PDF elements using AI-generated styles.""" try: text = paragraph_data.get("text", "") if text: return [Paragraph(text, self._create_normal_style(styles))] return [] except Exception as e: self.logger.warning(f"Error rendering paragraph: {str(e)}") return [] def _render_json_code_block(self, code_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON code block to PDF elements using AI-generated styles.""" try: code = code_data.get("code", "") language = code_data.get("language", "") code_style_def = styles.get("code_block", {}) if code: elements = [] if language: lang_style = ParagraphStyle( 'CodeLanguage', fontSize=code_style_def.get("font_size", 9), textColor=self._hex_to_color(code_style_def.get("color", "#2F2F2F")), fontName='Helvetica-Bold' ) elements.append(Paragraph(f"Code ({language}):", lang_style)) code_style = ParagraphStyle( 'CodeBlock', fontSize=code_style_def.get("font_size", 9), textColor=self._hex_to_color(code_style_def.get("color", "#2F2F2F")), fontName=code_style_def.get("font", "Courier"), backColor=self._hex_to_color(code_style_def.get("background", "#F5F5F5")), spaceAfter=code_style_def.get("space_after", 6) ) elements.append(Paragraph(code, code_style)) return elements return [] except Exception as e: self.logger.warning(f"Error rendering code block: {str(e)}") return [] def _render_json_image(self, image_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON image to PDF elements.""" try: base64_data = image_data.get("base64Data", "") alt_text = image_data.get("altText", "Image") if base64_data: # For now, just add a placeholder since reportlab image handling is complex return [Paragraph(f"[Image: {alt_text}]", self._create_normal_style(styles))] return [] except Exception as e: self.logger.warning(f"Error rendering image: {str(e)}") return [Paragraph(f"[Image: {image_data.get('altText', 'Image')}]", self._create_normal_style(styles))]