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
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/*
WORKDIR /app

View File

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

View File

@@ -7,15 +7,62 @@ Always exactly one page, A4 landscape.
from io import BytesIO
from datetime import datetime
from collections import defaultdict
import os
import logging
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
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
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)
MARGIN = 10 * mm
@@ -186,13 +233,13 @@ def generate_pdf(doc) -> bytes:
# ── Draw header ───────────────────────────────────────────────────
y = y_top
c.setFont('Helvetica-Bold', TITLE_SIZE)
c.setFont(_FONT_BOLD, TITLE_SIZE)
set_fill(c, HEADER_BG)
c.drawString(x0, y - TITLE_SIZE, doc.event.title)
y -= TITLE_SIZE + 5
if doc.event.subtitle:
c.setFont('Helvetica', SUB_SIZE)
c.setFont(_FONT_REGULAR, SUB_SIZE)
set_fill(c, (0.4, 0.4, 0.4))
c.drawString(x0, y - SUB_SIZE, doc.event.subtitle)
y -= SUB_SIZE + 3
@@ -208,7 +255,7 @@ def generate_pdf(doc) -> bytes:
parts.append(f'Datum: {date_display}')
if 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))
c.drawString(x0, y - INFO_SIZE, ' | '.join(parts))
y -= INFO_SIZE + 3
@@ -230,7 +277,7 @@ def generate_pdf(doc) -> bytes:
c.line(tx, table_top, tx, table_top + TIME_AXIS_H)
# label
label = fmt_time(m)
c.setFont('Helvetica', time_axis_font)
c.setFont(_FONT_REGULAR, time_axis_font)
set_fill(c, AXIS_TEXT)
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):
slot_idx = (m - t_start) // 15
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)
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
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,
'Helvetica-Bold', date_font, AXIS_TEXT, 'center')
_FONT_BOLD, date_font, AXIS_TEXT, 'center')
# Vertical grid lines (15-min slots, hour lines darker)
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 '')
if row_h > block_title_font * 2.2 and block.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.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)
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)
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.restoreState()
@@ -331,7 +378,7 @@ def generate_pdf(doc) -> bytes:
# ── Legend ────────────────────────────────────────────────────────
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)
c.drawString(x0, legend_y_top, 'Legenda:')
legend_y_top -= LEGEND_ITEM_H
@@ -349,7 +396,7 @@ def generate_pdf(doc) -> bytes:
fill_rgb, BORDER, 0.3)
# Type name NEXT TO the square
c.setFont('Helvetica', 7)
c.setFont(_FONT_REGULAR, 7)
set_fill(c, (0.15, 0.15, 0.15))
c.drawString(lx + LEGEND_BOX_W + 3,
ly - LEGEND_ITEM_H + 2 + (LEGEND_ITEM_H - 2 - 7) / 2,
@@ -357,7 +404,7 @@ def generate_pdf(doc) -> bytes:
# ── Footer ────────────────────────────────────────────────────────
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)
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 {
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 class="form-group">
<label>Den</label>
<input type="date" id="modalBlockDate">
<select id="modalBlockDate"></select>
</div>
<div class="form-group">
<label>Typ programu</label>
@@ -177,16 +177,25 @@
<div class="form-row">
<div class="form-group">
<label>Začátek</label>
<input type="time" id="modalBlockStart">
<input type="time" id="modalBlockStart" step="900">
</div>
<div class="form-group">
<label>Konec</label>
<input type="time" id="modalBlockEnd">
<input type="time" id="modalBlockEnd" step="900">
</div>
</div>
<div class="form-group">
<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 class="form-group">
<label>Garant</label>

View File

@@ -173,10 +173,11 @@ const App = {
document.getElementById('modalBlockEnd').value = block.end || '';
document.getElementById('modalBlockResponsible').value = block.responsible || '';
document.getElementById('modalBlockNotes').value = block.notes || '';
this._updateDuration();
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('blockModal').classList.remove('hidden');
},
@@ -190,10 +191,11 @@ const App = {
document.getElementById('modalBlockEnd').value = end || '10:00';
document.getElementById('modalBlockResponsible').value = '';
document.getElementById('modalBlockNotes').value = '';
document.getElementById('modalBlockDate').value = date || '';
this._updateDuration();
this._populateTypeSelect(null);
this._populateDaySelect(date);
this._updateDuration();
document.getElementById('modalDeleteBtn').style.display = 'none';
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() {
const startVal = document.getElementById('modalBlockStart').value;
const endVal = document.getElementById('modalBlockEnd').value;
if (!startVal || !endVal) {
document.getElementById('modalBlockDuration').value = '';
document.getElementById('modalDurHours').value = '';
document.getElementById('modalDurMinutes').value = '';
return;
}
const s = this.parseTimeToMin(startVal);
let e = this.parseTimeToMin(endVal);
if (e < s) e += 24 * 60; // overnight
if (e <= s) e += 24 * 60; // overnight
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() {
@@ -334,19 +377,21 @@ const App = {
document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal());
document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock());
// Duration ↔ end sync
document.getElementById('modalBlockDuration').addEventListener('input', (e) => {
const durStr = e.target.value.trim();
// Duration ↔ end sync (hours + minutes fields)
const durUpdater = () => {
const startVal = document.getElementById('modalBlockStart').value;
if (!startVal || !durStr) return;
const durParts = durStr.split(':').map(Number);
if (durParts.length < 2 || isNaN(durParts[0]) || isNaN(durParts[1])) return;
const durMin = durParts[0] * 60 + durParts[1];
if (durMin <= 0) return;
const durMin = this._getDurationMinutes();
if (!startVal || durMin <= 0) return;
const startMin = this.parseTimeToMin(startVal);
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', () => {
this._updateDuration();

View File

@@ -48,7 +48,7 @@ def test_health(client):
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["version"] == "4.0.0"
assert data["version"] == "4.1.0"
def test_root_returns_html(client):