diff --git a/Dockerfile b/Dockerfile index 762ef12..0e68c00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/config.py b/app/config.py index e7a23fa..e5155a1 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,5 @@ """Application configuration.""" -VERSION = "4.0.0" +VERSION = "4.1.0" MAX_FILE_SIZE_MB = 10 DEFAULT_COLOR = "#ffffff" diff --git a/app/core/pdf_generator.py b/app/core/pdf_generator.py index 1dba7dd..e0dbff7 100644 --- a/app/core/pdf_generator.py +++ b/app/core/pdf_generator.py @@ -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}') diff --git a/app/static/css/app.css b/app/static/css/app.css index 7d02ac9..4c63199 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -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; +} diff --git a/app/static/index.html b/app/static/index.html index 9c46ab5..aa846ec 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -168,7 +168,7 @@
- +
@@ -177,16 +177,25 @@
- +
- +
- +
+
+ + hod +
+
+ + min +
+
diff --git a/app/static/js/app.js b/app/static/js/app.js index 80e4862..f7d3361 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -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(); diff --git a/tests/test_api.py b/tests/test_api.py index 7009a67..19c64d6 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.0.0" + assert data["version"] == "4.1.0" def test_root_returns_html(client):