feat: přidáno URL pole pro bloky – klikatelný odkaz v PDF
Some checks failed
Build & Push Docker / build (push) Has been cancelled
Some checks failed
Build & Push Docker / build (push) Has been cancelled
- Block model: nové volitelné pole 'url' (Optional[str]) - Frontend: URL input v modálu pro přidání/editaci bloku - PDF generátor: c.linkURL() – celý blok je klikatelný odkaz - sample.json: ukázkový blok s URL - index.html: dokumentace URL pole - .github/copilot-instructions.md: přidány Copilot instrukce
This commit is contained in:
@@ -1,14 +1,23 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Pre-commit hook: run tests and prevent commit on failures.
|
# 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..."
|
echo "Running pytest (fast) before commit..."
|
||||||
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
|
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
|
||||||
echo "RUN_INTEGRATION=1: including integration tests"
|
echo "RUN_INTEGRATION=1: including integration tests"
|
||||||
pytest -q
|
$PYTEST -q
|
||||||
else
|
else
|
||||||
pytest -q -m "not integration"
|
$PYTEST -q -m "not integration"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Tests passed. Proceeding with commit."
|
echo "Tests passed. Proceeding with commit."
|
||||||
|
|||||||
48
.github/copilot-instructions.md
vendored
Normal file
48
.github/copilot-instructions.md
vendored
Normal file
@@ -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.
|
||||||
@@ -21,20 +21,30 @@ from .validator import ScenarsError
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Font registration ─────────────────────────────────────────────────────────
|
# ── 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_REGULAR = 'Helvetica'
|
||||||
_FONT_BOLD = 'Helvetica-Bold'
|
_FONT_BOLD = 'Helvetica-Bold'
|
||||||
_FONT_ITALIC = 'Helvetica-Oblique'
|
_FONT_ITALIC = 'Helvetica-Oblique'
|
||||||
|
|
||||||
_LIBERATION_PATHS = [
|
# Font candidates: (family_name, [(search_dirs, regular, bold, italic)])
|
||||||
'/usr/share/fonts/truetype/liberation',
|
_FONT_CANDIDATES = [
|
||||||
'/usr/share/fonts/liberation',
|
# LiberationSans — Linux / Docker (fonts-liberation package)
|
||||||
'/usr/share/fonts/truetype',
|
('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):
|
def _find_font_file(dirs: list, filename: str):
|
||||||
for base in _LIBERATION_PATHS:
|
for base in dirs:
|
||||||
path = os.path.join(base, filename)
|
path = os.path.join(base, filename)
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
return path
|
return path
|
||||||
@@ -43,22 +53,23 @@ def _find_font(filename: str):
|
|||||||
|
|
||||||
def _register_fonts():
|
def _register_fonts():
|
||||||
global _FONT_REGULAR, _FONT_BOLD, _FONT_ITALIC
|
global _FONT_REGULAR, _FONT_BOLD, _FONT_ITALIC
|
||||||
regular = _find_font('LiberationSans-Regular.ttf')
|
for family, dirs, reg_name, bold_name, italic_name in _FONT_CANDIDATES:
|
||||||
bold = _find_font('LiberationSans-Bold.ttf')
|
regular = _find_font_file(dirs, reg_name)
|
||||||
italic = _find_font('LiberationSans-Italic.ttf')
|
bold = _find_font_file(dirs, bold_name)
|
||||||
if regular and bold and italic:
|
italic = _find_font_file(dirs, italic_name)
|
||||||
try:
|
if regular and bold and italic:
|
||||||
pdfmetrics.registerFont(TTFont('LiberationSans', regular))
|
try:
|
||||||
pdfmetrics.registerFont(TTFont('LiberationSans-Bold', bold))
|
pdfmetrics.registerFont(TTFont(f'{family}', regular))
|
||||||
pdfmetrics.registerFont(TTFont('LiberationSans-Italic', italic))
|
pdfmetrics.registerFont(TTFont(f'{family}-Bold', bold))
|
||||||
_FONT_REGULAR = 'LiberationSans'
|
pdfmetrics.registerFont(TTFont(f'{family}-Italic', italic))
|
||||||
_FONT_BOLD = 'LiberationSans-Bold'
|
_FONT_REGULAR = family
|
||||||
_FONT_ITALIC = 'LiberationSans-Italic'
|
_FONT_BOLD = f'{family}-Bold'
|
||||||
logger.info('PDF: Using LiberationSans (Czech diacritics supported)')
|
_FONT_ITALIC = f'{family}-Italic'
|
||||||
except Exception as e:
|
logger.info(f'PDF: Using {family} (Czech diacritics supported)')
|
||||||
logger.warning(f'PDF: Font registration failed: {e}')
|
return
|
||||||
else:
|
except Exception as e:
|
||||||
logger.warning('PDF: LiberationSans not found, Czech diacritics may be broken')
|
logger.warning(f'PDF: {family} registration failed: {e}')
|
||||||
|
logger.warning('PDF: No TrueType font found, Czech diacritics may be broken')
|
||||||
|
|
||||||
|
|
||||||
_register_fonts()
|
_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
|
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:
|
def generate_pdf(doc) -> bytes:
|
||||||
if not doc.blocks:
|
if not doc.blocks:
|
||||||
raise ScenarsError("No blocks provided")
|
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)
|
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)
|
c.clipPath(p, stroke=0, fill=0)
|
||||||
|
|
||||||
set_fill(c, text_rgb)
|
|
||||||
|
|
||||||
fn_num = footnote_map.get(block.id)
|
fn_num = footnote_map.get(block.id)
|
||||||
title_text = block.title + (' →' if overnight else '')
|
title_text = block.title + (' →' if overnight else '')
|
||||||
dim_rgb = ((text_rgb[0] * 0.78, text_rgb[1] * 0.78, text_rgb[2] * 0.78)
|
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))
|
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)
|
text_w_avail = max(1.0, bw - 2 * inset - 4)
|
||||||
|
text_h_avail = row_h - 2 * inset - 2
|
||||||
sup_size = max(4.0, block_title_font * 0.65)
|
|
||||||
resp_size = max(4.0, block_time_font)
|
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)
|
has_responsible = bool(block.responsible)
|
||||||
if has_responsible and row_h >= block_title_font + resp_size + 3:
|
MIN_TITLE_FONT = 3.5
|
||||||
# Two-line: title + responsible
|
|
||||||
title_y = row_y + row_h * 0.55
|
# ── Layout decision: horizontal vs vertical ───────────
|
||||||
resp_y = row_y + row_h * 0.55 - block_title_font - 1
|
# When a block is too narrow for any word at a readable
|
||||||
fitted_resp = fit_text(c, block.responsible, _FONT_ITALIC, resp_size,
|
# size, rotate text 90° to leverage the full row height.
|
||||||
text_w_avail)
|
MIN_HORIZ_FONT = 5.0
|
||||||
c.setFont(_FONT_ITALIC, resp_size)
|
_words = title_text.split()
|
||||||
set_fill(c, dim_rgb)
|
_longest_word_w = max(
|
||||||
c.drawCentredString(bx + bw / 2, resp_y, fitted_resp)
|
(c.stringWidth(w, _FONT_BOLD, MIN_HORIZ_FONT) for w in _words),
|
||||||
else:
|
default=0) if _words else 0
|
||||||
# Single line: title centred
|
use_vertical = _longest_word_w > text_w_avail
|
||||||
title_y = row_y + (row_h - block_title_font) / 2
|
|
||||||
|
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:
|
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()
|
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 ────────────────────────────────────────────────────────
|
||||||
legend_y_top = table_top - num_days * row_h - 6
|
legend_y_top = table_top - num_days * row_h - 6
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Block(BaseModel):
|
|||||||
type_id: str
|
type_id: str
|
||||||
responsible: Optional[str] = None
|
responsible: Optional[str] = None
|
||||||
notes: 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"
|
series_id: Optional[str] = None # shared across blocks added via "add to all days"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@
|
|||||||
<li><strong>Program přes půlnoc</strong> — Konec < Začátek je validní (blok přechází přes půlnoc). V editoru označen „→", v PDF správně vykreslí.</li>
|
<li><strong>Program přes půlnoc</strong> — Konec < Začátek je validní (blok přechází přes půlnoc). V editoru označen „→", v PDF správně vykreslí.</li>
|
||||||
<li><strong>Garant</strong> — zobrazí se v bloku v editoru i v PDF (pod názvem bloku).</li>
|
<li><strong>Garant</strong> — zobrazí se v bloku v editoru i v PDF (pod názvem bloku).</li>
|
||||||
<li><strong>Poznámka</strong> — 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.</li>
|
<li><strong>Poznámka</strong> — 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.</li>
|
||||||
|
<li><strong>URL odkaz</strong> — volitelný. V PDF bude celý blok klikatelný a odkazuje na zadanou adresu.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Export / Import</h3>
|
<h3>Export / Import</h3>
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu (musí existovat v program_types)</td></tr>
|
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu (musí existovat v program_types)</td></tr>
|
||||||
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant — zobrazí se v editoru i PDF</td></tr>
|
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant — zobrazí se v editoru i PDF</td></tr>
|
||||||
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka — jen v PDF, jako horní index + stránka 2</td></tr>
|
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka — jen v PDF, jako horní index + stránka 2</td></tr>
|
||||||
|
<tr><td>blocks[].url</td><td>string?</td><td>URL odkaz — v PDF je celý blok klikatelný</td></tr>
|
||||||
<tr><td>blocks[].series_id</td><td>string?</td><td>Sdílené ID série — bloky přidané přes „Přidat do všech dnů" sdílejí toto ID</td></tr>
|
<tr><td>blocks[].series_id</td><td>string?</td><td>Sdílené ID série — bloky přidané přes „Přidat do všech dnů" sdílejí toto ID</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -220,6 +222,10 @@
|
|||||||
<label>Poznámka</label>
|
<label>Poznámka</label>
|
||||||
<textarea id="modalBlockNotes" rows="3" placeholder="Poznámka"></textarea>
|
<textarea id="modalBlockNotes" rows="3" placeholder="Poznámka"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>URL odkaz</label>
|
||||||
|
<input type="url" id="modalBlockUrl" placeholder="https://…">
|
||||||
|
</div>
|
||||||
<!-- Shown only when creating a new block -->
|
<!-- Shown only when creating a new block -->
|
||||||
<div class="form-group series-row hidden" id="seriesRow">
|
<div class="form-group series-row hidden" id="seriesRow">
|
||||||
<label class="series-label">
|
<label class="series-label">
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ 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 || '';
|
||||||
|
document.getElementById('modalBlockUrl').value = block.url || '';
|
||||||
|
|
||||||
this._populateTypeSelect(block.type_id);
|
this._populateTypeSelect(block.type_id);
|
||||||
this._populateDaySelect(block.date);
|
this._populateDaySelect(block.date);
|
||||||
@@ -222,6 +223,7 @@ 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('modalBlockUrl').value = '';
|
||||||
|
|
||||||
this._populateTypeSelect(null);
|
this._populateTypeSelect(null);
|
||||||
this._populateDaySelect(date);
|
this._populateDaySelect(date);
|
||||||
@@ -312,6 +314,7 @@ const App = {
|
|||||||
const end = document.getElementById('modalBlockEnd').value;
|
const end = document.getElementById('modalBlockEnd').value;
|
||||||
const responsible = document.getElementById('modalBlockResponsible').value.trim() || null;
|
const responsible = document.getElementById('modalBlockResponsible').value.trim() || null;
|
||||||
const notes = document.getElementById('modalBlockNotes').value.trim() || null;
|
const notes = document.getElementById('modalBlockNotes').value.trim() || null;
|
||||||
|
const url = document.getElementById('modalBlockUrl').value.trim() || null;
|
||||||
|
|
||||||
const timeRe = /^\d{2}:\d{2}$/;
|
const timeRe = /^\d{2}:\d{2}$/;
|
||||||
if (!title) { this.toast('Zadejte název bloku', 'error'); return; }
|
if (!title) { this.toast('Zadejte název bloku', 'error'); return; }
|
||||||
@@ -324,7 +327,7 @@ const App = {
|
|||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
const existing = this.state.blocks[idx];
|
const existing = this.state.blocks[idx];
|
||||||
Object.assign(this.state.blocks[idx], {
|
Object.assign(this.state.blocks[idx], {
|
||||||
date, title, type_id, start, end, responsible, notes,
|
date, title, type_id, start, end, responsible, notes, url,
|
||||||
series_id: existing.series_id || null
|
series_id: existing.series_id || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -336,14 +339,14 @@ const App = {
|
|||||||
const series_id = this.uid();
|
const series_id = this.uid();
|
||||||
const dates = this.getDates();
|
const dates = this.getDates();
|
||||||
for (const d of dates) {
|
for (const d of dates) {
|
||||||
this.state.blocks.push({ id: this.uid(), date: d, title, type_id, start, end, responsible, notes, series_id });
|
this.state.blocks.push({ id: this.uid(), date: d, title, type_id, start, end, responsible, notes, url, series_id });
|
||||||
}
|
}
|
||||||
document.getElementById('blockModal').classList.add('hidden');
|
document.getElementById('blockModal').classList.add('hidden');
|
||||||
this.renderCanvas();
|
this.renderCanvas();
|
||||||
this.toast(`Blok přidán do ${dates.length} dnů`, 'success');
|
this.toast(`Blok přidán do ${dates.length} dnů`, 'success');
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
this.state.blocks.push({ id: this.uid(), date, title, type_id, start, end, responsible, notes, series_id: null });
|
this.state.blocks.push({ id: this.uid(), date, title, type_id, start, end, responsible, notes, url, series_id: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
"id": "d1_b3", "date": "2026-03-01",
|
"id": "d1_b3", "date": "2026-03-01",
|
||||||
"start": "09:00", "end": "11:00",
|
"start": "09:00", "end": "11:00",
|
||||||
"title": "Stopovací hra v lese", "type_id": "main",
|
"title": "Stopovací hra v lese", "type_id": "main",
|
||||||
"responsible": "Lucka", "notes": "4 skupiny"
|
"responsible": "Lucka", "notes": "4 skupiny",
|
||||||
|
"url": "https://example.com/stopovaci-hra"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "d1_b4", "date": "2026-03-01",
|
"id": "d1_b4", "date": "2026-03-01",
|
||||||
|
|||||||
Reference in New Issue
Block a user