feat: v4.1 - Czech diacritics in PDF (Liberation fonts), hour/min duration fields, day select in modal
Some checks failed
Build & Push Docker / build (push) Has been cancelled

This commit is contained in:
2026-02-20 17:39:38 +01:00
parent f0e7c3b093
commit 6c4ca5e9be
7 changed files with 175 additions and 37 deletions

View File

@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \ && apt-get install -y --no-install-recommends curl fonts-liberation \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app

View File

@@ -1,5 +1,5 @@
"""Application configuration.""" """Application configuration."""
VERSION = "4.0.0" VERSION = "4.1.0"
MAX_FILE_SIZE_MB = 10 MAX_FILE_SIZE_MB = 10
DEFAULT_COLOR = "#ffffff" DEFAULT_COLOR = "#ffffff"

View File

@@ -7,15 +7,62 @@ Always exactly one page, A4 landscape.
from io import BytesIO from io import BytesIO
from datetime import datetime from datetime import datetime
from collections import defaultdict from collections import defaultdict
import os
import logging
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.pdfgen import canvas as rl_canvas from reportlab.pdfgen import canvas as rl_canvas
import logging from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from .validator import ScenarsError from .validator import ScenarsError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Font registration ─────────────────────────────────────────────────────────
# LiberationSans supports Czech diacritics. Fallback to Helvetica if not found.
_FONT_REGULAR = 'Helvetica'
_FONT_BOLD = 'Helvetica-Bold'
_FONT_ITALIC = 'Helvetica-Oblique'
_LIBERATION_PATHS = [
'/usr/share/fonts/truetype/liberation',
'/usr/share/fonts/liberation',
'/usr/share/fonts/truetype',
]
def _find_font(filename: str):
for base in _LIBERATION_PATHS:
path = os.path.join(base, filename)
if os.path.isfile(path):
return path
return None
def _register_fonts():
global _FONT_REGULAR, _FONT_BOLD, _FONT_ITALIC
regular = _find_font('LiberationSans-Regular.ttf')
bold = _find_font('LiberationSans-Bold.ttf')
italic = _find_font('LiberationSans-Italic.ttf')
if regular and bold and italic:
try:
pdfmetrics.registerFont(TTFont('LiberationSans', regular))
pdfmetrics.registerFont(TTFont('LiberationSans-Bold', bold))
pdfmetrics.registerFont(TTFont('LiberationSans-Italic', italic))
_FONT_REGULAR = 'LiberationSans'
_FONT_BOLD = 'LiberationSans-Bold'
_FONT_ITALIC = 'LiberationSans-Italic'
logger.info('PDF: Using LiberationSans (Czech diacritics supported)')
except Exception as e:
logger.warning(f'PDF: Font registration failed: {e}')
else:
logger.warning('PDF: LiberationSans not found, Czech diacritics may be broken')
_register_fonts()
PAGE_W, PAGE_H = landscape(A4) PAGE_W, PAGE_H = landscape(A4)
MARGIN = 10 * mm MARGIN = 10 * mm
@@ -186,13 +233,13 @@ def generate_pdf(doc) -> bytes:
# ── Draw header ─────────────────────────────────────────────────── # ── Draw header ───────────────────────────────────────────────────
y = y_top y = y_top
c.setFont('Helvetica-Bold', TITLE_SIZE) c.setFont(_FONT_BOLD, TITLE_SIZE)
set_fill(c, HEADER_BG) set_fill(c, HEADER_BG)
c.drawString(x0, y - TITLE_SIZE, doc.event.title) c.drawString(x0, y - TITLE_SIZE, doc.event.title)
y -= TITLE_SIZE + 5 y -= TITLE_SIZE + 5
if doc.event.subtitle: if doc.event.subtitle:
c.setFont('Helvetica', SUB_SIZE) c.setFont(_FONT_REGULAR, SUB_SIZE)
set_fill(c, (0.4, 0.4, 0.4)) set_fill(c, (0.4, 0.4, 0.4))
c.drawString(x0, y - SUB_SIZE, doc.event.subtitle) c.drawString(x0, y - SUB_SIZE, doc.event.subtitle)
y -= SUB_SIZE + 3 y -= SUB_SIZE + 3
@@ -208,7 +255,7 @@ def generate_pdf(doc) -> bytes:
parts.append(f'Datum: {date_display}') parts.append(f'Datum: {date_display}')
if doc.event.location: if doc.event.location:
parts.append(f'Místo: {doc.event.location}') parts.append(f'Místo: {doc.event.location}')
c.setFont('Helvetica', INFO_SIZE) c.setFont(_FONT_REGULAR, INFO_SIZE)
set_fill(c, (0.5, 0.5, 0.5)) set_fill(c, (0.5, 0.5, 0.5))
c.drawString(x0, y - INFO_SIZE, ' | '.join(parts)) c.drawString(x0, y - INFO_SIZE, ' | '.join(parts))
y -= INFO_SIZE + 3 y -= INFO_SIZE + 3
@@ -230,7 +277,7 @@ def generate_pdf(doc) -> bytes:
c.line(tx, table_top, tx, table_top + TIME_AXIS_H) c.line(tx, table_top, tx, table_top + TIME_AXIS_H)
# label # label
label = fmt_time(m) label = fmt_time(m)
c.setFont('Helvetica', time_axis_font) c.setFont(_FONT_REGULAR, time_axis_font)
set_fill(c, AXIS_TEXT) set_fill(c, AXIS_TEXT)
c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, label) c.drawCentredString(tx, table_top + (TIME_AXIS_H - time_axis_font) / 2, label)
@@ -244,7 +291,7 @@ def generate_pdf(doc) -> bytes:
for m in range(t_start, t_end + 1, 60): for m in range(t_start, t_end + 1, 60):
slot_idx = (m - t_start) // 15 slot_idx = (m - t_start) // 15
tx = x0 + DATE_COL_W + slot_idx * slot_w tx = x0 + DATE_COL_W + slot_idx * slot_w
c.setFont('Helvetica', time_axis_font) c.setFont(_FONT_REGULAR, time_axis_font)
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))
@@ -263,7 +310,7 @@ def generate_pdf(doc) -> bytes:
# Date label cell # Date label cell
fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4) fill_rect(c, x0, row_y, DATE_COL_W, row_h, AXIS_BG, BORDER, 0.4)
draw_clipped_text(c, format_date_cs(date_key), x0, row_y, DATE_COL_W, row_h, draw_clipped_text(c, format_date_cs(date_key), x0, row_y, DATE_COL_W, row_h,
'Helvetica-Bold', date_font, AXIS_TEXT, 'center') _FONT_BOLD, date_font, AXIS_TEXT, 'center')
# Vertical grid lines (15-min slots, hour lines darker) # Vertical grid lines (15-min slots, hour lines darker)
for slot_i in range(num_15min_slots + 1): for slot_i in range(num_15min_slots + 1):
@@ -315,15 +362,15 @@ def generate_pdf(doc) -> bytes:
title_line = block.title + ('' if overnight else '') title_line = block.title + ('' if overnight else '')
if row_h > block_title_font * 2.2 and block.responsible: if row_h > block_title_font * 2.2 and block.responsible:
# Two lines: title + responsible # Two lines: title + responsible
c.setFont('Helvetica-Bold', block_title_font) c.setFont(_FONT_BOLD, block_title_font)
c.drawCentredString(bx + bw / 2, row_y + row_h / 2 + 1, title_line) c.drawCentredString(bx + bw / 2, row_y + row_h / 2 + 1, title_line)
c.setFont('Helvetica', block_time_font) 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) 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)) 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, c.drawCentredString(bx + bw / 2, row_y + row_h / 2 - block_title_font + 1,
block.responsible) block.responsible)
else: else:
c.setFont('Helvetica-Bold', block_title_font) c.setFont(_FONT_BOLD, block_title_font)
c.drawCentredString(bx + bw / 2, row_y + (row_h - block_title_font) / 2, title_line) c.drawCentredString(bx + bw / 2, row_y + (row_h - block_title_font) / 2, title_line)
c.restoreState() c.restoreState()
@@ -331,7 +378,7 @@ def generate_pdf(doc) -> bytes:
# ── Legend ──────────────────────────────────────────────────────── # ── Legend ────────────────────────────────────────────────────────
legend_y_top = table_top - num_days * row_h - 6 legend_y_top = table_top - num_days * row_h - 6
c.setFont('Helvetica-Bold', 7) c.setFont(_FONT_BOLD, 7)
set_fill(c, HEADER_BG) set_fill(c, HEADER_BG)
c.drawString(x0, legend_y_top, 'Legenda:') c.drawString(x0, legend_y_top, 'Legenda:')
legend_y_top -= LEGEND_ITEM_H legend_y_top -= LEGEND_ITEM_H
@@ -349,7 +396,7 @@ def generate_pdf(doc) -> bytes:
fill_rgb, BORDER, 0.3) fill_rgb, BORDER, 0.3)
# Type name NEXT TO the square # Type name NEXT TO the square
c.setFont('Helvetica', 7) c.setFont(_FONT_REGULAR, 7)
set_fill(c, (0.15, 0.15, 0.15)) set_fill(c, (0.15, 0.15, 0.15))
c.drawString(lx + LEGEND_BOX_W + 3, c.drawString(lx + LEGEND_BOX_W + 3,
ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2, ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2,
@@ -357,7 +404,7 @@ def generate_pdf(doc) -> bytes:
# ── Footer ──────────────────────────────────────────────────────── # ── Footer ────────────────────────────────────────────────────────
gen_date = datetime.now().strftime('%d.%m.%Y %H:%M') gen_date = datetime.now().strftime('%d.%m.%Y %H:%M')
c.setFont('Helvetica-Oblique', 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}') c.drawCentredString(PAGE_W / 2, MARGIN - 2, f'Vygenerováno Scenár Creatorem v4 | {gen_date}')

View File

@@ -875,3 +875,40 @@ body {
.block-el:hover::after { .block-el:hover::after {
opacity: 1; opacity: 1;
} }
/* Duration row (hours + minutes) */
.duration-row {
display: flex;
gap: 8px;
align-items: center;
}
.duration-field {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
}
.duration-field input[type="number"] {
width: 60px;
text-align: center;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--text);
background: var(--white);
}
.duration-field input[type="number"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.duration-unit {
font-size: 12px;
color: var(--text-light);
white-space: nowrap;
}

View File

@@ -168,7 +168,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Den</label> <label>Den</label>
<input type="date" id="modalBlockDate"> <select id="modalBlockDate"></select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Typ programu</label> <label>Typ programu</label>
@@ -177,16 +177,25 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>Začátek</label> <label>Začátek</label>
<input type="time" id="modalBlockStart"> <input type="time" id="modalBlockStart" step="900">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Konec</label> <label>Konec</label>
<input type="time" id="modalBlockEnd"> <input type="time" id="modalBlockEnd" step="900">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Nebo trvání</label> <label>Nebo trvání</label>
<input type="text" id="modalBlockDuration" placeholder="HH:MM (např. 1:30)"> <div class="duration-row">
<div class="duration-field">
<input type="number" id="modalDurHours" min="0" max="23" placeholder="0">
<span class="duration-unit">hod</span>
</div>
<div class="duration-field">
<input type="number" id="modalDurMinutes" min="0" max="59" step="15" placeholder="0">
<span class="duration-unit">min</span>
</div>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Garant</label> <label>Garant</label>

View File

@@ -173,10 +173,11 @@ const App = {
document.getElementById('modalBlockEnd').value = block.end || ''; document.getElementById('modalBlockEnd').value = block.end || '';
document.getElementById('modalBlockResponsible').value = block.responsible || ''; document.getElementById('modalBlockResponsible').value = block.responsible || '';
document.getElementById('modalBlockNotes').value = block.notes || ''; document.getElementById('modalBlockNotes').value = block.notes || '';
this._updateDuration();
this._populateTypeSelect(block.type_id); this._populateTypeSelect(block.type_id);
document.getElementById('modalBlockDate').value = block.date || ''; this._populateDaySelect(block.date);
this._updateDuration();
document.getElementById('modalDeleteBtn').style.display = 'inline-block'; document.getElementById('modalDeleteBtn').style.display = 'inline-block';
document.getElementById('blockModal').classList.remove('hidden'); document.getElementById('blockModal').classList.remove('hidden');
}, },
@@ -190,10 +191,11 @@ const App = {
document.getElementById('modalBlockEnd').value = end || '10:00'; document.getElementById('modalBlockEnd').value = end || '10:00';
document.getElementById('modalBlockResponsible').value = ''; document.getElementById('modalBlockResponsible').value = '';
document.getElementById('modalBlockNotes').value = ''; document.getElementById('modalBlockNotes').value = '';
document.getElementById('modalBlockDate').value = date || '';
this._updateDuration();
this._populateTypeSelect(null); this._populateTypeSelect(null);
this._populateDaySelect(date);
this._updateDuration();
document.getElementById('modalDeleteBtn').style.display = 'none'; document.getElementById('modalDeleteBtn').style.display = 'none';
document.getElementById('blockModal').classList.remove('hidden'); document.getElementById('blockModal').classList.remove('hidden');
}, },
@@ -210,18 +212,59 @@ const App = {
}); });
}, },
_populateDaySelect(selectedDate) {
const sel = document.getElementById('modalBlockDate');
sel.innerHTML = '';
const dates = this.getDates();
if (dates.length === 0) {
// Fallback: show today
const today = new Date().toISOString().slice(0, 10);
const opt = document.createElement('option');
opt.value = today;
opt.textContent = this._formatDateLabel(today);
sel.appendChild(opt);
return;
}
dates.forEach(date => {
const opt = document.createElement('option');
opt.value = date;
opt.textContent = this._formatDateLabel(date);
if (date === selectedDate) opt.selected = true;
sel.appendChild(opt);
});
// If none selected, default to first
if (!selectedDate || !dates.includes(selectedDate)) {
sel.value = dates[0];
}
},
_formatDateLabel(dateStr) {
const d = new Date(dateStr + 'T12:00:00');
const weekday = d.toLocaleDateString('cs-CZ', { weekday: 'long' });
const weekdayCap = weekday.charAt(0).toUpperCase() + weekday.slice(1);
return `${weekdayCap} (${d.getDate()}.${d.getMonth() + 1})`;
},
_updateDuration() { _updateDuration() {
const startVal = document.getElementById('modalBlockStart').value; const startVal = document.getElementById('modalBlockStart').value;
const endVal = document.getElementById('modalBlockEnd').value; const endVal = document.getElementById('modalBlockEnd').value;
if (!startVal || !endVal) { if (!startVal || !endVal) {
document.getElementById('modalBlockDuration').value = ''; document.getElementById('modalDurHours').value = '';
document.getElementById('modalDurMinutes').value = '';
return; return;
} }
const s = this.parseTimeToMin(startVal); const s = this.parseTimeToMin(startVal);
let e = this.parseTimeToMin(endVal); let e = this.parseTimeToMin(endVal);
if (e < s) e += 24 * 60; // overnight if (e <= s) e += 24 * 60; // overnight
const dur = e - s; const dur = e - s;
document.getElementById('modalBlockDuration').value = this.minutesToTime(dur); document.getElementById('modalDurHours').value = Math.floor(dur / 60);
document.getElementById('modalDurMinutes').value = dur % 60;
},
_getDurationMinutes() {
const h = parseInt(document.getElementById('modalDurHours').value) || 0;
const m = parseInt(document.getElementById('modalDurMinutes').value) || 0;
return h * 60 + m;
}, },
_saveModal() { _saveModal() {
@@ -334,19 +377,21 @@ const App = {
document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal()); document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal());
document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock()); document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock());
// Duration ↔ end sync // Duration ↔ end sync (hours + minutes fields)
document.getElementById('modalBlockDuration').addEventListener('input', (e) => { const durUpdater = () => {
const durStr = e.target.value.trim();
const startVal = document.getElementById('modalBlockStart').value; const startVal = document.getElementById('modalBlockStart').value;
if (!startVal || !durStr) return; const durMin = this._getDurationMinutes();
const durParts = durStr.split(':').map(Number); if (!startVal || durMin <= 0) return;
if (durParts.length < 2 || isNaN(durParts[0]) || isNaN(durParts[1])) return;
const durMin = durParts[0] * 60 + durParts[1];
if (durMin <= 0) return;
const startMin = this.parseTimeToMin(startVal); const startMin = this.parseTimeToMin(startVal);
const endMin = startMin + durMin; const endMin = startMin + durMin;
document.getElementById('modalBlockEnd').value = this.minutesToTime(endMin % 1440 || endMin); // Allow overnight (> 24h notation handled as raw minutes)
}); const h = Math.floor(endMin / 60) % 24;
const m = endMin % 60;
document.getElementById('modalBlockEnd').value =
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
document.getElementById('modalDurHours').addEventListener('input', durUpdater);
document.getElementById('modalDurMinutes').addEventListener('input', durUpdater);
document.getElementById('modalBlockEnd').addEventListener('input', () => { document.getElementById('modalBlockEnd').addEventListener('input', () => {
this._updateDuration(); this._updateDuration();

View File

@@ -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.0.0" assert data["version"] == "4.1.0"
def test_root_returns_html(client): def test_root_returns_html(client):