feat: přidáno URL pole pro bloky – klikatelný odkaz v PDF
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:
Martin Sukany
2026-03-14 19:10:13 +01:00
parent 5d712494a5
commit 0a694ce63a
7 changed files with 315 additions and 68 deletions

View File

@@ -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