Files
scenar-creator/app/core/pdf_generator.py
Daneel e2bdadd0ce
Some checks failed
Build & Push Docker / build (push) Has been cancelled
feat: refactor to FastAPI architecture v2.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:28:21 +01:00

195 lines
6.2 KiB
Python

"""
PDF generation for Scenar Creator using ReportLab.
Generates A4 landscape timetable PDF with colored blocks and legend.
"""
import pandas as pd
from io import BytesIO
from datetime import datetime
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT
import logging
from .validator import ScenarsError
logger = logging.getLogger(__name__)
def hex_to_reportlab_color(hex_color: str) -> colors.Color:
"""Convert hex color (AARRGGBB or #RRGGBB) to ReportLab color."""
h = hex_color.lstrip('#')
if len(h) == 8: # AARRGGBB format
h = h[2:] # strip alpha
if len(h) == 6:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return colors.Color(r / 255.0, g / 255.0, b / 255.0)
return colors.white
def generate_pdf(data: pd.DataFrame, title: str, detail: str,
program_descriptions: dict, program_colors: dict) -> bytes:
"""
Generate a PDF timetable.
Args:
data: DataFrame with validated schedule data
title: Event title
detail: Event detail/description
program_descriptions: {type: description}
program_colors: {type: color_hex in AARRGGBB format}
Returns:
bytes: PDF file content
Raises:
ScenarsError: if data is invalid
"""
if data.empty:
raise ScenarsError("Data is empty after validation")
missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors]
if missing_types:
raise ScenarsError(
f"Missing type definitions: {', '.join(missing_types)}. "
"Please define all program types."
)
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=landscape(A4),
leftMargin=10 * mm,
rightMargin=10 * mm,
topMargin=10 * mm,
bottomMargin=10 * mm,
)
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'TimetableTitle', parent=styles['Title'],
fontSize=18, alignment=TA_CENTER, spaceAfter=2 * mm
)
detail_style = ParagraphStyle(
'TimetableDetail', parent=styles['Normal'],
fontSize=12, alignment=TA_CENTER, spaceAfter=4 * mm,
textColor=colors.gray
)
cell_style = ParagraphStyle(
'CellStyle', parent=styles['Normal'],
fontSize=7, alignment=TA_CENTER, leading=9
)
legend_style = ParagraphStyle(
'LegendStyle', parent=styles['Normal'],
fontSize=8, alignment=TA_LEFT
)
elements = []
elements.append(Paragraph(title, title_style))
elements.append(Paragraph(detail, detail_style))
data = data.sort_values(by=["Datum", "Zacatek"])
start_times = data["Zacatek"]
end_times = data["Konec"]
min_time = min(start_times)
max_time = max(end_times)
time_slots = pd.date_range(
datetime.combine(datetime.today(), min_time),
datetime.combine(datetime.today(), max_time),
freq='15min'
).time
# Build header row
header = ["Datum"] + [t.strftime("%H:%M") for t in time_slots]
table_data = [header]
cell_colors = [] # list of (row, col, color) for styling
grouped_data = data.groupby(data['Datum'])
row_idx = 1
for date_val, group in grouped_data:
day_name = date_val.strftime("%A")
date_str = date_val.strftime(f"%d.%m {day_name}")
row = [date_str] + [""] * len(time_slots)
table_data.append(row)
row_idx += 1
# Create a sub-row for blocks
block_row = [""] * (len(time_slots) + 1)
for _, blk in group.iterrows():
try:
start_idx = list(time_slots).index(blk["Zacatek"]) + 1
end_idx = list(time_slots).index(blk["Konec"]) + 1
except ValueError:
continue
label = blk['Program']
if pd.notna(blk.get('Garant')):
label += f"\n{blk['Garant']}"
block_row[start_idx] = Paragraph(label.replace('\n', '<br/>'), cell_style)
rl_color = hex_to_reportlab_color(program_colors[blk["Typ"]])
for ci in range(start_idx, end_idx):
cell_colors.append((row_idx, ci, rl_color))
table_data.append(block_row)
row_idx += 1
# Calculate column widths
avail_width = landscape(A4)[0] - 20 * mm
date_col_width = 30 * mm
slot_width = max(12 * mm, (avail_width - date_col_width) / max(len(time_slots), 1))
col_widths = [date_col_width] + [slot_width] * len(time_slots)
table = Table(table_data, colWidths=col_widths, repeatRows=1)
style_cmds = [
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.83, 0.83, 0.83)),
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('FONTSIZE', (0, 0), (-1, 0), 7),
('FONTSIZE', (0, 1), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.Color(0.97, 0.97, 0.97)]),
]
for r, c, clr in cell_colors:
style_cmds.append(('BACKGROUND', (c, r), (c, r), clr))
table.setStyle(TableStyle(style_cmds))
elements.append(table)
# Legend
elements.append(Spacer(1, 5 * mm))
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
legend_data = []
legend_colors_list = []
for i, (typ, desc) in enumerate(program_descriptions.items()):
legend_data.append([Paragraph(f"{desc} ({typ})", legend_style)])
legend_colors_list.append(hex_to_reportlab_color(program_colors[typ]))
if legend_data:
legend_table = Table(legend_data, colWidths=[80 * mm])
legend_cmds = [
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]
for i, clr in enumerate(legend_colors_list):
legend_cmds.append(('BACKGROUND', (0, i), (0, i), clr))
legend_table.setStyle(TableStyle(legend_cmds))
elements.append(legend_table)
doc.build(elements)
return buffer.getvalue()