""" 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) # Make title shorter to prevent wrapping/overlapping if len(document_title) > 40: document_title = "PowerOn - Consent Agreement" # 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, 50)) # Increased spacing to prevent overlap story.append(Paragraph(f"Generated: {self._format_timestamp()}", self._create_normal_style(styles))) story.append(Spacer(1, 30)) # Add spacing before page break story.append(PageBreak()) # Process each section sections = json_content.get("sections", []) self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER") for i, section in enumerate(sections): self.services.utils.debugLogToFile(f"PDF SECTION {i}: content_type={section.get('content_type', 'unknown')}, id={section.get('id', 'unknown')}", "PDF_RENDERER") section_elements = self._render_json_section(section, styles) self.services.utils.debugLogToFile(f"PDF SECTION {i} ELEMENTS: {len(section_elements)} elements", "PDF_RENDERER") 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) # Use base template method like DOCX does (this works!) styles = await self._get_ai_styles(ai_service, style_template, self._get_default_pdf_styles()) if styles is None: return self._get_default_pdf_styles() # Convert colors to PDF format after getting styles styles = self._convert_colors_format(styles) # Validate and fix contrast issues return self._validate_pdf_styles_contrast(styles) async def _get_ai_styles_with_pdf_colors(self, ai_service, style_template: str, default_styles: Dict[str, Any]) -> Dict[str, Any]: """Get AI styles with proper PDF color conversion.""" if not ai_service: return default_styles try: from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum request_options = AiCallOptions() request_options.operationType = OperationTypeEnum.GENERAL request = AiCallRequest(prompt=style_template, context="", options=request_options) # Check if AI service is properly configured if not hasattr(ai_service, 'aiObjects') or not ai_service.aiObjects: self.logger.warning("AI service not properly configured, using defaults") return default_styles response = await ai_service.aiObjects.call(request) # Check if response is valid if not response: self.logger.warning("AI service returned no response, using defaults") return default_styles import json import re # Clean and parse JSON result = response.content.strip() if response and response.content else "" # Check if result is empty if not result: self.logger.warning("AI styling returned empty response, using defaults") return default_styles # Log the raw response for debugging self.logger.debug(f"AI styling raw response: {result[:200]}...") # Extract JSON from various formats json_match = re.search(r'```json\s*\n(.*?)\n```', result, re.DOTALL) if json_match: result = json_match.group(1).strip() elif result.startswith('```json'): result = re.sub(r'^```json\s*', '', result) result = re.sub(r'\s*```$', '', result) elif result.startswith('```'): result = re.sub(r'^```\s*', '', result) result = re.sub(r'\s*```$', '', result) # Try to extract JSON from explanatory text json_patterns = [ r'\{[^{}]*"title"[^{}]*\}', # Simple JSON object r'\{.*?"title".*?\}', # JSON with title field r'\{.*?"font_size".*?\}', # JSON with font_size field ] for pattern in json_patterns: json_match = re.search(pattern, result, re.DOTALL) if json_match: result = json_match.group(0) break # Additional cleanup - remove any leading/trailing whitespace and newlines result = result.strip() # Check if result is still empty after cleanup if not result: self.logger.warning("AI styling returned empty content after cleanup, using defaults") return default_styles # Try to parse JSON try: styles = json.loads(result) self.logger.debug(f"Successfully parsed AI styles: {list(styles.keys())}") except json.JSONDecodeError as json_error: self.logger.warning(f"AI styling returned invalid JSON: {json_error}") # Use print instead of logger to avoid truncation self.services.utils.debugLogToFile(f"FULL AI RESPONSE THAT FAILED TO PARSE: {result}", "PDF_RENDERER") self.services.utils.debugLogToFile(f"RESPONSE LENGTH: {len(result)} characters", "PDF_RENDERER") self.logger.warning(f"Raw content that failed to parse: {result}") # Try to fix incomplete JSON by adding missing closing braces open_braces = result.count('{') close_braces = result.count('}') if open_braces > close_braces: # JSON is incomplete, add missing closing braces missing_braces = open_braces - close_braces result = result + '}' * missing_braces self.logger.info(f"Added {missing_braces} missing closing brace(s)") # Try parsing the fixed JSON try: styles = json.loads(result) self.logger.info("Successfully fixed incomplete JSON") except json.JSONDecodeError as fix_error: self.logger.warning(f"Fixed JSON still invalid: {fix_error}") # Try to extract just the JSON part if it's embedded in text json_start = result.find('{') json_end = result.rfind('}') if json_start != -1 and json_end != -1 and json_end > json_start: json_part = result[json_start:json_end+1] try: styles = json.loads(json_part) self.logger.info("Successfully extracted JSON from explanatory text") except json.JSONDecodeError: self.logger.warning("Could not extract valid JSON from response, using defaults") return default_styles else: return default_styles else: # Try to extract just the JSON part if it's embedded in text json_start = result.find('{') json_end = result.rfind('}') if json_start != -1 and json_end != -1 and json_end > json_start: json_part = result[json_start:json_end+1] try: styles = json.loads(json_part) self.logger.info("Successfully extracted JSON from explanatory text") except json.JSONDecodeError: self.logger.warning("Could not extract valid JSON from response, using defaults") return default_styles else: return default_styles # Convert colors to PDF format (keep as hex strings, PDF renderer will convert them) styles = self._convert_colors_format(styles) return styles except Exception as e: self.logger.warning(f"AI styling failed: {str(e)}, using defaults") return default_styles def _convert_colors_format(self, styles: Dict[str, Any]) -> Dict[str, Any]: """Convert colors to proper format for PDF compatibility.""" try: for style_name, style_config in styles.items(): if isinstance(style_config, dict): for prop, value in style_config.items(): if isinstance(value, str) and value.startswith('#') and len(value) == 7: # Convert #RRGGBB to #AARRGGBB (add FF alpha channel) for consistency styles[style_name][prop] = f"FF{value[1:]}" elif isinstance(value, str) and value.startswith('#') and len(value) == 9: # Already aRGB format, keep as is pass return styles except Exception as e: self.logger.warning(f"Color conversion failed: {str(e)}") return styles def _get_safe_color(self, color_value: str, default: str = "#000000") -> str: """Get a safe hex color value for PDF.""" if isinstance(color_value, str) and color_value.startswith('#'): if len(color_value) == 7: return f"FF{color_value[1:]}" elif len(color_value) == 9: return color_value return default 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", {}) # DEBUG: Show what color and spacing is being used for title title_color = title_style_def.get("color", "#1F4E79") title_space_after = title_style_def.get("space_after", 30) self.services.utils.debugLogToFile(f"PDF TITLE COLOR: {title_color} -> {self._hex_to_color(title_color)}", "PDF_RENDERER") self.services.utils.debugLogToFile(f"PDF TITLE SPACE_AFTER: {title_space_after}", "PDF_RENDERER") return ParagraphStyle( 'CustomTitle', fontSize=title_style_def.get("font_size", 20), # Reduced from 24 to 20 spaceAfter=title_style_def.get("space_after", 30), alignment=self._get_alignment(title_style_def.get("align", "center")), textColor=self._hex_to_color(title_color), leading=title_style_def.get("font_size", 20) * 1.4, # Add line spacing for multi-line titles spaceBefore=0 # Ensure no space before title ) 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.""" if not align or not isinstance(align, str): return TA_LEFT align_map = { "center": TA_CENTER, "left": TA_LEFT, "justify": TA_JUSTIFY, "right": TA_LEFT, # ReportLab doesn't have TA_RIGHT, use LEFT as fallback "0": TA_LEFT, # Handle numeric strings "1": TA_CENTER, "2": TA_JUSTIFY } return align_map.get(align.lower().strip(), TA_LEFT) def _get_table_alignment(self, align: str) -> str: """Convert alignment string to ReportLab table alignment string.""" if not align or not isinstance(align, str): return 'LEFT' align_map = { "center": 'CENTER', "left": 'LEFT', "justify": 'LEFT', # Tables don't support justify, use LEFT "right": 'RIGHT', "0": 'LEFT', # Handle numeric strings "1": 'CENTER', "2": 'LEFT' # Tables don't support justify, use LEFT } return align_map.get(align.lower().strip(), 'LEFT') def _hex_to_color(self, hex_color: str) -> colors.Color: """Convert hex color to reportlab color.""" try: hex_color = hex_color.lstrip('#') # Handle aRGB format (8 characters: FF + RGB) if len(hex_color) == 8: # Skip the alpha channel (first 2 characters) hex_color = hex_color[2:] # Handle RGB format (6 characters) if len(hex_color) == 6: 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) # Fallback for other formats return colors.black 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) elements = self._get_section_data(section) # Process each element in the section all_elements = [] for element in elements: if section_type == "table": all_elements.extend(self._render_json_table(element, styles)) elif section_type == "bullet_list": all_elements.extend(self._render_json_bullet_list(element, styles)) elif section_type == "heading": all_elements.extend(self._render_json_heading(element, styles)) elif section_type == "paragraph": all_elements.extend(self._render_json_paragraph(element, styles)) elif section_type == "code_block": all_elements.extend(self._render_json_code_block(element, styles)) elif section_type == "image": all_elements.extend(self._render_json_image(element, styles)) else: # Fallback to paragraph for unknown types all_elements.extend(self._render_json_paragraph(element, styles)) return all_elements 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_table_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))]