"""
HTML Renderer for Report Generation
Renders GeneratedReport objects to HTML using Jinja2 templates.
Handles image embedding, Markdown conversion, and styling.
"""
import os
import base64
import logging
import markdown
from datetime import datetime
from typing import Optional, Dict, Any
from pathlib import Path
from .config import ReportConfig
logger = logging.getLogger(__name__)
class HTMLRenderer:
"""
Renders reports to HTML format using Jinja2 templates.
"""
def __init__(self, config: Optional[ReportConfig] = None):
"""
Initialize the HTML renderer.
Args:
config: Report configuration
"""
self.config = config or ReportConfig()
self._setup_jinja()
self._setup_markdown()
def _setup_jinja(self):
"""Setup Jinja2 environment."""
try:
from jinja2 import Environment, FileSystemLoader, select_autoescape
# Check if templates directory exists, create default template if not
templates_dir = self.config.templates_dir
if not os.path.exists(os.path.join(templates_dir, 'report_base.html')):
self._create_default_template()
self.jinja_env = Environment(
loader=FileSystemLoader(templates_dir),
autoescape=select_autoescape(['html', 'xml'])
)
# Add custom filters
self.jinja_env.filters['markdown'] = self._markdown_filter
self.jinja_env.filters['format_datetime'] = self._format_datetime
except ImportError:
logger.error("Jinja2 not installed. Install with: pip install Jinja2")
raise
def _setup_markdown(self):
"""Setup Markdown processor."""
self.md = markdown.Markdown(
extensions=['tables', 'fenced_code', 'nl2br', 'toc'],
output_format='html5'
)
def _markdown_filter(self, text: str) -> str:
"""Jinja2 filter to convert Markdown to HTML."""
if not text:
return ''
self.md.reset()
return self.md.convert(text)
def _format_datetime(self, dt, format_str: str = '%Y-%m-%d %H:%M:%S') -> str:
"""Jinja2 filter to format datetime objects."""
if isinstance(dt, datetime):
return dt.strftime(format_str)
return str(dt)
def _create_default_template(self):
"""Create default HTML template if not exists."""
template_path = os.path.join(self.config.templates_dir, 'report_base.html')
css_path = os.path.join(self.config.templates_dir, 'report_styles.css')
os.makedirs(self.config.templates_dir, exist_ok=True)
# Default HTML template with improved structure
html_template = '''
{{ report.title }}
{% for section in report.sections|sort(attribute='order') %}
{{ section.content|markdown|safe }}
{% if section.images %}
Evidence Images
{% for img in section.images[:max_images] %}
{% if img.embedded_data %}
{% elif img.url %}
{% elif img.path %}
{% else %}
📷 Image: {{ img.id }}
{% endif %}
{{ img.caption|default('Evidence ' + loop.index|string) }}
{% endfor %}
{% endif %}
{% endfor %}
'''
# Default CSS styles with improved readability
css_styles = '''/* DetectifAI Report Styles - Enhanced Readability */
:root {
--primary-color: #1a365d;
--secondary-color: #2d3748;
--accent-color: #3182ce;
--danger-color: #e53e3e;
--warning-color: #dd6b20;
--success-color: #38a169;
--bg-color: #ffffff;
--text-color: #2d3748;
--border-color: #e2e8f0;
--section-bg: #f8fafc;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 12pt;
line-height: 1.8;
color: var(--text-color);
background-color: #f7fafc;
}
.report-container {
max-width: 210mm;
margin: 20px auto;
background: var(--bg-color);
box-shadow: 0 4px 30px rgba(0,0,0,0.15);
}
/* Header Styles */
.report-header {
background: linear-gradient(135deg, var(--primary-color) 0%, #2c5282 100%);
color: white;
padding: 40px 50px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid var(--accent-color);
}
.report-header .logo h1 {
font-size: 32pt;
margin-bottom: 8px;
font-weight: 700;
}
.report-header .subtitle {
font-size: 12pt;
opacity: 0.95;
font-weight: 300;
}
.classification {
padding: 10px 20px;
border-radius: 6px;
font-weight: bold;
text-transform: uppercase;
font-size: 10pt;
letter-spacing: 1px;
}
.classification.confidential {
background: var(--danger-color);
}
.classification.internal {
background: var(--warning-color);
}
.classification.public {
background: var(--success-color);
}
/* Content Styles */
.report-content {
padding: 50px;
}
.report-section {
margin-bottom: 50px;
page-break-inside: avoid;
}
.section-content {
background: var(--section-bg);
padding: 30px;
border-radius: 8px;
border-left: 4px solid var(--accent-color);
}
.section-divider {
height: 2px;
background: linear-gradient(to right, transparent, var(--border-color), transparent);
margin: 40px 0;
}
h1, h2, h3, h4 {
color: var(--primary-color);
margin-top: 30px;
margin-bottom: 20px;
font-weight: 600;
}
h1 {
font-size: 28pt;
border-bottom: 3px solid var(--accent-color);
padding-bottom: 15px;
margin-top: 0;
}
h2 {
font-size: 20pt;
border-bottom: 2px solid var(--border-color);
padding-bottom: 12px;
margin-top: 0;
}
h3 {
font-size: 16pt;
color: var(--secondary-color);
}
h4 {
font-size: 13pt;
color: var(--secondary-color);
}
p {
margin-bottom: 16px;
text-align: justify;
}
strong {
color: var(--primary-color);
font-weight: 600;
}
/* Table Styles - Enhanced for Professional Reports */
table {
width: 100%;
border-collapse: collapse;
margin: 25px 0;
font-size: 11pt;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
background: white;
}
thead {
background: var(--primary-color);
color: white;
}
th, td {
border: 1px solid var(--border-color);
padding: 14px 16px;
text-align: left;
vertical-align: top;
}
th {
background: var(--primary-color);
color: white;
font-weight: 600;
text-transform: uppercase;
font-size: 10pt;
letter-spacing: 0.5px;
position: sticky;
top: 0;
z-index: 10;
}
tbody tr:nth-child(odd) {
background: white;
}
tbody tr:nth-child(even) {
background: #f8fafc;
}
tbody tr:hover {
background: #edf2f7;
transition: background 0.2s ease;
}
td:first-child {
font-weight: 600;
color: var(--secondary-color);
}
/* Table caption */
table caption {
caption-side: top;
padding: 10px;
font-weight: 600;
color: var(--primary-color);
text-align: left;
font-size: 12pt;
}
/* Evidence Gallery */
.evidence-gallery {
margin: 30px 0;
padding: 25px;
background: white;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.gallery-title {
font-size: 14pt;
color: var(--primary-color);
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid var(--accent-color);
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 25px;
}
.evidence-item {
border: 2px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
background: #f8fafc;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.evidence-item:hover {
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
}
.evidence-image {
width: 100%;
height: 180px;
object-fit: cover;
border-bottom: 2px solid var(--border-color);
}
.image-placeholder {
width: 100%;
height: 180px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #edf2f7 0%, #e2e8f0 100%);
color: #718096;
font-size: 11pt;
border-bottom: 2px solid var(--border-color);
}
.evidence-item figcaption {
padding: 12px;
font-size: 10pt;
color: #4a5568;
background: white;
font-weight: 500;
text-align: center;
line-height: 1.4;
border-top: 1px solid var(--border-color);
}
.evidence-item figcaption::before {
content: "📷 ";
color: var(--accent-color);
}
/* Image counter for evidence */
.evidence-gallery {
counter-reset: evidence-counter;
}
.evidence-item {
counter-increment: evidence-counter;
}
.evidence-item figcaption::before {
content: "Evidence #" counter(evidence-counter) ": ";
font-weight: 600;
color: var(--primary-color);
display: block;
margin-bottom: 4px;
}
/* Lists - Enhanced Formatting */
ul, ol {
margin: 16px 0;
padding-left: 30px;
}
ul {
list-style-type: disc;
}
ul ul {
list-style-type: circle;
margin-top: 8px;
}
ol {
list-style-type: decimal;
}
ol ol {
list-style-type: lower-alpha;
margin-top: 8px;
}
li {
margin-bottom: 10px;
line-height: 1.6;
padding-left: 8px;
}
li::marker {
color: var(--accent-color);
font-weight: 600;
}
/* Blockquotes for important notes */
blockquote {
margin: 20px 0;
padding: 20px 25px;
border-left: 4px solid var(--accent-color);
background: #f8fafc;
border-radius: 0 8px 8px 0;
font-style: italic;
color: var(--secondary-color);
}
blockquote p {
margin-bottom: 0;
}
blockquote strong {
font-style: normal;
color: var(--primary-color);
}
/* Code blocks */
code {
background: #edf2f7;
padding: 3px 8px;
border-radius: 4px;
font-size: 10pt;
font-family: 'Courier New', monospace;
color: #c53030;
}
pre {
background: #2d3748;
color: #e2e8f0;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
font-size: 10pt;
margin: 20px 0;
line-height: 1.4;
}
pre code {
background: transparent;
padding: 0;
color: inherit;
}
/* Definition Lists for Metadata */
dl {
margin: 20px 0;
display: grid;
grid-template-columns: max-content auto;
gap: 12px 20px;
}
dt {
font-weight: 600;
color: var(--primary-color);
text-align: right;
}
dt::after {
content: ":";
}
dd {
margin: 0;
color: var(--text-color);
}
/* Horizontal spacing improvements */
.section-content > *:first-child {
margin-top: 0;
}
.section-content > *:last-child {
margin-bottom: 0;
}
/* Footer Styles */
.report-footer {
background: var(--section-bg);
padding: 30px 50px;
border-top: 3px solid var(--accent-color);
font-size: 10pt;
color: #718096;
}
.footer-content {
max-width: 100%;
}
.footer-info {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.footer-info p {
margin-bottom: 8px;
text-align: left;
}
.disclaimer {
margin-top: 20px;
font-style: italic;
padding: 15px;
background: #fff3cd;
border-left: 4px solid var(--warning-color);
border-radius: 4px;
color: #856404;
text-align: left;
}
/* Print/PDF Styles - Professional Multi-Page Layout */
@media print, (min-width: 0) {
body {
background: white;
font-size: 11pt;
}
.report-container {
box-shadow: none;
max-width: 100%;
margin: 0;
}
.report-header {
page-break-after: avoid;
}
.report-section {
page-break-inside: avoid;
orphans: 3;
widows: 3;
}
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
page-break-inside: avoid;
}
.section-divider {
page-break-before: avoid;
page-break-after: avoid;
}
table {
page-break-inside: avoid;
}
thead {
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
.evidence-gallery {
page-break-inside: avoid;
}
.evidence-item {
page-break-inside: avoid;
}
/* Force proper page breaks for long sections */
.section-metadata,
.section-details,
.section-summary {
page-break-inside: avoid;
}
/* Ensure images don't break awkwardly */
img {
max-width: 100%;
page-break-inside: avoid;
}
a {
text-decoration: none;
color: var(--primary-color);
}
/* Print URLs for important links */
a[href^="http"]::after {
content: " (" attr(href) ")";
font-size: 8pt;
color: #718096;
}
}
/* Threat Level Indicators */
.threat-critical {
color: #c53030;
font-weight: bold;
background: #fff5f5;
padding: 2px 6px;
border-radius: 3px;
}
.threat-high {
color: #dd6b20;
font-weight: bold;
background: #fffaf0;
padding: 2px 6px;
border-radius: 3px;
}
.threat-medium {
color: #d69e2e;
background: #fffff0;
padding: 2px 6px;
border-radius: 3px;
}
.threat-low {
color: #38a169;
background: #f0fff4;
padding: 2px 6px;
border-radius: 3px;
}
/* Horizontal rules */
hr {
border: none;
border-top: 2px solid var(--border-color);
margin: 30px 0;
}'''
with open(template_path, 'w', encoding='utf-8') as f:
f.write(html_template)
with open(css_path, 'w', encoding='utf-8') as f:
f.write(css_styles)
logger.info(f"Created default templates at {self.config.templates_dir}")
def _embed_images(self, report) -> None:
"""
Embed images as base64 in the report.
Args:
report: GeneratedReport object (modified in place)
"""
for section in report.sections:
for img in section.images:
if img.get('path') and os.path.exists(img['path']):
try:
with open(img['path'], 'rb') as f:
img_data = f.read()
img['embedded_data'] = base64.b64encode(img_data).decode('utf-8')
except Exception as e:
logger.warning(f"Failed to embed image {img['path']}: {e}")
def _process_image_placeholders(self, content: str, data_collector) -> str:
"""
Replace image placeholders with actual image references.
Args:
content: Report content with placeholders
data_collector: DataCollector instance
Returns:
Processed content
"""
import re
# Replace [[IMAGE:id]] placeholders
def replace_image(match):
img_id = match.group(1)
path = data_collector.get_image_path(img_id, 'keyframe') if data_collector else None
# Check for URL or local path
if path and (path.startswith('http') or os.path.exists(path)):
return f''
return f'*[Image {img_id} not available]*'
# Replace [[FACE:id]] placeholders
def replace_face(match):
face_id = match.group(1)
path = data_collector.get_image_path(face_id, 'face') if data_collector else None
if path and (path.startswith('http') or os.path.exists(path)):
return f''
return f'*[Face {face_id} not available]*'
content = re.sub(r'\[\[IMAGE:([^\]]+)\]\]', replace_image, content)
content = re.sub(r'\[\[FACE:([^\]]+)\]\]', replace_face, content)
return content
def _cleanup_remaining_placeholders(self, report) -> None:
"""
Remove any remaining [[IMAGE:...]] and [[FACE:...]] placeholders
from all section content. These are replaced with italic 'not available'
messages so no raw placeholder text appears in the final report.
"""
import re
for section in report.sections:
if not section.content:
continue
section.content = re.sub(
r'\[\[IMAGE:[^\]]+\]\]',
'*[Image not available]*',
section.content
)
section.content = re.sub(
r'\[\[FACE:[^\]]+\]\]',
'*[Face image not available]*',
section.content
)
def render(
self,
report,
output_path: Optional[str] = None,
embed_images: bool = True
) -> str:
"""
Render report to HTML.
Args:
report: GeneratedReport object
output_path: Output file path (auto-generated if None)
embed_images: Whether to embed images as base64
Returns:
Path to generated HTML file
"""
logger.info(f"Rendering HTML report: {report.report_id}")
# Embed images if requested
if embed_images and self.config.include_evidence_images:
self._embed_images(report)
# Clean up any remaining [[IMAGE:...]] and [[FACE:...]] placeholders in section content
self._cleanup_remaining_placeholders(report)
# Generate output path if not provided
if not output_path:
output_path = os.path.join(
self.config.output_dir,
f"{report.report_id}.html"
)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Render template
try:
template = self.jinja_env.get_template('report_base.html')
html_content = template.render(
report=report,
config=self.config,
max_images=self.config.max_images_per_event * 10,
generated_at=datetime.utcnow()
)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
logger.info(f"✅ HTML report saved to: {output_path}")
return output_path
except Exception as e:
logger.error(f"Error rendering HTML: {e}")
raise