feat: refactor to FastAPI architecture v2.0
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
194
app/core/pdf_generator.py
Normal file
194
app/core/pdf_generator.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user