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
Some checks failed
Build & Push Docker / build (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Application configuration."""
|
||||
|
||||
VERSION = "4.0.0"
|
||||
VERSION = "4.1.0"
|
||||
MAX_FILE_SIZE_MB = 10
|
||||
DEFAULT_COLOR = "#ffffff"
|
||||
|
||||
@@ -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}')
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user