feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
Some checks failed
Build & Push Docker / build (push) Has been cancelled
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>
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
"""
|
||||
PDF generation for Scenar Creator using ReportLab.
|
||||
PDF generation for Scenar Creator v3 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 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
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||
import logging
|
||||
|
||||
from .validator import ScenarsError
|
||||
@@ -20,175 +20,270 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def hex_to_reportlab_color(hex_color: str) -> colors.Color:
|
||||
"""Convert hex color (AARRGGBB or #RRGGBB) to ReportLab color."""
|
||||
"""Convert #RRGGBB hex color to ReportLab color."""
|
||||
h = hex_color.lstrip('#')
|
||||
if len(h) == 8: # AARRGGBB format
|
||||
h = h[2:] # strip alpha
|
||||
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 generate_pdf(data: pd.DataFrame, title: str, detail: str,
|
||||
program_descriptions: dict, program_colors: dict) -> bytes:
|
||||
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.
|
||||
Generate a PDF timetable from a ScenarioDocument.
|
||||
|
||||
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}
|
||||
doc: ScenarioDocument instance
|
||||
|
||||
Returns:
|
||||
bytes: PDF file content
|
||||
|
||||
Raises:
|
||||
ScenarsError: if data is invalid
|
||||
"""
|
||||
if data.empty:
|
||||
raise ScenarsError("Data is empty after validation")
|
||||
if not doc.blocks:
|
||||
raise ScenarsError("No blocks provided")
|
||||
|
||||
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."
|
||||
)
|
||||
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()
|
||||
doc = SimpleDocTemplate(
|
||||
page_w, page_h = landscape(A4)
|
||||
doc_pdf = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=landscape(A4),
|
||||
leftMargin=10 * mm,
|
||||
rightMargin=10 * mm,
|
||||
topMargin=10 * mm,
|
||||
bottomMargin=10 * mm,
|
||||
leftMargin=12 * mm,
|
||||
rightMargin=12 * mm,
|
||||
topMargin=12 * mm,
|
||||
bottomMargin=12 * mm,
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle(
|
||||
'TimetableTitle', parent=styles['Title'],
|
||||
fontSize=18, alignment=TA_CENTER, spaceAfter=2 * mm
|
||||
fontSize=20, alignment=TA_LEFT, spaceAfter=1 * mm,
|
||||
textColor=colors.Color(0.118, 0.161, 0.231),
|
||||
fontName='Helvetica-Bold'
|
||||
)
|
||||
detail_style = ParagraphStyle(
|
||||
'TimetableDetail', parent=styles['Normal'],
|
||||
fontSize=12, alignment=TA_CENTER, spaceAfter=4 * mm,
|
||||
textColor=colors.gray
|
||||
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'
|
||||
)
|
||||
cell_style = ParagraphStyle(
|
||||
'CellStyle', parent=styles['Normal'],
|
||||
fontSize=7, alignment=TA_CENTER, leading=9
|
||||
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=8, alignment=TA_LEFT
|
||||
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 = []
|
||||
elements.append(Paragraph(title, title_style))
|
||||
elements.append(Paragraph(detail, detail_style))
|
||||
|
||||
data = data.sort_values(by=["Datum", "Zacatek"])
|
||||
# Header
|
||||
elements.append(Paragraph(doc.event.title, title_style))
|
||||
if doc.event.subtitle:
|
||||
elements.append(Paragraph(doc.event.subtitle, subtitle_style))
|
||||
|
||||
start_times = data["Zacatek"]
|
||||
end_times = data["Konec"]
|
||||
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))
|
||||
|
||||
min_time = min(start_times)
|
||||
max_time = max(end_times)
|
||||
elements.append(Spacer(1, 3 * mm))
|
||||
|
||||
time_slots = pd.date_range(
|
||||
datetime.combine(datetime.today(), min_time),
|
||||
datetime.combine(datetime.today(), max_time),
|
||||
freq='15min'
|
||||
).time
|
||||
# Group blocks by date
|
||||
blocks_by_date = defaultdict(list)
|
||||
for block in doc.blocks:
|
||||
blocks_by_date[block.date].append(block)
|
||||
|
||||
# Build header row
|
||||
header = ["Datum"] + [t.strftime("%H:%M") for t in time_slots]
|
||||
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]
|
||||
cell_colors = [] # list of (row, col, color) for styling
|
||||
slot_count = len(time_slots) - 1
|
||||
|
||||
grouped_data = data.groupby(data['Datum'])
|
||||
row_idx = 1
|
||||
# Build grid and track colored cells
|
||||
cell_colors_list = []
|
||||
|
||||
for date_val, group in grouped_data:
|
||||
day_name = date_val.strftime("%A")
|
||||
date_str = date_val.strftime(f"%d.%m {day_name}")
|
||||
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 "")
|
||||
|
||||
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
|
||||
# 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)
|
||||
|
||||
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)
|
||||
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.83, 0.83, 0.83)),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('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'),
|
||||
('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)]),
|
||||
('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, clr in cell_colors:
|
||||
style_cmds.append(('BACKGROUND', (c, r), (c, r), clr))
|
||||
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))
|
||||
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
|
||||
legend_items = []
|
||||
for pt in doc.program_types:
|
||||
legend_items.append([Paragraph(f" {pt.name}", 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])
|
||||
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 = [
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('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, clr in enumerate(legend_colors_list):
|
||||
legend_cmds.append(('BACKGROUND', (0, i), (0, i), clr))
|
||||
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)
|
||||
|
||||
doc.build(elements)
|
||||
# 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()
|
||||
|
||||
Reference in New Issue
Block a user