From feb75219a73a80cb9f64bd4ccd3c0b6544e4bd7b Mon Sep 17 00:00:00 2001 From: Daneel Date: Fri, 20 Feb 2026 17:43:19 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v4.2=20-=20garant=20v=20editoru+PDF,=20?= =?UTF-8?q?footnote=20index=20v=20blok=C5=AF,=20str=C3=A1nka=20s=20pozn?= =?UTF-8?q?=C3=A1mkami?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 2 +- app/core/pdf_generator.py | 147 ++++++++++++++++++++++++++++++++++---- app/static/css/app.css | 11 +++ app/static/js/canvas.js | 12 +++- tests/test_api.py | 2 +- tests/test_pdf.py | 33 +++++++++ 6 files changed, 190 insertions(+), 17 deletions(-) diff --git a/app/config.py b/app/config.py index e5155a1..f3eee10 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,5 @@ """Application configuration.""" -VERSION = "4.1.0" +VERSION = "4.2.0" MAX_FILE_SIZE_MB = 10 DEFAULT_COLOR = "#ffffff" diff --git a/app/core/pdf_generator.py b/app/core/pdf_generator.py index e0dbff7..b22db76 100644 --- a/app/core/pdf_generator.py +++ b/app/core/pdf_generator.py @@ -295,6 +295,15 @@ def generate_pdf(doc) -> bytes: set_fill(c, AXIS_TEXT) c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m)) + # ── Footnote map: blocks with notes get sequential numbers ──────── + footnotes = [] # [(num, block), ...] + footnote_map = {} # block.id → footnote number + for b in sorted(doc.blocks, key=lambda x: (x.date, x.start)): + if b.notes: + num = len(footnotes) + 1 + footnotes.append((num, b)) + footnote_map[b.id] = num + # ── Draw day rows ───────────────────────────────────────────────── blocks_by_date = defaultdict(list) for b in doc.blocks: @@ -359,19 +368,43 @@ def generate_pdf(doc) -> bytes: set_fill(c, text_rgb) - title_line = block.title + (' →' if overnight else '') - if row_h > block_title_font * 2.2 and block.responsible: - # Two lines: title + responsible - c.setFont(_FONT_BOLD, block_title_font) - c.drawCentredString(bx + bw / 2, row_y + row_h / 2 + 1, title_line) - c.setFont(_FONT_REGULAR, block_time_font) - set_fill(c, (text_rgb[0] * 0.8, text_rgb[1] * 0.8, text_rgb[2] * 0.8) - if is_light(pt.color) else (0.85, 0.85, 0.85)) - c.drawCentredString(bx + bw / 2, row_y + row_h / 2 - block_title_font + 1, - block.responsible) + fn_num = footnote_map.get(block.id) + title_text = block.title + (' →' if overnight else '') + dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78) + if is_light(pt.color) else (0.82, 0.82, 0.82)) + + # Determine vertical layout: how many lines fit? + has_responsible = bool(block.responsible) + sup_size = max(4.0, block_title_font * 0.65) + resp_size = max(4.0, block_time_font) + + if has_responsible and row_h >= block_title_font + resp_size + 3: + # Two-line: title (with superscript) + responsible + title_y = row_y + row_h * 0.55 + resp_y = row_y + row_h * 0.55 - block_title_font - 1 + # responsible + c.setFont(_FONT_ITALIC, resp_size) + set_fill(c, dim_rgb) + c.drawCentredString(bx + bw / 2, resp_y, block.responsible) else: - c.setFont(_FONT_BOLD, block_title_font) - c.drawCentredString(bx + bw / 2, row_y + (row_h - block_title_font) / 2, title_line) + # Single line: title centred + title_y = row_y + (row_h - block_title_font) / 2 + + # Title + c.setFont(_FONT_BOLD, block_title_font) + set_fill(c, text_rgb) + if fn_num is not None: + # Draw title then superscript footnote number + title_w = c.stringWidth(title_text, _FONT_BOLD, block_title_font) + tx = bx + bw / 2 - title_w / 2 + c.drawString(tx, title_y, title_text) + # Superscript: small number raised by ~font_size * 0.5 + c.setFont(_FONT_BOLD, sup_size) + set_fill(c, dim_rgb) + c.drawString(tx + title_w + 0.5, title_y + block_title_font * 0.45, + str(fn_num)) + else: + c.drawCentredString(bx + bw / 2, title_y, title_text) c.restoreState() @@ -402,11 +435,97 @@ def generate_pdf(doc) -> bytes: ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2, pt.name) - # ── Footer ──────────────────────────────────────────────────────── + # ── Footer (page 1) ─────────────────────────────────────────────── gen_date = datetime.now().strftime('%d.%m.%Y %H:%M') c.setFont(_FONT_ITALIC, 6.5) set_fill(c, FOOTER_TEXT) - c.drawCentredString(PAGE_W / 2, MARGIN - 2, f'Vygenerováno Scenár Creatorem v4 | {gen_date}') + footer_note = ' | Poznámky na str. 2' if footnotes else '' + c.drawCentredString(PAGE_W / 2, MARGIN - 2, + f'Vygenerováno Scenár Creatorem v4 | {gen_date}{footer_note}') + + # ── Page 2: Poznámky ke scénáři ─────────────────────────────────── + if footnotes: + c.showPage() + + NOTE_MARGIN = 15 * mm + ny = PAGE_H - NOTE_MARGIN + + # Page title + c.setFont(_FONT_BOLD, 14) + set_fill(c, HEADER_BG) + c.drawString(NOTE_MARGIN, ny - 14, 'Poznámky ke scénáři') + ny -= 14 + 4 + + # Subtitle: event title + date + ev_info = doc.event.title + date_display = doc.event.date_from or doc.event.date + date_to_display = doc.event.date_to + if date_display: + if date_to_display and date_to_display != date_display: + ev_info += f' | {date_display} – {date_to_display}' + else: + ev_info += f' | {date_display}' + c.setFont(_FONT_REGULAR, 9) + set_fill(c, AXIS_TEXT) + c.drawString(NOTE_MARGIN, ny - 9, ev_info) + ny -= 9 + 8 + + # Separator line + set_stroke(c, GRID_HOUR) + c.setLineWidth(0.5) + c.line(NOTE_MARGIN, ny, PAGE_W - NOTE_MARGIN, ny) + ny -= 8 + + # Footnote entries + for fn_num, block in footnotes: + # Block header: number + title + day + time + day_label = format_date_cs(block.date) + time_str = f'{block.start}–{block.end}' + resp_str = f' ({block.responsible})' if block.responsible else '' + header_text = f'{fn_num}. {block.title}{resp_str} — {day_label}, {time_str}' + + # Check space, add new page if needed + if ny < NOTE_MARGIN + 30: + c.showPage() + ny = PAGE_H - NOTE_MARGIN + + c.setFont(_FONT_BOLD, 9) + set_fill(c, HEADER_BG) + c.drawString(NOTE_MARGIN, ny - 9, header_text) + ny -= 9 + 3 + + # Note text (wrapped manually) + note_text = block.notes or '' + words = note_text.split() + line_w = PAGE_W - 2 * NOTE_MARGIN - 10 + c.setFont(_FONT_REGULAR, 8.5) + set_fill(c, (0.15, 0.15, 0.15)) + line = '' + for word in words: + test_line = (line + ' ' + word).strip() + if c.stringWidth(test_line, _FONT_REGULAR, 8.5) > line_w: + if ny < NOTE_MARGIN + 15: + c.showPage() + ny = PAGE_H - NOTE_MARGIN + c.drawString(NOTE_MARGIN + 8, ny - 8.5, line) + ny -= 8.5 + 2 + line = word + else: + line = test_line + if line: + if ny < NOTE_MARGIN + 15: + c.showPage() + ny = PAGE_H - NOTE_MARGIN + c.drawString(NOTE_MARGIN + 8, ny - 8.5, line) + ny -= 8.5 + 2 + + ny -= 5 # spacing between footnotes + + # Footer on notes page + c.setFont(_FONT_ITALIC, 6.5) + set_fill(c, FOOTER_TEXT) + c.drawCentredString(PAGE_W / 2, MARGIN - 2, + f'Poznámky ke scénáři — {doc.event.title} | {gen_date}') c.save() return buf.getvalue() diff --git a/app/static/css/app.css b/app/static/css/app.css index 4c63199..9df56e1 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -912,3 +912,14 @@ body { color: var(--text-light); white-space: nowrap; } + +/* Responsible in block */ +.block-responsible { + font-size: 9px; + opacity: 0.75; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.1; + font-style: italic; +} diff --git a/app/static/js/canvas.js b/app/static/js/canvas.js index 3c4e267..0d75dcc 100644 --- a/app/static/js/canvas.js +++ b/app/static/js/canvas.js @@ -233,14 +233,24 @@ const Canvas = { const inner = document.createElement('div'); inner.className = 'block-inner'; const timeLabel = `${block.start}–${block.end}`; + const nameEl = document.createElement('span'); nameEl.className = 'block-title'; - nameEl.textContent = block.title; + nameEl.textContent = block.title + (block.notes ? ' *' : ''); + const timeEl = document.createElement('span'); timeEl.className = 'block-time'; timeEl.textContent = timeLabel + (isOvernight ? ' →' : ''); + inner.appendChild(nameEl); inner.appendChild(timeEl); + + if (block.responsible) { + const respEl = document.createElement('span'); + respEl.className = 'block-responsible'; + respEl.textContent = block.responsible; + inner.appendChild(respEl); + } el.appendChild(inner); // Click to edit diff --git a/tests/test_api.py b/tests/test_api.py index 19c64d6..be471d1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -48,7 +48,7 @@ def test_health(client): assert r.status_code == 200 data = r.json() assert data["status"] == "ok" - assert data["version"] == "4.1.0" + assert data["version"] == "4.2.0" def test_root_returns_html(client): diff --git a/tests/test_pdf.py b/tests/test_pdf.py index e21ab06..14dec4c 100644 --- a/tests/test_pdf.py +++ b/tests/test_pdf.py @@ -140,3 +140,36 @@ def test_generate_pdf_many_types_legend(): doc = make_doc(program_types=types, blocks=blocks) pdf_bytes = generate_pdf(doc) assert pdf_bytes[:5] == b'%PDF-' + + +def test_generate_pdf_with_notes_creates_second_page(): + """Blocks with notes should produce a 2-page PDF (timetable + notes).""" + import re + types = [ProgramType(id="ws", name="Workshop", color="#0070C0")] + blocks = [ + Block(id="b1", date="2026-03-01", start="09:00", end="10:00", + title="Opening", type_id="ws", notes="Bring the flipchart and markers."), + Block(id="b2", date="2026-03-01", start="10:00", end="11:00", + title="Teambuilding", type_id="ws", notes="Outdoor if weather permits."), + Block(id="b3", date="2026-03-01", start="11:00", end="12:00", + title="Lunch", type_id="ws"), # no notes + ] + doc = make_doc(program_types=types, blocks=blocks) + pdf_bytes = generate_pdf(doc) + assert pdf_bytes[:5] == b'%PDF-' + pages = len(re.findall(rb'/Type\s*/Page[^s]', pdf_bytes)) + assert pages == 2, f"Expected 2 pages (timetable + notes), got {pages}" + + +def test_generate_pdf_no_notes_single_page(): + """Without notes, PDF should be exactly 1 page.""" + import re + doc = make_doc( + blocks=[ + Block(id="b1", date="2026-03-01", start="09:00", end="10:00", + title="No notes here", type_id="ws"), + ] + ) + pdf_bytes = generate_pdf(doc) + pages = len(re.findall(rb'/Type\s*/Page[^s]', pdf_bytes)) + assert pages == 1, f"Expected 1 page, got {pages}"