Files
scenar-creator/app/core/pdf_generator.py
Daneel 25fd578543
Some checks failed
Build & Push Docker / build (push) Has been cancelled
feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
- 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>
2026-02-20 17:02:51 +01:00

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()