diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 08cf75a..e155d24 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,14 +1,23 @@ #!/usr/bin/env bash set -euo pipefail # Pre-commit hook: run tests and prevent commit on failures. -# By default run fast tests (exclude integration). To include integration tests set RUN_INTEGRATION=1. +# By default run fast tests (exclude integration). To include integration tests +# set RUN_INTEGRATION=1. + +# Use venv pytest if available, otherwise fall back to system pytest +REPO_DIR="$(git rev-parse --show-toplevel)" +if [ -x "${REPO_DIR}/.venv/bin/pytest" ]; then + PYTEST="${REPO_DIR}/.venv/bin/pytest" +else + PYTEST="pytest" +fi echo "Running pytest (fast) before commit..." if [ "${RUN_INTEGRATION:-0}" = "1" ]; then echo "RUN_INTEGRATION=1: including integration tests" - pytest -q + $PYTEST -q else - pytest -q -m "not integration" + $PYTEST -q -m "not integration" fi echo "Tests passed. Proceeding with commit." diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..587c7cc --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,48 @@ +# Copilot instructions for scenar-creator + +## Big picture (read this first) +- This is a single FastAPI service that serves both API and static frontend (no separate frontend build step). +- Entry point: `app/main.py` mounts `/static` and serves `index.html` at `/`. +- API boundary is under `app/api/` (`/api/health`, `/api/validate`, `/api/sample`, `/api/generate-pdf`). +- Core domain object is `ScenarioDocument` in `app/models/event.py` (JSON-first design, no DB). +- PDF rendering is the main backend logic in `app/core/pdf_generator.py` (ReportLab canvas, A4 landscape timetable). + +## Data model and domain rules (project-specific) +- Multi-day events use `event.date_from` + `event.date_to`; keep backward compatibility with legacy `event.date`. +- `blocks[].date` is always a concrete day (`YYYY-MM-DD`) even for multi-day events. +- Overnight blocks are valid: `end <= start` means crossing midnight. This rule exists in frontend canvas and backend PDF. +- `series_id` groups blocks created by “add to all days”; deleting a series removes all blocks with matching `series_id`. +- `program_types[].id` must match each `blocks[].type_id`; unknown types are validation errors. + +## Frontend architecture conventions +- Frontend is vanilla JS globals loaded in order from `index.html`: `API` -> `Canvas` -> import/export helpers -> `App`. +- `App.state` is the source of truth (`event`, `program_types`, `blocks`) in `app/static/js/app.js`. +- Canvas timeline is horizontal time axis with 15-minute snapping (`Canvas.GRID_MINUTES = 15`) in `app/static/js/canvas.js`. +- Drag/resize behavior: native pointer drag for move + interact.js resize handle for duration updates. +- UI copy and date labels are Czech (e.g., `Pondělí (20.2)`), so preserve localization in new UI text. + +## Backend conventions +- Keep API handlers thin; place formatting/rendering logic in `app/core/`. +- Raise `ScenarsError` from core logic and translate to HTTP 422 in API (`app/api/pdf.py`). +- PDF generator handles: + - legend from `program_types` + - optional responsible person text inside blocks + - notes as superscript markers + optional page 2 note listing +- Czech diacritics in PDFs rely on LiberationSans registration with Helvetica fallback. + +## Developer workflows +- Local run: `uvicorn app.main:app --reload --port 8080` +- Tests: `python3 -m pytest tests/ -v` +- Container image includes required fonts (`fonts-liberation`) in `Dockerfile`; do not remove unless PDF text rendering is reworked. +- Health endpoint for local/container checks: `/api/health`. + +## Testing patterns to preserve +- API tests use `fastapi.testclient.TestClient` (`tests/test_api.py`). +- PDF tests assert magic header `%PDF-` and page count with regex (`tests/test_pdf.py`). +- Keep coverage for overnight blocks, multi-day ranges, legacy `event.date`, and `series_id` behavior when changing models/UI. + +## High-impact files +- `app/core/pdf_generator.py` — timeline layout math, fonts, notes page logic. +- `app/static/js/canvas.js` — drag/resize + time-range computation. +- `app/static/js/app.js` — modal save/delete logic, series expansion, document import/export shape. +- `app/models/event.py` — schema contract used by both API and frontend JSON. diff --git a/app/core/pdf_generator.py b/app/core/pdf_generator.py index 3daafc7..e497460 100644 --- a/app/core/pdf_generator.py +++ b/app/core/pdf_generator.py @@ -21,20 +21,30 @@ from .validator import ScenarsError logger = logging.getLogger(__name__) # ── Font registration ───────────────────────────────────────────────────────── -# LiberationSans supports Czech diacritics. Fallback to Helvetica if not found. +# Czech diacritics require a TrueType font. Try LiberationSans (Linux/Docker), +# then Arial (macOS), then fall back to Helvetica (no diacritics). _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', +# Font candidates: (family_name, [(search_dirs, regular, bold, italic)]) +_FONT_CANDIDATES = [ + # LiberationSans — Linux / Docker (fonts-liberation package) + ('LiberationSans', [ + '/usr/share/fonts/truetype/liberation', + '/usr/share/fonts/liberation', + '/usr/share/fonts/truetype', + ], 'LiberationSans-Regular.ttf', 'LiberationSans-Bold.ttf', 'LiberationSans-Italic.ttf'), + # Arial — macOS system font + ('Arial', [ + '/System/Library/Fonts/Supplemental', + '/Library/Fonts', + ], 'Arial.ttf', 'Arial Bold.ttf', 'Arial Italic.ttf'), ] -def _find_font(filename: str): - for base in _LIBERATION_PATHS: +def _find_font_file(dirs: list, filename: str): + for base in dirs: path = os.path.join(base, filename) if os.path.isfile(path): return path @@ -43,22 +53,23 @@ def _find_font(filename: str): 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') + for family, dirs, reg_name, bold_name, italic_name in _FONT_CANDIDATES: + regular = _find_font_file(dirs, reg_name) + bold = _find_font_file(dirs, bold_name) + italic = _find_font_file(dirs, italic_name) + if regular and bold and italic: + try: + pdfmetrics.registerFont(TTFont(f'{family}', regular)) + pdfmetrics.registerFont(TTFont(f'{family}-Bold', bold)) + pdfmetrics.registerFont(TTFont(f'{family}-Italic', italic)) + _FONT_REGULAR = family + _FONT_BOLD = f'{family}-Bold' + _FONT_ITALIC = f'{family}-Italic' + logger.info(f'PDF: Using {family} (Czech diacritics supported)') + return + except Exception as e: + logger.warning(f'PDF: {family} registration failed: {e}') + logger.warning('PDF: No TrueType font found, Czech diacritics may be broken') _register_fonts() @@ -172,6 +183,26 @@ def fit_text(c, text: str, font: str, size: float, max_w: float) -> str: return (text[:lo] + ellipsis) if lo < len(text) else text +def wrap_text(c, text: str, font: str, size: float, max_w: float) -> list: + """Word-wrap text into lines fitting within max_w points.""" + if not text: + return [] + words = text.split() + if not words: + return [] + lines = [] + current = words[0] + for word in words[1:]: + test = current + ' ' + word + if c.stringWidth(test, font, size) <= max_w: + current = test + else: + lines.append(current) + current = word + lines.append(current) + return lines + + def generate_pdf(doc) -> bytes: if not doc.blocks: raise ScenarsError("No blocks provided") @@ -385,57 +416,205 @@ def generate_pdf(doc) -> bytes: p.rect(bx + inset + 1, row_y + inset + 1, bw - 2 * inset - 2, row_h - 2 * inset - 2) c.clipPath(p, stroke=0, fill=0) - set_fill(c, text_rgb) - fn_num = footnote_map.get(block.id) title_text = block.title + (' →' if overnight else '') dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78) if is_light(pt.color) else (0.82, 0.82, 0.82)) - # Available width for text (inset + 2pt padding each side) + # Available dimensions for text text_w_avail = max(1.0, bw - 2 * inset - 4) - - sup_size = max(4.0, block_title_font * 0.65) + text_h_avail = row_h - 2 * inset - 2 resp_size = max(4.0, block_time_font) - - # Truncate title to fit (leave room for superscript number) - sup_reserve = (c.stringWidth(str(fn_num), _FONT_BOLD, sup_size) + 1.5 - if fn_num else 0) - fitted_title = fit_text(c, title_text, _FONT_BOLD, block_title_font, - text_w_avail - sup_reserve) - - # Determine vertical layout: how many lines fit? has_responsible = bool(block.responsible) - if has_responsible and row_h >= block_title_font + resp_size + 3: - # Two-line: title + responsible - title_y = row_y + row_h * 0.55 - resp_y = row_y + row_h * 0.55 - block_title_font - 1 - fitted_resp = fit_text(c, block.responsible, _FONT_ITALIC, resp_size, - text_w_avail) - c.setFont(_FONT_ITALIC, resp_size) - set_fill(c, dim_rgb) - c.drawCentredString(bx + bw / 2, resp_y, fitted_resp) - else: - # Single line: title centred - title_y = row_y + (row_h - block_title_font) / 2 + MIN_TITLE_FONT = 3.5 + + # ── Layout decision: horizontal vs vertical ─────────── + # When a block is too narrow for any word at a readable + # size, rotate text 90° to leverage the full row height. + MIN_HORIZ_FONT = 5.0 + _words = title_text.split() + _longest_word_w = max( + (c.stringWidth(w, _FONT_BOLD, MIN_HORIZ_FONT) for w in _words), + default=0) if _words else 0 + use_vertical = _longest_word_w > text_w_avail + + if use_vertical: + # ── Vertical text (90° CCW rotation) ────────────── + # Row height becomes text "width"; block width becomes + # stacking "height" for multiple lines. + vert_w = row_h - 2 * inset - 4 + vert_h = bw - 2 * inset - 4 + + resp_h_v = (resp_size + 1) if has_responsible else 0 + title_h_v = max(block_title_font, vert_h - resp_h_v) + + t_font = block_title_font + t_leading = t_font * 1.15 + title_lines = wrap_text(c, title_text, _FONT_BOLD, t_font, vert_w) + + def _v_overflow(lines, fnt, sz, lh, h_avail, w_avail): + if len(lines) * lh > h_avail: + return True + return any(c.stringWidth(ln, fnt, sz) > w_avail for ln in lines) + + while (_v_overflow(title_lines, _FONT_BOLD, t_font, + t_leading, title_h_v, vert_w) + and t_font > MIN_TITLE_FONT): + t_font = max(MIN_TITLE_FONT, t_font - 0.5) + t_leading = t_font * 1.15 + title_lines = wrap_text(c, title_text, _FONT_BOLD, + t_font, vert_w) + + # Truncate excess lines at min font + max_vl = max(1, int(title_h_v / t_leading)) + if len(title_lines) > max_vl: + kept = title_lines[:max_vl - 1] if max_vl > 1 else [] + remaining = ' '.join(title_lines[len(kept):]) + kept.append(fit_text(c, remaining, _FONT_BOLD, + t_font, vert_w)) + title_lines = kept + + # Footnote superscript sizing for vertical mode + v_sup_size = max(3.0, t_font * 0.65) + v_sup_reserve = (c.stringWidth(str(fn_num), _FONT_BOLD, + v_sup_size) + 1.0 + if fn_num else 0) + + n_lines = len(title_lines) + + # Total stacking height (along block width) + content_stack = t_font + (n_lines - 1) * t_leading + if has_responsible: + content_stack += 1 + resp_size + + # Rotate: translate to block center, then 90° CCW. + # After rotation: new +X = page up, new +Y = page left. + # drawCentredString(rx, ry) centres text at rx along the + # vertical page axis; ry controls horizontal page offset. + c.translate(bx + bw / 2, row_y + row_h / 2) + c.rotate(90) + + # Stack lines left → right (decreasing ry) + first_ry = (content_stack - t_font) / 2 + ry = first_ry + for li, line in enumerate(title_lines): + c.setFont(_FONT_BOLD, t_font) + set_fill(c, text_rgb) + if fn_num is not None and li == n_lines - 1: + # Last line: draw title + superscript number + line_w = c.stringWidth(line, _FONT_BOLD, t_font) + lx = -(line_w + v_sup_reserve) / 2 + c.drawString(lx, ry, line) + c.setFont(_FONT_BOLD, v_sup_size) + set_fill(c, dim_rgb) + c.drawString(lx + line_w + 0.5, + ry + t_font * 0.45, str(fn_num)) + else: + c.drawCentredString(0, ry, line) + ry -= t_leading + + if has_responsible: + ry -= 1 + fitted_r = fit_text(c, block.responsible, + _FONT_ITALIC, resp_size, vert_w) + c.setFont(_FONT_ITALIC, resp_size) + set_fill(c, dim_rgb) + c.drawCentredString(0, ry, fitted_r) - # 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(fitted_title, _FONT_BOLD, block_title_font) - tx = bx + bw / 2 - (title_w + sup_reserve) / 2 - c.drawString(tx, title_y, fitted_title) - 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, fitted_title) + # ── Horizontal text (normal) ────────────────────── + resp_h = (resp_size + 2) if has_responsible else 0 + title_h_avail = max(block_title_font, text_h_avail - resp_h) + + # Pre-compute footnote reserve so the shrink loop + # accounts for it — prevents truncation of titles + # that barely fit without the superscript. + def _sup_reserve_for(fsize): + if not fn_num: + return 0 + ss = max(3.0, fsize * 0.65) + return c.stringWidth(str(fn_num), _FONT_BOLD, ss) + 1.5 + + t_font = block_title_font + t_leading = t_font * 1.15 + _sr = _sup_reserve_for(t_font) + title_lines = wrap_text(c, title_text, _FONT_BOLD, + t_font, text_w_avail - _sr) + + def _h_overflow(lines, fnt, sz, lh, h_avail, w_avail): + if len(lines) * lh > h_avail: + return True + return any(c.stringWidth(ln, fnt, sz) > w_avail + for ln in lines) + + while (_h_overflow(title_lines, _FONT_BOLD, t_font, + t_leading, title_h_avail, + text_w_avail - _sr) + and t_font > MIN_TITLE_FONT): + t_font = max(MIN_TITLE_FONT, t_font - 0.5) + t_leading = t_font * 1.15 + _sr = _sup_reserve_for(t_font) + title_lines = wrap_text(c, title_text, _FONT_BOLD, + t_font, text_w_avail - _sr) + + # Truncate excess lines at min font + max_title_lines = max(1, int(title_h_avail / t_leading)) + if len(title_lines) > max_title_lines: + kept = (title_lines[:max_title_lines - 1] + if max_title_lines > 1 else []) + remaining = ' '.join(title_lines[len(kept):]) + kept.append(fit_text(c, remaining, _FONT_BOLD, + t_font, text_w_avail - _sr)) + title_lines = kept + + # Final footnote superscript sizing + sup_size = max(3.0, t_font * 0.65) + sup_reserve = _sup_reserve_for(t_font) + + # Vertical centering + n_title = len(title_lines) + content_h = t_font + (n_title - 1) * t_leading + if has_responsible: + content_h += 2 + resp_size + first_baseline = row_y + (row_h + content_h) / 2 - t_font + + # Draw title lines (top → bottom) + ty = first_baseline + for i, line in enumerate(title_lines): + c.setFont(_FONT_BOLD, t_font) + set_fill(c, text_rgb) + + if fn_num is not None and i == n_title - 1: + line_w = c.stringWidth(line, _FONT_BOLD, t_font) + lx = bx + bw / 2 - (line_w + sup_reserve) / 2 + c.drawString(lx, ty, line) + c.setFont(_FONT_BOLD, sup_size) + set_fill(c, dim_rgb) + c.drawString(lx + line_w + 0.5, + ty + t_font * 0.45, str(fn_num)) + else: + c.drawCentredString(bx + bw / 2, ty, line) + ty -= t_leading + + # Draw responsible person (below title) + if has_responsible: + resp_y = ty - 1 + fitted_resp = fit_text(c, block.responsible, + _FONT_ITALIC, resp_size, + text_w_avail) + c.setFont(_FONT_ITALIC, resp_size) + set_fill(c, dim_rgb) + c.drawCentredString(bx + bw / 2, resp_y, fitted_resp) c.restoreState() + # If block has a URL, make the entire block a clickable link + if getattr(block, 'url', None): + c.linkURL(block.url, + (bx + inset, row_y + inset, + bx + bw - inset, row_y + row_h - inset), + relative=0) + # ── Legend ──────────────────────────────────────────────────────── legend_y_top = table_top - num_days * row_h - 6 diff --git a/app/models/event.py b/app/models/event.py index 83ad8be..4b2c803 100644 --- a/app/models/event.py +++ b/app/models/event.py @@ -16,6 +16,7 @@ class Block(BaseModel): type_id: str responsible: Optional[str] = None notes: Optional[str] = None + url: Optional[str] = None # clickable link in PDF export series_id: Optional[str] = None # shared across blocks added via "add to all days" diff --git a/app/static/index.html b/app/static/index.html index 8b133d5..a01207d 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -120,6 +120,7 @@
  • Program přes půlnoc — Konec < Začátek je validní (blok přechází přes půlnoc). V editoru označen „→", v PDF správně vykreslí.
  • Garant — zobrazí se v bloku v editoru i v PDF (pod názvem bloku).
  • Poznámka — nezobrazuje se v editoru, pouze v PDF jako horní index (¹ ²...) u názvu bloku. Všechny poznámky jsou vypsány na 2. stránce PDF.
  • +
  • URL odkaz — volitelný. V PDF bude celý blok klikatelný a odkazuje na zadanou adresu.
  • Export / Import

    @@ -154,6 +155,7 @@ blocks[].type_idstringID typu programu (musí existovat v program_types) blocks[].responsiblestring?Garant — zobrazí se v editoru i PDF blocks[].notesstring?Poznámka — jen v PDF, jako horní index + stránka 2 + blocks[].urlstring?URL odkaz — v PDF je celý blok klikatelný blocks[].series_idstring?Sdílené ID série — bloky přidané přes „Přidat do všech dnů" sdílejí toto ID @@ -220,6 +222,10 @@ +
    + + +