Some checks failed
Build & Push Docker / build (push) Has been cancelled
- Remove all Excel code (import, export, template, pandas, openpyxl) - New canvas-based schedule editor with drag & drop (interact.js) - Modern 3-panel UI: sidebar, canvas, documentation tab - New data model: Block with id/date/start/end, ProgramType with id/name/color - Clean API: GET /api/health, POST /api/validate, GET /api/sample, POST /api/generate-pdf - Rewritten PDF generator using ScenarioDocument directly (no DataFrame) - Professional PDF output: dark header, colored blocks, merged cells, legend, footer - Sample JSON: "Zimní výjezd oddílu" with 11 blocks, 3 program types - 30 tests passing (API, core models, PDF generation) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""
|
|
PDF generation for Scenar Creator v3 using ReportLab.
|
|
Generates A4 landscape timetable PDF with colored blocks and legend.
|
|
"""
|
|
|
|
from io import BytesIO
|
|
from datetime import datetime
|
|
from collections import defaultdict
|
|
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, TA_RIGHT
|
|
import logging
|
|
|
|
from .validator import ScenarsError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def hex_to_reportlab_color(hex_color: str) -> colors.Color:
|
|
"""Convert #RRGGBB hex color to ReportLab color."""
|
|
h = hex_color.lstrip('#')
|
|
if len(h) == 8:
|
|
h = h[2:]
|
|
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 is_light_color(hex_color: str) -> bool:
|
|
"""Check if a color is light (needs dark text)."""
|
|
h = hex_color.lstrip('#')
|
|
if len(h) == 8:
|
|
h = h[2:]
|
|
if len(h) == 6:
|
|
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
return luminance > 0.6
|
|
return False
|
|
|
|
|
|
def time_to_minutes(time_str: str) -> int:
|
|
"""Convert HH:MM to minutes since midnight."""
|
|
parts = time_str.split(":")
|
|
return int(parts[0]) * 60 + int(parts[1])
|
|
|
|
|
|
def generate_pdf(doc) -> bytes:
|
|
"""
|
|
Generate a PDF timetable from a ScenarioDocument.
|
|
|
|
Args:
|
|
doc: ScenarioDocument instance
|
|
|
|
Returns:
|
|
bytes: PDF file content
|
|
"""
|
|
if not doc.blocks:
|
|
raise ScenarsError("No blocks provided")
|
|
|
|
type_map = {pt.id: pt for pt in doc.program_types}
|
|
for block in doc.blocks:
|
|
if block.type_id not in type_map:
|
|
raise ScenarsError(
|
|
f"Missing type definition: '{block.type_id}'. "
|
|
"Please define all program types."
|
|
)
|
|
|
|
buffer = BytesIO()
|
|
page_w, page_h = landscape(A4)
|
|
doc_pdf = SimpleDocTemplate(
|
|
buffer,
|
|
pagesize=landscape(A4),
|
|
leftMargin=12 * mm,
|
|
rightMargin=12 * mm,
|
|
topMargin=12 * mm,
|
|
bottomMargin=12 * mm,
|
|
)
|
|
|
|
styles = getSampleStyleSheet()
|
|
title_style = ParagraphStyle(
|
|
'TimetableTitle', parent=styles['Title'],
|
|
fontSize=20, alignment=TA_LEFT, spaceAfter=1 * mm,
|
|
textColor=colors.Color(0.118, 0.161, 0.231),
|
|
fontName='Helvetica-Bold'
|
|
)
|
|
subtitle_style = ParagraphStyle(
|
|
'TimetableSubtitle', parent=styles['Normal'],
|
|
fontSize=12, alignment=TA_LEFT, spaceAfter=1 * mm,
|
|
textColor=colors.Color(0.4, 0.4, 0.4),
|
|
fontName='Helvetica'
|
|
)
|
|
info_style = ParagraphStyle(
|
|
'InfoStyle', parent=styles['Normal'],
|
|
fontSize=10, alignment=TA_LEFT, spaceAfter=4 * mm,
|
|
textColor=colors.Color(0.5, 0.5, 0.5),
|
|
fontName='Helvetica'
|
|
)
|
|
cell_style_white = ParagraphStyle(
|
|
'CellStyleWhite', parent=styles['Normal'],
|
|
fontSize=8, alignment=TA_CENTER, leading=10,
|
|
textColor=colors.white, fontName='Helvetica-Bold'
|
|
)
|
|
cell_style_dark = ParagraphStyle(
|
|
'CellStyleDark', parent=styles['Normal'],
|
|
fontSize=8, alignment=TA_CENTER, leading=10,
|
|
textColor=colors.Color(0.1, 0.1, 0.1), fontName='Helvetica-Bold'
|
|
)
|
|
time_style = ParagraphStyle(
|
|
'TimeStyle', parent=styles['Normal'],
|
|
fontSize=7, alignment=TA_RIGHT, leading=9,
|
|
textColor=colors.Color(0.5, 0.5, 0.5), fontName='Helvetica'
|
|
)
|
|
legend_style = ParagraphStyle(
|
|
'LegendStyle', parent=styles['Normal'],
|
|
fontSize=9, alignment=TA_LEFT, fontName='Helvetica'
|
|
)
|
|
footer_style = ParagraphStyle(
|
|
'FooterStyle', parent=styles['Normal'],
|
|
fontSize=8, alignment=TA_CENTER,
|
|
textColor=colors.Color(0.6, 0.6, 0.6), fontName='Helvetica-Oblique'
|
|
)
|
|
|
|
elements = []
|
|
|
|
# Header
|
|
elements.append(Paragraph(doc.event.title, title_style))
|
|
if doc.event.subtitle:
|
|
elements.append(Paragraph(doc.event.subtitle, subtitle_style))
|
|
|
|
info_parts = []
|
|
if doc.event.date:
|
|
info_parts.append(f"Datum: {doc.event.date}")
|
|
if doc.event.location:
|
|
info_parts.append(f"M\u00edsto: {doc.event.location}")
|
|
if info_parts:
|
|
elements.append(Paragraph(" | ".join(info_parts), info_style))
|
|
|
|
elements.append(Spacer(1, 3 * mm))
|
|
|
|
# Group blocks by date
|
|
blocks_by_date = defaultdict(list)
|
|
for block in doc.blocks:
|
|
blocks_by_date[block.date].append(block)
|
|
|
|
sorted_dates = sorted(blocks_by_date.keys())
|
|
|
|
# Find global time range
|
|
all_starts = [time_to_minutes(b.start) for b in doc.blocks]
|
|
all_ends = [time_to_minutes(b.end) for b in doc.blocks]
|
|
global_start = (min(all_starts) // 30) * 30
|
|
global_end = ((max(all_ends) + 29) // 30) * 30
|
|
|
|
# Generate 30-min time slots
|
|
time_slots = []
|
|
t = global_start
|
|
while t <= global_end:
|
|
h, m = divmod(t, 60)
|
|
time_slots.append(f"{h:02d}:{m:02d}")
|
|
t += 30
|
|
|
|
# Build table: time column + one column per day
|
|
header = [""] + [d for d in sorted_dates]
|
|
|
|
table_data = [header]
|
|
slot_count = len(time_slots) - 1
|
|
|
|
# Build grid and track colored cells
|
|
cell_colors_list = []
|
|
|
|
for slot_idx in range(slot_count):
|
|
slot_start = time_slots[slot_idx]
|
|
slot_end = time_slots[slot_idx + 1]
|
|
row = [Paragraph(slot_start, time_style)]
|
|
|
|
for date_key in sorted_dates:
|
|
cell_content = ""
|
|
for block in blocks_by_date[date_key]:
|
|
block_start_min = time_to_minutes(block.start)
|
|
block_end_min = time_to_minutes(block.end)
|
|
slot_start_min = time_to_minutes(slot_start)
|
|
slot_end_min = time_to_minutes(slot_end)
|
|
|
|
if block_start_min <= slot_start_min and block_end_min >= slot_end_min:
|
|
pt = type_map[block.type_id]
|
|
light = is_light_color(pt.color)
|
|
cs = cell_style_dark if light else cell_style_white
|
|
|
|
if block_start_min == slot_start_min:
|
|
label = block.title
|
|
if block.responsible:
|
|
label += f"<br/><font size='6'>{block.responsible}</font>"
|
|
cell_content = Paragraph(label, cs)
|
|
else:
|
|
cell_content = ""
|
|
cell_colors_list.append((len(table_data), len(row), pt.color))
|
|
break
|
|
|
|
row.append(cell_content if cell_content else "")
|
|
|
|
table_data.append(row)
|
|
|
|
# Column widths
|
|
avail_width = page_w - 24 * mm
|
|
time_col_width = 18 * mm
|
|
day_col_width = (avail_width - time_col_width) / max(len(sorted_dates), 1)
|
|
col_widths = [time_col_width] + [day_col_width] * len(sorted_dates)
|
|
|
|
row_height = 20
|
|
table = Table(table_data, colWidths=col_widths, rowHeights=[24] + [row_height] * slot_count)
|
|
|
|
style_cmds = [
|
|
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.118, 0.161, 0.231)),
|
|
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
|
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
|
('FONTSIZE', (0, 0), (-1, 0), 10),
|
|
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('ALIGN', (0, 1), (0, -1), 'RIGHT'),
|
|
('FONTSIZE', (0, 1), (-1, -1), 7),
|
|
('GRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
|
|
('LINEBELOW', (0, 0), (-1, 0), 1.5, colors.Color(0.118, 0.161, 0.231)),
|
|
('ROWBACKGROUNDS', (1, 1), (-1, -1), [colors.white, colors.Color(0.98, 0.98, 0.98)]),
|
|
]
|
|
|
|
for r, c, hex_clr in cell_colors_list:
|
|
rl_color = hex_to_reportlab_color(hex_clr)
|
|
style_cmds.append(('BACKGROUND', (c, r), (c, r), rl_color))
|
|
|
|
# Merge cells for blocks spanning multiple time slots
|
|
for date_idx, date_key in enumerate(sorted_dates):
|
|
col = date_idx + 1
|
|
for block in blocks_by_date[date_key]:
|
|
block_start_min = time_to_minutes(block.start)
|
|
block_end_min = time_to_minutes(block.end)
|
|
|
|
start_row = None
|
|
end_row = None
|
|
for slot_idx in range(slot_count):
|
|
slot_min = time_to_minutes(time_slots[slot_idx])
|
|
if slot_min == block_start_min:
|
|
start_row = slot_idx + 1
|
|
if slot_idx + 1 < len(time_slots):
|
|
next_slot_min = time_to_minutes(time_slots[slot_idx + 1])
|
|
if next_slot_min == block_end_min:
|
|
end_row = slot_idx + 1
|
|
|
|
if start_row is not None and end_row is not None and end_row > start_row:
|
|
style_cmds.append(('SPAN', (col, start_row), (col, end_row)))
|
|
|
|
table.setStyle(TableStyle(style_cmds))
|
|
elements.append(table)
|
|
|
|
# Legend
|
|
elements.append(Spacer(1, 5 * mm))
|
|
legend_items = []
|
|
for pt in doc.program_types:
|
|
legend_items.append([Paragraph(f" {pt.name}", legend_style)])
|
|
|
|
if legend_items:
|
|
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
|
|
elements.append(Spacer(1, 2 * mm))
|
|
legend_table = Table(legend_items, colWidths=[60 * mm])
|
|
legend_cmds = [
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('BOX', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
|
|
('INNERGRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
|
|
]
|
|
for i, pt in enumerate(doc.program_types):
|
|
rl_color = hex_to_reportlab_color(pt.color)
|
|
legend_cmds.append(('BACKGROUND', (0, i), (0, i), rl_color))
|
|
if not is_light_color(pt.color):
|
|
legend_cmds.append(('TEXTCOLOR', (0, i), (0, i), colors.white))
|
|
legend_table.setStyle(TableStyle(legend_cmds))
|
|
elements.append(legend_table)
|
|
|
|
# Footer
|
|
elements.append(Spacer(1, 5 * mm))
|
|
gen_date = datetime.now().strftime("%d.%m.%Y %H:%M")
|
|
elements.append(Paragraph(
|
|
f"Vygenerov\u00e1no Scen\u00e1r Creatorem | {gen_date}",
|
|
footer_style
|
|
))
|
|
|
|
doc_pdf.build(elements)
|
|
return buffer.getvalue()
|