feat: v4.2 - garant v editoru+PDF, footnote index v bloků, stránka s poznámkami
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,5 +1,5 @@
|
|||||||
"""Application configuration."""
|
"""Application configuration."""
|
||||||
|
|
||||||
VERSION = "4.1.0"
|
VERSION = "4.2.0"
|
||||||
MAX_FILE_SIZE_MB = 10
|
MAX_FILE_SIZE_MB = 10
|
||||||
DEFAULT_COLOR = "#ffffff"
|
DEFAULT_COLOR = "#ffffff"
|
||||||
|
|||||||
@@ -295,6 +295,15 @@ def generate_pdf(doc) -> bytes:
|
|||||||
set_fill(c, AXIS_TEXT)
|
set_fill(c, AXIS_TEXT)
|
||||||
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, fmt_time(m))
|
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 ─────────────────────────────────────────────────
|
# ── Draw day rows ─────────────────────────────────────────────────
|
||||||
blocks_by_date = defaultdict(list)
|
blocks_by_date = defaultdict(list)
|
||||||
for b in doc.blocks:
|
for b in doc.blocks:
|
||||||
@@ -359,19 +368,43 @@ def generate_pdf(doc) -> bytes:
|
|||||||
|
|
||||||
set_fill(c, text_rgb)
|
set_fill(c, text_rgb)
|
||||||
|
|
||||||
title_line = block.title + (' →' if overnight else '')
|
fn_num = footnote_map.get(block.id)
|
||||||
if row_h > block_title_font * 2.2 and block.responsible:
|
title_text = block.title + (' →' if overnight else '')
|
||||||
# Two lines: title + responsible
|
dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78)
|
||||||
c.setFont(_FONT_BOLD, block_title_font)
|
if is_light(pt.color) else (0.82, 0.82, 0.82))
|
||||||
c.drawCentredString(bx + bw / 2, row_y + row_h / 2 + 1, title_line)
|
|
||||||
c.setFont(_FONT_REGULAR, block_time_font)
|
# Determine vertical layout: how many lines fit?
|
||||||
set_fill(c, (text_rgb[0] * 0.8, text_rgb[1] * 0.8, text_rgb[2] * 0.8)
|
has_responsible = bool(block.responsible)
|
||||||
if is_light(pt.color) else (0.85, 0.85, 0.85))
|
sup_size = max(4.0, block_title_font * 0.65)
|
||||||
c.drawCentredString(bx + bw / 2, row_y + row_h / 2 - block_title_font + 1,
|
resp_size = max(4.0, block_time_font)
|
||||||
block.responsible)
|
|
||||||
|
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:
|
else:
|
||||||
c.setFont(_FONT_BOLD, block_title_font)
|
# Single line: title centred
|
||||||
c.drawCentredString(bx + bw / 2, row_y + (row_h - block_title_font) / 2, title_line)
|
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()
|
c.restoreState()
|
||||||
|
|
||||||
@@ -402,11 +435,97 @@ def generate_pdf(doc) -> bytes:
|
|||||||
ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2,
|
ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2,
|
||||||
pt.name)
|
pt.name)
|
||||||
|
|
||||||
# ── Footer ────────────────────────────────────────────────────────
|
# ── Footer (page 1) ───────────────────────────────────────────────
|
||||||
gen_date = datetime.now().strftime('%d.%m.%Y %H:%M')
|
gen_date = datetime.now().strftime('%d.%m.%Y %H:%M')
|
||||||
c.setFont(_FONT_ITALIC, 6.5)
|
c.setFont(_FONT_ITALIC, 6.5)
|
||||||
set_fill(c, FOOTER_TEXT)
|
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()
|
c.save()
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|||||||
@@ -912,3 +912,14 @@ body {
|
|||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
white-space: nowrap;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -233,14 +233,24 @@ const Canvas = {
|
|||||||
const inner = document.createElement('div');
|
const inner = document.createElement('div');
|
||||||
inner.className = 'block-inner';
|
inner.className = 'block-inner';
|
||||||
const timeLabel = `${block.start}–${block.end}`;
|
const timeLabel = `${block.start}–${block.end}`;
|
||||||
|
|
||||||
const nameEl = document.createElement('span');
|
const nameEl = document.createElement('span');
|
||||||
nameEl.className = 'block-title';
|
nameEl.className = 'block-title';
|
||||||
nameEl.textContent = block.title;
|
nameEl.textContent = block.title + (block.notes ? ' *' : '');
|
||||||
|
|
||||||
const timeEl = document.createElement('span');
|
const timeEl = document.createElement('span');
|
||||||
timeEl.className = 'block-time';
|
timeEl.className = 'block-time';
|
||||||
timeEl.textContent = timeLabel + (isOvernight ? ' →' : '');
|
timeEl.textContent = timeLabel + (isOvernight ? ' →' : '');
|
||||||
|
|
||||||
inner.appendChild(nameEl);
|
inner.appendChild(nameEl);
|
||||||
inner.appendChild(timeEl);
|
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);
|
el.appendChild(inner);
|
||||||
|
|
||||||
// Click to edit
|
// Click to edit
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def test_health(client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
data = r.json()
|
data = r.json()
|
||||||
assert data["status"] == "ok"
|
assert data["status"] == "ok"
|
||||||
assert data["version"] == "4.1.0"
|
assert data["version"] == "4.2.0"
|
||||||
|
|
||||||
|
|
||||||
def test_root_returns_html(client):
|
def test_root_returns_html(client):
|
||||||
|
|||||||
@@ -140,3 +140,36 @@ def test_generate_pdf_many_types_legend():
|
|||||||
doc = make_doc(program_types=types, blocks=blocks)
|
doc = make_doc(program_types=types, blocks=blocks)
|
||||||
pdf_bytes = generate_pdf(doc)
|
pdf_bytes = generate_pdf(doc)
|
||||||
assert pdf_bytes[:5] == b'%PDF-'
|
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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user