442 lines
21 KiB
Python
442 lines
21 KiB
Python
"""
|
|
HTML renderer for report generation.
|
|
"""
|
|
|
|
from .rendererBaseTemplate import BaseRenderer
|
|
from typing import Dict, Any, Tuple, List
|
|
|
|
class RendererHtml(BaseRenderer):
|
|
"""Renders content to HTML format with format-specific extraction."""
|
|
|
|
@classmethod
|
|
def get_supported_formats(cls) -> List[str]:
|
|
"""Return supported HTML formats."""
|
|
return ['html', 'htm']
|
|
|
|
@classmethod
|
|
def get_format_aliases(cls) -> List[str]:
|
|
"""Return format aliases."""
|
|
return ['web', 'webpage']
|
|
|
|
@classmethod
|
|
def get_priority(cls) -> int:
|
|
"""Return priority for HTML renderer."""
|
|
return 100
|
|
|
|
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 HTML format using AI-analyzed styling."""
|
|
try:
|
|
# Generate HTML using AI-analyzed styling
|
|
html_content = await self._generate_html_from_json(extracted_content, title, user_prompt, ai_service)
|
|
|
|
return html_content, "text/html"
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error rendering HTML: {str(e)}")
|
|
# Return minimal HTML fallback
|
|
return f"<html><head><title>{title}</title></head><body><h1>{title}</h1><p>Error rendering report: {str(e)}</p></body></html>", "text/html"
|
|
|
|
async def _generate_html_from_json(self, json_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> str:
|
|
"""Generate HTML content from structured JSON document using AI-generated styling."""
|
|
try:
|
|
# Get AI-generated styling definitions
|
|
styles = await self._get_html_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)
|
|
|
|
# Build HTML document
|
|
html_parts = []
|
|
|
|
# HTML document structure
|
|
html_parts.append('<!DOCTYPE html>')
|
|
html_parts.append('<html lang="en">')
|
|
html_parts.append('<head>')
|
|
html_parts.append('<meta charset="UTF-8">')
|
|
html_parts.append('<meta name="viewport" content="width=device-width, initial-scale=1.0">')
|
|
html_parts.append(f'<title>{document_title}</title>')
|
|
html_parts.append('<style>')
|
|
html_parts.append(self._generate_css_styles(styles))
|
|
html_parts.append('</style>')
|
|
html_parts.append('</head>')
|
|
html_parts.append('<body>')
|
|
|
|
# Document header
|
|
html_parts.append(f'<header><h1 class="document-title">{document_title}</h1></header>')
|
|
|
|
# Main content
|
|
html_parts.append('<main>')
|
|
|
|
# Process each section
|
|
sections = json_content.get("sections", [])
|
|
for section in sections:
|
|
section_html = self._render_json_section(section, styles)
|
|
if section_html:
|
|
html_parts.append(section_html)
|
|
|
|
html_parts.append('</main>')
|
|
|
|
# Footer
|
|
html_parts.append('<footer>')
|
|
html_parts.append(f'<p class="generated-info">Generated: {self._format_timestamp()}</p>')
|
|
html_parts.append('</footer>')
|
|
|
|
html_parts.append('</body>')
|
|
html_parts.append('</html>')
|
|
|
|
return '\n'.join(html_parts)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error generating HTML from JSON: {str(e)}")
|
|
raise Exception(f"HTML generation failed: {str(e)}")
|
|
|
|
async def _get_html_styles(self, user_prompt: str, ai_service=None) -> Dict[str, Any]:
|
|
"""Get HTML styling definitions using base template AI styling."""
|
|
style_schema = {
|
|
"title": {"font_size": "2.5em", "color": "#1F4E79", "font_weight": "bold", "text_align": "center", "margin": "0 0 1em 0"},
|
|
"heading1": {"font_size": "2em", "color": "#2F2F2F", "font_weight": "bold", "text_align": "left", "margin": "1.5em 0 0.5em 0"},
|
|
"heading2": {"font_size": "1.5em", "color": "#4F4F4F", "font_weight": "bold", "text_align": "left", "margin": "1em 0 0.5em 0"},
|
|
"paragraph": {"font_size": "1em", "color": "#2F2F2F", "font_weight": "normal", "text_align": "left", "margin": "0 0 1em 0", "line_height": "1.6"},
|
|
"table": {"border": "1px solid #ddd", "border_collapse": "collapse", "width": "100%", "margin": "1em 0"},
|
|
"table_header": {"background": "#4F4F4F", "color": "#FFFFFF", "font_weight": "bold", "text_align": "center", "padding": "12px"},
|
|
"table_cell": {"background": "#FFFFFF", "color": "#2F2F2F", "font_weight": "normal", "text_align": "left", "padding": "8px", "border": "1px solid #ddd"},
|
|
"bullet_list": {"font_size": "1em", "color": "#2F2F2F", "margin": "0 0 1em 0", "padding_left": "20px"},
|
|
"code_block": {"font_family": "Courier New, monospace", "font_size": "0.9em", "color": "#2F2F2F", "background": "#F5F5F5", "padding": "1em", "border": "1px solid #ddd", "border_radius": "4px", "margin": "1em 0"},
|
|
"image": {"max_width": "100%", "height": "auto", "margin": "1em 0", "border_radius": "4px"},
|
|
"body": {"font_family": "Arial, sans-serif", "background": "#FFFFFF", "color": "#2F2F2F", "margin": "0", "padding": "20px"}
|
|
}
|
|
|
|
style_template = self._create_ai_style_template("html", user_prompt, style_schema)
|
|
styles = await self._get_ai_styles(ai_service, style_template, self._get_default_html_styles())
|
|
|
|
# Validate and fix contrast issues
|
|
return self._validate_html_styles_contrast(styles)
|
|
|
|
def _validate_html_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("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["color"] = "#FFFFFF"
|
|
elif bg_color.upper() == "#000000" and text_color.upper() == "#000000":
|
|
header["background"] = "#4F4F4F"
|
|
header["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("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["color"] = "#2F2F2F"
|
|
elif bg_color.upper() == "#000000" and text_color.upper() == "#000000":
|
|
cell["background"] = "#FFFFFF"
|
|
cell["color"] = "#2F2F2F"
|
|
|
|
return styles
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Style validation failed: {str(e)}")
|
|
return self._get_default_html_styles()
|
|
|
|
|
|
def _get_default_html_styles(self) -> Dict[str, Any]:
|
|
"""Default HTML styles."""
|
|
return {
|
|
"title": {"font_size": "2.5em", "color": "#1F4E79", "font_weight": "bold", "text_align": "center", "margin": "0 0 1em 0"},
|
|
"heading1": {"font_size": "2em", "color": "#2F2F2F", "font_weight": "bold", "text_align": "left", "margin": "1.5em 0 0.5em 0"},
|
|
"heading2": {"font_size": "1.5em", "color": "#4F4F4F", "font_weight": "bold", "text_align": "left", "margin": "1em 0 0.5em 0"},
|
|
"paragraph": {"font_size": "1em", "color": "#2F2F2F", "font_weight": "normal", "text_align": "left", "margin": "0 0 1em 0", "line_height": "1.6"},
|
|
"table": {"border": "1px solid #ddd", "border_collapse": "collapse", "width": "100%", "margin": "1em 0"},
|
|
"table_header": {"background": "#4F4F4F", "color": "#FFFFFF", "font_weight": "bold", "text_align": "center", "padding": "12px"},
|
|
"table_cell": {"background": "#FFFFFF", "color": "#2F2F2F", "font_weight": "normal", "text_align": "left", "padding": "8px", "border": "1px solid #ddd"},
|
|
"bullet_list": {"font_size": "1em", "color": "#2F2F2F", "margin": "0 0 1em 0", "padding_left": "20px"},
|
|
"code_block": {"font_family": "Courier New, monospace", "font_size": "0.9em", "color": "#2F2F2F", "background": "#F5F5F5", "padding": "1em", "border": "1px solid #ddd", "border_radius": "4px", "margin": "1em 0"},
|
|
"image": {"max_width": "100%", "height": "auto", "margin": "1em 0", "border_radius": "4px"},
|
|
"body": {"font_family": "Arial, sans-serif", "background": "#FFFFFF", "color": "#2F2F2F", "margin": "0", "padding": "20px"}
|
|
}
|
|
|
|
def _generate_css_styles(self, styles: Dict[str, Any]) -> str:
|
|
"""Generate CSS from style definitions."""
|
|
css_parts = []
|
|
|
|
# Body styles
|
|
body_style = styles.get("body", {})
|
|
css_parts.append("body {")
|
|
for property_name, value in body_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Document title
|
|
title_style = styles.get("title", {})
|
|
css_parts.append(".document-title {")
|
|
for property_name, value in title_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Headings
|
|
for heading_level in ["heading1", "heading2"]:
|
|
heading_style = styles.get(heading_level, {})
|
|
css_class = f"h{heading_level[-1]}"
|
|
css_parts.append(f"{css_class} {{")
|
|
for property_name, value in heading_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Paragraphs
|
|
paragraph_style = styles.get("paragraph", {})
|
|
css_parts.append("p {")
|
|
for property_name, value in paragraph_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Tables
|
|
table_style = styles.get("table", {})
|
|
css_parts.append("table {")
|
|
for property_name, value in table_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Table headers
|
|
table_header_style = styles.get("table_header", {})
|
|
css_parts.append("th {")
|
|
for property_name, value in table_header_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Table cells
|
|
table_cell_style = styles.get("table_cell", {})
|
|
css_parts.append("td {")
|
|
for property_name, value in table_cell_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Lists
|
|
bullet_list_style = styles.get("bullet_list", {})
|
|
css_parts.append("ul {")
|
|
for property_name, value in bullet_list_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Code blocks
|
|
code_block_style = styles.get("code_block", {})
|
|
css_parts.append("pre {")
|
|
for property_name, value in code_block_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Images
|
|
image_style = styles.get("image", {})
|
|
css_parts.append("img {")
|
|
for property_name, value in image_style.items():
|
|
css_property = property_name.replace("_", "-")
|
|
css_parts.append(f" {css_property}: {value};")
|
|
css_parts.append("}")
|
|
|
|
# Generated info
|
|
css_parts.append(".generated-info {")
|
|
css_parts.append(" font-size: 0.9em;")
|
|
css_parts.append(" color: #666;")
|
|
css_parts.append(" text-align: center;")
|
|
css_parts.append(" margin-top: 2em;")
|
|
css_parts.append(" padding-top: 1em;")
|
|
css_parts.append(" border-top: 1px solid #ddd;")
|
|
css_parts.append("}")
|
|
|
|
return '\n'.join(css_parts)
|
|
|
|
def _render_json_section(self, section: Dict[str, Any], styles: Dict[str, Any]) -> str:
|
|
"""Render a single JSON section to HTML using AI-generated styles."""
|
|
try:
|
|
section_type = self._get_section_type(section)
|
|
section_data = self._get_section_data(section)
|
|
|
|
if section_type == "table":
|
|
# Process the section data to extract table structure
|
|
processed_data = self._process_section_by_type(section)
|
|
return self._render_json_table(processed_data, styles)
|
|
elif section_type == "bullet_list":
|
|
# Process the section data to extract bullet list structure
|
|
processed_data = self._process_section_by_type(section)
|
|
return self._render_json_bullet_list(processed_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":
|
|
# Process the section data to extract code block structure
|
|
processed_data = self._process_section_by_type(section)
|
|
return self._render_json_code_block(processed_data, styles)
|
|
elif section_type == "image":
|
|
# Process the section data to extract image structure
|
|
processed_data = self._process_section_by_type(section)
|
|
return self._render_json_image(processed_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 f'<div class="error">[Error rendering section: {str(e)}]</div>'
|
|
|
|
def _render_json_table(self, table_data: Dict[str, Any], styles: Dict[str, Any]) -> str:
|
|
"""Render a JSON table to HTML using AI-generated styles."""
|
|
try:
|
|
headers = table_data.get("headers", [])
|
|
rows = table_data.get("rows", [])
|
|
|
|
if not headers or not rows:
|
|
return ""
|
|
|
|
html_parts = ['<table>']
|
|
|
|
# Table header
|
|
html_parts.append('<thead><tr>')
|
|
for header in headers:
|
|
html_parts.append(f'<th>{header}</th>')
|
|
html_parts.append('</tr></thead>')
|
|
|
|
# Table body
|
|
html_parts.append('<tbody>')
|
|
for row in rows:
|
|
html_parts.append('<tr>')
|
|
for cell_data in row:
|
|
html_parts.append(f'<td>{cell_data}</td>')
|
|
html_parts.append('</tr>')
|
|
html_parts.append('</tbody>')
|
|
|
|
html_parts.append('</table>')
|
|
return '\n'.join(html_parts)
|
|
|
|
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]) -> str:
|
|
"""Render a JSON bullet list to HTML using AI-generated styles."""
|
|
try:
|
|
items = list_data.get("items", [])
|
|
|
|
if not items:
|
|
return ""
|
|
|
|
html_parts = ['<ul>']
|
|
for item in items:
|
|
if isinstance(item, str):
|
|
html_parts.append(f'<li>{item}</li>')
|
|
elif isinstance(item, dict) and "text" in item:
|
|
html_parts.append(f'<li>{item["text"]}</li>')
|
|
html_parts.append('</ul>')
|
|
|
|
return '\n'.join(html_parts)
|
|
|
|
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]) -> str:
|
|
"""Render a JSON heading to HTML using AI-generated styles."""
|
|
try:
|
|
# Normalize non-dict inputs
|
|
if isinstance(heading_data, str):
|
|
heading_data = {"text": heading_data, "level": 2}
|
|
elif isinstance(heading_data, list):
|
|
# Render a list as bullet list under a default heading label
|
|
return self._render_json_bullet_list({"items": heading_data}, styles)
|
|
elif not isinstance(heading_data, dict):
|
|
return ""
|
|
|
|
level = heading_data.get("level", 1)
|
|
text = heading_data.get("text", "")
|
|
|
|
if text:
|
|
level = max(1, min(6, level))
|
|
return f'<h{level}>{text}</h{level}>'
|
|
|
|
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]) -> str:
|
|
"""Render a JSON paragraph to HTML using AI-generated styles."""
|
|
try:
|
|
# Normalize non-dict inputs
|
|
if isinstance(paragraph_data, str):
|
|
paragraph_data = {"text": paragraph_data}
|
|
elif isinstance(paragraph_data, list):
|
|
# Treat list as bullet list paragraph
|
|
return self._render_json_bullet_list({"items": paragraph_data}, styles)
|
|
elif not isinstance(paragraph_data, dict):
|
|
return ""
|
|
|
|
text = paragraph_data.get("text", "")
|
|
|
|
if text:
|
|
return f'<p>{text}</p>'
|
|
|
|
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]) -> str:
|
|
"""Render a JSON code block to HTML using AI-generated styles."""
|
|
try:
|
|
code = code_data.get("code", "")
|
|
language = code_data.get("language", "")
|
|
|
|
if code:
|
|
if language:
|
|
return f'<pre><code class="language-{language}">{code}</code></pre>'
|
|
else:
|
|
return f'<pre><code>{code}</code></pre>'
|
|
|
|
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]) -> str:
|
|
"""Render a JSON image to HTML."""
|
|
try:
|
|
base64_data = image_data.get("base64Data", "")
|
|
alt_text = image_data.get("altText", "Image")
|
|
|
|
if base64_data:
|
|
return f'<img src="data:image/png;base64,{base64_data}" alt="{alt_text}">'
|
|
|
|
return ""
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Error rendering image: {str(e)}")
|
|
return f'<div class="error">[Image: {image_data.get("altText", "Image")}]</div>'
|