fix: PDF generator - always one page, canvas API, improved layout
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
This commit is contained in:
@@ -1,289 +1,317 @@
|
|||||||
"""
|
"""
|
||||||
PDF generation for Scenar Creator v3 using ReportLab.
|
PDF generation for Scenar Creator v3 using ReportLab Canvas API.
|
||||||
Generates A4 landscape timetable PDF with colored blocks and legend.
|
Generates A4 landscape timetable — always exactly one page.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from reportlab.lib import colors
|
|
||||||
from reportlab.lib.pagesizes import A4, landscape
|
from reportlab.lib.pagesizes import A4, landscape
|
||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
from reportlab.pdfgen import canvas as rl_canvas
|
||||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
||||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .validator import ScenarsError
|
from .validator import ScenarsError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PAGE_W, PAGE_H = landscape(A4)
|
||||||
|
MARGIN = 10 * mm
|
||||||
|
|
||||||
def hex_to_reportlab_color(hex_color: str) -> colors.Color:
|
# Color palette for UI
|
||||||
"""Convert #RRGGBB hex color to ReportLab color."""
|
HEADER_BG = (0.118, 0.161, 0.231) # dark navy
|
||||||
|
GRID_LINE = (0.85, 0.85, 0.85)
|
||||||
|
HEADER_TEXT = (1.0, 1.0, 1.0)
|
||||||
|
BODY_ALT = (0.97, 0.97, 0.97)
|
||||||
|
FOOTER_TEXT = (0.6, 0.6, 0.6)
|
||||||
|
LABEL_TEXT = (0.35, 0.35, 0.35)
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_rgb(hex_color: str) -> tuple:
|
||||||
h = hex_color.lstrip('#')
|
h = hex_color.lstrip('#')
|
||||||
if len(h) == 8:
|
if len(h) == 8:
|
||||||
h = h[2:]
|
h = h[2:]
|
||||||
if len(h) == 6:
|
if len(h) != 6:
|
||||||
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
return (0.8, 0.8, 0.8)
|
||||||
return colors.Color(r / 255.0, g / 255.0, b / 255.0)
|
r = int(h[0:2], 16) / 255.0
|
||||||
return colors.white
|
g = int(h[2:4], 16) / 255.0
|
||||||
|
b = int(h[4:6], 16) / 255.0
|
||||||
|
return (r, g, b)
|
||||||
|
|
||||||
|
|
||||||
def is_light_color(hex_color: str) -> bool:
|
def is_light(hex_color: str) -> bool:
|
||||||
"""Check if a color is light (needs dark text)."""
|
r, g, b = hex_to_rgb(hex_color)
|
||||||
h = hex_color.lstrip('#')
|
return (0.299 * r + 0.587 * g + 0.114 * b) > 0.6
|
||||||
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:
|
def time_to_minutes(s: str) -> int:
|
||||||
"""Convert HH:MM to minutes since midnight."""
|
h, m = s.split(":")
|
||||||
parts = time_str.split(":")
|
return int(h) * 60 + int(m)
|
||||||
return int(parts[0]) * 60 + int(parts[1])
|
|
||||||
|
|
||||||
|
def fmt_time(total_minutes: int) -> str:
|
||||||
|
return f"{total_minutes // 60:02d}:{total_minutes % 60:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def set_fill(c, rgb):
|
||||||
|
c.setFillColorRGB(*rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def set_stroke(c, rgb):
|
||||||
|
c.setStrokeColorRGB(*rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_rect_filled(c, x, y, w, h, fill_rgb, stroke_rgb=None, stroke_width=0.5):
|
||||||
|
set_fill(c, fill_rgb)
|
||||||
|
if stroke_rgb:
|
||||||
|
set_stroke(c, stroke_rgb)
|
||||||
|
c.setLineWidth(stroke_width)
|
||||||
|
c.rect(x, y, w, h, fill=1, stroke=1)
|
||||||
|
else:
|
||||||
|
c.rect(x, y, w, h, fill=1, stroke=0)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_text_clipped(c, text, x, y, w, h, font, size, rgb, align="center"):
|
||||||
|
"""Draw text centered/left in a bounding box, clipped to width."""
|
||||||
|
c.saveState()
|
||||||
|
c.rect(x, y, w, h, fill=0, stroke=0)
|
||||||
|
c.clipPath(c.beginPath(), stroke=0, fill=0)
|
||||||
|
# Use a proper clip path
|
||||||
|
p = c.beginPath()
|
||||||
|
p.rect(x, y, w, h)
|
||||||
|
c.clipPath(p, stroke=0, fill=0)
|
||||||
|
set_fill(c, rgb)
|
||||||
|
c.setFont(font, size)
|
||||||
|
if align == "center":
|
||||||
|
c.drawCentredString(x + w / 2, y + (h - size) / 2 + 1, text)
|
||||||
|
elif align == "right":
|
||||||
|
c.drawRightString(x + w - 2, y + (h - size) / 2 + 1, text)
|
||||||
|
else:
|
||||||
|
c.drawString(x + 2, y + (h - size) / 2 + 1, text)
|
||||||
|
c.restoreState()
|
||||||
|
|
||||||
|
|
||||||
def generate_pdf(doc) -> bytes:
|
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:
|
if not doc.blocks:
|
||||||
raise ScenarsError("No blocks provided")
|
raise ScenarsError("No blocks provided")
|
||||||
|
|
||||||
type_map = {pt.id: pt for pt in doc.program_types}
|
type_map = {pt.id: pt for pt in doc.program_types}
|
||||||
for block in doc.blocks:
|
for block in doc.blocks:
|
||||||
if block.type_id not in type_map:
|
if block.type_id not in type_map:
|
||||||
raise ScenarsError(
|
raise ScenarsError(f"Missing type definition: '{block.type_id}'")
|
||||||
f"Missing type definition: '{block.type_id}'. "
|
|
||||||
"Please define all program types."
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer = BytesIO()
|
# Group by date
|
||||||
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)
|
blocks_by_date = defaultdict(list)
|
||||||
for block in doc.blocks:
|
for b in doc.blocks:
|
||||||
blocks_by_date[block.date].append(block)
|
blocks_by_date[b.date].append(b)
|
||||||
|
|
||||||
sorted_dates = sorted(blocks_by_date.keys())
|
sorted_dates = sorted(blocks_by_date.keys())
|
||||||
|
|
||||||
# Find global time range
|
# Time range (round to 30-min boundaries)
|
||||||
all_starts = [time_to_minutes(b.start) for b in doc.blocks]
|
all_starts = [time_to_minutes(b.start) for b in doc.blocks]
|
||||||
all_ends = [time_to_minutes(b.end) for b in doc.blocks]
|
all_ends = [time_to_minutes(b.end) for b in doc.blocks]
|
||||||
global_start = (min(all_starts) // 30) * 30
|
t_start = (min(all_starts) // 30) * 30
|
||||||
global_end = ((max(all_ends) + 29) // 30) * 30
|
t_end = ((max(all_ends) + 29) // 30) * 30
|
||||||
|
slot_minutes = 30
|
||||||
|
num_slots = (t_end - t_start) // slot_minutes
|
||||||
|
|
||||||
# Generate 30-min time slots
|
buf = BytesIO()
|
||||||
time_slots = []
|
c = rl_canvas.Canvas(buf, pagesize=landscape(A4))
|
||||||
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
|
# ── Layout constants ──────────────────────────────────────────────
|
||||||
header = [""] + [d for d in sorted_dates]
|
x0 = MARGIN
|
||||||
|
y_top = PAGE_H - MARGIN
|
||||||
|
|
||||||
table_data = [header]
|
# Header block
|
||||||
slot_count = len(time_slots) - 1
|
title_size = 16
|
||||||
|
subtitle_size = 10
|
||||||
|
info_size = 8
|
||||||
|
header_h = title_size + 4
|
||||||
|
|
||||||
# Build grid and track colored cells
|
has_subtitle = bool(doc.event.subtitle)
|
||||||
cell_colors_list = []
|
has_info = bool(doc.event.date or doc.event.location)
|
||||||
|
if has_subtitle:
|
||||||
|
header_h += subtitle_size + 2
|
||||||
|
if has_info:
|
||||||
|
header_h += info_size + 2
|
||||||
|
header_h += 4 # bottom padding
|
||||||
|
|
||||||
for slot_idx in range(slot_count):
|
# Legend block (one row per type)
|
||||||
slot_start = time_slots[slot_idx]
|
legend_item_h = 12
|
||||||
slot_end = time_slots[slot_idx + 1]
|
legend_h = len(doc.program_types) * legend_item_h + 6
|
||||||
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
|
# Footer
|
||||||
elements.append(Spacer(1, 5 * mm))
|
footer_h = 10
|
||||||
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)
|
# Available height for timetable
|
||||||
return buffer.getvalue()
|
avail_h = PAGE_H - 2 * MARGIN - header_h - legend_h - footer_h - 6
|
||||||
|
row_h = max(8, avail_h / max(num_slots, 1))
|
||||||
|
|
||||||
|
# Time axis width
|
||||||
|
time_col_w = 14 * mm
|
||||||
|
# Column widths for dates
|
||||||
|
avail_w = PAGE_W - 2 * MARGIN - time_col_w
|
||||||
|
day_col_w = avail_w / max(len(sorted_dates), 1)
|
||||||
|
|
||||||
|
# Font sizes scale with row height
|
||||||
|
cell_font_size = max(5.5, min(8.0, row_h * 0.45))
|
||||||
|
time_font_size = max(5.0, min(7.0, row_h * 0.42))
|
||||||
|
header_col_size = max(6.0, min(9.0, day_col_w * 0.08))
|
||||||
|
|
||||||
|
# ── Draw header ───────────────────────────────────────────────────
|
||||||
|
y = y_top
|
||||||
|
c.setFont("Helvetica-Bold", title_size)
|
||||||
|
set_fill(c, HEADER_BG)
|
||||||
|
c.drawString(x0, y - title_size, doc.event.title)
|
||||||
|
y -= title_size + 4
|
||||||
|
|
||||||
|
if has_subtitle:
|
||||||
|
c.setFont("Helvetica", subtitle_size)
|
||||||
|
set_fill(c, (0.4, 0.4, 0.4))
|
||||||
|
c.drawString(x0, y - subtitle_size, doc.event.subtitle)
|
||||||
|
y -= subtitle_size + 2
|
||||||
|
|
||||||
|
if has_info:
|
||||||
|
parts = []
|
||||||
|
if doc.event.date:
|
||||||
|
parts.append(f"Datum: {doc.event.date}")
|
||||||
|
if doc.event.location:
|
||||||
|
parts.append(f"Místo: {doc.event.location}")
|
||||||
|
c.setFont("Helvetica", info_size)
|
||||||
|
set_fill(c, (0.5, 0.5, 0.5))
|
||||||
|
c.drawString(x0, y - info_size, " | ".join(parts))
|
||||||
|
y -= info_size + 2
|
||||||
|
|
||||||
|
y -= 6 # padding before table
|
||||||
|
|
||||||
|
# ── Draw column headers ───────────────────────────────────────────
|
||||||
|
col_header_h = 16
|
||||||
|
# Time axis header cell
|
||||||
|
draw_rect_filled(c, x0, y - col_header_h, time_col_w, col_header_h, HEADER_BG)
|
||||||
|
# Date header cells
|
||||||
|
for di, date_key in enumerate(sorted_dates):
|
||||||
|
cx = x0 + time_col_w + di * day_col_w
|
||||||
|
draw_rect_filled(c, cx, y - col_header_h, day_col_w, col_header_h, HEADER_BG, GRID_LINE)
|
||||||
|
c.saveState()
|
||||||
|
p = c.beginPath()
|
||||||
|
p.rect(cx + 1, y - col_header_h + 1, day_col_w - 2, col_header_h - 2)
|
||||||
|
c.clipPath(p, stroke=0, fill=0)
|
||||||
|
set_fill(c, HEADER_TEXT)
|
||||||
|
c.setFont("Helvetica-Bold", header_col_size)
|
||||||
|
c.drawCentredString(cx + day_col_w / 2, y - col_header_h + (col_header_h - header_col_size) / 2, date_key)
|
||||||
|
c.restoreState()
|
||||||
|
|
||||||
|
y -= col_header_h
|
||||||
|
|
||||||
|
# ── Draw time grid ────────────────────────────────────────────────
|
||||||
|
table_top = y
|
||||||
|
table_bottom = y - num_slots * row_h
|
||||||
|
|
||||||
|
for slot_i in range(num_slots):
|
||||||
|
slot_min = t_start + slot_i * slot_minutes
|
||||||
|
slot_label = fmt_time(slot_min)
|
||||||
|
row_y = y - slot_i * row_h
|
||||||
|
|
||||||
|
# Alternating row bg for day columns
|
||||||
|
alt_bg = BODY_ALT if slot_i % 2 == 1 else (1.0, 1.0, 1.0)
|
||||||
|
for di in range(len(sorted_dates)):
|
||||||
|
cx = x0 + time_col_w + di * day_col_w
|
||||||
|
draw_rect_filled(c, cx, row_y - row_h, day_col_w, row_h, alt_bg, GRID_LINE, 0.3)
|
||||||
|
|
||||||
|
# Time axis cell
|
||||||
|
draw_rect_filled(c, x0, row_y - row_h, time_col_w, row_h, (0.96, 0.96, 0.96), GRID_LINE, 0.3)
|
||||||
|
set_fill(c, LABEL_TEXT)
|
||||||
|
c.setFont("Helvetica", time_font_size)
|
||||||
|
c.drawRightString(x0 + time_col_w - 2, row_y - row_h + (row_h - time_font_size) / 2, slot_label)
|
||||||
|
|
||||||
|
# ── Draw program blocks ───────────────────────────────────────────
|
||||||
|
for di, date_key in enumerate(sorted_dates):
|
||||||
|
cx = x0 + time_col_w + di * day_col_w
|
||||||
|
for block in blocks_by_date[date_key]:
|
||||||
|
b_start = time_to_minutes(block.start)
|
||||||
|
b_end = time_to_minutes(block.end)
|
||||||
|
if b_start < t_start or b_end > t_end:
|
||||||
|
continue
|
||||||
|
offset_slots_start = (b_start - t_start) / slot_minutes
|
||||||
|
offset_slots_end = (b_end - t_start) / slot_minutes
|
||||||
|
block_top = table_top - offset_slots_start * row_h
|
||||||
|
block_bot = table_top - offset_slots_end * row_h
|
||||||
|
block_h = block_top - block_bot
|
||||||
|
|
||||||
|
pt = type_map[block.type_id]
|
||||||
|
fill_rgb = hex_to_rgb(pt.color)
|
||||||
|
text_rgb = (0.1, 0.1, 0.1) if is_light(pt.color) else (1.0, 1.0, 1.0)
|
||||||
|
|
||||||
|
# Block rectangle with slight inset
|
||||||
|
inset = 0.5
|
||||||
|
bx = cx + inset
|
||||||
|
by = block_bot + inset
|
||||||
|
bw = day_col_w - 2 * inset
|
||||||
|
bh = block_h - 2 * inset
|
||||||
|
|
||||||
|
c.saveState()
|
||||||
|
draw_rect_filled(c, bx, by, bw, bh, fill_rgb, GRID_LINE, 0.5)
|
||||||
|
|
||||||
|
# Clip text to block
|
||||||
|
p = c.beginPath()
|
||||||
|
p.rect(bx + 1, by + 1, bw - 2, bh - 2)
|
||||||
|
c.clipPath(p, stroke=0, fill=0)
|
||||||
|
|
||||||
|
set_fill(c, text_rgb)
|
||||||
|
lines = [block.title]
|
||||||
|
if block.responsible:
|
||||||
|
lines.append(block.responsible)
|
||||||
|
|
||||||
|
if len(lines) == 1 or bh < cell_font_size * 2.5:
|
||||||
|
c.setFont("Helvetica-Bold", cell_font_size)
|
||||||
|
c.drawCentredString(bx + bw / 2, by + (bh - cell_font_size) / 2, lines[0])
|
||||||
|
else:
|
||||||
|
c.setFont("Helvetica-Bold", cell_font_size)
|
||||||
|
c.drawCentredString(bx + bw / 2, by + bh / 2 + 1, lines[0])
|
||||||
|
c.setFont("Helvetica", max(4.5, cell_font_size - 1.0))
|
||||||
|
set_fill(c, (text_rgb[0] * 0.85, text_rgb[1] * 0.85, text_rgb[2] * 0.85) if is_light(pt.color)
|
||||||
|
else (0.9, 0.9, 0.9))
|
||||||
|
c.drawCentredString(bx + bw / 2, by + bh / 2 - cell_font_size + 1, lines[1])
|
||||||
|
|
||||||
|
c.restoreState()
|
||||||
|
|
||||||
|
# ── Legend ────────────────────────────────────────────────────────
|
||||||
|
legend_y = table_bottom - 8
|
||||||
|
legend_x = x0
|
||||||
|
legend_label_h = legend_item_h
|
||||||
|
|
||||||
|
c.setFont("Helvetica-Bold", 7)
|
||||||
|
set_fill(c, HEADER_BG)
|
||||||
|
c.drawString(legend_x, legend_y, "Legenda:")
|
||||||
|
legend_y -= 4
|
||||||
|
|
||||||
|
legend_box_w = 10 * mm
|
||||||
|
legend_text_w = 50 * mm
|
||||||
|
legend_col_stride = legend_box_w + legend_text_w + 4 * mm
|
||||||
|
cols = max(1, int((PAGE_W - 2 * MARGIN) / legend_col_stride))
|
||||||
|
|
||||||
|
for i, pt in enumerate(doc.program_types):
|
||||||
|
col = i % cols
|
||||||
|
row_idx = i // cols
|
||||||
|
lx = legend_x + col * legend_col_stride
|
||||||
|
ly = legend_y - row_idx * legend_label_h
|
||||||
|
|
||||||
|
fill_rgb = hex_to_rgb(pt.color)
|
||||||
|
text_rgb = (0.1, 0.1, 0.1) if is_light(pt.color) else (1.0, 1.0, 1.0)
|
||||||
|
|
||||||
|
draw_rect_filled(c, lx, ly - legend_label_h + 2, legend_box_w, legend_label_h - 2, fill_rgb, GRID_LINE, 0.3)
|
||||||
|
c.setFont("Helvetica-Bold", 6.5)
|
||||||
|
set_fill(c, text_rgb)
|
||||||
|
c.drawCentredString(lx + legend_box_w / 2, ly - legend_label_h + 2 + (legend_label_h - 2 - 6.5) / 2, pt.name[:10])
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 7)
|
||||||
|
set_fill(c, LABEL_TEXT)
|
||||||
|
c.drawString(lx + legend_box_w + 2, ly - legend_label_h + 2 + (legend_label_h - 2 - 7) / 2, pt.name)
|
||||||
|
|
||||||
|
# ── Footer ────────────────────────────────────────────────────────
|
||||||
|
gen_date = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||||
|
c.setFont("Helvetica-Oblique", 6.5)
|
||||||
|
set_fill(c, FOOTER_TEXT)
|
||||||
|
c.drawCentredString(PAGE_W / 2, MARGIN - 2, f"Vygenerováno Scenár Creatorem | {gen_date}")
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
return buf.getvalue()
|
||||||
|
|||||||
Reference in New Issue
Block a user