Files
card-layouts/tools/render_card.py
T

6578 lines
297 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
from __future__ import annotations
import base64
import json
import html as html_lib
import os
import re
import shutil
import subprocess
import sys
import tempfile
import textwrap
import unicodedata
from functools import lru_cache
from pathlib import Path
from typing import Callable, Iterable
from urllib.parse import urljoin
REPO_ROOT = Path(__file__).resolve().parents[1]
EDU_ROOT = Path(__file__).resolve().parents[3]
CARD_ROOT = REPO_ROOT
SERIES_ROOT = CARD_ROOT.parent
PP_ROOT = EDU_ROOT / "pp" / "json"
SOURCE_PATH = CARD_ROOT / "json" / "card_source.json"
TOOLTIP_WRAP_CHARS = 92
DEFAULT_TEMPLATE_PATH = REPO_ROOT / "templates" / "karta-klasyczna.json"
REFERENCE_KINDS = {"strategy", "card", "content", "formula"}
REFERENCE_KIND_LABELS = {
"strategy": "strategie",
"card": "karty",
"content": "treści",
"formula": "wzory",
}
UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
)
UUID_PATH_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
def configure_card_root(card_root: Path) -> None:
global CARD_ROOT, SERIES_ROOT, SOURCE_PATH
CARD_ROOT = card_root.resolve()
SERIES_ROOT = CARD_ROOT.parent
SOURCE_PATH = CARD_ROOT / "json" / "card_source.json"
def resolve_card_path(value: str) -> Path:
path = Path(value)
if path.is_absolute():
return path
return (CARD_ROOT / path).resolve()
def load_source(path: Path | None = None) -> dict:
source = path or SOURCE_PATH
return json.loads(source.read_text(encoding="utf-8"))
def build_reference_registry_index(data: dict, errors: list[str] | None = None) -> dict[str, dict]:
cached = data.get("_reference_registry_index")
cached_errors = data.get("_reference_registry_errors")
if isinstance(cached, dict) and isinstance(cached_errors, list):
if errors is not None:
errors.extend(cached_errors)
return cached
registry_index: dict[str, dict] = {}
registry_errors: list[str] = []
configs = data.get("reference_registries", []) or []
if not isinstance(configs, list):
registry_errors.append("reference_registries musi być listą")
configs = []
for position, config in enumerate(configs, start=1):
prefix = f"reference_registries[{position}]"
if not isinstance(config, dict):
registry_errors.append(f"{prefix}: wpis musi być obiektem")
continue
expected_uuid = str(config.get("uuid", "")).strip()
source = str(config.get("source", "")).strip()
if not UUID_RE.fullmatch(expected_uuid):
registry_errors.append(f"{prefix}: niepoprawny UUID rejestru {expected_uuid or '-'}")
continue
if expected_uuid in registry_index:
registry_errors.append(f"{prefix}: zduplikowany rejestr {expected_uuid}")
continue
if not source:
registry_errors.append(f"{prefix}: brak source")
continue
source_path = resolve_card_path(source)
if not source_path.is_file():
registry_errors.append(f"{prefix}: brak źródła rejestru {source}")
continue
try:
registry = load_json_file(str(source_path))
except (OSError, json.JSONDecodeError) as exc:
registry_errors.append(f"{prefix}: nie można odczytać {source}: {exc}")
continue
if registry.get("schema") != "esc-reference-registry.v1":
registry_errors.append(
f"{prefix}: {source} nie używa schema esc-reference-registry.v1"
)
continue
actual_uuid = str(registry.get("uuid", "")).strip()
if actual_uuid != expected_uuid:
registry_errors.append(
f"{prefix}: oczekiwano rejestru {expected_uuid}, źródło zawiera {actual_uuid or '-'}"
)
continue
entries = registry.get("entries", [])
if not isinstance(entries, list):
registry_errors.append(f"{prefix}: entries musi być listą")
continue
base_url = str(registry.get("base_url", "")).strip()
root_path = str(registry.get("root_path", "")).strip()
if not base_url:
registry_errors.append(f"{prefix}: brak base_url")
if not root_path:
registry_errors.append(f"{prefix}: brak root_path")
local_root = (source_path.parent / (root_path or ".")).resolve()
entries_by_uuid: dict[str, dict] = {}
for entry_position, raw_entry in enumerate(entries, start=1):
entry_prefix = f"{prefix}.entries[{entry_position}]"
if not isinstance(raw_entry, dict):
registry_errors.append(f"{entry_prefix}: wpis musi być obiektem")
continue
entry_uuid = str(raw_entry.get("uuid", "")).strip()
kind = str(raw_entry.get("kind", "")).strip()
title = str(raw_entry.get("title", "")).strip()
path = str(raw_entry.get("path", "")).strip()
if not UUID_RE.fullmatch(entry_uuid):
registry_errors.append(f"{entry_prefix}: niepoprawny UUID {entry_uuid or '-'}")
continue
if entry_uuid in entries_by_uuid:
registry_errors.append(f"{entry_prefix}: zduplikowany UUID {entry_uuid}")
continue
if kind not in REFERENCE_KINDS:
registry_errors.append(f"{entry_prefix}: nieznany rodzaj {kind or '-'}")
continue
if not title:
registry_errors.append(f"{entry_prefix}: brak title")
if kind == "strategy" and not str(raw_entry.get("code", "")).strip():
registry_errors.append(f"{entry_prefix}: strategia nie ma code")
if not path:
registry_errors.append(f"{entry_prefix}: brak path")
elif not (local_root / path).is_file():
registry_errors.append(f"{entry_prefix}: brak lokalnego dokumentu {path}")
entry = dict(raw_entry)
entry["url"] = urljoin(base_url, path) if base_url and path else ""
entries_by_uuid[entry_uuid] = entry
registry_index[expected_uuid] = {
"uuid": expected_uuid,
"name": str(registry.get("name", "")),
"title": str(registry.get("title", "")),
"base_url": base_url,
"source_path": str(source_path),
"entries": entries_by_uuid,
}
data["_reference_registry_index"] = registry_index
data["_reference_registry_errors"] = registry_errors
if errors is not None:
errors.extend(registry_errors)
return registry_index
def resolve_external_reference(data: dict, reference: dict) -> dict:
registry_uuid = str(reference.get("registry_uuid", ""))
entry_uuid = str(reference.get("uuid", ""))
registry = build_reference_registry_index(data).get(registry_uuid, {})
return registry.get("entries", {}).get(entry_uuid, {})
def resolve_template_path(value: str | None) -> Path:
if not value:
return DEFAULT_TEMPLATE_PATH
path = Path(value)
if path.is_absolute():
return path
candidates = [
(Path.cwd() / path).resolve(),
(REPO_ROOT / path).resolve(),
(CARD_ROOT / path).resolve(),
]
for candidate in candidates:
if candidate.exists():
return candidate
return candidates[0]
def load_template(data: dict, cli_template: str | None = None) -> dict:
template_path = resolve_template_path(cli_template or data.get("template"))
if not template_path.exists():
raise SystemExit(f"Brak szablonu karty: {template_path}")
template = json.loads(template_path.read_text(encoding="utf-8"))
if template.get("schema") != "esc-card-template.v1":
raise SystemExit(
f"Nieobsługiwany schema szablonu {template_path}; oczekiwano esc-card-template.v1"
)
template["_path"] = str(template_path)
return template
def template_emitter(template: dict, kind: str) -> str:
return str(template.get("emitters", {}).get(kind) or template.get("id") or "classic").lower()
def uses_5a_template(template: dict) -> bool:
return template_emitter(template, "html") == "5a" or template_emitter(template, "latex") == "5a"
def tex_escape(value: str) -> str:
replacements = {
"\\": r"\textbackslash{}",
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\textasciicircum{}",
}
return "".join(replacements.get(char, char) for char in value)
def label_set(sections: list[dict]) -> set[str]:
labels: set[str] = set()
for section in sections:
labels.update(re.findall(r"\\label\{([^}]+)\}", section.get("content_tex", "")))
return labels
def graphics_paths(sections: list[dict]) -> set[str]:
paths: set[str] = set()
for section in sections:
paths.update(re.findall(r"\\includegraphics(?:\[[^\]]+\])?\{([^}]+)\}", section.get("content_tex", "")))
return paths
def section_assets(sections: list[dict]) -> list[dict]:
return [
asset
for section in sections
for asset in section.get("assets", []) or []
if isinstance(asset, dict)
]
def build_learning_tree_index(data: dict) -> tuple[dict[str, dict], list[str]]:
index: dict[str, dict] = {}
duplicates: list[str] = []
for requirement_ref, requirement in data.get("educational_requirements", {}).items():
learning_tree = requirement.get("learning_tree", {})
for column in ("ogolne", "zawodowe"):
for item in learning_tree.get(column, []) or []:
tree_id = item.get("tree_id", "")
if not tree_id:
continue
if tree_id in index:
duplicates.append(tree_id)
continue
index[tree_id] = {
"requirement_ref": requirement_ref,
"requirement": requirement,
"column": column,
"item": item,
"effect_ref": item.get("effect_ref"),
"criterion_refs": [
criterion.get("criterion_ref")
for criterion in item.get("kw", []) or []
if criterion.get("criterion_ref")
],
}
return index, duplicates
def validate_source(data: dict) -> None:
errors: list[str] = []
if data.get("schema") != "esc-card-source.v1":
errors.append("nieobsługiwany schema; oczekiwano esc-card-source.v1")
for key in ("card", "generated", "sections", "tasks", "tasks_order"):
if key not in data:
errors.append(f"brak pola {key}")
for item in data.get("source_tree", {}).get("items", []):
source_path = item.get("path")
if source_path and not (SERIES_ROOT / source_path).exists():
errors.append(f"brak materiału źródłowego: {source_path}")
sections = data.get("sections", [])
labels = label_set(sections)
for graphic in graphics_paths(data.get("sections", [])):
if not (CARD_ROOT / "doc" / graphic).exists():
errors.append(f"brak grafiki użytej w treści: {graphic}")
html_asset_targets: dict[str, str] = {}
supported_asset_suffixes = {".png", ".jpg", ".jpeg", ".pdf"}
for asset in section_assets(sections):
path = str(asset.get("path", "")).strip()
label = str(asset.get("label", "")).strip()
if not path:
errors.append("asset sekcji bez path")
continue
source = resolve_web_asset_source(path)
if not source.is_file():
errors.append(f"brak assetu sekcji: {path}")
if source.suffix.lower() not in supported_asset_suffixes:
errors.append(
f"nieobsługiwany format assetu {path}; dozwolone: PNG, JPG/JPEG, PDF"
)
if not label:
errors.append(f"asset {path}: brak label")
elif label in labels:
errors.append(f"zduplikowana etykieta assetu: {label}")
else:
labels.add(label)
target_name = Path(html_figure_asset(path)).name
previous = html_asset_targets.get(target_name)
if previous and previous != path:
errors.append(
f"kolizja nazw assetów HTML {target_name}: {previous} oraz {path}"
)
html_asset_targets[target_name] = path
tasks = data.get("tasks", {})
educational_requirements = data.get("educational_requirements", {})
learning_effects = data.get("learning_effects", {})
assessment_criteria = data.get("assessment_criteria", {})
educational_model = data.get("educational_model", {})
reference_registries = build_reference_registry_index(data, errors)
margin_source = data.get("side_margin_tree_layout") or educational_model.get("side_margin_tree_layout", {})
validate_side_margin_tree_layout(margin_source, errors)
curriculum_domains = set(educational_model.get("curriculum_domains", {}))
learning_tree_index, duplicate_tree_ids = build_learning_tree_index(data)
for tree_id in duplicate_tree_ids:
errors.append(f"zduplikowany tree_id: {tree_id}")
for requirement_ref, requirement in educational_requirements.items():
for effect_ref in requirement.get("learning_effects", []):
if effect_ref not in learning_effects:
errors.append(f"{requirement_ref}: link do nieistniejącego efektu {effect_ref}")
learning_tree = requirement.get("learning_tree", {})
if not learning_tree:
errors.append(f"{requirement_ref}: brak learning_tree w źródle prawdy")
for column in ("ogolne", "zawodowe"):
for item in learning_tree.get(column, []) or []:
if not item.get("tree_id"):
errors.append(f"{requirement_ref}: learning_tree {column} bez tree_id")
effect_ref = item.get("effect_ref")
if effect_ref not in learning_effects:
errors.append(f"{requirement_ref}: learning_tree wskazuje brak efektu {effect_ref}")
if not item.get("display"):
errors.append(f"{requirement_ref}: learning_tree {column} bez display")
if not item.get("kw"):
errors.append(f"{requirement_ref}: {item.get('display', effect_ref)} bez listy KW")
for criterion in item.get("kw", []) or []:
criterion_ref = criterion.get("criterion_ref")
if criterion_ref not in assessment_criteria:
errors.append(f"{requirement_ref}: learning_tree wskazuje brak kryterium {criterion_ref}")
if not criterion.get("display"):
errors.append(f"{requirement_ref}: learning_tree KW bez display")
for criterion_ref, criterion in assessment_criteria.items():
for effect_ref in criterion.get("learning_effects", []):
if effect_ref not in learning_effects:
errors.append(f"{criterion_ref}: link do nieistniejącego efektu {effect_ref}")
for effect_ref, effect in learning_effects.items():
for item in effect.get("curriculum_refs", []):
domain = item.get("domain")
if domain not in curriculum_domains:
errors.append(f"{effect_ref}: nieznana domena curriculum_refs {domain}")
for criterion_ref, criterion in assessment_criteria.items():
for item in criterion.get("curriculum_refs", []):
domain = item.get("domain")
if domain not in curriculum_domains:
errors.append(f"{criterion_ref}: nieznana domena curriculum_refs {domain}")
for task_ref in data.get("tasks_order", []):
if task_ref not in tasks:
errors.append(f"tasks_order wskazuje brakujące zadanie: {task_ref}")
title_block = data.get("title_block") or {}
if title_block:
resource_url = str(title_block.get("url", "")).strip()
repository_url = str(title_block.get("repository_url", "")).strip()
host_uuid = str(title_block.get("url_host_uuid", "")).strip()
document_uuid = str(title_block.get("doc_uuid", "")).strip()
url_port = str(title_block.get("url_port", "")).strip()
if resource_url and not re.match(r"^https?://", resource_url):
errors.append("title_block.url musi być kanonicznym adresem HTTP(S)")
if repository_url and not re.match(r"^https?://", repository_url):
errors.append("title_block.repository_url musi być adresem HTTP(S)")
if title_block.get("show_repository_qr") is True and not repository_url:
errors.append(
"title_block.show_repository_qr=true wymaga title_block.repository_url"
)
if bool(host_uuid) != bool(document_uuid):
errors.append("title_block.url_host_uuid i doc_uuid muszą występować razem")
if host_uuid and not UUID_PATH_RE.fullmatch(host_uuid):
errors.append("title_block.url_host_uuid musi być UUID w zapisie 8-4-4-4-12")
if document_uuid and not UUID_PATH_RE.fullmatch(document_uuid):
errors.append("title_block.doc_uuid musi być UUID w zapisie 8-4-4-4-12")
authority = f"{host_uuid}:{url_port}" if host_uuid and url_port else host_uuid
derived_url = f"https://{authority}/{document_uuid}" if authority and document_uuid else ""
if resource_url and derived_url and resource_url != derived_url:
errors.append(
"title_block.url musi odpowiadać url_host_uuid, opcjonalnemu url_port i doc_uuid"
)
if title_block.get("show_qr") is True and not (resource_url or derived_url):
errors.append(
"title_block.show_qr=true wymaga title_block.url albo pary url_host_uuid/doc_uuid"
)
if title_block.get("height_cm", 3) not in (3, 3.0):
errors.append("title_block.height_cm musi wynosić dokładnie 3")
if title_block.get("repeat_on_every_page", True) is not True:
errors.append("title_block.repeat_on_every_page musi być true")
if title_block.get("replace_front_matter", True) is not True:
errors.append("title_block.replace_front_matter musi być true")
for task_ref, task in tasks.items():
if "steps" in task:
errors.append(
f"zadanie {task_ref}: steps nie może należeć bezpośrednio do task; użyj flow[].kind=block"
)
flow = task.get("flow", []) or []
if not flow:
continue
known_ids: set[str] = set()
exercise_refs: list[tuple[str, str]] = []
for item in flow:
kind = str(item.get("kind", ""))
item_id = str(item.get("id", "")).strip()
if not item_id:
errors.append(f"zadanie {task_ref}: element flow bez id")
elif item_id in known_ids:
errors.append(f"zadanie {task_ref}: zduplikowane id flow {item_id}")
else:
known_ids.add(item_id)
if kind == "block":
for step in item.get("steps", []) or []:
step_id = str(step.get("id", "")).strip()
if not step_id:
errors.append(f"zadanie {task_ref}: blok {item_id} ma step bez id")
elif step_id in known_ids:
errors.append(f"zadanie {task_ref}: zduplikowane id {step_id}")
else:
known_ids.add(step_id)
elif kind == "exercise":
for based_on in item.get("based_on", []) or []:
exercise_refs.append((item_id, str(based_on)))
else:
errors.append(f"zadanie {task_ref}: nieznany kind flow {kind or '-'}")
for exercise_id, based_on in exercise_refs:
if based_on not in known_ids:
errors.append(
f"zadanie {task_ref}: exercise {exercise_id} wskazuje brakujące based_on {based_on}"
)
for section in data.get("sections", []):
section_title = section.get("title", "")
section_area_tree_refs = section.get("area_tree_refs", []) or []
section_step_tree_refs: set[str] = set()
section_step_ids: set[str] = set()
for area_ref in section.get("area_refs", []) or []:
if area_ref not in curriculum_domains:
errors.append(f"sekcja {section_title}: nieznany obszar {area_ref}")
for tree_ref in section_area_tree_refs:
if tree_ref not in learning_tree_index:
errors.append(f"sekcja {section_title}: area_tree_refs wskazuje brak drzewka {tree_ref}")
for requirement_ref in section.get("educational_requirement_refs", []) or []:
if requirement_ref not in educational_requirements:
errors.append(f"sekcja {section_title}: brak wymagania {requirement_ref}")
for effect_ref in section.get("learning_effect_refs", []) or []:
if effect_ref not in learning_effects:
errors.append(f"sekcja {section_title}: brak efektu {effect_ref}")
for criterion_ref in section.get("assessment_criterion_refs", []) or []:
if criterion_ref not in assessment_criteria:
errors.append(f"sekcja {section_title}: brak kryterium {criterion_ref}")
if section.get("content_kind") == "tasks":
for task_ref in section.get("task_refs", []):
if task_ref not in tasks:
errors.append(f"sekcja zadań wskazuje brakujące zadanie: {task_ref}")
for step in section.get("steps", []) or []:
step_id = step.get("id", "")
if not step_id:
errors.append(f"sekcja {section_title}: step bez id")
elif step_id in section_step_ids:
errors.append(f"sekcja {section_title}: zduplikowany step id {step_id}")
section_step_ids.add(step_id)
if not step.get("title"):
errors.append(f"sekcja {section_title}: step {step_id} bez title")
if not step.get("tree_refs"):
errors.append(f"sekcja {section_title}: step {step_id} bez tree_refs")
for requirement_ref in step.get("educational_requirement_refs", []) or []:
if requirement_ref not in educational_requirements:
errors.append(f"sekcja {section_title}: step {step_id} brak wymagania {requirement_ref}")
for effect_ref in step.get("learning_effect_refs", []) or []:
if effect_ref not in learning_effects:
errors.append(f"sekcja {section_title}: step {step_id} brak efektu {effect_ref}")
for criterion_ref in step.get("assessment_criterion_refs", []) or []:
if criterion_ref not in assessment_criteria:
errors.append(f"sekcja {section_title}: step {step_id} brak kryterium {criterion_ref}")
for tree_ref in step.get("tree_refs", []) or []:
section_step_tree_refs.add(tree_ref)
if tree_ref not in learning_tree_index:
errors.append(f"sekcja {section_title}: step {step_id} brak drzewka {tree_ref}")
elif section_area_tree_refs and tree_ref not in section_area_tree_refs:
errors.append(f"sekcja {section_title}: step {step_id} wskazuje drzewko spoza area_tree_refs {tree_ref}")
for label in step.get("equation_refs", []) + step.get("figure_refs", []):
if label not in labels:
errors.append(f"sekcja {section_title}: step {step_id} link do nieistniejącej etykiety {label}")
for reference in step.get("references", []) or []:
if not isinstance(reference, dict):
errors.append(
f"sekcja {section_title}: step {step_id} referencja nie jest obiektem"
)
continue
kind = str(reference.get("kind", ""))
registry_uuid = str(reference.get("registry_uuid", ""))
entry_uuid = str(reference.get("uuid", ""))
if kind not in REFERENCE_KINDS:
errors.append(
f"sekcja {section_title}: step {step_id} nieznany rodzaj referencji {kind or '-'}"
)
continue
if not UUID_RE.fullmatch(registry_uuid):
errors.append(
f"sekcja {section_title}: step {step_id} niepoprawny UUID rejestru {registry_uuid or '-'}"
)
continue
if not UUID_RE.fullmatch(entry_uuid):
errors.append(
f"sekcja {section_title}: step {step_id} niepoprawny UUID wpisu {entry_uuid or '-'}"
)
continue
registry = reference_registries.get(registry_uuid)
if not registry:
errors.append(
f"sekcja {section_title}: step {step_id} link do niezaładowanego rejestru {registry_uuid}"
)
continue
entry = registry.get("entries", {}).get(entry_uuid)
if not entry:
errors.append(
f"sekcja {section_title}: step {step_id} link do nieistniejącego wpisu {entry_uuid}"
)
continue
if entry.get("kind") != kind:
errors.append(
f"sekcja {section_title}: step {step_id} rodzaj {kind} nie zgadza się z {entry.get('kind')} dla {entry_uuid}"
)
for tree_ref in section_step_tree_refs:
if section_area_tree_refs and tree_ref not in section_area_tree_refs:
errors.append(f"sekcja {section_title}: area_tree_refs nie zawiera drzewka ze stepów {tree_ref}")
for task_ref, task in tasks.items():
if "prompt_tex" not in task:
errors.append(f"{task_ref}: brak prompt_tex")
if "criterion" not in task:
errors.append(f"{task_ref}: brak criterion")
for requirement_ref in task.get("educational_requirement_refs", []):
if requirement_ref not in educational_requirements:
errors.append(f"{task_ref}: brak wymagania {requirement_ref}")
for effect_ref in task.get("learning_effect_refs", []):
if effect_ref not in learning_effects:
errors.append(f"{task_ref}: brak efektu {effect_ref}")
criterion_ref = task.get("assessment_criterion_ref")
if criterion_ref and criterion_ref not in assessment_criteria:
errors.append(f"{task_ref}: brak kryterium {criterion_ref}")
links = task.get("links", {})
for label in links.get("equations", []) + links.get("figures", []):
if label not in labels:
errors.append(f"{task_ref}: link do nieistniejącej etykiety {label}")
area_map = educational_model.get("right_margin_curriculum", {}).get("areas", {})
for area_id, area in area_map.items():
if area_id not in curriculum_domains:
errors.append(f"obszar {area_id}: brak w curriculum_domains")
for requirement_ref in area.get("we", []) or []:
if requirement_ref not in educational_requirements:
errors.append(f"obszar {area_id}: brak wymagania {requirement_ref}")
for effect_ref in area.get("en", []) or []:
if effect_ref not in learning_effects:
errors.append(f"obszar {area_id}: brak efektu {effect_ref}")
for criterion_ref in area.get("kw", []) or []:
if criterion_ref not in assessment_criteria:
errors.append(f"obszar {area_id}: brak kryterium {criterion_ref}")
concrete_codes = area.get("concrete_codes", {})
if not concrete_codes:
errors.append(f"obszar {area_id}: brak concrete_codes")
continue
general_codes = concrete_codes.get("ogolne", {})
vocational_codes = concrete_codes.get("zawodowe", {})
if not general_codes.get("en"):
errors.append(f"obszar {area_id}: brak concrete_codes.ogolne.en")
if not general_codes.get("kw"):
errors.append(f"obszar {area_id}: brak concrete_codes.ogolne.kw")
if not vocational_codes.get("ek"):
errors.append(f"obszar {area_id}: brak concrete_codes.zawodowe.ek")
if not vocational_codes.get("kw"):
errors.append(f"obszar {area_id}: brak concrete_codes.zawodowe.kw")
for item in general_codes.get("en", []) + vocational_codes.get("ek", []):
effect_ref = item.get("effect_ref")
if effect_ref not in learning_effects:
errors.append(f"obszar {area_id}: concrete_codes wskazuje brak efektu {effect_ref}")
if not item.get("display"):
errors.append(f"obszar {area_id}: concrete_codes EN/EK bez display")
for item in general_codes.get("kw", []) + vocational_codes.get("kw", []):
criterion_ref = item.get("criterion_ref")
parent_effect_ref = item.get("parent_effect_ref")
if criterion_ref not in assessment_criteria:
errors.append(f"obszar {area_id}: concrete_codes wskazuje brak kryterium {criterion_ref}")
if parent_effect_ref not in learning_effects:
errors.append(f"obszar {area_id}: concrete_codes wskazuje brak efektu nadrzędnego {parent_effect_ref}")
if not item.get("display"):
errors.append(f"obszar {area_id}: concrete_codes KW bez display")
if errors:
raise SystemExit("Błędy źródła karty:\n" + "\n".join(f"- {item}" for item in errors))
def validate_side_margin_tree_layout(layout: dict, errors: list[str]) -> None:
if not layout:
return
columns = layout.get("columns", []) or []
found_general = False
found_vocational = False
for column in columns:
column_id = str(column.get("id", "")).strip().lower()
label = str(column.get("label", "")).strip().lower()
side = str(column.get("side", "")).strip().lower()
column_name = column.get("id") or column.get("label") or "<bez id>"
is_general = column_id == "ogolne" or label == "og"
is_vocational = column_id in {"zawodowe", "techniczne"} or label in {"tech", "zaw"}
if is_general:
found_general = True
if side != "right":
errors.append(
f"side_margin_tree_layout: kolumna kształcenia ogólnego {column_name!r}/OG musi mieć side='right', ma {side!r}"
)
if is_vocational:
found_vocational = True
if side != "left":
errors.append(
f"side_margin_tree_layout: kolumna techniczna/zawodowa {column_name!r}/TECH musi mieć side='left', ma {side!r}"
)
if not found_general:
errors.append("side_margin_tree_layout: brak kolumny kształcenia ogólnego id='ogolne' lub label='OG'")
if not found_vocational:
errors.append("side_margin_tree_layout: brak kolumny technicznej/zawodowej id='zawodowe' lub label='TECH'")
def macro(name: str, value: str) -> str:
return rf"\newcommand{{\{name}}}{{{tex_escape(value)}}}"
def render_preamble(data: dict) -> list[str]:
card = data["card"]
return [
r"\documentclass[12pt,a4paper]{article}",
"",
r"\usepackage[T1]{fontenc}",
r"\usepackage{lmodern}",
r"\usepackage[utf8]{inputenc}",
r"\usepackage[polish]{babel}",
r"\usepackage{csquotes}",
r"\usepackage{amsmath}",
r"\usepackage{amssymb}",
r"\usepackage{xcolor}",
r"\usepackage[a4paper,left=2.15cm,right=2.15cm,top=3.75cm,bottom=2.2cm,includehead]{geometry}",
r"\usepackage{eso-pic}",
r"\usepackage{marginnote}",
r"\usepackage{array}",
r"\usepackage{tabularx}",
r"\usepackage{graphicx}",
r"\usepackage{float}",
r"\usepackage{silence}",
r"\WarningFilter{soulutf8}{This package is obsolete}",
r"\usepackage{pdfcomment}",
r"\usepackage{enumitem}",
r"\usepackage{needspace}",
r"\usepackage{fancyhdr}",
r"\usepackage{lastpage}",
r"\usepackage{microtype}",
r"\usepackage{qrcode}",
r"\IfFileExists{references.bib}{%",
r" \usepackage[backend=biber,style=numeric,sorting=none]{biblatex}%",
r" \addbibresource{references.bib}%",
r"}{}",
"",
r"\hypersetup{hidelinks}",
"",
r"\IfFileExists{build-meta.tex}{%",
r" \input{build-meta.tex}%",
r"}{%",
r" \newcommand{\BuildCommit}{lokalna}%",
r"}",
"",
macro("DocumentAuthor", card["author"]),
macro("DocumentYear", card["year"]),
macro("CardSeries", card["series"]),
macro("CardNumber", card["number"]),
macro("CardCount", card["count"]),
macro("CardSlug", card["slug"]),
macro("CardVersion", card["version"]),
macro("DocumentUUID", card["uuid"]),
"",
r"\newcommand{\EmptyCheck}{\(\square\)}",
r"\newcommand{\EscAnswerLines}[1][3]{\par\noindent\dotfill\par\noindent\dotfill\par\noindent\dotfill}",
r"\newcommand{\EscWriteRow}[1][2.8em]{\rule{0pt}{#1}}",
r"\setlength{\parindent}{0pt}",
r"\setlength{\parskip}{0.45em}",
r"\setlength{\headheight}{30mm}",
r"\setlength{\headsep}{3mm}",
r"\setlength{\footskip}{24pt}",
r"\setlength{\marginparwidth}{1.5cm}",
r"\setlength{\marginparsep}{0.25cm}",
r"\renewcommand{\arraystretch}{1.22}",
r"% Margin separator lines disabled for layout trial.",
"",
*render_page_header_tex_macro(data),
r"\pagestyle{fancy}",
r"\fancyhf{}",
r"\fancyhead[C]{\ESCPageResourceHeader}",
r"\lfoot{\scriptsize rewizja \BuildCommit}",
r"\cfoot{\scriptsize \thepage/\pageref{LastPage}}",
r"\rfoot{\scriptsize \DocumentUUID}",
r"\renewcommand{\headrulewidth}{0pt}",
"",
r"\newcommand{\ESCMarginTag}[4]{%",
r" \hspace*{#1}\textcolor{#2}{#3~#4}%",
r"}",
r"\newcommand{\ESCSectionBlockStart}{%",
r" \Needspace{8\baselineskip}%",
r" \par\vspace{0.35em}%",
r" \noindent\textcolor{black!28}{\rule{\textwidth}{0.35pt}}%",
r" \par\vspace{0.15em}%",
r"}",
r"\newcommand{\ESCSectionBlockEnd}{%",
r" \par\vspace{0.25em}%",
r" \noindent\textcolor{black!18}{\rule{\textwidth}{0.25pt}}%",
r" \par\vspace{0.45em}%",
r"}",
r"\newcommand{\ESCTinyStepSeparator}{%",
r" \par\vspace{0.10em}%",
r" \noindent{\color{black!18}\leaders\hbox{\rule{0.60em}{0.22pt}\hspace{0.38em}}\hfill\kern0pt}%",
r" \par\vspace{0.10em}%",
r"}",
"",
]
def tree_branch_tex(branch: str) -> str:
if branch == "|--":
return r"\textbar{}-{}-"
return r"+{}-{}-"
def render_tree_lines(data: dict) -> list[str]:
tree = data.get("source_tree", {})
items = tree.get("items", [])
lines = [
r".",
rf"{tree_branch_tex('`--')} {tex_escape(tree.get('root_label', ''))}",
]
for index, item in enumerate(items):
branch = "`--" if index == len(items) - 1 else "|--"
suffix = "/" if item.get("type") == "directory" else ""
lines.append(rf"{tree_branch_tex(branch)} {tex_escape(item['label'] + suffix)}")
return lines
def render_tree_macro(data: dict) -> list[str]:
tree = data.get("source_tree")
if not tree:
return [r"\newcommand{\ESCSourceTreeWidget}{}"]
lines = [
r"\newcommand{\ESCSourceTreeWidget}{%",
r" \marginnote{%",
r" \raggedright",
rf" {{\scriptsize\bfseries {tex_escape(tree['title'])}\par}}%",
r" \vspace{0.18em}%",
r" {\fontsize{4.8}{5.4}\selectfont\ttfamily",
]
for line in render_tree_lines(data):
lines.append(rf" {line}\par")
lines.extend(
[
r" }%",
r" }%",
r"}",
"",
]
)
return lines
def render_front_matter(card: dict) -> list[str]:
rows = [
("Projekt", card["project"]),
("Seria", card.get("series_title", card["series"])),
("Karta", card["title"]),
("Przedmiot", card["subject"]),
("Poziom", card["level"]),
("Data aktualizacji", card["revision_date"]),
("Status", card["status"]),
("Numer karty", r"\small\texttt{\CardNumber/\CardCount}"),
("Wersja karty", r"\small\texttt{\CardVersion}"),
("UUID", r"\small\texttt{\DocumentUUID}"),
("Rewizja źródła", r"\small\texttt{\BuildCommit}"),
]
lines = [r"\noindent\strut\ESCLearningTreeWidget%", r"\begin{tabular}{@{}lp{0.72\textwidth}@{}}"]
for label, value in rows:
if value.startswith("\\"):
rendered = value
else:
rendered = tex_escape(value)
lines.append(rf"{tex_escape(label)}: & {rendered} \\")
lines.extend(
[
r"\end{tabular}",
"",
r"\vspace{0.8em}",
r"\noindent\rule{\textwidth}{0.5pt}",
"",
]
)
return lines
def render_front_page_scope(data: dict) -> list[str]:
scope = data.get("front_page_scope")
if not scope:
return []
lines = [
r"\vspace{1.0em}",
rf"\noindent{{\Large\bfseries {tex_escape(scope.get('title', 'Zakres i cel opracowania'))}}}\par",
r"\vspace{0.35em}",
scope.get("content_tex", ""),
"",
r"\vspace{0.8em}",
r"\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}",
"",
]
return lines
def render_task_flow_tex(task_ref: str, task: dict) -> list[str]:
title = task.get("title") or task_ref
lines = [
rf"\subsection*{{{tex_escape(task_ref.upper())} · {tex_escape(title)}}}",
]
if task.get("uuid"):
lines.append(rf"{{\scriptsize\ttfamily {tex_escape(task['uuid'])}}}\par")
for item in task.get("flow", []) or []:
kind = item.get("kind")
if kind == "block":
lines.extend(
[
r"\par\medskip\noindent\textcolor{black!35}{\rule{\linewidth}{0.35pt}}\par",
rf"\noindent{{\large\bfseries Block · {tex_escape(item.get('title', ''))}}}\par",
]
)
if item.get("content_tex"):
lines.append(str(item["content_tex"]))
steps = item.get("steps", []) or []
if steps:
lines.append(r"\begin{enumerate}[label=\textbf{K\arabic*.},leftmargin=*]")
for step in steps:
content = str(step.get("content_tex", ""))
suffix = f" {content}" if content else ""
lines.append(rf" \item \textbf{{{tex_escape(step.get('title', ''))}}}{suffix}")
lines.append(r"\end{enumerate}")
elif kind == "exercise":
lines.extend(
[
r"\par\medskip\noindent\textcolor{black!55}{\rule{\linewidth}{0.6pt}}\par",
rf"\noindent{{\large\bfseries Exercise · {tex_escape(item.get('title', ''))}}}\par",
str(item.get("prompt_tex", "")),
]
)
if item.get("evidence_tex"):
lines.append(rf"\par\textbf{{Evidence:}} {item['evidence_tex']}")
lines.append(rf"\par\textbf{{Acceptance:}} {tex_escape(item.get('criterion', ''))}")
lines.append(rf"\par\textbf{{Task acceptance:}} {tex_escape(task.get('criterion', ''))}")
return lines
def render_tasks(section: dict, data: dict) -> list[str]:
tasks = data["tasks"]
lines: list[str] = []
legacy_refs: list[str] = []
for task_ref in section["task_refs"]:
task = tasks[task_ref]
if task.get("flow"):
lines.extend(render_task_flow_tex(task_ref, task))
else:
legacy_refs.append(task_ref)
if legacy_refs:
lines.append(r"\begin{enumerate}[label=\textbf{\arabic*.}]")
for task_ref in legacy_refs:
lines.append(rf" \item {tasks[task_ref]['prompt_tex']}")
lines.append(r"\end{enumerate}")
return lines
def ref_number(ref: str) -> int | None:
match = re.search(r"(\d+)$", ref)
return int(match.group(1)) if match else None
def compact_ref_numbers(refs: list[str]) -> list[str]:
numbers = [ref_number(ref) for ref in refs]
if not numbers or any(number is None for number in numbers):
return [ref.split(".")[-1] for ref in refs]
values = sorted(number for number in numbers if number is not None)
if len(values) <= 3:
return [f"{value:02d}" for value in values]
ranges: list[list[int]] = [[values[0]]]
for value in values[1:]:
if value == ranges[-1][-1] + 1:
ranges[-1].append(value)
else:
ranges.append([value])
compact: list[str] = []
for item in ranges:
if len(item) == 1:
compact.append(f"{item[0]:02d}")
else:
compact.append(f"{item[0]:02d}-{item[-1]:02d}")
return compact
ROMAN_NUMERALS = {
1: "I",
2: "II",
3: "III",
4: "IV",
5: "V",
6: "VI",
7: "VII",
8: "VIII",
9: "IX",
10: "X",
11: "XI",
12: "XII",
13: "XIII",
14: "XIV",
}
GENERAL_PP_FILES = {
"FP": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-fiz.json",
"FR": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-fiz.json",
"IP": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-inf.json",
"IR": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-inf.json",
"MP": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-mat.json",
"MR": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-mat.json",
}
VOCATIONAL_PP_FILES = {
"EL": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-elektronik.json",
"AU": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-automatyk.json",
"RO": PP_ROOT / "KsztalcenieZawodowe" / "2021.05.28" / "pp-technik-robotyk.json",
"I2": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-informatyk.json",
"I3": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-informatyk.json",
"I4": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-programista.json",
}
VOCATIONAL_DISPLAY_CODES = {
"EL": "ELM02",
"AU": "ELM04",
"RO": "ELM07",
"I2": "INF02",
"I3": "INF03",
"I4": "INF04",
}
def configure_pp_root(pp_root: Path) -> None:
global PP_ROOT, GENERAL_PP_FILES, VOCATIONAL_PP_FILES
PP_ROOT = pp_root.resolve()
GENERAL_PP_FILES = {
"FP": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-fiz.json",
"FR": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-fiz.json",
"IP": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-inf.json",
"IR": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-inf.json",
"MP": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-mat.json",
"MR": PP_ROOT / "KsztalcenieOgolne" / "2024.06.28" / "pp-mat.json",
}
VOCATIONAL_PP_FILES = {
"EL": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-elektronik.json",
"AU": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-automatyk.json",
"RO": PP_ROOT / "KsztalcenieZawodowe" / "2021.05.28" / "pp-technik-robotyk.json",
"I2": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-informatyk.json",
"I3": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-informatyk.json",
"I4": PP_ROOT / "KsztalcenieZawodowe" / "2019.05" / "pp-technik-programista.json",
}
load_json_file.cache_clear()
def configure_dictionaries(data: dict) -> None:
refs = data.get("dictionary_refs", {})
pp_ref = refs.get("pp_json_root")
candidates = []
if pp_ref:
candidates.append(resolve_card_path(pp_ref))
candidates.append(EDU_ROOT / "pp" / "json")
for candidate in candidates:
if candidate.exists():
configure_pp_root(candidate)
return
@lru_cache(maxsize=None)
def load_json_file(path: str) -> dict:
json_path = Path(path)
if not json_path.exists():
return {}
return json.loads(json_path.read_text(encoding="utf-8"))
def level_matches(context_source: str, level: str | None) -> bool:
if not level:
return True
if context_source.endswith("R"):
return "rozszerzony" in level.lower()
if context_source.endswith("P"):
return "podstawowy" in level.lower()
return True
def find_general_description(context_source: str | None, official: str) -> str:
if not context_source or context_source not in GENERAL_PP_FILES:
return ""
parts = [part for part in official.split(".") if part.isdigit()]
if len(parts) < 2:
return ""
chapter = ROMAN_NUMERALS.get(int(parts[0]))
item_no = int(parts[1])
if not chapter:
return ""
payload = load_json_file(str(GENERAL_PP_FILES[context_source]))
subject = payload.get("subject", context_source)
sections = payload.get("tresci_nauczania_wymagania_szczegolowe", [])
matching_sections = [section for section in sections if section.get("id") == chapter]
matching_sections.sort(key=lambda section: 0 if level_matches(context_source, section.get("level")) else 1)
for section in matching_sections:
for item in section.get("items", []):
if item.get("no") == item_no:
level = section.get("level") or payload.get("level", "")
return f"{subject}, {level}: {chapter}.{item_no}. {item.get('text', '')}"
return ""
def find_vocational_description(context_source: str | None, official: str, local: str) -> str:
if not context_source or context_source not in VOCATIONAL_PP_FILES:
return ""
parts = [part for part in official.split(".") if part.isdigit()]
if len(parts) < 3 or not local.isdigit():
return ""
qualification_no = int(parts[0])
unit_no = int(parts[1])
effect_no = int(parts[2])
criterion_no = int(local)
payload = load_json_file(str(VOCATIONAL_PP_FILES[context_source]))
profession = profession_label(payload.get("profession", context_source), context_source)
for qualification in payload.get("kwalifikacje_wyodrebnione_w_zawodzie", []):
qualification_id = qualification.get("id", "")
if not qualification_id.endswith(f".{qualification_no:02d}"):
continue
for unit in qualification.get("jednostki_efektow", []):
if not unit.get("id", "").endswith(f".{qualification_no:02d}.{unit_no}"):
continue
for effect in unit.get("efekty_ksztalcenia", []):
if effect.get("no") != effect_no:
continue
for criterion in effect.get("kryteria_weryfikacji", []):
if criterion.get("no") == criterion_no:
return (
f"{profession}, {qualification_id}: "
f"{unit.get('id')}.{effect_no}.{criterion_no}. {criterion.get('text', '')}"
)
return ""
def profession_label(value: object, fallback: str) -> str:
if isinstance(value, dict):
return str(value.get("name") or value.get("name_upper") or fallback)
if isinstance(value, str):
return value
return fallback
def find_vocational_effect_description(context_source: str, official: str, local: str) -> str:
if context_source not in VOCATIONAL_PP_FILES:
return ""
parts = [part for part in official.split(".") if part.isdigit()]
if len(parts) < 2 or not local.isdigit():
return ""
qualification_no = int(parts[0])
unit_no = int(parts[1])
effect_no = int(local)
payload = load_json_file(str(VOCATIONAL_PP_FILES[context_source]))
profession = profession_label(payload.get("profession", context_source), context_source)
for qualification in payload.get("kwalifikacje_wyodrebnione_w_zawodzie", []):
qualification_id = qualification.get("id", "")
if not qualification_id.endswith(f".{qualification_no:02d}"):
continue
for unit in qualification.get("jednostki_efektow", []):
if not unit.get("id", "").endswith(f".{qualification_no:02d}.{unit_no}"):
continue
for effect in unit.get("efekty_ksztalcenia", []):
if effect.get("no") == effect_no:
return (
f"{profession}, {qualification_id}: "
f"{unit.get('id')}.{effect_no}. {effect.get('text', '')}"
)
return ""
def official_code_description(source: str, official: str, local: str, context_source: str | None) -> str:
if source == "WS":
return find_general_description(context_source, official)
if source == "KW":
return find_vocational_description(context_source, official, local)
if source in GENERAL_PP_FILES:
return find_general_description(source, official)
if source in VOCATIONAL_PP_FILES:
return find_vocational_effect_description(source, official, local)
return ""
def ordered_intersection(left: Iterable[str], right: Iterable[str]) -> list[str]:
right_set = set(right)
return [item for item in left if item in right_set]
def criteria_for_effect(effect_ref: str, data: dict) -> list[str]:
effects = data.get("learning_effects", {})
criteria = data.get("assessment_criteria", {})
refs = list(effects.get(effect_ref, {}).get("assessment_criteria", []))
for criterion_ref, criterion in criteria.items():
if effect_ref in criterion.get("learning_effects", []) and criterion_ref not in refs:
refs.append(criterion_ref)
return refs
def format_local_suffix(local: str) -> str:
return local.zfill(2) if local.isdigit() else local
def format_margin_code(code: dict | str, context_source: str | None = None) -> tuple[str, str, str, str, str]:
if isinstance(code, str):
parts = code.split(maxsplit=1)
if len(parts) == 2:
return parts[0], parts[1], "", "", ""
return "", code, "", "", ""
source = str(code.get("source", "")).strip()
official = str(code.get("official", "")).strip()
local = str(code.get("local", "")).strip()
effective_context = str(code.get("context_source") or context_source or "").strip() or None
official_description = str(code.get("official_description", "")).strip()
if not official_description:
official_description = official_code_description(source, official, local, effective_context)
if not official_description and official:
official_description = f"{source} {official}: punkt podstawy programowej. Pełny opis uzupełniamy w słowniku PP."
local_description = str(code.get("local_description", "")).strip()
if not local_description and local:
local_description = f"{source} {official}.{local}: lokalne doprecyzowanie opisane w słowniku."
return source, official, format_local_suffix(local), official_description, local_description
def effect_margin_codes(effect_ref: str, data: dict, column: str) -> list[tuple[str, str, str, str, str]]:
effects = data.get("learning_effects", {})
effect = effects.get(effect_ref, {})
codes = effect.get("margin_codes", {}).get(column, [])
result = []
for code in codes:
if isinstance(code, dict):
code = dict(code)
code.setdefault("local_description", effect.get("text", ""))
result.append(format_margin_code(code))
return result
def criterion_margin_codes(
criterion_ref: str,
data: dict,
column: str,
parent_source: str | None = None,
) -> list[tuple[str, str, str, str, str]]:
criterion = data.get("assessment_criteria", {}).get(criterion_ref, {})
codes_by_source = criterion.get("margin_codes_by_source", {}).get(column, {})
if parent_source and parent_source in codes_by_source:
result = []
for code in codes_by_source[parent_source]:
if isinstance(code, dict):
code = dict(code)
code.setdefault("context_source", parent_source)
code.setdefault("local_description", criterion.get("text", ""))
result.append(format_margin_code(code, parent_source))
return result
codes = criterion.get("margin_codes", {}).get(column, [])
if codes:
result = []
for code in codes:
if isinstance(code, dict):
code = dict(code)
code.setdefault("local_description", criterion.get("text", ""))
result.append(format_margin_code(code, parent_source))
return result
return [("KW", compact, "", "", "") for compact in compact_ref_numbers([criterion_ref])]
def display_effect_prefix(prefix: str, column: str) -> str:
if column == "ogolne" and prefix in GENERAL_PP_FILES:
return f"EN {prefix} WO"
if column == "zawodowe" and prefix in VOCATIONAL_PP_FILES:
return f"EK {VOCATIONAL_DISPLAY_CODES.get(prefix, prefix)}"
return prefix
def display_criterion_prefix(prefix: str, parent_prefix: str, column: str) -> str:
if column == "ogolne" and parent_prefix in GENERAL_PP_FILES and prefix in {"WS", "WO"}:
return f"KW {parent_prefix} {prefix}"
if column == "zawodowe" and parent_prefix in VOCATIONAL_PP_FILES and prefix in {"KW", "EK"}:
return f"{prefix} {VOCATIONAL_DISPLAY_CODES.get(parent_prefix, parent_prefix)}"
return prefix
def append_effect_with_criteria_tags(
tags: list[tuple[str, str, str, str, str, str, str]],
effect_ref: str,
data: dict,
column: str,
effect_color: str,
criterion_refs: list[str] | None = None,
) -> None:
if criterion_refs is None:
criterion_refs = criteria_for_effect(effect_ref, data)
for prefix, official, local, official_tip, local_tip in effect_margin_codes(effect_ref, data, column):
tags.append(("0.28em", effect_color, display_effect_prefix(prefix, column), official, local, official_tip, local_tip))
for criterion_ref in criterion_refs:
for criterion_prefix, criterion_official, criterion_local, criterion_official_tip, criterion_local_tip in criterion_margin_codes(
criterion_ref, data, column, prefix
):
tags.append(
(
"0.54em",
"blue!70!black",
display_criterion_prefix(criterion_prefix, prefix, column),
criterion_official,
criterion_local,
criterion_official_tip,
criterion_local_tip,
)
)
def learning_tree_item_prefix(item: dict, column: str) -> str:
if column == "ogolne":
return f"EN {item.get('source', '')} {item.get('kind', 'WO')}".strip()
return f"EK {item.get('qualification') or VOCATIONAL_DISPLAY_CODES.get(item.get('source', ''), item.get('source', ''))}".strip()
def learning_tree_kw_prefix(item: dict, column: str) -> str:
if column == "ogolne":
return f"KW {item.get('source', '')} {item.get('kind', 'WS')}".strip()
return f"KW {item.get('qualification') or VOCATIONAL_DISPLAY_CODES.get(item.get('source', ''), item.get('source', ''))}".strip()
def learning_tree_item_tip(item: dict, column: str) -> str:
source = item.get("source", "")
official = item.get("official", "")
local = item.get("local", "")
if column == "ogolne":
return official_code_description(source, official, local, source)
return official_code_description(source, official, local, source)
def learning_tree_kw_tip(item: dict, column: str) -> str:
source = item.get("source", "")
kind = item.get("kind", "")
official = item.get("official", "")
local = item.get("local", "")
if column == "ogolne":
return official_code_description(kind, official, local, source)
return official_code_description(kind, official, local, source)
def append_source_truth_learning_tree_tags(
tags: list[tuple[str, str, str, str, str, str, str]],
requirement: dict,
column: str,
effect_color: str,
) -> bool:
tree = requirement.get("learning_tree", {})
items = tree.get(column, []) or []
if not items:
return False
for item in items:
tags.append(
(
"0.28em",
effect_color,
learning_tree_item_prefix(item, column),
item.get("official", ""),
item.get("local", ""),
learning_tree_item_tip(item, column),
item.get("text", ""),
)
)
for criterion in item.get("kw", []) or []:
tags.append(
(
"0.54em",
"blue!70!black",
learning_tree_kw_prefix(criterion, column),
criterion.get("official", ""),
criterion.get("local", ""),
learning_tree_kw_tip(criterion, column),
criterion.get("text", ""),
)
)
return True
def learning_tree_tags(
data: dict,
column: str = "ogolne",
effect_color: str = "green!50!black",
) -> list[tuple[str, str, str, str, str, str, str]]:
requirements = data.get("educational_requirements", {})
effects = data.get("learning_effects", {})
tags: list[tuple[str, str, str, str, str, str, str]] = []
used_effects: set[str] = set()
used_criteria: set[str] = set()
for requirement_ref, requirement in requirements.items():
for compact in compact_ref_numbers([requirement_ref]):
tags.append(("0.00em", "red", "WE", compact, "", "", ""))
if append_source_truth_learning_tree_tags(tags, requirement, column, effect_color):
used_effects.update(requirement.get("learning_effects", []))
for item in requirement.get("learning_tree", {}).get(column, []) or []:
for criterion in item.get("kw", []) or []:
criterion_ref = criterion.get("criterion_ref")
if criterion_ref:
used_criteria.add(criterion_ref)
continue
for effect_ref in requirement.get("learning_effects", []):
used_effects.add(effect_ref)
effect_criteria = criteria_for_effect(effect_ref, data)
append_effect_with_criteria_tags(tags, effect_ref, data, column, effect_color, effect_criteria)
for criterion_ref in effect_criteria:
used_criteria.add(criterion_ref)
for effect_ref in effects:
if effect_ref in used_effects:
continue
effect_criteria = criteria_for_effect(effect_ref, data)
append_effect_with_criteria_tags(tags, effect_ref, data, column, effect_color, effect_criteria)
for criterion_ref in effect_criteria:
used_criteria.add(criterion_ref)
for criterion_ref in data.get("assessment_criteria", {}):
if criterion_ref in used_criteria:
continue
for criterion_prefix, criterion_official, criterion_local, criterion_official_tip, criterion_local_tip in criterion_margin_codes(
criterion_ref, data, column
):
tags.append(
(
"0.54em",
"blue!70!black",
criterion_prefix,
criterion_official,
criterion_local,
criterion_official_tip,
criterion_local_tip,
)
)
return tags
def section_margin_tags(
section: dict,
data: dict,
column: str = "ogolne",
effect_color: str = "green!50!black",
) -> list[tuple[str, str, str, str, str, str, str]]:
requirements = data.get("educational_requirements", {})
section_requirements = section.get("educational_requirement_refs", [])
section_effects = section.get("learning_effect_refs", [])
section_criteria = section.get("assessment_criterion_refs", [])
tags: list[tuple[str, str, str, str, str, str, str]] = []
used_effects: set[str] = set()
used_criteria: set[str] = set()
for requirement_ref in section_requirements:
for compact in compact_ref_numbers([requirement_ref]):
tags.append(("0.00em", "red", "WE", compact, "", "", ""))
requirement_effects = requirements.get(requirement_ref, {}).get("learning_effects", [])
for effect_ref in ordered_intersection(requirement_effects, section_effects):
used_effects.add(effect_ref)
effect_criteria = criteria_for_effect(effect_ref, data)
selected_criteria = ordered_intersection(effect_criteria, section_criteria) if section_criteria else effect_criteria
append_effect_with_criteria_tags(tags, effect_ref, data, column, effect_color, selected_criteria)
for criterion_ref in selected_criteria:
used_criteria.add(criterion_ref)
for effect_ref in section_effects:
if effect_ref in used_effects:
continue
effect_criteria = criteria_for_effect(effect_ref, data)
selected_criteria = ordered_intersection(effect_criteria, section_criteria) if section_criteria else effect_criteria
append_effect_with_criteria_tags(tags, effect_ref, data, column, effect_color, selected_criteria)
for criterion_ref in selected_criteria:
used_criteria.add(criterion_ref)
for criterion_ref in section_criteria:
if criterion_ref in used_criteria:
continue
for criterion_prefix, criterion_official, criterion_local, criterion_official_tip, criterion_local_tip in criterion_margin_codes(
criterion_ref, data, column
):
tags.append(
(
"0.54em",
"blue!70!black",
criterion_prefix,
criterion_official,
criterion_local,
criterion_official_tip,
criterion_local_tip,
)
)
return tags
def tooltip_tex(visible: str, tooltip: str) -> str:
if not visible:
return ""
escaped_visible = tex_escape(visible).replace(" ", r"~")
if not tooltip:
return escaped_visible
wrapped_tooltip = r"\textCR ".join(
tex_escape(line)
for line in textwrap.wrap(
tooltip,
width=TOOLTIP_WRAP_CHARS,
break_long_words=False,
break_on_hyphens=False,
)
)
return rf"\pdftooltip[width=\textwidth]{{{escaped_visible}}}{{{wrapped_tooltip}}}"
def render_margin_tag(
indent: str,
color: str,
prefix: str,
official: str,
local: str,
official_tip: str,
local_tip: str,
) -> str:
base = " ".join(part for part in (prefix, official) if part)
local_suffix = f".{local}" if local else ""
base_code = tooltip_tex(base, official_tip)
local_code = tooltip_tex(local_suffix, local_tip)
return (
rf" \hspace*{{{indent}}}"
rf"\textcolor{{{color}}}{{{base_code}}}"
rf"\textcolor{{black!48}}{{{local_code}}}\par%"
)
def render_margin_column(
tags: list[tuple[str, str, str, str, str, str, str]],
title: str,
width: str = r"\marginparwidth",
) -> list[str]:
lines = [
rf" \begin{{minipage}}[t]{{{width}}}%",
r" \raggedright",
rf" {{\bfseries\textcolor{{black!65}}{{{tex_escape(title)}}}\par}}%",
r" \vspace{0.08em}%",
]
if tags:
for indent, color, prefix, official, local, official_tip, local_tip in tags:
lines.append(render_margin_tag(indent, color, prefix, official, local, official_tip, local_tip))
else:
lines.append(r" \textcolor{black!35}{--}\par%")
lines.append(r" \end{minipage}%")
return lines
def render_margin_note(
tags: list[tuple[str, str, str, str, str, str, str]],
title: str,
side: str,
voffset: str = "",
) -> list[str]:
if not tags:
return []
right_shift = "0.06cm" if side == "right" else ""
column_width = rf"\dimexpr\marginparwidth-{right_shift}\relax" if right_shift else r"\marginparwidth"
content = [
r" \begin{minipage}{\marginparwidth}%",
r" \raggedright",
r" {\fontsize{3.55}{3.95}\selectfont\ttfamily",
]
if right_shift:
content.append(rf" \hspace*{{{right_shift}}}%")
content.extend(render_margin_column(tags, title, column_width))
content.extend([r" }%", r" \end{minipage}%"])
if side == "left":
lines = [r"\reversemarginpar", r"\marginnote[%"]
lines.extend(content)
lines.append(rf"]{{}}[{voffset}]" if voffset else r"]{}")
lines.append(r"\normalmarginpar")
lines.append("")
return lines
lines = [r"\marginnote{%"]
lines.extend(content)
lines.append(rf"}}[{voffset}]" if voffset else r"}")
lines.append("")
return lines
def margin_layout(data: dict | None = None) -> dict[str, dict[str, str]]:
layout = {}
source = {}
if data:
source = data.get("side_margin_tree_layout") or data.get("educational_model", {}).get("side_margin_tree_layout", {})
for column in source.get("columns", []) or []:
column_id = column.get("id")
if column_id:
layout[column_id] = {
"side": column.get("side", ""),
"label": column.get("label", column_id),
}
layout.setdefault("ogolne", {"side": "right", "label": "OG"})
layout.setdefault("zawodowe", {"side": "left", "label": "TECH"})
return layout
def render_dual_margin_block(
general_tags: list[tuple[str, str, str, str, str, str, str]],
vocational_tags: list[tuple[str, str, str, str, str, str, str]],
voffset: str = "",
data: dict | None = None,
) -> list[str]:
lines: list[str] = []
layout = margin_layout(data)
columns = {
"ogolne": general_tags,
"zawodowe": vocational_tags,
}
ordered_columns = sorted(
columns,
key=lambda column_id: (0 if layout[column_id]["side"] == "left" else 1, column_id),
)
for column_id in ordered_columns:
tags = columns[column_id]
column = layout[column_id]
lines.extend(render_margin_note(tags, column["label"], column["side"], voffset))
return lines
def learning_tree_tooltip(tree_ref: str, tree: dict) -> str:
requirement_ref = tree.get("requirement_ref", "")
requirement = tree.get("requirement", {})
item = tree.get("item", {})
compact_requirement = " ".join(compact_ref_numbers([requirement_ref])) or requirement_ref
parts = [
tree_ref,
f"WE {compact_requirement}: {requirement.get('label', '')}. {requirement.get('text', '')}".strip(),
f"{item.get('display', '')}: {item.get('text', '')}".strip(),
]
for criterion in item.get("kw", []) or []:
parts.append(f"{criterion.get('display', '')}: {criterion.get('text', '')}".strip())
return " | ".join(part for part in parts if part)
def render_learning_tree_token(alias: str, tree_ref: str, tree_index: dict[str, dict]) -> str:
tree = tree_index.get(tree_ref)
if not tree:
return tex_escape(alias)
return tooltip_tex(alias, learning_tree_tooltip(tree_ref, tree))
def external_reference_label(reference: dict, entry: dict) -> str:
kind = str(reference.get("kind", entry.get("kind", "")))
if kind == "strategy":
return str(entry.get("code", entry.get("title", "")))
if kind == "card":
return str(entry.get("title", entry.get("code", "")))
return str(entry.get("code") or entry.get("title") or reference.get("uuid", ""))
def grouped_external_references(references: list[dict]) -> list[tuple[str, list[dict]]]:
groups: dict[str, list[dict]] = {}
for reference in references:
if not isinstance(reference, dict):
continue
kind = str(reference.get("kind", ""))
groups.setdefault(kind, []).append(reference)
return [(kind, groups[kind]) for kind in REFERENCE_KIND_LABELS if groups.get(kind)]
def render_external_reference_tex(data: dict, reference: dict) -> str:
entry = resolve_external_reference(data, reference)
label = external_reference_label(reference, entry)
kind = str(reference.get("kind", ""))
if kind == "strategy":
visible = rf"\texttt{{{tex_escape(label)}}}"
else:
visible = tex_escape(label)
url = str(entry.get("url", ""))
if not url:
return visible
return rf"\href{{{tex_escape(url)}}}{{{visible}}}"
def section_tree_refs(section: dict) -> list[str]:
refs = list(section.get("area_tree_refs", []) or [])
if refs:
return refs
for step in section.get("steps", []) or []:
for tree_ref in step.get("tree_refs", []) or []:
if tree_ref not in refs:
refs.append(tree_ref)
return refs
def render_section_step_tree_map(section: dict, data: dict) -> list[str]:
if section.get("render_latex_step_map") is False:
return []
if section.get("render_step_map") is False:
return []
steps = section.get("steps", []) or []
if not steps:
return []
tree_index, _ = build_learning_tree_index(data)
tree_refs = section_tree_refs(section)
aliases = {tree_ref: f"D{index + 1}" for index, tree_ref in enumerate(tree_refs)}
area_label = " ".join(section.get("area_refs", []) or [])
legend = " ".join(
render_learning_tree_token(aliases[tree_ref], tree_ref, tree_index)
for tree_ref in tree_refs
)
lines = [
r"\begingroup",
r"\scriptsize\ttfamily\color{black!60}\sloppy",
]
if area_label:
lines.append(rf"\noindent obszar: {tex_escape(area_label)}\quad drzewka: {legend}\par")
else:
lines.append(rf"\noindent drzewka: {legend}\par")
lines.append(r"\vspace{0.10em}%")
for index, step in enumerate(steps, start=1):
if index > 1:
lines.append(r"\ESCTinyStepSeparator")
tree_tokens = " ".join(
tex_escape(aliases.get(tree_ref, "?"))
for tree_ref in step.get("tree_refs", []) or []
)
reference_chunks = []
for kind, references in grouped_external_references(step.get("references", []) or []):
reference_tokens = " ".join(
render_external_reference_tex(data, reference) for reference in references
)
reference_chunks.append(f"{REFERENCE_KIND_LABELS[kind]}: {reference_tokens}")
reference_suffix = rf"\quad {'; '.join(reference_chunks)}" if reference_chunks else ""
step_text = f"K{index}: {step.get('title', '')}"
lines.append(rf"\noindent {tex_escape(step_text)}\quad {tree_tokens}{reference_suffix}\par")
lines.extend([r"\par\vspace{0.18em}%", r"\endgroup", ""])
return lines
def render_learning_tree_macro(data: dict) -> list[str]:
lines = [r"\newcommand{\ESCLearningTreeWidget}{%"]
block = render_dual_margin_block(
learning_tree_tags(data, column="ogolne", effect_color="green!50!black"),
learning_tree_tags(data, column="zawodowe", effect_color="orange!85!black"),
data=data,
)
if block:
lines.extend(" " + line for line in block)
lines.extend([r"}", ""])
return lines
def render_section_margin(section: dict, data: dict) -> list[str]:
general_tags = section_margin_tags(section, data, column="ogolne", effect_color="green!50!black")
vocational_tags = section_margin_tags(section, data, column="zawodowe", effect_color="orange!85!black")
if not general_tags and not vocational_tags:
return []
return render_dual_margin_block(general_tags, vocational_tags, voffset="-3.1em", data=data)
def render_sections(data: dict) -> list[str]:
lines: list[str] = []
for section in data["sections"]:
command = r"\section*" if section.get("starred") else r"\section"
lines.append(r"\ESCSectionBlockStart")
lines.append(rf"{command}{{{tex_escape(section['title'])}}}")
lines.extend(render_section_margin(section, data))
lines.append("")
lines.extend(render_section_step_tree_map(section, data))
if section.get("content_kind") == "tasks":
lines.extend(render_tasks(section, data))
else:
lines.append(section.get("content_tex", ""))
lines.extend(render_section_assets_tex(section, data))
lines.append(r"\ESCSectionBlockEnd")
lines.append("")
return lines
def render_learning_map_block() -> list[str]:
return [
r"\noindent\strut\ESCLearningTreeWidget\par",
"",
]
def tex_inline(value: object) -> str:
return tex_escape(str(value))
def tex_mono_body(value: object) -> str:
text = tex_escape(str(value))
text = text.replace(r"\_", r"\_\allowbreak{}")
for marker in ("/", "-", "."):
text = text.replace(marker, marker + r"\allowbreak{}")
return text
def tex_mono(value: object) -> str:
return rf"\texttt{{\small {tex_mono_body(value)}}}"
def tex_cell(value: object) -> str:
if isinstance(value, str) and (
value.startswith(r"\EmptyCheck")
or value.startswith(r"\EscWriteRow")
or value.startswith(r"\EscAnswerLines")
or value.startswith(r"\dotfill")
):
return value
cite_placeholders: list[tuple[str, str]] = []
source = str(value)
def cite_repl(match: re.Match[str]) -> str:
placeholder = f"@@CITE{len(cite_placeholders)}@@"
cite_placeholders.append((placeholder, rf"\cite{{{match.group(1)}}}"))
return placeholder
source = re.sub(r"\\cite\{([A-Za-z0-9_.:-]+)\}", cite_repl, source)
text = tex_inline(source)
text = text.replace(r"\_", r"\_\allowbreak{}")
for marker in ("/", ",", "(", ")"):
text = text.replace(marker, marker + r"\allowbreak{}")
for placeholder, cite_command in cite_placeholders:
text = text.replace(tex_escape(placeholder), cite_command)
return text
def block_itemize(items: Iterable[object]) -> str:
lines = [r"\begin{itemize}[label=--]"]
for item in items:
lines.append(rf" \item {tex_inline(item)}")
lines.append(r"\end{itemize}")
return "\n".join(lines)
def block_enumerate(items: Iterable[object]) -> str:
lines = [r"\begin{enumerate}[label=\arabic*.,leftmargin=*]"]
for item in items:
lines.append(rf" \item {tex_inline(item)}")
lines.append(r"\end{enumerate}")
return "\n".join(lines)
def block_mono_list(items: Iterable[tuple[str, object]]) -> str:
lines = [r"\begin{itemize}[label={},leftmargin=0pt,itemsep=0.08em,topsep=0.15em]"]
for label, value in items:
if value in (None, ""):
continue
lines.append(rf" \item \texttt{{\small {tex_escape(label)}: {tex_mono_body(value)}}}")
lines.append(r"\end{itemize}")
return "\n".join(lines)
def block_resources_compact(resources: dict) -> str:
return block_mono_list(
[
("repo", resources.get("repo_url", "")),
("lab", resources.get("starter_path", "")),
("notebook", resources.get("notebook_path", "")),
("constants", resources.get("constants_path", "")),
("image", resources.get("container_image", "")),
("compose", resources.get("compose_path", "")),
("check", resources.get("teacher_check", "")),
]
)
def block_commands_compact(commands: Iterable[object]) -> str:
return block_mono_list((("$", command) for command in commands))
def block_agenda_compact(agenda: list[dict]) -> str:
items = [f"{row.get('time', '')}: {row.get('work', '')}" for row in agenda if row.get("time") or row.get("work")]
return r"\textbf{Agenda 45 min:} " + " · ".join(tex_inline(item) for item in items)
def block_compact_table(headers: list[str], rows: list[list[object]], spec: str) -> str:
lines = [rf"\begin{{tabularx}}{{\textwidth}}{{{spec}}}"]
lines.append(" & ".join(rf"\textbf{{{tex_cell(header)}}}" for header in headers) + r" \\")
for row in rows:
padded = list(row) + [""] * max(0, len(headers) - len(row))
lines.append(" & ".join(tex_cell(cell) for cell in padded[: len(headers)]) + r" \\")
lines.append(r"\end{tabularx}")
return "\n".join(lines)
def block_table(headers: list[str], rows: list[list[object]], spec: str | None = None) -> str:
column_spec = spec or ("|" + "|".join(["X"] * len(headers)) + "|")
lines = [rf"\begin{{tabularx}}{{\textwidth}}{{{column_spec}}}", r"\hline"]
lines.append(" & ".join(tex_cell(header) for header in headers) + r" \\")
lines.append(r"\hline")
for row in rows:
padded = list(row) + [""] * max(0, len(headers) - len(row))
lines.append(" & ".join(tex_cell(cell) for cell in padded[: len(headers)]) + r" \\")
lines.append(r"\hline")
lines.append(r"\end{tabularx}")
return "\n".join(lines)
def block_gate_table(criteria: list[dict]) -> str:
rows = []
for criterion in criteria:
rows.append([r"\EmptyCheck", criterion.get("check") or criterion.get("criterion") or "", criterion.get("evidence", "")])
return block_table(["OK", "Kryterium", "Dowód"], rows, r"|p{0.9cm}|X|X|")
def block_tree_refs(data: dict, requirement_refs: Iterable[str]) -> list[str]:
refs: list[str] = []
for requirement_ref in requirement_refs:
requirement = data.get("educational_requirements", {}).get(requirement_ref, {})
for column in ("ogolne", "zawodowe"):
for item in requirement.get("learning_tree", {}).get(column, []) or []:
tree_id = item.get("tree_id")
if tree_id and tree_id not in refs:
refs.append(tree_id)
return refs
def block_section(
data: dict,
title: str,
content_tex: str,
requirement_refs: list[str],
effect_refs: list[str],
criterion_refs: list[str],
) -> dict:
tree_refs = block_tree_refs(data, requirement_refs)
section: dict = {
"title": title,
"starred": False,
"content_tex": content_tex,
"educational_requirement_refs": requirement_refs,
"learning_effect_refs": effect_refs,
"assessment_criterion_refs": criterion_refs,
"area_tree_refs": tree_refs,
"render_latex_step_map": False,
}
if tree_refs:
safe_id = html_id(title).lower() or "section"
section["steps"] = [
{
"id": f"{data['card']['id']}.{safe_id}.step.01",
"title": title,
"content_kind": "paragraph",
"content_tex": content_tex.split("\n", 1)[0],
"educational_requirement_refs": requirement_refs,
"learning_effect_refs": effect_refs,
"assessment_criterion_refs": criterion_refs,
"tree_refs": tree_refs,
}
]
return section
def build_block_card_sections(data: dict) -> list[dict]:
sections: list[dict] = []
section_specs: list[tuple[str, str, list[str], list[str], list[str]]] = []
section_specs.append(
(
"Cele operacyjne",
block_enumerate(data.get("objectives", [])),
["ESC04.WE01", "ESC04.WE02", "ESC04.WE03", "ESC04.WE04"],
["ESC04.EN03", "ESC04.EN04", "ESC04.EN05", "ESC04.EN07"],
["ESC04.KW01", "ESC04.KW04", "ESC04.KW05", "ESC04.KW09"],
)
)
theory = data.get("theory_refs", {})
theory_rows = []
for ref in theory.get("known_refs", []):
cite = rf"\cite{{{ref.get('cite')}}}" if ref.get("cite") else ""
theory_rows.append([f"{ref.get('id', '')} {cite}", ref.get("label", ""), ref.get("note", "")])
concept_rows = [[concept.get("term", ""), concept.get("definition", "")] for concept in theory.get("new_concepts", [])]
theory_parts = []
if theory.get("intro"):
theory_parts.append(tex_inline(theory.get("intro", "")))
theory_parts.extend(
[
block_table(["Karta / źródło", "Temat", "Użycie w symulacji"], theory_rows, r"|p{3.6cm}|p{4.0cm}|X|"),
block_table(["Nowe pojęcie", "Definicja robocza"], concept_rows, r"|p{3.7cm}|X|"),
]
)
section_specs.append(("Teoria pod model i symulację", "\n\n".join(theory_parts), ["ESC04.WE01", "ESC04.WE02"], ["ESC04.EN01", "ESC04.EN02", "ESC04.EN04"], ["ESC04.KW01", "ESC04.KW04"]))
math_block = data.get("math_block", {})
math_lines = [r"\begin{itemize}[label=--]"]
for task in math_block.get("tasks", []):
math_lines.append(rf" \item {tex_inline(task.get('prompt', ''))}")
if task.get("equation_tex"):
math_lines.append(r" \begin{equation}")
math_lines.append(f" {task['equation_tex']}")
math_lines.append(r" \end{equation}")
if task.get("answer_hint"):
math_lines.append(rf" \textit{{Wniosek:}} {tex_inline(task.get('answer_hint', ''))}")
math_lines.append(r" \EscAnswerLines")
math_lines.append(r"\end{itemize}")
section_specs.append((math_block.get("title", "Matematyka"), "\n".join(math_lines), ["ESC04.WE01"], ["ESC04.EN03"], ["ESC04.KW01"]))
model = data.get("model_block", {})
model_rows = [[item.get("name", ""), item.get("value", "")] for item in model.get("constants", [])]
model_tex = "\n\n".join(
[
rf"Notebook: {tex_mono(model.get('notebook', ''))}. Kontrakt stałych: {tex_mono(model.get('output_constants', ''))}.",
block_itemize(model.get("steps", [])),
r"\textbf{Stałe z modelu}",
block_table(["Stała", "Wartość"], model_rows, r"|p{5.0cm}|X|"),
]
)
section_specs.append((model.get("title", "Model"), model_tex, ["ESC04.WE03"], ["ESC04.EN05"], ["ESC04.KW02", "ESC04.KW03"]))
part_a = data.get("parts", {}).get("a", {})
code = data.get("code_block", {})
container = data.get("container_test", {})
code_parts = []
if part_a:
code_parts.append(rf"\textbf{{{tex_inline(part_a.get('title', 'Część A'))}:}} {tex_inline(part_a.get('body', ''))}")
code_parts.extend(
[
tex_inline(code.get("context", "")),
r"\textbf{Pliki szkieletu:}",
block_itemize(code.get("files", [])),
r"\textbf{Interfejs bloczka:}",
block_itemize(code.get("functions", [])),
r"\textbf{Uczeń odpowiada za:}",
block_itemize(code.get("student_owns", [])),
r"\textbf{Test kontenerowy:} mock Modbus sprawdza nastawy, telemetrię i warunek bezpieczeństwa przed przejściem na sprzęt.",
rf"\textbf{{Warunek PASS:}} {tex_inline(container.get('pass_condition', ''))}",
]
)
section_specs.append(("Bloczek RP2350 i test w kontenerze", "\n\n".join(code_parts), ["ESC04.WE02", "ESC04.WE03"], ["ESC04.EN04", "ESC04.EN05", "ESC04.EN06"], ["ESC04.KW04", "ESC04.KW05", "ESC04.KW06"]))
resources = data.get("resources", {})
operation_tex = "\n\n".join(
[
r"\textbf{Zasoby robocze}",
block_resources_compact(resources),
r"\textbf{Komendy terminala}",
block_commands_compact(container.get("commands", [])),
block_agenda_compact(data.get("agenda", [])),
]
)
section_specs.append(("Uruchomienie: zasoby, komendy, agenda", operation_tex, ["ESC04.WE03"], ["ESC04.EN05", "ESC04.EN06"], ["ESC04.KW03", "ESC04.KW05", "ESC04.KW06"]))
section_specs.append(("Bramka między A i B", block_gate_table(data.get("hw_gate", {}).get("criteria", [])), ["ESC04.WE03", "ESC04.WE04"], ["ESC04.EN05", "ESC04.EN07"], ["ESC04.KW05", "ESC04.KW07"]))
bom_rows = [[r"\EmptyCheck", item.get("item", ""), item.get("check", "")] for item in data.get("bom", [])]
section_specs.append(("BOM i checklista stanowiska", block_compact_table(["OK", "Element", "Krótka kontrola"], bom_rows, r"@{}p{0.75cm}p{3.2cm}X@{}"), ["ESC04.WE04"], ["ESC04.EN01"], ["ESC04.KW07"]))
part_b = data.get("parts", {}).get("b", {})
if part_b:
section_specs.append((part_b.get("title", "Część B"), tex_inline(part_b.get("body", "")), ["ESC04.WE04"], ["ESC04.EN07"], ["ESC04.KW07", "ESC04.KW09"]))
safety_rows = [[rule.get("title", ""), rule.get("body", "")] for rule in data.get("safety_rules", [])]
section_specs.append(("BHP i złote zasady", block_table(["Zasada", "Opis"], safety_rows, r"|p{4.2cm}|X|"), ["ESC04.WE04"], ["ESC04.EN01", "ESC04.EN07"], ["ESC04.KW07", "ESC04.KW10"]))
figure_lines: list[str] = []
for figure in data.get("figures", []):
figure_width = figure.get("width", r"\textwidth")
figure_lines.extend(
[
r"\begin{figure}[H]",
r"\centering",
rf"\includegraphics[width={figure_width}]{{{tex_inline(figure.get('path', ''))}}}",
rf"\caption{{{tex_inline(figure.get('caption', ''))}}}",
rf"\label{{{tex_inline(figure.get('label', ''))}}}",
r"\end{figure}",
]
)
settings_rows = [[row.get("test", ""), row.get("dps", ""), row.get("xy", "")] for row in data.get("reference_settings", [])]
figure_lines.append(block_table(["Test", "DPS3010U", "XY6020L"], settings_rows, r"|p{4.0cm}|p{4.0cm}|X|"))
section_specs.append((data.get("settings_section_title", "Kaskada i nastawy referencyjne"), "\n\n".join(figure_lines), ["ESC04.WE01", "ESC04.WE04"], ["ESC04.EN02", "ESC04.EN03"], ["ESC04.KW01", "ESC04.KW07"]))
procedure_rows = [[r"\EmptyCheck", step.get("step", ""), step.get("action", ""), step.get("condition", "")] for step in data.get("hardware_procedure", [])]
section_specs.append(("Procedura sprzętowa krok po kroku", block_table(["OK", "Krok", "Działanie", "Warunek przejścia dalej"], procedure_rows, r"|p{0.75cm}|p{1.0cm}|p{4.35cm}|X|"), ["ESC04.WE04", "ESC04.WE02"], ["ESC04.EN04", "ESC04.EN07"], ["ESC04.KW07", "ESC04.KW08"]))
obs = data.get("observation_table", {})
obs_rows = []
column_count = len(obs.get("columns", []))
for row in obs.get("rows", []):
cells = list(row.get("cells", []))
cells += [""] * max(0, column_count - len(cells))
obs_rows.append([cell if cell else r"\EscWriteRow[3.0em]" for cell in cells[:column_count]])
trouble_rows = [[row.get("symptom", ""), row.get("hypothesis", ""), row.get("action", ""), row.get("result", "") or r"\EscWriteRow[2.8em]"] for row in data.get("troubleshooting", [])]
observation_tex = "\n\n".join(
[
r"{\renewcommand{\arraystretch}{1.9}\footnotesize",
block_table(obs.get("columns", []), obs_rows, "|" + "|".join(["X"] * max(1, column_count)) + "|"),
r"\normalsize}",
r"\subsection*{Aktywny troubleshooting log}",
block_table(["Objaw", "Hipoteza", "Akcja", "Wynik / dowód w repo"], trouble_rows, r"|p{2.7cm}|X|X|X|"),
]
)
section_specs.append((obs.get("title", "Karta obserwacji HITL"), observation_tex, ["ESC04.WE04", "ESC04.WE02"], ["ESC04.EN04", "ESC04.EN07"], ["ESC04.KW08", "ESC04.KW09"]))
analysis_lines = [r"\begin{itemize}[label=--]"]
for task in data.get("analysis_tasks", []):
analysis_lines.append(rf" \item {tex_inline(task)}")
analysis_lines.append(r" \EscAnswerLines")
analysis_lines.append(r"\end{itemize}")
analysis_tex = "\n".join(analysis_lines)
section_specs.append(("Model vs pomiar i analiza danych", analysis_tex, ["ESC04.WE01", "ESC04.WE03"], ["ESC04.EN03", "ESC04.EN07"], ["ESC04.KW01", "ESC04.KW09", "ESC04.KW10"]))
record = data.get("session_record", {})
decision = data.get("team_decision", {})
record_rows = [[item.get("path", ""), item.get("purpose", "")] for item in record.get("files", [])]
decision_lines = [block_table(["Plik", "Po co go zbieramy"], record_rows, r"|p{5.8cm}|X|"), r"\textbf{Źródła osi czasu:}", block_itemize(record.get("timeline_sources", []))]
for question in decision.get("questions", []):
decision_lines.append(rf"\textbf{{{tex_inline(question)}}} \dotfill\par")
section_specs.append((record.get("title", "Rekord przebiegu zajęć"), "\n\n".join(decision_lines), ["ESC04.WE03"], ["ESC04.EN05", "ESC04.EN07"], ["ESC04.KW03", "ESC04.KW10"]))
section_specs.append((data.get("git_gate", {}).get("title", "Brama wyjścia"), block_gate_table(data.get("git_gate", {}).get("criteria", [])), ["ESC04.WE03", "ESC04.WE04"], ["ESC04.EN05", "ESC04.EN07"], ["ESC04.KW09", "ESC04.KW10"]))
ai = data.get("ai_rules", {})
ai_tex = "\n\n".join(
[
rf"Ślad pracy: {tex_mono(ai.get('log_path', ''))}",
r"\textbf{Wolno:}",
block_itemize(ai.get("allowed", [])),
r"\textbf{Zakaz:}",
block_itemize(ai.get("forbidden", [])),
r"\textbf{Uczeń sam odpowiada za:}",
block_itemize(ai.get("student_owns", [])),
]
)
section_specs.append(("Praca z agentem AI", ai_tex, ["ESC04.WE03"], ["ESC04.EN05", "ESC04.EN07"], ["ESC04.KW03", "ESC04.KW10"]))
for title, content, reqs, effects, criteria in section_specs:
sections.append(block_section(data, title, content, reqs, effects, criteria))
return sections
def prepare_data(data: dict) -> dict:
configure_dictionaries(data)
if "sections" not in data and data.get("objectives"):
data = dict(data)
data.setdefault("tasks", {})
data.setdefault("tasks_order", [])
data.setdefault(
"front_page_scope",
{
"title": data.get("mission_title", "Misja"),
"content_tex": tex_inline(data.get("mission", "")),
},
)
data.setdefault(
"educational_model",
{
"curriculum_domains": {
"FIZ": "fizyka",
"MAT": "matematyka",
"INF": "informatyka",
"ELE": "elektronika",
"AUT": "automatyka",
}
},
)
data["sections"] = build_block_card_sections(data)
return data
def render_bibliography_tex(data: dict) -> list[str]:
refs = data.get("references", [])
bibliography_path = data.get("generated", {}).get("bibliography")
if not refs or not bibliography_path:
return []
return [
r"\ESCSectionBlockStart",
r"\section{Bibliografia i źródła}",
rf"\nocite{{{','.join(refs)}}}",
r"\printbibliography[heading=none]",
r"\ESCSectionBlockEnd",
"",
]
def render_main_tex(data: dict) -> str:
lines: list[str] = [
"% Generated from json/card_source.json by tools/render_card.py.",
"% Do not edit manually; update the JSON and regenerate.",
"",
]
lines.extend(render_preamble(data))
lines.extend(render_learning_tree_macro(data))
lines.extend(
[
r"\begin{document}",
r"\sloppy",
"",
]
)
title_block = data.get("title_block") or {}
if not title_block or title_block.get("replace_front_matter", True) is not True:
lines.extend(render_front_matter(data["card"]))
lines.extend(render_front_page_scope(data))
if data.get("front_page_break", True) is not False:
lines.append(r"\clearpage")
lines.append("")
lines.extend(render_sections(data))
lines.extend(render_bibliography_tex(data))
lines.append(r"\end{document}")
lines.append("")
return "\n".join(lines)
def html_escape(value: object) -> str:
return html_lib.escape(str(value), quote=True)
def html_id(value: str) -> str:
return re.sub(r"[^a-zA-Z0-9_.:-]+", "-", value).strip("-")
def collect_numbered_labels(data: dict) -> tuple[dict[str, int], dict[str, int]]:
equation_numbers: dict[str, int] = {}
figure_numbers: dict[str, int] = {}
equation_no = 0
figure_no = 0
for section in data.get("sections", []):
content = section.get("content_tex", "")
for match in re.finditer(
r"\\begin\{(?P<kind>equation|figure)\}.*?\\label\{(?P<label>[^}]+)\}.*?\\end\{(?P=kind)\}",
content,
re.S,
):
if match.group("kind") == "equation":
equation_no += 1
equation_numbers[match.group("label")] = equation_no
else:
figure_no += 1
figure_numbers[match.group("label")] = figure_no
for asset in section.get("assets", []) or []:
label = str(asset.get("label", ""))
if label:
figure_no += 1
figure_numbers[label] = figure_no
return equation_numbers, figure_numbers
def html_ref(label: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int], parens: bool) -> str:
if label in equation_numbers:
text = f"({equation_numbers[label]})" if parens else str(equation_numbers[label])
elif label in figure_numbers:
text = str(figure_numbers[label])
else:
text = label
return rf'<a class="xref" href="#{html_escape(html_id(label))}">{html_escape(text)}</a>'
def latex_text_segment_to_html(segment: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str:
placeholders: list[str] = []
def keep(value: str) -> str:
token = f"\u0000{len(placeholders)}\u0000"
placeholders.append(value)
return token
def cleanup_latex_text(value: str) -> str:
replacements = {
r"\allowbreak{}": "",
r"\allowbreak": "",
r"\small": "",
r"\par": " ",
r"\,": " ",
r"\ ": " ",
r"\%": "%",
r"\_": "_",
r"\&": "&",
r"\#": "#",
r"\$": "$",
r"\{": "{",
r"\}": "}",
r"\vspace{1.2em}": "",
"--": "-",
}
for old, new in replacements.items():
value = value.replace(old, new)
return " ".join(value.split())
def html_text(value: str) -> str:
return html_escape(cleanup_latex_text(value))
def text_command_repl(tag: str) -> Callable[[re.Match[str]], str]:
def repl(match: re.Match[str]) -> str:
return keep(f"<{tag}>{html_text(match.group(1))}</{tag}>")
return repl
def texttt_repl(match: re.Match[str]) -> str:
return keep(f"<code>{html_text(match.group(1))}</code>")
def answer_lines_repl(match: re.Match[str]) -> str:
count = int(match.group(1) or "3")
count = max(1, min(count, 8))
return keep("".join('<span class="answer-line"></span>' for _ in range(count)))
segment = segment.replace(r"\allowbreak{}", "").replace(r"\allowbreak", "")
segment = re.sub(r"\\texttt\{\\small\s+([^{}]*)\}", texttt_repl, segment)
segment = re.sub(r"\\texttt\{([^{}]*)\}", texttt_repl, segment)
segment = re.sub(r"\\textbf\{([^{}]*)\}", text_command_repl("strong"), segment)
segment = re.sub(r"\\textit\{([^{}]*)\}", text_command_repl("em"), segment)
segment = re.sub(r"\\textcolor\{[^{}]+\}\{([^{}]*)\}", r"\1", segment)
segment = re.sub(r"\\cite\{([^}]+)\}", lambda m: keep(f'[{html_text(m.group(1))}]'), segment)
segment = re.sub(r"\\eqref\{([^}]+)\}", lambda m: keep(html_ref(m.group(1), equation_numbers, figure_numbers, True)), segment)
segment = re.sub(r"\\ref\{([^}]+)\}", lambda m: keep(html_ref(m.group(1), equation_numbers, figure_numbers, False)), segment)
segment = re.sub(r"\\EscAnswerLines(?:\[(\d+)\])?", answer_lines_repl, segment)
segment = re.sub(r"\\EscWriteRow(?:\[[^\]]+\])?", lambda m: keep('<span class="write-row"></span>'), segment)
segment = segment.replace(r"\EmptyCheck", keep('<span class="empty-check"></span>'))
segment = re.sub(r"\\begin\{equation\}(.*?)\\end\{equation\}", lambda m: keep(render_equation_html(m.group(1), equation_numbers)), segment, flags=re.S)
segment = segment.replace(r"\dotfill", keep('<span class="dotfill"></span>'))
replacements = {
"``": '"',
"''": '"',
"\n": " ",
}
for old, new in replacements.items():
segment = segment.replace(old, new)
escaped = html_text(segment)
# Polecenia mogą być zagnieżdżone, np. \textbf{...\texttt{...}}.
# Zewnętrzny placeholder powstaje po wewnętrznym, dlatego rozwijamy je
# w odwrotnej kolejności. W przeciwnym razie znaczniki NUL wyciekały do HTML.
for index in reversed(range(len(placeholders))):
value = placeholders[index]
escaped = escaped.replace(html_escape(f"\u0000{index}\u0000"), value)
return escaped
def latex_inline_to_html(text: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str:
parts: list[str] = []
cursor = 0
for match in re.finditer(r"\\\((.*?)\\\)", text, re.S):
parts.append(latex_text_segment_to_html(text[cursor : match.start()], equation_numbers, figure_numbers))
math = html_lib.escape(match.group(1), quote=False)
parts.append(rf'<span class="math">\( {math} \)</span>')
cursor = match.end()
parts.append(latex_text_segment_to_html(text[cursor:], equation_numbers, figure_numbers))
return "".join(parts)
def clean_display_math(value: str) -> tuple[str, str]:
label_match = re.search(r"\\label\{([^}]+)\}", value)
label = label_match.group(1) if label_match else ""
value = re.sub(r"\\label\{[^}]+\}", "", value).strip()
value = re.sub(r"\\textcolor\{[^{}]+\}\{([^{}]*)\}", r"\1", value)
return label, value
def render_equation_html(body: str, equation_numbers: dict[str, int]) -> str:
label, math = clean_display_math(body)
number = equation_numbers.get(label)
number_html = f'<span class="equation-number">({number})</span>' if number else ""
id_attr = f' id="{html_escape(html_id(label))}"' if label else ""
return (
f'<div class="equation"{id_attr}>'
f'<div class="math display">\\[{html_lib.escape(math, quote=False)}\\]</div>'
f"{number_html}</div>"
)
def html_figure_asset(graphic_path: str) -> str:
path = Path(graphic_path)
if path.suffix.lower() == ".pdf":
return f"figures/{path.stem}.png"
return f"figures/{path.name}"
def html_pdf_asset(graphic_path: str) -> str:
return f"figures/{Path(graphic_path).name}"
def resolve_web_asset_source(graphic_path: str) -> Path:
path = Path(graphic_path)
if path.is_absolute():
return path
candidates = [
CARD_ROOT / "doc" / path,
CARD_ROOT / path,
REPO_ROOT / path,
]
for candidate in candidates:
if candidate.exists():
return candidate.resolve()
return candidates[0].resolve()
def html_relative_asset_href(data: dict, graphic_path: str) -> str:
source = resolve_web_asset_source(graphic_path)
return os.path.relpath(source, html_output_dir(data)).replace("\\", "/")
def render_figure_html(body: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str:
graphic_match = re.search(r"\\includegraphics(?:\[[^\]]+\])?\{([^}]+)\}", body)
caption_match = re.search(r"\\caption\{(.*?)\}", body, re.S)
label_match = re.search(r"\\label\{([^}]+)\}", body)
graphic = graphic_match.group(1) if graphic_match else ""
caption = caption_match.group(1).strip() if caption_match else ""
label = label_match.group(1) if label_match else ""
number = figure_numbers.get(label)
id_attr = f' id="{html_escape(html_id(label))}"' if label else ""
caption_prefix = f"Rys. {number}. " if number else ""
image_html = ""
if graphic:
image_html = (
f'<img src="{html_escape(html_figure_asset(graphic))}" '
f'alt="{html_escape(caption or Path(graphic).stem)}" loading="lazy">'
)
return (
f'<figure{id_attr}>{image_html}'
f'<figcaption>{html_escape(caption_prefix)}{latex_inline_to_html(caption, equation_numbers, figure_numbers)}</figcaption>'
f"</figure>"
)
def render_section_assets_html(section: dict, figure_numbers: dict[str, int]) -> str:
figures: list[str] = []
for asset in section.get("assets", []) or []:
path = str(asset.get("path", ""))
label = str(asset.get("label", ""))
caption = str(asset.get("caption", ""))
alt = str(asset.get("alt") or caption)
kind = str(asset.get("kind") or "screenshot")
width = float(asset.get("width", 1.0))
href = html_figure_asset(path)
image = (
f'<img src="{html_escape(href)}" alt="{html_escape(alt)}" '
f'loading="lazy" style="width:{width * 100:.3g}%">'
)
if asset.get("full_size_link", True):
image = f'<a href="{html_escape(href)}" target="_blank" rel="noopener">{image}</a>'
number = figure_numbers.get(label)
prefix = f"Rys. {number}. " if number else ""
figures.append(
f'<figure class="card-asset asset-{html_escape(kind)}" id="{html_escape(html_id(label))}">'
f'{image}<figcaption>{html_escape(prefix + caption)}</figcaption></figure>'
)
return "".join(figures)
def split_latex_rows(body: str) -> list[str]:
body = re.sub(r"\\hline", "", body)
return [row.strip() for row in re.split(r"\\\\", body) if row.strip()]
def render_tabular_html(body: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str:
rows = split_latex_rows(body)
html_rows: list[str] = []
for row_index, row in enumerate(rows):
cells = [cell.strip() for cell in row.split("&")]
tag = "th" if row_index == 0 else "td"
rendered_cells = "".join(
f"<{tag}>{latex_inline_to_html(cell, equation_numbers, figure_numbers)}</{tag}>"
for cell in cells
)
html_rows.append(f"<tr>{rendered_cells}</tr>")
return f'<div class="table-wrap"><table>{"".join(html_rows)}</table></div>'
def render_itemize_html(body: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str:
items = [item.strip().rstrip(",") for item in re.split(r"\\item", body) if item.strip()]
return "<ul>" + "".join(f"<li>{latex_inline_to_html(item, equation_numbers, figure_numbers)}</li>" for item in items) + "</ul>"
def render_enumerate_html(body: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str:
items = [item.strip().rstrip(",") for item in re.split(r"\\item", body) if item.strip()]
return "<ol>" + "".join(f"<li>{latex_inline_to_html(item, equation_numbers, figure_numbers)}</li>" for item in items) + "</ol>"
def render_paragraphs_html(text: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> list[str]:
paragraphs = [paragraph.strip() for paragraph in re.split(r"\n\s*\n", text) if paragraph.strip()]
return [f"<p>{latex_inline_to_html(paragraph, equation_numbers, figure_numbers)}</p>" for paragraph in paragraphs]
def render_latex_fragment_html(text: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str:
patterns = {
"equation": r"\\begin\{equation\}(.*?)\\end\{equation\}",
"figure": r"\\begin\{figure\}\[H\](.*?)\\end\{figure\}",
"tabularx": r"\\begin\{tabularx\}.*?\n(.*?)\\end\{tabularx\}",
"itemize": r"\\begin\{itemize\}(?:\[[^\]]+\])?(.*?)\\end\{itemize\}",
"enumerate": r"\\begin\{enumerate\}(?:\[[^\]]+\])?(.*?)\\end\{enumerate\}",
"verbatim": r"\\begin\{verbatim\}(.*?)\\end\{verbatim\}",
}
combined = re.compile("|".join(f"(?P<{name}>{pattern})" for name, pattern in patterns.items()), re.S)
parts: list[str] = []
cursor = 0
for match in combined.finditer(text):
parts.extend(render_paragraphs_html(text[cursor : match.start()], equation_numbers, figure_numbers))
kind = match.lastgroup or ""
body = match.group(kind)
if kind == "equation":
body = re.match(patterns["equation"], body, re.S).group(1) # type: ignore[union-attr]
parts.append(render_equation_html(body, equation_numbers))
elif kind == "figure":
body = re.match(patterns["figure"], body, re.S).group(1) # type: ignore[union-attr]
parts.append(render_figure_html(body, equation_numbers, figure_numbers))
elif kind == "tabularx":
body = re.match(patterns["tabularx"], body, re.S).group(1) # type: ignore[union-attr]
parts.append(render_tabular_html(body, equation_numbers, figure_numbers))
elif kind == "itemize":
body = re.match(patterns["itemize"], body, re.S).group(1) # type: ignore[union-attr]
parts.append(render_itemize_html(body, equation_numbers, figure_numbers))
elif kind == "enumerate":
body = re.match(patterns["enumerate"], body, re.S).group(1) # type: ignore[union-attr]
parts.append(render_enumerate_html(body, equation_numbers, figure_numbers))
elif kind == "verbatim":
body = re.match(patterns["verbatim"], body, re.S).group(1) # type: ignore[union-attr]
parts.append(f"<pre><code>{html_escape(body.strip())}</code></pre>")
cursor = match.end()
parts.extend(render_paragraphs_html(text[cursor:], equation_numbers, figure_numbers))
return "\n".join(parts)
def tree_html_data(data: dict) -> dict[str, dict]:
tree_index, _ = build_learning_tree_index(data)
payload: dict[str, dict] = {}
for tree_ref, tree in tree_index.items():
requirement_ref = tree.get("requirement_ref", "")
requirement = tree.get("requirement", {})
item = tree.get("item", {})
payload[tree_ref] = {
"tree_ref": tree_ref,
"line": "OG" if tree.get("column") == "ogolne" else "TECH",
"we": f"WE {' '.join(compact_ref_numbers([requirement_ref]))}",
"we_label": requirement.get("label", ""),
"we_text": requirement.get("text", ""),
"effect": item.get("display", ""),
"effect_text": item.get("text", ""),
"kw": [
{"display": criterion.get("display", ""), "text": criterion.get("text", "")}
for criterion in item.get("kw", []) or []
],
}
return payload
def render_tree_button(alias: str, tree_ref: str, tree_index: dict[str, dict]) -> str:
tree = tree_index.get(tree_ref, {})
item = tree.get("item", {})
title = " | ".join(part for part in (tree_ref, item.get("display", ""), item.get("text", "")) if part)
return (
f'<button class="tree-token" type="button" data-tree="{html_escape(tree_ref)}" '
f'title="{html_escape(title)}">{html_escape(alias)}</button>'
)
def render_external_reference_html(data: dict, reference: dict) -> str:
entry = resolve_external_reference(data, reference)
label = external_reference_label(reference, entry)
kind = str(reference.get("kind", ""))
title = str(entry.get("title", label))
registry_uuid = str(reference.get("registry_uuid", ""))
entry_uuid = str(reference.get("uuid", ""))
visible = f"<code>{html_escape(label)}</code>" if kind == "strategy" else html_escape(label)
url = str(entry.get("url", ""))
if not url:
return visible
return (
f'<a class="external-reference" href="{html_escape(url)}" '
f'data-reference-kind="{html_escape(kind)}" '
f'data-registry-uuid="{html_escape(registry_uuid)}" '
f'data-reference-uuid="{html_escape(entry_uuid)}" '
f'title="{html_escape(title)}" target="_blank" rel="noopener">{visible}</a>'
)
def render_step_map_html(section: dict, data: dict) -> str:
if section.get("render_step_map") is False:
return ""
steps = section.get("steps", []) or []
if not steps:
return ""
tree_index, _ = build_learning_tree_index(data)
tree_refs = section_tree_refs(section)
aliases = {tree_ref: f"D{index + 1}" for index, tree_ref in enumerate(tree_refs)}
area = " ".join(section.get("area_refs", []) or [])
legend = " ".join(render_tree_button(aliases[tree_ref], tree_ref, tree_index) for tree_ref in tree_refs)
rows = []
for index, step in enumerate(steps, start=1):
tokens = " ".join(render_tree_button(aliases.get(tree_ref, "?"), tree_ref, tree_index) for tree_ref in step.get("tree_refs", []) or [])
refs = []
refs.extend(f'<a href="#{html_escape(html_id(label))}">{html_escape(label)}</a>' for label in step.get("equation_refs", []) or [])
refs.extend(f'<a href="#{html_escape(html_id(label))}">{html_escape(label)}</a>' for label in step.get("figure_refs", []) or [])
for kind, references in grouped_external_references(step.get("references", []) or []):
refs.append(f"<span>{html_escape(REFERENCE_KIND_LABELS[kind])}:</span>")
refs.extend(render_external_reference_html(data, reference) for reference in references)
refs_html = f'<div class="step-refs">{" ".join(refs)}</div>' if refs else ""
rows.append(
f'<div class="step-row"><div class="step-title"><span>K{index}</span> '
f'{html_escape(step.get("title", ""))}</div><div class="step-trees">{tokens}</div>{refs_html}</div>'
)
return (
f'<details class="step-map" open><summary>Drzewka obszaru {html_escape(area)}</summary>'
f'<div class="tree-legend">{legend}</div><div class="steps">{"".join(rows)}</div></details>'
)
def margin_color_class(color: str) -> str:
if color == "red":
return "we"
if "green" in color:
return "og"
if "orange" in color:
return "tech"
if "blue" in color:
return "kw"
return "muted"
def render_margin_tag_html(tag: tuple[str, str, str, str, str, str, str]) -> str:
indent, color, prefix, official, local, official_tip, local_tip = tag
visible = " ".join(part for part in (prefix, official) if part)
local_suffix = f".{local}" if local else ""
tooltip = " | ".join(part for part in (official_tip, local_tip) if part)
return (
f'<button class="margin-tag {margin_color_class(color)}" style="--indent:{html_escape(indent)}" '
f'type="button" data-margin-display="{html_escape(visible + local_suffix)}" '
f'data-margin-tip="{html_escape(tooltip)}" title="{html_escape(tooltip)}">'
f'<span>{html_escape(visible)}</span><span class="local">{html_escape(local_suffix)}</span></button>'
)
def render_margin_column_html(tags: list[tuple[str, str, str, str, str, str, str]], title: str) -> str:
body = "".join(render_margin_tag_html(tag) for tag in tags) if tags else '<span class="margin-empty">-</span>'
return f'<div class="margin-title">{html_escape(title)}</div><div class="margin-list">{body}</div>'
def render_dual_margin_html(
general_tags: list[tuple[str, str, str, str, str, str, str]],
vocational_tags: list[tuple[str, str, str, str, str, str, str]],
data: dict | None = None,
) -> tuple[str, str]:
layout = margin_layout(data)
sides = {"left": "", "right": ""}
columns = {
"ogolne": general_tags,
"zawodowe": vocational_tags,
}
for column_id, tags in columns.items():
column = layout[column_id]
sides[column["side"]] += render_margin_column_html(tags, column["label"])
return sides["left"], sides["right"]
def render_section_margins_html(section: dict, data: dict) -> tuple[str, str]:
return render_dual_margin_html(
section_margin_tags(section, data, column="ogolne", effect_color="green!50!black"),
section_margin_tags(section, data, column="zawodowe", effect_color="orange!85!black"),
data=data,
)
def render_running_header_html(card: dict) -> str:
return (
f'<div class="running-header"><strong>{html_escape(card_prefix(card))}: '
f'{html_escape(card["title"])}</strong><span>{html_escape(card["author"])}, '
f'{html_escape(card["year"])}</span></div>'
)
def render_task_flow_html(
task_ref: str,
task: dict,
equation_numbers: dict[str, int],
figure_numbers: dict[str, int],
) -> str:
title = task.get("title") or task_ref
uuid_html = f'<code>{html_escape(task["uuid"])}</code>' if task.get("uuid") else ""
flow: list[str] = []
for item in task.get("flow", []) or []:
item_id = f'{task_ref}-{item.get("id", "item")}'
if item.get("kind") == "block":
content = render_latex_fragment_html(
item.get("content_tex", ""), equation_numbers, figure_numbers
)
steps = []
for step in item.get("steps", []) or []:
step_content = render_latex_fragment_html(
step.get("content_tex", ""), equation_numbers, figure_numbers
)
steps.append(
f'<li id="{html_escape(html_id(task_ref + "-" + step.get("id", "step")))}">'
f'<strong>{html_escape(step.get("title", ""))}</strong>{step_content}</li>'
)
steps_html = f'<ol class="block-steps">{"".join(steps)}</ol>' if steps else ""
flow.append(
f'<section class="task-block" id="{html_escape(html_id(item_id))}">'
f'<header><span>Block</span><h4>{html_escape(item.get("title", ""))}</h4></header>'
f'{content}{steps_html}</section>'
)
elif item.get("kind") == "exercise":
prompt = render_latex_fragment_html(
item.get("prompt_tex", ""), equation_numbers, figure_numbers
)
evidence = render_latex_fragment_html(
item.get("evidence_tex", ""), equation_numbers, figure_numbers
)
evidence_html = f'<div class="exercise-evidence"><strong>Evidence</strong>{evidence}</div>' if evidence else ""
refs = " ".join(item.get("based_on", []) or [])
refs_html = f'<code class="exercise-based-on">based on: {html_escape(refs)}</code>' if refs else ""
flow.append(
f'<section class="task-exercise" id="{html_escape(html_id(item_id))}">'
f'<header><span>Exercise</span><h4>{html_escape(item.get("title", ""))}</h4>{refs_html}</header>'
f'{prompt}{evidence_html}<p class="exercise-criterion"><strong>Acceptance:</strong> '
f'{html_escape(item.get("criterion", ""))}</p></section>'
)
return (
f'<article class="task-flow" id="{html_escape(html_id(task_ref))}">'
f'<header class="task-flow-header"><span>{html_escape(task_ref.upper())}</span>'
f'<h3>{html_escape(title)}</h3>{uuid_html}</header>{"".join(flow)}'
f'<footer><strong>Task acceptance:</strong> {html_escape(task.get("criterion", ""))}</footer>'
'</article>'
)
def render_tasks_html(section: dict, data: dict, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str:
items = []
for task_ref in section.get("task_refs", []) or []:
task = data["tasks"][task_ref]
if task.get("flow"):
items.append(render_task_flow_html(task_ref, task, equation_numbers, figure_numbers))
else:
items.append(
f'<article class="task-legacy" id="{html_escape(html_id(task_ref))}">'
f'<strong>{html_escape(task_ref.upper())}</strong>'
f'{latex_inline_to_html(task.get("prompt_tex", ""), equation_numbers, figure_numbers)}</article>'
)
return f'<div class="tasks">{"".join(items)}</div>'
def render_learning_tree_overview_html(data: dict) -> str:
requirements = data.get("educational_requirements", {})
chunks: list[str] = []
for requirement_ref, requirement in requirements.items():
body: list[str] = []
for column, title in (("ogolne", "OG"), ("zawodowe", "TECH")):
items = requirement.get("learning_tree", {}).get(column, []) or []
if not items:
continue
body.append(f"<h4>{title}</h4>")
for item in items:
kw = "".join(
f'<li><code>{html_escape(criterion.get("display", ""))}</code> {html_escape(criterion.get("text", ""))}</li>'
for criterion in item.get("kw", []) or []
)
body.append(
f'<details><summary><code>{html_escape(item.get("display", ""))}</code> '
f'{html_escape(item.get("text", ""))}</summary><ul>{kw}</ul></details>'
)
chunks.append(
f'<details class="we-block"><summary><strong>WE {" ".join(compact_ref_numbers([requirement_ref]))}</strong> '
f'{html_escape(requirement.get("label", ""))}</summary><p>{html_escape(requirement.get("text", ""))}</p>'
f'{"".join(body)}</details>'
)
return "".join(chunks)
def bibliography_entries(data: dict) -> dict[str, dict[str, str]]:
bibliography_path = data.get("generated", {}).get("bibliography")
if not bibliography_path:
return {}
bib_path = CARD_ROOT / bibliography_path
if not bib_path.exists():
return {}
content = bib_path.read_text(encoding="utf-8")
entries: dict[str, dict[str, str]] = {}
for match in re.finditer(r"@\w+\{([^,]+),(.*?)(?=\n@\w+\{|\Z)", content, re.S):
key = match.group(1).strip()
body = match.group(2)
fields: dict[str, str] = {}
for field in ("title", "author", "organization", "year", "url", "note"):
field_match = re.search(rf"\b{field}\s*=\s*\{{(.*?)\}}\s*,?", body, re.S)
if field_match:
fields[field] = " ".join(field_match.group(1).replace("{{", "{").replace("}}", "}").split())
entries[key] = fields
return entries
def render_bibliography_section_html(data: dict) -> str:
refs = data.get("references", [])
if not refs:
return ""
entries = bibliography_entries(data)
items = []
for key in refs:
entry = entries.get(key, {})
title = entry.get("title", key)
year = f" ({entry['year']})" if entry.get("year") else ""
note = f"{html_escape(entry['note'])}" if entry.get("note") else ""
url = entry.get("url", "")
title_html = f'<a href="{html_escape(url)}">{html_escape(title)}</a>' if url else html_escape(title)
items.append(f'<li id="bib-{html_escape(html_id(key))}"><code>{html_escape(key)}</code> {title_html}{html_escape(year)}{note}</li>')
return f'<ol>{"".join(items)}</ol>'
def render_main_html(data: dict) -> str:
equation_numbers, figure_numbers = collect_numbered_labels(data)
card = data["card"]
use_resource_header = bool(data.get("title_block"))
show_tree_inspector = data.get("generated", {}).get("html_tree_inspector", True)
tree_data = (
json.dumps(tree_html_data(data), ensure_ascii=False).replace("</", "<\\/")
if show_tree_inspector
else "{}"
)
toc = "".join(
f'<a href="#{html_escape(html_id("section-" + str(index)))}">{index}. {html_escape(section.get("title", ""))}</a>'
for index, section in enumerate(data.get("sections", []), start=1)
)
bibliography_html = render_bibliography_section_html(data)
total_pages = 2 + len(data.get("sections", [])) + (1 if bibliography_html else 0)
rows = [
("Projekt", card["project"]),
("Seria", card.get("series_title", card["series"])),
("Karta", card["title"]),
("Przedmiot", card["subject"]),
("Poziom", card["level"]),
("Data aktualizacji", card["revision_date"]),
("Status", card["status"]),
("Numer karty", f"{card['number']}/{card['count']}"),
("Wersja karty", card["version"]),
("UUID", card["uuid"]),
]
metadata = "".join(f"<dt>{html_escape(label)}</dt><dd>{html_escape(value)}</dd>" for label, value in rows)
front_left, front_right = render_dual_margin_html(
learning_tree_tags(data, column="ogolne", effect_color="green!50!black"),
learning_tree_tags(data, column="zawodowe", effect_color="orange!85!black"),
data=data,
)
sections_html: list[str] = []
for index, section in enumerate(data.get("sections", []), start=1):
page_number = index + 1
content = (
render_tasks_html(section, data, equation_numbers, figure_numbers)
if section.get("content_kind") == "tasks"
else render_latex_fragment_html(section.get("content_tex", ""), equation_numbers, figure_numbers)
)
content += render_section_assets_html(section, figure_numbers)
left_margin, right_margin = render_section_margins_html(section, data)
task_identity = section.get("task_identity", {}) or {}
identity_html = ""
if task_identity:
identity_html = (
'<span class="task-identity">'
f'<strong>{html_escape(task_identity.get("label", ""))}</strong>'
f'<span class="task-identity-name">{html_escape(task_identity.get("name", ""))}</span>'
f'<code>{html_escape(task_identity.get("uuid", ""))}</code>'
"</span>"
)
heading = (
f'<h2 class="section-heading"><span class="section-index">{index}</span>'
f'<span class="section-title">{html_escape(section.get("title", ""))}</span>{identity_html}</h2>'
)
sections_html.append(
f'<article class="sheet section-sheet" id="{html_escape(html_id("section-" + str(index)))}">'
f'{render_page_header_html(data, page_number, total_pages) if use_resource_header else ""}'
f'<aside class="pdf-margin left">{left_margin}</aside>'
f'<section class="pdf-main card-section">{render_running_header_html(card) if not use_resource_header else ""}'
f'{heading}'
f'{render_step_map_html(section, data)}{content}</section>'
f'<aside class="pdf-margin right">{right_margin}</aside></article>'
)
if bibliography_html:
bibliography_page = len(data.get("sections", [])) + 2
sections_html.append(
f'<article class="sheet section-sheet" id="bibliografia-i-zrodla">'
f'{render_page_header_html(data, bibliography_page, total_pages) if use_resource_header else ""}'
f'<aside class="pdf-margin left"></aside>'
f'<section class="pdf-main card-section">{render_running_header_html(card) if not use_resource_header else ""}'
f'<h2>Bibliografia i źródła</h2>{bibliography_html}</section>'
f'<aside class="pdf-margin right"></aside></article>'
)
scope = data.get("front_page_scope", {})
scope_html = (
f'<section class="front-scope"><h2>{html_escape(scope.get("title", "Zakres i cel opracowania"))}</h2>'
f'{render_latex_fragment_html(scope.get("content_tex", ""), equation_numbers, figure_numbers)}</section>'
if scope
else ""
)
front_intro_html = (
scope_html
if use_resource_header
else (
f'<header class="hero"><h1>{html_escape(card_prefix(card))}: {html_escape(card["title"])}</h1>'
f'<div class="byline">{html_escape(card["author"])}, {html_escape(card["year"])}</div>'
f'<dl class="meta">{metadata}</dl></header>{scope_html}'
)
)
dictionary_page = total_pages
css = r"""
:root{--ink:#111;--muted:#666;--line:#b9b9b9;--line-soft:#dddddd;--paper:#fff;--desk:#eeeeee;--og:#008000;--tech:#d36b00;--kw:#0018c8;--we:#e00000}
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--desk);color:var(--ink);font-family:Georgia,"Times New Roman",serif;line-height:1.42}
.topbar{position:sticky;top:0;z-index:20;background:rgba(245,245,245,.94);border-bottom:1px solid #d0d0d0;padding:8px 14px;font-family:system-ui,-apple-system,Segoe UI,sans-serif}
.topbar details{max-width:1120px;margin:0 auto}.topbar summary{cursor:pointer;font-weight:650}.toc-links{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.toc-links a{font-size:13px;color:#333;text-decoration:none;border:1px solid #ccc;background:white;border-radius:4px;padding:3px 7px}
.paper{padding:18px 12px 44px;overflow-x:auto}.sheet{width:210mm;min-height:297mm;margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:grid;grid-template-columns:22mm minmax(0,1fr) 22mm;column-gap:4mm;align-content:start;padding:13mm 21.5mm 11mm}.resource-header-enabled .sheet{padding-top:9mm;row-gap:6mm}
.pdf-main{min-width:0}.running-header{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;border-bottom:1px solid #111;padding-bottom:3px;margin-bottom:26px;font-size:15px}.running-header span{white-space:nowrap}
.hero h1{font-size:24px;line-height:1.15;margin:0 0 4px;border-bottom:1px solid #111;padding-bottom:4px}.hero .byline{float:right;margin-top:-31px;font-size:15px}
.meta{clear:both;display:grid;grid-template-columns:148px minmax(0,1fr);gap:4px 18px;padding:34px 0 24px;border-bottom:1px solid #111;font-size:17px}.meta dt{color:#333}.meta dd{margin:0;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}
.front-scope{padding:26px 0 10px;border-bottom:1px solid var(--line-soft)}.front-scope h2{font-size:28px;margin:0 0 12px}.front-scope p{font-size:18px}
.card-section h2{font-size:28px;line-height:1.2;margin:0 0 18px}.section-heading{display:flex;align-items:flex-start;gap:0}.section-heading .section-index{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:26px;margin-right:20px;font-weight:400}.section-heading .section-title{min-width:0}.task-identity{margin-left:auto;padding-left:22px;text-align:right;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;line-height:1.25;color:#555;white-space:nowrap}.task-identity strong,.task-identity-name,.task-identity code{display:block}.task-identity strong{font-size:12px;color:#111}.task-identity-name{max-width:240px;overflow:hidden;text-overflow:ellipsis}.task-identity code{font-size:9px;color:#777}
p{font-size:17px;margin:0 0 12px}.pdf-main ul{font-size:17px;margin:8px 0 14px 24px;padding:0}.pdf-main li{margin:5px 0}
.pdf-margin{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:7px;line-height:1.14;color:#333;align-self:start;position:sticky;top:48px;max-height:calc(100vh - 80px);overflow:hidden}.pdf-margin.left{text-align:left}.pdf-margin.right{text-align:left}
.margin-title{font-weight:700;color:#666;margin-bottom:3px;letter-spacing:.02em}.margin-list{display:flex;flex-direction:column;align-items:flex-start}.margin-tag{display:block;margin:0 0 1px;padding:0;border:0;background:transparent;font:inherit;line-height:inherit;text-align:left;cursor:pointer;margin-left:var(--indent)}.margin-tag.we{color:var(--we)}.margin-tag.og,.margin-tag.tech{color:var(--og)}.margin-tag.kw{color:var(--kw)}.margin-tag .local{color:#777}.margin-tag:hover{text-decoration:underline}.margin-empty{color:#aaa}
.step-map{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.3;color:#5c5c5c;background:transparent;border:0;border-top:1px solid #cfcfcf;border-bottom:1px solid #e0e0e0;border-radius:0;padding:8px 0;margin:0 0 16px}.step-map summary{cursor:pointer;font-weight:400;color:#555}
.tree-legend{display:flex;flex-wrap:wrap;gap:4px;margin:7px 0}.steps{display:grid;gap:6px}.step-row{border-top:1px dashed #c9c9c9;padding-top:6px}.step-row:first-child{border-top:0}.step-title span{font-weight:700;margin-right:7px}.step-trees{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}.step-refs{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}.step-refs a{font-size:10px;color:#666}
.tree-token{font:10px/1 "Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #c8c8c8;background:#fff;border-radius:3px;padding:3px 5px;cursor:pointer}.tree-token.active{background:#111;color:white;border-color:#111}
.equation{display:grid;grid-template-columns:1fr auto;align-items:center;gap:14px;margin:18px 0;padding:4px 0;background:transparent;border:0}.equation .display{font-size:17px}.equation-number{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:15px}
figure{margin:24px 0;text-align:center}figure img{max-width:100%;display:block;margin:0 auto;border:0;border-radius:0}figcaption{font-size:14px;color:#444;margin-top:8px;text-align:left}.card-asset.asset-screenshot img{border:1px solid #aaa}.card-asset a{display:block;cursor:zoom-in}
.table-wrap{overflow:auto;margin:14px 0}table{border-collapse:collapse;width:100%;font-size:16px}th,td{border:1px solid #111;padding:5px 8px;text-align:left;vertical-align:top}th{font-weight:400;background:white}
.empty-check{display:inline-block;width:1em;height:1em;border:1px solid #111;vertical-align:-.12em}.answer-line,.dotfill{display:block;border-bottom:1px dotted #111;height:1.45em;min-width:10em}.write-row{display:block;min-height:2.8em}
code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:17px}.task-legacy{margin:10px 0}.task-legacy>strong{display:block;font:700 11px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #aaa;margin:8px 0 18px}.task-flow-header{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:10px;padding:8px 11px;background:#f3f3f3;border-bottom:1px solid #aaa}.task-flow-header span{font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow-header h3{font-size:18px;margin:0}.task-flow-header code{font-size:8px;color:#777}.task-block,.task-exercise{margin:10px 12px;padding:0 0 10px;border-bottom:1px solid #ddd}.task-block header,.task-exercise header{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.task-block header span,.task-exercise header span{font:700 9px/1.2 "Latin Modern Mono",ui-monospace,monospace;text-transform:uppercase;color:#666}.task-block h4,.task-exercise h4{font-size:16px;margin:0}.block-steps{counter-reset:block-step;margin:5px 0 0 24px!important}.block-steps li{margin:7px 0}.block-steps li::marker{font-family:"Latin Modern Mono",ui-monospace,monospace}.task-exercise{border:1px solid #777;border-left:4px solid #111;padding:9px 11px;background:#fafafa}.task-exercise header{flex-wrap:wrap}.exercise-based-on{margin-left:auto;font-size:8px;color:#777}.exercise-evidence{border-top:1px dashed #bbb;margin-top:8px;padding-top:6px}.exercise-evidence>strong{font-size:11px;text-transform:uppercase}.exercise-criterion{font-size:13px;margin:7px 0 0}.task-flow>footer{padding:8px 11px;border-top:1px solid #aaa;font-size:12px}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.dictionary-sheet .pdf-main{grid-column:2}.dictionary-sheet .pdf-margin{display:none}
.inspector{position:fixed;right:14px;bottom:14px;width:min(360px,calc(100vw - 28px));max-height:46vh;overflow:auto;background:white;border:1px solid #cfcfcf;box-shadow:0 4px 18px rgba(0,0,0,.18);border-radius:6px;padding:12px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:13px;z-index:30}.inspector h2{font-size:14px;margin:0 0 8px}.inspector .empty{color:#777}.tree-card h3{font-size:15px;margin:6px 0}.tree-card code{display:inline-block;margin:4px 0}.tree-card .line{font-weight:700}.tree-card .line.OG{color:var(--og)}.tree-card .line.TECH{color:var(--tech)}.kw-list{padding-left:18px}.kw-list code{color:var(--kw)}
@media(max-width:900px){.sheet{display:block;min-height:0;padding:26px 18px}.pdf-margin{position:static;max-height:none;overflow:visible;margin:10px 0;padding:8px;border:1px solid #ddd}.pdf-margin.left{border-left:3px solid var(--tech)}.pdf-margin.right{border-left:3px solid var(--og)}.hero .byline{float:none;margin:0 0 14px}.meta{grid-template-columns:1fr}.section-heading{flex-wrap:wrap}.task-identity{width:100%;padding:6px 0 0}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:static}}
@media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{display:grid;grid-template-columns:22mm minmax(0,1fr) 22mm;column-gap:4mm;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;row-gap:6mm}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet{padding:0;row-gap:6mm}.sheet>.page-resource-header{display:none!important}.pdf-margin{position:static;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.task-block,.task-exercise,figure{break-inside:avoid}}
"""
css += page_resource_header_css()
if use_resource_header:
css += page_resource_print_css(data)
js = r"""
const treeData = JSON.parse(document.getElementById('tree-data').textContent);
const inspector = document.getElementById('tree-inspector');
function esc(value){
return String(value ?? '').replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
}
function renderTree(ref){
const item = treeData[ref];
document.querySelectorAll('.tree-token').forEach(btn => btn.classList.toggle('active', btn.dataset.tree === ref));
if(!item){ inspector.innerHTML = '<p class="empty">Brak danych drzewka.</p>'; return; }
const line = item.line === 'TECH' ? 'TECH' : 'OG';
inspector.innerHTML = `<div class="tree-card">
<div class="line ${line}">${esc(line)}</div>
<h3>${esc(item.we)}: ${esc(item.we_label)}</h3>
<p>${esc(item.we_text)}</p>
<p><code>${esc(item.effect)}</code><br>${esc(item.effect_text)}</p>
<h4>KW</h4>
<ul class="kw-list">${item.kw.map(k => `<li><code>${esc(k.display)}</code> ${esc(k.text)}</li>`).join('')}</ul>
<p><small>${esc(item.tree_ref)}</small></p>
</div>`;
}
document.addEventListener('click', event => {
const button = event.target.closest('.tree-token');
if(button){ renderTree(button.dataset.tree); return; }
const margin = event.target.closest('.margin-tag');
if(margin){
document.querySelectorAll('.tree-token').forEach(btn => btn.classList.remove('active'));
const display = esc(margin.dataset.marginDisplay || '');
const tip = esc(margin.dataset.marginTip || 'Brak opisu.');
inspector.innerHTML = `<div class="tree-card"><h3>${display}</h3><p>${tip}</p></div>`;
}
});
"""
inspector_html = (
'<aside class="inspector"><h2>Opis kodu</h2><div id="tree-inspector">'
'<p class="empty">Kliknij kod z marginesu albo D1, D2 itd., żeby zobaczyć opis.</p>'
"</div></aside>"
if show_tree_inspector
else ""
)
if not show_tree_inspector:
js = ""
return f"""<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html_escape(card_prefix(card))}: {html_escape(card['title'])}</title>
<style>{css}</style>
<script>
window.MathJax = {{tex: {{inlineMath: [['\\\\(','\\\\)']], displayMath: [['\\\\[','\\\\]']]}}, svg: {{fontCache: 'global'}}}};
</script>
<script defer src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
</head>
<body class="{'resource-header-enabled' if use_resource_header else ''}">
<script type="application/json" id="tree-data">{tree_data}</script>
<nav class="topbar"><details><summary>Spis treści</summary><div class="toc-links">{toc}</div></details></nav>
<main class="paper">
<article class="sheet front-sheet">
{render_page_header_html(data, 1, total_pages) if use_resource_header else ''}
<aside class="pdf-margin left">{front_left}</aside>
<section class="pdf-main">
{front_intro_html}
</section>
<aside class="pdf-margin right">{front_right}</aside>
</article>
{''.join(sections_html)}
<article class="sheet dictionary-sheet">{render_page_header_html(data, dictionary_page, total_pages) if use_resource_header else ''}<aside class="pdf-margin left"></aside><section class="pdf-main card-section">{render_running_header_html(card) if not use_resource_header else ''}<h2>Słownik WE/EN/EK/KW</h2>{render_learning_tree_overview_html(data)}</section><aside class="pdf-margin right"></aside></article>
</main>
{inspector_html}
<script>{js}</script>
</body>
</html>
"""
def text_5a(value: object) -> str:
text = str(value)
text = text.replace("<->", "")
text = text.replace("->", "")
text = text.replace("--", "")
return " ".join(text.split())
def agenda_time_5a(value: object) -> str:
return text_5a(value).replace(" min", "")
def card_title_line_5a(card: dict) -> str:
title = str(card.get("title", "")).strip()
prefix = card_prefix(card)
if title.startswith(f"{prefix}:") or title.startswith(f"{prefix} ·"):
return title
return f"{prefix} · {title}" if title else prefix
def card_prefix(card: dict) -> str:
series = str(card.get("series", "card")).strip().upper() or "CARD"
number = str(card.get("number", "")).strip()
return f"{series}-{number}" if number else series
def publication_url(data: dict) -> str:
"""Return the canonical publication URL used by the title block and QR."""
title_block = data.get("title_block") or {}
card = data.get("card") or {}
for value in (
title_block.get("url"),
data.get("publication_url"),
card.get("publication_url"),
card.get("url"),
):
if value:
return str(value).strip()
host = title_block.get("url_host_uuid") or data.get("url_host_uuid")
port = title_block.get("url_port") or data.get("url_port")
document = title_block.get("doc_uuid") or data.get("doc_uuid") or card.get("uuid")
if host and document:
authority = str(host).strip()
if port:
authority += f":{str(port).strip()}"
return f"https://{authority}/{str(document).strip()}"
return ""
def repository_url(data: dict) -> str:
"""Return the optional source-repository URL used by the second QR cell."""
title_block = data.get("title_block") or {}
card = data.get("card") or {}
for value in (
title_block.get("repository_url"),
data.get("repository_url"),
card.get("repository_url"),
):
if value:
return str(value).strip()
return ""
def title_block_fields(data: dict) -> dict[str, str | bool]:
"""Resolve the ISO-7200-like header while keeping card metadata canonical."""
card = data["card"]
configured = data.get("title_block") or {}
url = publication_url(data)
source_url = repository_url(data)
def value(name: str, default: object = "") -> str:
current = configured.get(name, default)
return str(current).strip() if current is not None else ""
revision_date = str(card.get("revision_date", "")).strip()
return {
"category": value("category", f"KARTA PRACY · {card.get('subject', '')}").upper(),
"standard": value("standard", "ISO 7200"),
"prepared_by": value("prepared_by", card.get("author", "")) or "",
"prepared_on": value("prepared_on", revision_date),
"checked_by": value("checked_by", "") or "",
"approved_by": value("approved_by", "") or "",
"title": value("title", card.get("title", "")),
"url": url,
"repository_url": source_url,
"revision": value("revision", card.get("version", "")),
"issued_on": value("issued_on", revision_date),
"series": value("series", card_prefix(card)),
"document_type": value("document_type", "karta pracy"),
"tool": value("tool", "card-layouts"),
"sheet": value("sheet", "1 / 1"),
"show_qr": bool(configured.get("show_qr", bool(url))),
"show_repository_qr": bool(
configured.get("show_repository_qr", bool(source_url))
),
}
@lru_cache(maxsize=32)
def qr_svg(value: str) -> str:
"""Generate a self-contained QR SVG without a network dependency.
ReportLab ships a standards-compliant QR encoder on the supported
workstation image. The TeX renderer uses its native qrcode package;
HTML receives the same payload as inline SVG so the generated card is
fully portable.
"""
if not value:
return ""
try:
from reportlab.graphics.barcode.qr import QrCodeWidget
except ImportError:
return qr_svg_from_tex(value)
widget = QrCodeWidget(value=value, barLevel="L")
widget.qr.make()
modules = widget.qr.modules
size = len(modules)
border = 4
path_parts: list[str] = []
for row_index, row in enumerate(modules):
column = 0
while column < size:
if not row[column]:
column += 1
continue
start = column
while column < size and row[column]:
column += 1
length = column - start
path_parts.append(f"M{start + border} {row_index + border}h{length}v1h-{length}z")
full_size = size + border * 2
return (
f'<svg class="title-qr" viewBox="0 0 {full_size} {full_size}" role="img" '
f'aria-label="Kod QR: {html_escape(value)}" shape-rendering="crispEdges">'
'<rect width="100%" height="100%" fill="#fefefc"/>'
f'<path d="{" ".join(path_parts)}" fill="#1c1c1a"/>'
"</svg>"
)
def qr_svg_from_tex(value: str) -> str:
"""Use the local TeX QR encoder when the optional Python module is absent."""
latex = shutil.which("latex")
dvisvgm = shutil.which("dvisvgm")
if not latex or not dvisvgm or any(char in value for char in "{}\\\n\r"):
return ""
source = (
r"\documentclass{standalone}" "\n"
r"\usepackage{qrcode}" "\n"
r"\pagestyle{empty}" "\n"
r"\begin{document}" "\n"
rf"\qrcode[level=L,height=30mm]{{{value}}}" "\n"
r"\end{document}" "\n"
)
with tempfile.TemporaryDirectory(prefix="card-qr-") as temporary_dir:
workdir = Path(temporary_dir)
source_path = workdir / "qr.tex"
source_path.write_text(source, encoding="utf-8")
latex_result = subprocess.run(
[latex, "-interaction=nonstopmode", "-halt-on-error", source_path.name],
cwd=workdir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=20,
check=False,
)
if latex_result.returncode != 0:
return ""
svg_path = workdir / "qr.svg"
svg_result = subprocess.run(
[dvisvgm, "--no-fonts", "--exact-bbox", f"--output={svg_path.name}", "qr.dvi"],
cwd=workdir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=20,
check=False,
)
if svg_result.returncode != 0 or not svg_path.is_file():
return ""
svg = svg_path.read_text(encoding="utf-8")
svg_start = svg.find("<svg ")
if svg_start < 0:
return ""
svg = svg[svg_start:]
return svg.replace(
"<svg ",
f'<svg class="title-qr" role="img" aria-label="Kod QR: {html_escape(value)}" ',
1,
)
def render_page_header_html(data: dict, page: int, total: int) -> str:
"""Render the canonical 30 mm resource header used by every page wrapper."""
card = data["card"]
fields = title_block_fields(data)
url = str(fields["url"])
source_url = str(fields["repository_url"])
def qr_cell(label: str, target: str, enabled: bool, modifier: str) -> str:
qr = qr_svg(target) if enabled else ""
if enabled and not qr:
raise RuntimeError(
"Nie można wygenerować lokalnego QR: zainstaluj reportlab albo TeX z pakietem qrcode i dvisvgm"
)
classes = f"resource-qr resource-qr-{modifier}"
if qr:
return (
f'<a class="{classes}" href="{html_escape(target)}" '
f'aria-label="{html_escape(label)}: otwórz {html_escape(target)}">'
f'{qr}<span class="resource-qr-label">{html_escape(label)}</span></a>'
)
return (
f'<div class="{classes} resource-qr-empty">'
f'<span>QR</span><strong>{html_escape(label)} wyłączony</strong></div>'
)
page_qr_html = qr_cell("HTML", url, bool(fields["show_qr"]), "page")
repository_qr_html = (
qr_cell("GITEA", source_url, True, "repository")
if fields["show_repository_qr"]
else ""
)
def datum(label: str, value: object, class_name: str = "") -> str:
suffix = f" {class_name}" if class_name else ""
return (
f'<div class="resource-datum{suffix}"><span>{html_escape(label)}</span>'
f'<strong>{html_escape(value or "")}</strong></div>'
)
card_number = f"{card.get('number', '')}/{card.get('count', '')}"
sheet_number = f"{page}/{total}"
header_class = "page-resource-header page-resource-header--dual" if repository_qr_html else "page-resource-header"
source_link = (
f'<a class="resource-url resource-repository-url" href="{html_escape(source_url)}">'
f'<span>GITEA</span>{html_escape(source_url)}</a>'
if source_url
else ""
)
return (
f'<header class="{header_class}" data-resource-url="{html_escape(url)}" '
f'data-repository-url="{html_escape(source_url)}" '
f'data-page="{page}" data-page-total="{total}">'
f'{repository_qr_html}'
'<div class="resource-primary">'
f'<div class="resource-title"><span>{html_escape(fields["category"])}</span>'
f'<strong>{html_escape(card_prefix(card))}: {html_escape(card.get("title", ""))}</strong></div>'
'<div class="resource-details">'
f'{datum("Projekt", card.get("project", ""))}'
f'{datum("Seria", card.get("series_title", card.get("series", "")))}'
f'{datum("Przedmiot", card.get("subject", ""))}'
f'{datum("Poziom", card.get("level", ""), "resource-level")}'
'</div>'
'<div class="resource-urls">'
f'<a class="resource-url" href="{html_escape(url)}"><span>HTML</span>{html_escape(url or "brak URL")}</a>'
'</div>'
'</div>'
'<div class="resource-meta">'
f'{datum("Data", card.get("revision_date", ""))}'
f'{datum("Status", card.get("status", ""))}'
f'{datum("Karta", card_number)}'
f'{datum("Wersja", card.get("version", ""))}'
f'{datum("Arkusz", sheet_number)}'
f'{datum("Autor", card.get("author", ""))}'
f'{datum("UUID", card.get("uuid", ""), "resource-uuid")}'
f'{source_link}'
'</div>'
f'{page_qr_html}'
'</header>'
)
def page_resource_header_css() -> str:
return r"""
.page-resource-header{grid-column:1/-1;width:100%;height:30mm;min-height:30mm;max-height:30mm;border:1px solid #1c1c1a;display:grid;grid-template-columns:minmax(0,1fr) 47mm 24mm;background:#fff;color:#111;overflow:hidden;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.12}.page-resource-header--dual{grid-template-columns:24mm minmax(0,1fr) 39mm 24mm}
.resource-qr{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35mm;border-right:1px solid #aaa;padding:.65mm .8mm;color:#555;text-decoration:none;font-size:7px}.resource-qr-page{border-right:0}.resource-qr .title-qr{display:block;width:22mm;height:22mm;max-width:22mm;max-height:22mm;flex:none}.resource-qr-label{font:700 5px/1 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.08em;color:#444}.resource-qr-empty{text-align:center}.resource-qr-empty span,.resource-qr-empty strong{display:block;font:600 5.5px/1.15 ui-monospace,SFMono-Regular,Consolas,monospace}
.resource-primary{min-width:0;display:grid;grid-template-rows:auto 1fr auto;padding:.9mm 1.5mm;border-right:1px solid #aaa}.resource-title{display:grid;grid-template-rows:auto auto;border-bottom:1px solid #ddd;padding-bottom:.45mm;min-width:0}.resource-title span{font:600 5.2px/1.05 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.06em;color:#555;white-space:nowrap}.resource-title strong{font:700 10px/1.05 Georgia,"Times New Roman",serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}.resource-details{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-content:center;column-gap:2mm;row-gap:.3mm;min-width:0}.resource-datum{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.8mm;align-items:baseline;min-width:0}.resource-datum span{font:500 5.5px/1.05 ui-monospace,SFMono-Regular,Consolas,monospace;text-transform:uppercase;color:#666;white-space:nowrap}.resource-datum strong{font:600 6.8px/1.08 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.resource-level{grid-column:1/-1}.resource-url{font:500 5.2px/1.05 ui-monospace,SFMono-Regular,Consolas,monospace;color:#222;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-top:1px solid #eee;padding-top:.4mm}.resource-urls{min-width:0}.resource-url{display:block}.resource-url span{display:inline-block;width:8mm;font-weight:700;color:#666}.resource-meta{padding:.8mm 1.1mm;border-right:1px solid #aaa;display:grid;grid-template-columns:1fr 1fr;gap:.3mm 1mm;align-content:center;min-width:0}.resource-meta .resource-datum{display:block}.resource-meta .resource-datum span,.resource-meta .resource-datum strong{display:block}.resource-meta .resource-datum strong{font-size:6.2px}.resource-meta .resource-uuid{grid-column:1/-1;border-top:1px solid #ddd;padding-top:.35mm}.resource-meta .resource-uuid strong{font-size:4.9px;overflow-wrap:anywhere;white-space:normal}.resource-meta .resource-repository-url{grid-column:1/-1;font-size:4.2px;line-height:1.05;white-space:normal;overflow-wrap:anywhere;max-height:2.4em}.resource-meta .resource-repository-url span{width:7mm}
"""
def css_paged_string(value: object) -> str:
return (
str(value)
.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\r", "")
.replace("\n", "\\A ")
)
def page_resource_print_css(data: dict) -> str:
"""Render a real 30 mm repeated header in CSS paged-media margin boxes."""
card = data["card"]
fields = title_block_fields(data)
url = str(fields["url"])
source_url = str(fields["repository_url"])
def qr_background(target: str, enabled: bool) -> str:
qr = qr_svg(target) if enabled else ""
if enabled and not qr:
raise RuntimeError("Nie można wygenerować QR do nagłówka wydruku HTML")
qr_data = base64.b64encode(qr.encode("utf-8")).decode("ascii") if qr else ""
return f'url("data:image/svg+xml;base64,{qr_data}")' if qr_data else "none"
page_qr_background = qr_background(url, bool(fields["show_qr"]))
repository_qr_enabled = bool(fields["show_repository_qr"])
repository_qr_background = qr_background(source_url, repository_qr_enabled)
middle = "\n".join(
[
f"{fields['category']} | {card_prefix(card)}: {card.get('title', '')}",
f"Projekt: {card.get('project', '')} · Seria: {card.get('series_title', card.get('series', ''))}",
f"Przedmiot: {card.get('subject', '')}",
f"Poziom: {card.get('level', '')}",
url,
]
)
meta_before_page = "\n".join(
[
f"DATA {card.get('revision_date', '')} · STATUS {card.get('status', '')}",
f"KARTA {card.get('number', '')}/{card.get('count', '')} · WERSJA {card.get('version', '')}",
"STRONA ",
]
)
meta_after_page = "\n".join(
[
f" · AUTOR {card.get('author', '')}",
f"UUID {card.get('uuid', '')}",
]
)
qr_box = (
"height:30mm;box-sizing:border-box;padding:24mm .8mm .5mm;"
"border:1px solid #111;background-color:#fff;background-repeat:no-repeat;"
"background-position:center 1mm;background-size:22mm 22mm;"
"vertical-align:middle;text-align:center;"
"font:700 5px/1 ui-monospace,SFMono-Regular,Consolas,monospace;"
"letter-spacing:.08em;color:#444"
)
if repository_qr_enabled:
def header_row(primary: object = "", meta: object = "") -> str:
left = str(primary).replace("\n", " ")[:92]
right = str(meta).replace("\n", " ")
return f"{left:<92} {right}"
center_before_page = "\n".join(
[
header_row(
fields["category"],
f"DATA {card.get('revision_date', '')} · STATUS {card.get('status', '')}",
),
header_row(
f"{card_prefix(card)}: {card.get('title', '')}",
f"KARTA {card.get('number', '')}/{card.get('count', '')} · WERSJA {card.get('version', '')}",
),
header_row(
f"PROJEKT {card.get('project', '')} · SERIA {card.get('series_title', card.get('series', ''))}",
f"AUTOR {card.get('author', '')}",
),
header_row(f"PRZEDMIOT {card.get('subject', '')}", "ARKUSZ "),
]
)
center_after_page = "\n" + "\n".join(
[
header_row(
f"POZIOM {card.get('level', '')}",
f"UUID {card.get('uuid', '')}",
),
header_row(f"GITEA {source_url}"),
header_row(f"HTML {url}"),
]
)
return f"""
@page{{
size:A4;
margin:35mm 21.5mm 15mm;
@top-left{{content:"GITEA";width:24mm;{qr_box};background-image:{repository_qr_background}}}
@top-center{{content:"{css_paged_string(center_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(center_after_page)}";width:119mm;height:30mm;box-sizing:border-box;padding:1mm;border-block:1px solid #111;background-color:#fff;background-image:linear-gradient(#aaa,#aaa);background-repeat:no-repeat;background-position:80mm 0;background-size:1px 100%;white-space:pre;vertical-align:middle;text-align:left;font:600 5.3px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111;overflow:hidden}}
@top-right{{content:"HTML";width:24mm;{qr_box};background-image:{page_qr_background}}}
}}
"""
return f"""
@page{{
size:A4;
margin:35mm 21.5mm 15mm;
@top-left{{content:"{css_paged_string(meta_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(meta_after_page)}";width:47mm;height:30mm;box-sizing:border-box;padding:1.5mm 1.7mm;border:1px solid #111;white-space:pre-wrap;vertical-align:middle;text-align:left;font:600 5.7px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111}}
@top-center{{content:"{css_paged_string(middle)}";width:96mm;height:30mm;box-sizing:border-box;padding:1.5mm 2mm;border-block:1px solid #111;white-space:pre-wrap;vertical-align:middle;text-align:left;font:600 6.2px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111}}
@top-right{{content:"HTML";width:24mm;{qr_box};background-image:{page_qr_background}}}
}}
"""
def render_page_header_tex_macro(data: dict) -> list[str]:
"""Define the repeated 30 mm TeX header for the classic emitter."""
card = data["card"]
fields = title_block_fields(data)
url = str(fields["url"])
source_url = str(fields["repository_url"])
dual_qr = bool(fields["show_repository_qr"])
if fields["show_qr"] and not url:
raise RuntimeError("Nie można wygenerować QR bez kanonicznego URL")
if dual_qr and not source_url:
raise RuntimeError("Nie można wygenerować QR bez adresu repozytorium")
for field_name, target in (("url", url), ("repository_url", source_url)):
if target and any(char in target for char in "{}\\\n\r%#"):
raise RuntimeError(
f"title_block.{field_name} zawiera znaki nieobsługiwane przez nagłówek TeX"
)
page_qr = (
rf"\qrcode[level=L,height=22mm]{{{url}}}"
if fields["show_qr"]
else r"{\tiny QR wyłączony}"
)
source_qr = rf"\qrcode[level=L,height=22mm]{{{source_url}}}" if dual_qr else ""
title = f"{card_prefix(card)}: {card.get('title', '')}"
card_number = f"{card.get('number', '')}/{card.get('count', '')}"
primary_width = "76mm" if dual_qr else "89mm"
meta_width = "39mm" if dual_qr else "47mm"
repository_meta_line = (
rf" {{\fontsize{{3.8}}{{4.3}}\selectfont\ttfamily GITEA: \href{{{source_url}}}{{\nolinkurl{{{source_url}}}}}}}%"
if source_url
else ""
)
source_cell = (
[
r" \begin{minipage}[c][29mm][c]{24mm}\centering%",
" " + source_qr + r"\par%",
r" \vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily GITEA}%",
r" \end{minipage}%",
r" \vrule width0.35pt\hspace{1mm}%",
]
if dual_qr
else []
)
return [
r"\newcommand{\ESCPageResourceHeader}{%",
r" \begingroup%",
r" \setlength{\parskip}{0pt}%",
r" \setlength{\fboxsep}{0pt}%",
r" \setlength{\fboxrule}{0.35pt}%",
r" \noindent\fbox{%",
r" \begin{minipage}[c][29.5mm][c]{\dimexpr\textwidth-2\fboxrule\relax}%",
*source_cell,
rf" \begin{{minipage}}[c][29mm][c]{{{primary_width}}}%",
rf" {{\fontsize{{5.5}}{{6.2}}\selectfont\ttfamily\textcolor{{black!58}}{{{tex_escape(fields['category'])}}}}}\par%",
rf" {{\fontsize{{9.2}}{{10.1}}\selectfont\bfseries {tex_escape(title)}}}\par%",
r" \vspace{0.1mm}%",
rf" {{\fontsize{{5.6}}{{6.3}}\selectfont Projekt: \texttt{{{tex_escape(card.get('project', ''))}}}\quad Seria: \texttt{{{tex_escape(card.get('series_title', card.get('series', '')))}}}}}\par%",
rf" {{\fontsize{{5.6}}{{6.3}}\selectfont Przedmiot: \texttt{{{tex_escape(card.get('subject', ''))}}}}}\par%",
rf" {{\fontsize{{5.0}}{{5.7}}\selectfont Poziom: \texttt{{{tex_escape(card.get('level', ''))}}}}}\par%",
rf" {{\fontsize{{4.2}}{{4.8}}\selectfont\ttfamily HTML: \href{{{url}}}{{\nolinkurl{{{url}}}}}}}%",
r" \end{minipage}%",
r" \hfill\vrule width0.35pt\hspace{1mm}%",
rf" \begin{{minipage}}[c][29mm][c]{{{meta_width}}}%",
rf" {{\fontsize{{5.7}}{{6.5}}\selectfont Data: \texttt{{{tex_escape(card.get('revision_date', ''))}}}\hfill Status: \texttt{{{tex_escape(card.get('status', ''))}}}}}\par%",
rf" {{\fontsize{{5.7}}{{6.5}}\selectfont Karta: \texttt{{{tex_escape(card_number)}}}\hfill Wersja: \texttt{{{tex_escape(card.get('version', ''))}}}}}\par%",
rf" {{\fontsize{{5.7}}{{6.5}}\selectfont Autor: \texttt{{{tex_escape(card.get('author', ''))}}}}}\par%",
r" {\fontsize{5.7}{6.5}\selectfont Arkusz: \texttt{\thepage/\pageref{LastPage}}}\par%",
r" \vspace{0.5mm}\hrule\vspace{0.5mm}%",
rf" {{\fontsize{{4.7}}{{5.3}}\selectfont\ttfamily UUID: {tex_escape(card.get('uuid', ''))}}}\par%",
repository_meta_line,
r" \end{minipage}%",
r" \hfill\vrule width0.35pt\hspace{1mm}%",
r" \begin{minipage}[c][29mm][c]{24mm}\centering%",
" " + page_qr + r"\par%",
r" \vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily HTML}%",
r" \end{minipage}%",
r" \end{minipage}%",
r" }%",
r" \endgroup%",
r"}",
"",
]
def margin_code_5a(prefix: str, official: str, local: str) -> str:
if prefix == "WE":
return " ".join(part for part in ("WE", official) if part)
stem = re.sub(r"^(EN|EK|KW)\s+", "", prefix).strip()
code = " ".join(part for part in (stem, official) if part)
if local:
code += f".{local}"
return code
def margin_tree_groups_5a(tags: list[tuple[str, str, str, str, str, str, str]]) -> list[dict]:
groups: list[dict] = []
current_group: dict | None = None
current_item: dict | None = None
for indent, _color, prefix, official, local, _official_tip, _local_tip in tags:
code = margin_code_5a(prefix, official, local)
if not code:
continue
if prefix == "WE":
current_group = {"we": code, "items": []}
current_item = None
groups.append(current_group)
continue
if current_group is None:
current_group = {"we": "", "items": []}
groups.append(current_group)
if indent.startswith("0.28"):
current_item = {"code": code, "kw": []}
current_group["items"].append(current_item)
continue
if current_item is None:
current_item = {"code": code, "kw": []}
current_group["items"].append(current_item)
else:
current_item["kw"].append(code)
return groups
def margin_tree_groups_5b(tags: list[tuple[str, str, str, str, str, str, str]]) -> list[dict]:
groups: list[dict] = []
current_group: dict | None = None
for indent, _color, prefix, official, local, _official_tip, _local_tip in tags:
if prefix == "KW" or prefix.startswith("KW "):
continue
code = margin_code_5a(prefix, official, local)
if not code:
continue
if prefix == "WE":
current_group = {"we": code, "items": []}
groups.append(current_group)
continue
if current_group is None:
current_group = {"we": "", "items": []}
groups.append(current_group)
if indent.startswith("0.28"):
current_group["items"].append({"code": code})
return groups
def common_kw_5a(tags: list[tuple[str, str, str, str, str, str, str]]) -> str:
codes: list[str] = []
for _indent, _color, prefix, _official, local, _official_tip, _local_tip in tags:
if prefix != "KW":
continue
code = local or _official
if code and code not in codes:
codes.append(code)
return " ".join(sorted(codes)) if codes else "-"
def render_margin_tree_panel_5a_html(data: dict, column: str) -> str:
is_tech = column == "zawodowe"
tags = learning_tree_tags(
data,
column=column,
effect_color="orange!85!black" if is_tech else "green!50!black",
)
title = "TECH — zawodowe" if is_tech else "OG — ogólne"
subtitle = "WE → EK → KW" if is_tech else "WE → EN → KW"
dominance = "dominanta zawodowa:<br>ELE/AUT/ELM/INF" if is_tech else "dominanta ogólna:<br>FIZ/MAT/INF"
tree_parts: list[str] = []
for group in margin_tree_groups_5a(tags):
if group.get("we"):
tree_parts.append(f'<div class="tree-we">{html_escape(group["we"])}</div>')
for item in group.get("items", []):
tree_parts.append(
f'<div class="tree-line level-1"><span></span>{html_escape(item["code"])}</div>'
)
for kw_code in item.get("kw", []):
tree_parts.append(
f'<div class="tree-line level-2"><span></span>{html_escape(kw_code)}</div>'
)
tree_html = "".join(f"<div>{part}</div>" if part.startswith('<div class="tree-we"') else part for part in tree_parts)
return (
'<div class="margin-panel">'
'<div class="margin-panel-head">'
f'<div class="margin-panel-title">{html_escape(title)}</div>'
f'<div class="margin-panel-subtitle">{html_escape(subtitle)}</div>'
'</div>'
f'<div class="margin-panel-body">{tree_html}'
f'<div class="kw-common">KW wspólne:<br><span>{html_escape(common_kw_5a(tags))}</span></div>'
'</div>'
f'<div class="margin-panel-foot">{dominance}</div>'
'</div>'
)
def render_5a_section_header(number: int, title: str) -> str:
return (
'<div class="section-head">'
f'<span>§{number}</span><strong>{html_escape(title.upper())}</strong>'
'</div>'
)
def render_title_block_html(data: dict) -> str:
fields = title_block_fields(data)
def cell(label: str, value: object, extra_class: str = "") -> str:
classes = "title-cell" + (f" {extra_class}" if extra_class else "")
shown = html_escape(str(value)) if value else ""
return f'<div class="{classes}"><span>{html_escape(label)}</span><strong>{shown}</strong></div>'
qr = qr_svg(str(fields["url"])) if fields["show_qr"] else ""
qr_cell = (
f'<a class="title-qr-cell" href="{html_escape(str(fields["url"]))}" '
f'aria-label="Otwórz adres z kodu QR">{qr}</a>'
if qr
else '<div class="title-qr-cell title-qr-empty"><span>QR</span><strong>'
+ ("brak modułu QR" if fields["url"] else "brak URL")
+ "</strong></div>"
)
prepared = (
'<div class="title-cell signer"><span>Opracował</span>'
f'<strong>{html_escape(str(fields["prepared_by"]))}</strong>'
f'<em>{html_escape(str(fields["prepared_on"]))}</em></div>'
)
return (
'<section class="title-block" aria-label="Tabliczka dokumentu">'
'<div class="title-signers">'
f'{prepared}'
f'{cell("Sprawdził", fields["checked_by"], "signer")}'
f'{cell("Zatwierdził", fields["approved_by"], "signer")}'
'</div>'
'<div class="title-document">'
f'<div class="title-category"><strong>{html_escape(str(fields["category"]))}</strong><span>{html_escape(str(fields["standard"]))}</span></div>'
f'<div class="title-name"><span>Tytuł</span><strong>{html_escape(str(fields["title"]))}</strong></div>'
f'<div class="title-url"><span>URL</span><code>{html_escape(str(fields["url"])) if fields["url"] else ""}</code></div>'
'</div>'
'<div class="title-meta">'
f'{cell("Rewizja", fields["revision"])}'
f'{cell("Data wydania", fields["issued_on"])}'
f'{cell("Seria", fields["series"])}'
f'{cell("Typ", fields["document_type"])}'
f'{cell("Narzędzie", fields["tool"])}'
f'{cell("Arkusz", fields["sheet"])}'
'</div>'
f'{qr_cell}'
'</section>'
)
def render_metric_header_5a_html(data: dict) -> str:
card = data["card"]
fields = data.get("metric_fill_fields") or [
{"label": "Uczeń"},
{"label": "Klasa · data"},
]
first_label = fields[0].get("label", "Uczeń") if fields else "Uczeń"
second_label = fields[1].get("label", "Klasa · data") if len(fields) > 1 else "Klasa · data"
return (
'<div class="metric-box">'
'<div class="metric-grid">'
f'<div><span>Projekt</span><strong>{html_escape(card["project"])}</strong></div>'
f'<div><span>Przedmiot</span><strong>{html_escape(card["subject"])}</strong></div>'
f'<div><span>{html_escape(first_label)}</span><i></i></div>'
f'<div><span>{html_escape(second_label)}</span><i></i></div>'
'</div>'
'</div>'
)
def render_mission_5a_html(data: dict) -> str:
return (
'<div class="plain-box">'
'<div class="plain-title">Misja</div>'
f'<p>{html_escape(text_5a(data.get("mission", "")))}</p>'
'</div>'
)
def render_objectives_5a_html(data: dict) -> str:
refs = data.get("objective_refs", [])
rows: list[str] = []
for index, objective in enumerate(data.get("objectives", []), start=1):
ref = refs[index - 1] if index - 1 < len(refs) else {}
ref_text = f"← TECH {ref.get('tech', f'{index:02d}')} · OG {ref.get('og', f'{index:02d}')}"
rows.append(
'<div class="objective-row">'
'<span class="box-check"></span>'
f'<span>{index}. {html_escape(text_5a(objective))}</span>'
f'<code>{html_escape(ref_text)}</code>'
'</div>'
)
return (
'<div class="section-box">'
f'{render_5a_section_header(1, "Cele operacyjne")}'
f'<div class="section-body objectives">{"".join(rows)}</div>'
'</div>'
)
def render_agenda_5a_html(data: dict) -> str:
rows = []
for row in data.get("agenda", []):
rows.append(
f'<div class="agenda-time">{html_escape(agenda_time_5a(row.get("time", "")))}</div>'
f'<div>{html_escape(text_5a(row.get("work", "")))}</div>'
)
return (
'<div class="section-box">'
f'{render_5a_section_header(2, "Agenda 45 min")}'
f'<div class="section-body agenda-grid">{"".join(rows)}</div>'
'</div>'
)
def render_hw_gate_5a_html(data: dict) -> str:
criteria = data.get("hw_gate", {}).get("criteria", [])
rows: list[str] = []
for index, criterion in enumerate(criteria):
text = criterion.get("check") or criterion.get("criterion", "")
if index == len(criteria) - 1:
rows.append(
'<div class="gate-row sign-row"><span class="box-check"></span>'
f'<span>{html_escape(text_5a(text))}</span>'
'<code>podpis<i></i></code></div>'
)
else:
rows.append(
'<div class="gate-row"><span class="box-check"></span>'
f'<span>{html_escape(text_5a(text))}</span></div>'
)
return (
'<div class="section-box">'
f'{render_5a_section_header(3, "Warunki wejścia na sprzęt (bramka A → B)")}'
f'<div class="section-body gate">{"".join(rows)}</div>'
'</div>'
)
def render_theory_5a_html(data: dict) -> str:
theory = data.get("theory_refs", {})
rows = []
for index, item in enumerate(theory.get("known_refs", []), start=1):
rows.append(
'<tr>'
f'<td><code>{html_escape(item.get("id", ""))} [{index}]</code></td>'
f'<td>{html_escape(text_5a(item.get("note") or item.get("label", "")))}</td>'
'</tr>'
)
concept_rows = []
for concept in theory.get("new_concepts", []):
concept_rows.append(
'<tr>'
f'<td>{html_escape(text_5a(concept.get("term", "")))}</td>'
f'<td>{html_escape(text_5a(concept.get("definition", "")))}</td>'
'</tr>'
)
intro = theory.get("intro", "")
intro_html = f'<p>{html_escape(text_5a(intro))}</p>' if intro else ""
concept_html = (
'<table class="theory-table"><thead><tr>'
'<th>Nowe pojęcie</th><th>Definicja robocza</th>'
f'</tr></thead><tbody>{"".join(concept_rows)}</tbody></table>'
if concept_rows
else ""
)
return (
'<div class="section-box">'
f'{render_5a_section_header(4, "Teoria pod model i symulację")}'
'<div class="section-body">'
f'{intro_html}'
'<table class="theory-table"><thead><tr>'
'<th>Karta / źródło</th><th>Użycie w symulacji</th>'
f'</tr></thead><tbody>{"".join(rows)}</tbody></table>'
f'{concept_html}'
'</div></div>'
)
def render_stack_column_5a_html(title: str, rows: list[dict]) -> str:
cells = []
for row in rows:
cells.append(
f'<div><code>{html_escape(row.get("name", ""))}</code></div>'
f'<div>{html_escape(text_5a(row.get("use", "")))}</div>'
)
return (
'<div class="stack-column">'
f'<h3>{html_escape(title)}</h3>'
f'<div class="stack-grid">{"".join(cells)}</div>'
'</div>'
)
def render_tech_stack_5a_html(data: dict) -> str:
stack = data.get("tech_stack", {})
return (
'<div class="section-box">'
f'{render_5a_section_header(5, "Technologie stanowiska")}'
'<div class="section-body tech-stack">'
f'{render_stack_column_5a_html("Software", stack.get("software", []))}'
f'{render_stack_column_5a_html("Hardware", stack.get("hardware", []))}'
'</div></div>'
)
def section_box_5a_html(number: int, title: str, body: str, extra_class: str = "") -> str:
class_attr = f"section-box {extra_class}".strip()
return (
f'<div class="{html_escape(class_attr)}">'
f'{render_5a_section_header(number, title)}'
f'<div class="section-body">{body}</div>'
'</div>'
)
def render_part_banner_5a_html(part: dict) -> str:
return (
'<div class="part-banner">'
f'<strong>{html_escape(text_5a(part.get("title", "")))}</strong>'
f'<span>{html_escape(text_5a(part.get("body", "")))}</span>'
'</div>'
)
def render_html_list_5a(items: Iterable[object]) -> str:
return '<ul class="compact-list">' + "".join(f"<li>{html_escape(text_5a(item))}</li>" for item in items) + "</ul>"
def code_block_columns_5a(block: dict) -> list[dict]:
columns = block.get("columns")
if columns:
return [
{"title": column.get("title", ""), "items": column.get("items", [])}
for column in columns
]
return [
{"title": "Pliki szkieletu", "items": block.get("files", [])},
{"title": "Interfejs", "items": block.get("functions", [])},
{"title": "Uczeń odpowiada za", "items": block.get("student_owns", [])},
]
def render_code_columns_5a_html(block: dict) -> str:
columns = code_block_columns_5a(block)
return (
'<div class="three-col">'
+ "".join(
f'<div><h3>{html_escape(text_5a(column.get("title", "")))}</h3>{render_html_list_5a(column.get("items", []))}</div>'
for column in columns
)
+ '</div>'
)
def render_code_subblock_5a_html(block: dict) -> str:
pass_line = ""
if block.get("pass_condition"):
pass_line = f'<p class="pass-line">PASS: {html_escape(text_5a(block.get("pass_condition", "")))}</p>'
return (
f'<article class="code-subblock" data-block="{html_escape(slugify_web(block.get("slug") or block.get("title")))}">'
f'<h3>{html_escape(text_5a(block.get("title", "")))}</h3>'
f'<p>{html_escape(text_5a(block.get("context", "")))}</p>'
f'{render_code_columns_5a_html(block)}'
f'{pass_line}'
'</article>'
)
def render_math_5a_html(data: dict) -> str:
block = data.get("math_block", {})
tasks = []
for index, task in enumerate(block.get("tasks", []), start=1):
tasks.append(
'<div class="math-task">'
f'<div><strong>{index}.</strong> {html_escape(text_5a(task.get("prompt", "")))}</div>'
f'<code>{html_escape(task.get("equation_tex", ""))}</code>'
'<span class="answer-line"></span><span class="answer-line"></span>'
f'<em>{html_escape(text_5a(task.get("answer_hint", "")))}</em>'
'</div>'
)
return section_box_5a_html(6, block.get("title", "Do policzenia"), "".join(tasks), "dense")
def render_model_5a_html(data: dict) -> str:
model = data.get("model_block", {})
rows = "".join(
f'<tr><td><code>{html_escape(item.get("name", ""))}</code></td><td>{html_escape(text_5a(item.get("value", "")))}</td></tr>'
for item in model.get("constants", [])
)
body = (
'<div class="model-grid">'
'<div>'
f'<p>Notebook: <code>{html_escape(model.get("notebook", ""))}</code></p>'
f'<p>Kontrakt: <code>{html_escape(model.get("output_constants", ""))}</code></p>'
f'{render_html_list_5a(model.get("steps", []))}'
'</div>'
f'<table class="compact-table constants"><tbody>{rows}</tbody></table>'
'</div>'
)
return section_box_5a_html(7, model.get("title", "Model w notebooku"), body, "dense")
def render_code_block_5a_html(data: dict) -> str:
code = data.get("code_block", {})
container = data.get("container_test", {})
note_html = ""
if code.get("next_cards_note"):
note_html = f'<p class="pass-line">NOTATKA: {html_escape(text_5a(code.get("next_cards_note", "")))}</p>'
intro = f'<p>{html_escape(text_5a(code.get("context", "")))}</p>' if code.get("context") else ""
blocks = code.get("blocks", [])
if blocks:
block_html = "".join(render_code_subblock_5a_html(block) for block in blocks)
body = intro + block_html + note_html + f'<p class="pass-line">PASS: {html_escape(text_5a(container.get("pass_condition", "")))}</p>'
else:
body = (
intro
+ render_code_columns_5a_html(code)
+ note_html
+ f'<p class="pass-line">PASS: {html_escape(text_5a(container.get("pass_condition", "")))}</p>'
)
return section_box_5a_html(8, code.get("title", "Bloczek RP2350"), body, "dense")
def render_container_test_5a_html(data: dict) -> str:
container = data.get("container_test", {})
commands = "".join(f'<li><code>{html_escape(command)}</code></li>' for command in container.get("commands", []))
return section_box_5a_html(
9,
container.get("title", "Test w kontenerze"),
f'<ol class="command-list">{commands}</ol>',
"dense",
)
def render_bom_5a_html(data: dict) -> str:
rows = "".join(
'<tr>'
'<td><span class="box-check"></span></td>'
f'<td><code>{html_escape(item.get("item", ""))}</code></td>'
f'<td>{html_escape(text_5a(item.get("check", "")))}</td>'
'</tr>'
for item in data.get("bom", [])
)
return section_box_5a_html(10, "BOM i checklista stanowiska", f'<table class="compact-table check-table"><tbody>{rows}</tbody></table>', "dense")
def render_safety_5a_html(data: dict) -> str:
cards = "".join(
'<div class="safety-card">'
f'<strong>{html_escape(rule.get("title", ""))}</strong>'
f'<span>{html_escape(text_5a(rule.get("body", "")))}</span>'
'</div>'
for rule in data.get("safety_rules", [])
)
return section_box_5a_html(11, "BHP i złote zasady", f'<div class="safety-grid">{cards}</div>', "dense")
def render_settings_figures_5a_html(data: dict) -> str:
figure_html = "".join(
'<figure>'
f'<img src="{html_escape(html_figure_asset(figure.get("path", "")))}" alt="{html_escape(figure.get("caption", ""))}">'
f'<figcaption>{html_escape(text_5a(figure.get("caption", "")))}</figcaption>'
'</figure>'
for figure in data.get("figures", [])
)
rows = "".join(
'<tr>'
f'<td>{html_escape(text_5a(row.get("test", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("dps", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("xy", "")))}</td>'
'</tr>'
for row in data.get("reference_settings", [])
)
table = (
'<table class="compact-table"><thead><tr><th>Test</th><th>DPS3010U</th><th>XY6020L</th></tr></thead>'
f'<tbody>{rows}</tbody></table>'
)
return section_box_5a_html(12, data.get("settings_section_title", "Kaskada i nastawy referencyjne"), f'<div class="figure-grid">{figure_html}</div>{table}', "dense")
def render_connection_5a_html(data: dict, section_number: int = 13) -> str:
connection = data.get("connection_section", {})
if not connection:
return ""
figure = connection.get("figure", {})
figure_html = ""
if figure.get("path"):
full_pdf_path = figure.get("full_pdf_path") or figure.get("path", "")
image_html = (
f'<img src="{html_escape(html_figure_asset(figure.get("path", "")))}" '
f'alt="{html_escape(text_5a(figure.get("caption", "")))}">'
)
if full_pdf_path:
image_html = f'<a href="{html_escape(html_pdf_asset(full_pdf_path))}">{image_html}</a>'
figure_html = (
'<figure class="connection-figure">'
f'{image_html}'
f'<figcaption>{html_escape(text_5a(figure.get("caption", "")))}</figcaption>'
f'<a class="figure-pdf-link" href="{html_escape(html_pdf_asset(full_pdf_path))}">pełny PDF</a>'
'</figure>'
)
headers = ["RP2350", "OptoIzolator strona A", "OptoIzolator strona B", "XY6020L"]
rows = "".join(
"<tr>"
f'<td>{html_escape(text_5a(row.get("rp2350", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("isolator_a", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("isolator_b", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("xy", "")))}</td>'
"</tr>"
for row in connection.get("pin_table", [])
)
table = (
'<table class="compact-table connection-table"><thead><tr>'
+ "".join(f"<th>{html_escape(header)}</th>" for header in headers)
+ f"</tr></thead><tbody>{rows}</tbody></table>"
)
details = (
'<div class="connection-notes">'
f'<div><h3>Parametry łącza</h3>{render_html_list_5a(connection.get("parameters", []))}</div>'
f'<div><h3>Zakazy i kontrola</h3>{render_html_list_5a(connection.get("warnings", []))}</div>'
'</div>'
)
source_note = ""
if connection.get("source_note"):
source_note = f'<p class="pass-line">ŹRÓDŁA: {html_escape(text_5a(connection.get("source_note", "")))}</p>'
body = (
f"{figure_html}"
f'<p>{html_escape(text_5a(connection.get("intro", "")))}</p>'
f"{table}{details}{source_note}"
)
return section_box_5a_html(section_number, connection.get("title", "Podłączenie RP2350 ↔ XY6020L"), body, "dense")
def technical_schematic_web_path(data: dict) -> str:
figure = data.get("connection_section", {}).get("figure", {})
return (
figure.get("web_carrier_pdf_path")
or figure.get("full_pdf_path")
or figure.get("path")
or ""
)
def render_technical_schematic_5b_html(data: dict, section_number: int = 13) -> str:
connection = data.get("connection_section", {})
if not connection:
return ""
figure = connection.get("figure", {})
sheet_path = technical_schematic_web_path(data)
caption = text_5a(figure.get("caption", "Schemat blokowy połączenia"))
if sheet_path:
image_html = (
f'<img src="{html_escape(html_figure_asset(sheet_path))}" '
f'alt="{html_escape(caption)}">'
)
pdf_href = html_relative_asset_href(data, sheet_path)
image_html = (
f'<a class="technical-sheet-link" href="{html_escape(pdf_href)}">'
f"{image_html}</a>"
)
pdf_link = f'<a class="figure-pdf-link" href="{html_escape(pdf_href)}">pełny PDF</a>'
else:
image_html = '<div class="technical-sheet-missing">Brak renderu schematu</div>'
pdf_link = ""
body = (
'<figure class="technical-sheet-figure">'
f"{image_html}"
'<figcaption>'
f'<span>{html_escape(caption)}</span>'
f"{pdf_link}"
'</figcaption>'
'</figure>'
)
return section_box_5a_html(section_number, connection.get("title", "Podłączenie RP2350 ↔ XY6020L"), body, "dense technical-section")
def render_connection_details_5b_html(data: dict, section_number: int = 13) -> str:
connection = data.get("connection_section", {})
if not connection:
return ""
headers = ["RP2350", "OptoIzolator strona A", "OptoIzolator strona B", "XY6020L"]
rows = "".join(
"<tr>"
f'<td>{html_escape(text_5a(row.get("rp2350", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("isolator_a", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("isolator_b", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("xy", "")))}</td>'
"</tr>"
for row in connection.get("pin_table", [])
)
table = (
'<table class="compact-table connection-table"><thead><tr>'
+ "".join(f"<th>{html_escape(header)}</th>" for header in headers)
+ f"</tr></thead><tbody>{rows}</tbody></table>"
)
details = (
'<div class="connection-notes">'
f'<div><h3>Parametry łącza</h3>{render_html_list_5a(connection.get("parameters", []))}</div>'
f'<div><h3>Zakazy i kontrola</h3>{render_html_list_5a(connection.get("warnings", []))}</div>'
'</div>'
)
source_note = ""
if connection.get("source_note"):
source_note = f'<p class="pass-line">ŹRÓDŁA: {html_escape(text_5a(connection.get("source_note", "")))}</p>'
body = (
f'<p>{html_escape(text_5a(connection.get("intro", "")))}</p>'
f"{table}{details}{source_note}"
)
return section_box_5a_html(
section_number,
connection.get("title", "Podłączenie RP2350 ↔ XY6020L") + " -- tabela pinów i kontrola",
body,
"dense",
)
def render_procedure_5a_html(data: dict, section_number: int = 13) -> str:
rows = "".join(
'<tr>'
'<td><span class="box-check"></span></td>'
f'<td><code>{html_escape(step.get("step", ""))}</code></td>'
f'<td>{html_escape(text_5a(step.get("action", "")))}</td>'
f'<td>{html_escape(text_5a(step.get("condition", "")))}</td>'
'</tr>'
for step in data.get("hardware_procedure", [])
)
table = '<table class="compact-table procedure-table"><thead><tr><th>OK</th><th>Krok</th><th>Działanie</th><th>Warunek przejścia</th></tr></thead><tbody>' + rows + '</tbody></table>'
return section_box_5a_html(section_number, "Procedura sprzętowa krok po kroku", table, "dense")
def render_observation_5a_html(data: dict, section_number: int = 14) -> str:
obs = data.get("observation_table", {})
headers = "".join(f"<th>{html_escape(header)}</th>" for header in obs.get("columns", []))
rows = []
for row in obs.get("rows", []):
cells = row.get("cells", [])
rows.append("".join(f"<td>{html_or_write_box(cell, 'write-box')}</td>" for cell in cells))
table = f'<table class="obs-table"><thead><tr>{headers}</tr></thead><tbody>' + "".join(f"<tr>{row}</tr>" for row in rows) + "</tbody></table>"
return section_box_5a_html(section_number, obs.get("title", "Karta obserwacji"), table, "dense")
def render_troubleshooting_5a_html(data: dict, section_number: int = 15) -> str:
rows = "".join(
'<tr>'
f'<td>{html_escape(text_5a(row.get("symptom", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("hypothesis", "")))}</td>'
f'<td>{html_escape(text_5a(row.get("action", "")))}</td>'
f'<td>{html_or_write_box(row.get("result", ""), "write-box small")}</td>'
'</tr>'
for row in data.get("troubleshooting", [])
)
table = '<table class="compact-table trouble-table"><thead><tr><th>Objaw</th><th>Hipoteza</th><th>Akcja</th><th>Wynik</th></tr></thead><tbody>' + rows + '</tbody></table>'
return section_box_5a_html(section_number, "Troubleshooting log", table, "dense")
def html_or_write_box(value: object, class_name: str) -> str:
if value:
return html_escape(text_5a(value))
return f'<span class="{html_escape(class_name)}"></span>'
def render_analysis_5a_html(data: dict, section_number: int = 16, line_count: int = 3) -> str:
answer_lines = "".join("<i></i>" for _ in range(line_count))
items = "".join(
'<div class="analysis-item">'
f'<strong>{index}.</strong><span>{html_escape(text_5a(task))}</span>'
f'{answer_lines}'
'</div>'
for index, task in enumerate(data.get("analysis_tasks", []), start=1)
)
return section_box_5a_html(section_number, "Model vs pomiar i analiza danych", f'<div class="analysis-list">{items}</div>', "dense")
def render_team_decision_5a_html(data: dict, section_number: int = 17, line_count: int = 3) -> str:
decision = data.get("team_decision", {})
answer_lines = "".join("<span></span>" for _ in range(line_count))
fields = "".join(
'<div class="adr-field">'
f'<strong>{html_escape(text_5a(question))}</strong>'
f'{answer_lines}'
'</div>'
for question in decision.get("questions", [])
)
return section_box_5a_html(section_number, decision.get("title", "Decyzja zespołu"), f'<div class="adr-grid">{fields}</div>', "dense")
def render_git_gate_5a_html(data: dict, section_number: int = 18) -> str:
gate = data.get("git_gate", {})
rows = "".join(
'<tr>'
'<td><span class="box-check"></span></td>'
f'<td>{html_escape(text_5a(item.get("criterion", "")))}</td>'
f'<td><code>{html_escape(item.get("evidence", ""))}</code></td>'
'<td><span class="signature-line"></span></td>'
'</tr>'
for item in gate.get("criteria", [])
)
table = '<table class="compact-table git-gate"><thead><tr><th>OK</th><th>Kryterium</th><th>Dowód</th><th>Podpis</th></tr></thead><tbody>' + rows + '</tbody></table>'
return section_box_5a_html(section_number, gate.get("title", "Brama wyjścia"), table, "dense")
def render_session_ai_bibliography_5a_html(data: dict, section_number: int = 19) -> str:
record = data.get("session_record", {})
ai = data.get("ai_rules", {})
entries = bibliography_entries(data)
file_rows = "".join(
f'<li><code>{html_escape(item.get("path", ""))}</code> — {html_escape(text_5a(item.get("purpose", "")))}</li>'
for item in record.get("files", [])
)
bib_rows = "".join(
f'<li><code>{html_escape(ref)}</code> {html_escape(text_5a(entries.get(ref, {}).get("title", ref)))}</li>'
for ref in data.get("references", [])
)
body = (
'<div class="three-col">'
f'<div><h3>Rekord zajęć</h3><ul class="compact-list">{file_rows}</ul></div>'
f'<div><h3>Agent AI</h3><p><code>{html_escape(ai.get("log_path", ""))}</code></p><strong>Wolno</strong>{render_html_list_5a(ai.get("allowed", []))}<strong>Zakaz</strong>{render_html_list_5a(ai.get("forbidden", []))}</div>'
f'<div><h3>Bibliografia</h3><ol class="bib-list">{bib_rows}</ol></div>'
'</div>'
)
return section_box_5a_html(section_number, "Rekord, AI i bibliografia", body, "dense")
def build_5a_pages_html(data: dict) -> list[str]:
parts = data.get("parts", {})
return [
"".join(
[
render_metric_header_5a_html(data),
render_mission_5a_html(data),
render_objectives_5a_html(data),
render_agenda_5a_html(data),
render_hw_gate_5a_html(data),
]
),
"".join(
[
render_theory_5a_html(data),
render_tech_stack_5a_html(data),
render_math_5a_html(data),
]
),
"".join(
[
render_part_banner_5a_html(parts.get("a", {})),
render_model_5a_html(data),
render_code_block_5a_html(data),
render_container_test_5a_html(data),
render_bom_5a_html(data),
]
),
"".join(
[
render_part_banner_5a_html(parts.get("b", {})),
render_safety_5a_html(data),
render_settings_figures_5a_html(data),
render_procedure_5a_html(data),
]
),
"".join(
[
render_observation_5a_html(data),
]
),
"".join(
[
render_troubleshooting_5a_html(data),
render_analysis_5a_html(data),
]
),
"".join(
[
render_team_decision_5a_html(data),
render_git_gate_5a_html(data),
render_session_ai_bibliography_5a_html(data),
]
),
]
def footer_5a_html(data: dict, page: int, total: int) -> str:
card = data["card"]
return (
'<div class="page-footer">'
f'<span>commit local · {html_escape(card["uuid"])} · {html_escape(card["version"])} · {html_escape(card["revision_date"])}</span>'
f'<span>strona {page} / {total}</span>'
f'<span>{html_escape(card["status"])}</span>'
'</div>'
)
def render_running_card_header_5a_html(data: dict) -> str:
card = data["card"]
return (
'<div class="running-card-head">'
f'<span>{html_escape(card_title_line_5a(card))}</span>'
f'<code>KARTA {html_escape(card["number"])}/{html_escape(card["count"])}</code>'
'</div>'
)
def render_5a_page_html(data: dict, center: str, page: int, total: int) -> str:
return (
f'<div class="page" data-screen-label="5a strona {page}">'
f'{render_page_header_html(data, page, total)}'
'<div class="page-grid">'
f'{render_margin_tree_panel_5a_html(data, "zawodowe")}'
f'<main class="page-main">{center}</main>'
f'{render_margin_tree_panel_5a_html(data, "ogolne")}'
'</div>'
f'{footer_5a_html(data, page, total)}'
'</div>'
)
def render_main_html_5a(data: dict, template: dict) -> str:
pages = build_5a_pages_html(data)
total_pages = len(pages)
css = r"""
*{-webkit-print-color-adjust:exact;print-color-adjust:exact;box-sizing:border-box}
body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items:center;gap:24px;padding:24px}
.page{width:1123px;height:794px;background:#fefefc;color:#1c1c1a;border:1px solid rgba(0,0,0,.1);box-shadow:0 2px 10px rgba(0,0,0,.08);padding:10px 38px 30px;display:flex;flex-direction:column;font-family:'STIX Two Text',serif;font-size:13px;line-height:1.55}.page>.page-resource-header{flex:none;margin-bottom:8px}
.page-grid{flex:1;display:grid;grid-template-columns:112px 1fr 112px;column-gap:19px;min-height:0}
.margin-panel{display:flex;flex-direction:column;border:1px solid #d8d6d0;align-self:start;overflow:hidden}
.margin-panel-head{padding:5px 8px;background:#f4f3ef;border-bottom:1px solid #d8d6d0}
.margin-panel-title{font-weight:700;font-size:8.5px;letter-spacing:.06em;text-transform:uppercase;white-space:nowrap}
.margin-panel-subtitle{font:400 7px 'JetBrains Mono',monospace;color:#8a887f;white-space:nowrap}
.margin-panel-body{padding:6px 8px;display:flex;flex-direction:column;gap:3px;color:#1c1c1a}
.tree-we{font:600 7.5px/1.9 'JetBrains Mono',monospace;color:#3b5a8f}
.tree-line{display:flex;align-items:center;font:400 7.5px/1.9 'JetBrains Mono',monospace}
.tree-line span{width:4px;height:1px;background:#c9c7bf;margin-left:-7px;margin-right:3px;flex:none}.tree-line.level-1{margin-left:8px;border-left:1px solid #c9c7bf;padding-left:6px}.tree-line.level-2{margin-left:16px;border-left:1px solid #c9c7bf;padding-left:6px;color:#4a4944}
.kw-common{border-top:1px solid #e4e2dc;margin-top:2px;padding-top:3px;font:400 7px/1.6 'JetBrains Mono',monospace;color:#8a887f}.kw-common span{color:#1c1c1a}
.margin-panel-foot{padding:4px 8px;border-top:1px solid #d8d6d0;font:400 7px 'JetBrains Mono',monospace;color:#8a887f}
.page-main{display:flex;flex-direction:column;min-height:0}
.metric-box{border:1.5px solid #1c1c1a}.title-block{display:grid;grid-template-columns:1.08fr 2.55fr 1.25fr 66px;min-height:96px;border-bottom:1px solid #1c1c1a}.title-signers,.title-document{display:grid;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-signers{border-right:1px solid #1c1c1a}.title-document{border-right:1px solid #1c1c1a}.title-meta{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-cell,.title-name,.title-url{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:3px 6px;border-bottom:1px solid #d8d6d0}.title-signers .title-cell:last-child,.title-document>div:last-child,.title-meta .title-cell:nth-last-child(-n+2){border-bottom:0}.title-meta .title-cell:nth-child(odd){border-right:1px solid #d8d6d0}.title-cell span,.title-name span,.title-url span{font:600 6.7px/1.15 'JetBrains Mono',monospace;letter-spacing:.08em;text-transform:uppercase;color:#8a887f}.title-cell strong,.title-name strong{font-size:8.3px;line-height:1.13;font-weight:500;overflow-wrap:anywhere}.title-cell em{font:400 6.4px/1.1 'JetBrains Mono',monospace;color:#8a887f;font-style:normal}.title-category{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:2px 6px;background:#3b5a8f;color:#fefefc;font-size:7.1px;letter-spacing:.06em;overflow:hidden}.title-category strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.title-category span{font:400 6.1px 'JetBrains Mono',monospace;white-space:nowrap;color:#fefefc}.title-name strong{font-size:9.2px;font-weight:700}.title-url code{font:400 6.4px/1.15 'JetBrains Mono',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#4a4944}.title-qr-cell{display:flex;align-items:center;justify-content:center;padding:5px;border-left:1px solid #1c1c1a;background:#fefefc}.title-qr{width:100%;height:100%;display:block}.title-qr-empty{flex-direction:column;text-align:center;color:#8a887f}.title-qr-empty span{font:600 7px 'JetBrains Mono',monospace}.title-qr-empty strong{font:400 6px 'JetBrains Mono',monospace}.metric-title{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:9px 14px;border-bottom:1px solid #1c1c1a}.metric-title div{font-size:15px;font-weight:700}.metric-title span{font:500 9.5px 'JetBrains Mono',monospace;color:#4a4944;white-space:nowrap}
.metric-grid{display:grid;grid-template-columns:1fr 1fr;font-size:11px}.metric-grid div{display:flex;gap:8px;padding:4px 14px;border-bottom:1px solid #d8d6d0}.metric-grid div:nth-child(odd){border-right:1px solid #d8d6d0}.metric-grid div:nth-last-child(-n+2){border-bottom:0}.metric-grid span{font:500 8.5px/1.9 'JetBrains Mono',monospace;color:#8a887f;text-transform:uppercase;flex:none}.metric-grid strong{font-weight:400}.metric-grid i{flex:1;border-bottom:1px dotted #9a988f}
.plain-box{border:1px solid #1c1c1a;padding:8px 14px;margin-top:11px}.plain-title{font-weight:700;font-size:11.5px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:2px}.plain-box p{margin:0;text-align:justify}
.section-box{border:1px solid #1c1c1a;margin-top:10px}.section-box:first-child{margin-top:0}.section-box.dense{margin-top:8px}.section-head{display:flex;align-items:baseline;gap:8px;padding:5px 14px;background:#f4f3ef;border-bottom:1px solid #1c1c1a}.section-head span{font:600 9px 'JetBrains Mono',monospace;color:#8a887f}.section-head strong{font-weight:700;font-size:11.5px;letter-spacing:.12em;text-transform:uppercase}.section-body{padding:7px 14px}.dense .section-body{padding:6px 12px}
.objectives{display:flex;flex-direction:column;gap:4px}.objective-row{display:grid;grid-template-columns:16px 1fr 118px;column-gap:10px;align-items:baseline}.objective-row span:nth-child(2){text-align:justify}.objective-row code{font:500 8.5px 'JetBrains Mono',monospace;color:#3b5a8f;text-align:right}
.box-check{display:inline-block;width:11px;height:11px;border:1.2px solid #1c1c1a;align-self:center}
.agenda-grid{display:grid;grid-template-columns:56px 1fr;font-size:11px;line-height:1.4;padding-top:6px;padding-bottom:6px}.agenda-grid div{padding:2px 0;border-bottom:1px solid #f0efe9}.agenda-grid div:nth-last-child(-n+2){border-bottom:0}.agenda-time{font:500 9px/1.7 'JetBrains Mono',monospace;color:#3b5a8f;padding-right:8px!important}
.gate{display:flex;flex-direction:column;gap:4px;font-size:11.5px}.gate-row{display:grid;grid-template-columns:16px 1fr;column-gap:10px;align-items:center}.sign-row{grid-template-columns:16px 1fr 150px}.sign-row code{display:flex;align-items:baseline;gap:6px;font:400 8px 'JetBrains Mono',monospace;color:#8a887f}.sign-row i{flex:1;border-bottom:1px dotted #9a988f;height:12px}
.running-card-head{display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;border-bottom:1.5px solid #1c1c1a;font-size:11px}.running-card-head span{font-weight:600}.running-card-head code{font:500 9.5px 'JetBrains Mono',monospace;color:#8a887f}
.theory-table{width:100%;border-collapse:collapse;font-size:11px}.theory-table th{text-align:left;padding:3px 10px;border:1px solid #1c1c1a;background:#f4f3ef}.theory-table th:first-child{width:26%}.theory-table td{padding:3px 10px;vertical-align:top;border:1px solid #d8d6d0}.theory-table code{font:400 10px 'JetBrains Mono',monospace}
.tech-stack{display:grid;grid-template-columns:1fr 1fr;column-gap:18px;align-items:start}.stack-column h3{font:600 8.5px 'JetBrains Mono',monospace;letter-spacing:.08em;color:#4a4944;text-transform:uppercase;padding-bottom:3px;border-bottom:1px solid #1c1c1a;margin:0}.stack-grid{display:grid;grid-template-columns:88px 1fr;font-size:10px;line-height:1.35}.stack-grid div{padding:3px 0;border-bottom:1px solid #e4e2dc}.stack-grid div:nth-last-child(-n+2){border-bottom:0}.stack-grid code{font:400 9.5px/1.45 'JetBrains Mono',monospace}
.part-banner{border:1.5px solid #1c1c1a;background:#f4f3ef;padding:7px 14px;display:grid;grid-template-columns:190px 1fr;column-gap:12px;align-items:baseline}.part-banner strong{font:700 12px 'JetBrains Mono',monospace;letter-spacing:.08em}.part-banner span{font-size:12px;text-align:justify}
.compact-list{margin:3px 0 0 15px;padding:0;font-size:10.5px;line-height:1.32}.compact-list li{margin:1px 0}.code-subblock{border:1px solid #d8d6d0;border-left:3px solid #3b5a8f;padding:5px 7px;margin-top:5px}.code-subblock:first-child{margin-top:0}.code-subblock h3{margin:0 0 2px}.code-subblock>p{margin:0 0 4px}.three-col{display:grid;grid-template-columns:1fr 1fr 1fr;column-gap:12px}.three-col h3,.model-grid h3{font:600 8.5px 'JetBrains Mono',monospace;text-transform:uppercase;color:#4a4944;margin:0 0 3px}.three-col p,.model-grid p{margin:0 0 4px;font-size:10.5px}.pass-line{margin:5px 0 0;padding-top:4px;border-top:1px solid #e4e2dc;font-size:10.5px}
.math-task{display:grid;grid-template-columns:1fr 190px;column-gap:12px;align-items:start;border-bottom:1px solid #f0efe9;padding:3px 0;font-size:10.5px}.math-task:last-child{border-bottom:0}.math-task code{font:400 9px 'JetBrains Mono',monospace;color:#3b5a8f}.math-task em{grid-column:1 / -1;color:#8a887f;font-size:9.5px}.answer-line{display:block;border-bottom:1px dotted #9a988f;height:12px;margin-top:2px}
.model-grid{display:grid;grid-template-columns:1.5fr 1fr;column-gap:16px}.compact-table{width:100%;border-collapse:collapse;font-size:10px;line-height:1.25}.compact-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:3px 6px;text-align:left}.compact-table td{border:1px solid #d8d6d0;padding:3px 6px;vertical-align:top}.compact-table code{font:400 9px 'JetBrains Mono',monospace}.constants td:first-child{width:68%}.check-table td:first-child,.procedure-table td:first-child,.git-gate td:first-child{width:24px;text-align:center}.check-table td:nth-child(2){width:95px}
.command-list{margin:0 0 0 22px;padding:0;font-size:10.5px;line-height:1.45}.command-list code{font:400 9px 'JetBrains Mono',monospace}.safety-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.safety-card{border:1px solid #d8d6d0;padding:5px 7px}.safety-card strong{display:block;font:600 9px 'JetBrains Mono',monospace;color:#3b5a8f;margin-bottom:2px}.safety-card span{font-size:10.5px;line-height:1.32;text-align:justify}
.figure-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px}.figure-grid figure{margin:0}.figure-grid img{width:100%;max-height:160px;object-fit:contain;border:1px solid #d8d6d0;background:white}.figure-grid figcaption{font-size:8.5px;line-height:1.25;color:#4a4944;margin-top:2px}
.obs-table{width:100%;border-collapse:collapse;font-size:9px;line-height:1.22}.obs-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:3px 4px;text-align:left}.obs-table td{border:1px solid #d8d6d0;padding:4px;vertical-align:top;height:136px}.write-box{display:block;height:124px;border-bottom:1px dotted #9a988f}.write-box.small{height:32px}.trouble-table td{height:44px}.analysis-list{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px}.analysis-item{display:grid;grid-template-columns:22px 1fr;column-gap:6px;font-size:10.5px}.analysis-item i{grid-column:2;border-bottom:1px dotted #9a988f;height:13px}
.adr-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 14px}.adr-field strong{display:block;font-size:10.5px}.adr-field span{display:block;height:15px;border-bottom:1px dotted #9a988f}.signature-line{display:block;height:14px;border-bottom:1px dotted #9a988f}.git-gate td:last-child{width:74px}.bib-list{margin:2px 0 0 18px;padding:0;font-size:9px;line-height:1.25}.bib-list code{font:400 8.5px 'JetBrains Mono',monospace}
.page-footer{display:flex;justify-content:space-between;border-top:1.5px solid #1c1c1a;padding-top:8px;margin-top:14px;font:400 9.5px 'JetBrains Mono',monospace;color:#8a887f;gap:16px}
@media print{@page{size:A4 landscape;margin:0}body{background:#fff;padding:0;gap:0}.page{box-shadow:none!important;border:none!important;break-after:page}}
"""
css += page_resource_header_css()
card = data["card"]
return f"""<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<title>{html_escape(card_title_line_5a(card))} — karta pracy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=STIX+Two+Text:ital,wght@0,400;0,600;0,700;1,400;1,600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>{css}</style>
</head>
<body>
{"".join(render_5a_page_html(data, page, index, total_pages) for index, page in enumerate(pages, start=1))}
</body>
</html>
"""
def render_margin_tree_panel_5b_html(data: dict, column: str) -> str:
is_tech = column == "zawodowe"
tags = learning_tree_tags(
data,
column=column,
effect_color="orange!85!black" if is_tech else "green!50!black",
)
tree_parts: list[str] = []
for group in margin_tree_groups_5b(tags):
if group.get("we"):
tree_parts.append(f'<div class="tree-we">{html_escape(group["we"])}</div>')
for item in group.get("items", []):
tree_parts.append(f'<div class="tree-line level-1">{html_escape(item["code"])}</div>')
return '<aside class="margin-tree">' + "".join(tree_parts) + "</aside>"
def build_5b_pages_html(data: dict) -> list[dict]:
parts = data.get("parts", {})
return [
{
"kind": "portrait",
"center": "".join(
[
render_metric_header_5a_html(data),
render_objectives_5a_html(data),
render_agenda_5a_html(data),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_mission_5a_html(data),
render_theory_5a_html(data),
render_tech_stack_5a_html(data),
render_hw_gate_5a_html(data),
render_math_5a_html(data),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_part_banner_5a_html(parts.get("a", {})),
render_model_5a_html(data),
render_code_block_5a_html(data),
render_container_test_5a_html(data),
render_bom_5a_html(data),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_part_banner_5a_html(parts.get("b", {})),
render_safety_5a_html(data),
render_settings_figures_5a_html(data),
]
),
},
{
"kind": "technical_schematic",
"center": render_technical_schematic_5b_html(data, 13),
},
{
"kind": "portrait",
"center": "".join(
[
render_connection_details_5b_html(data, 13),
render_procedure_5a_html(data, 14),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_observation_5a_html(data, 15),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_troubleshooting_5a_html(data, 16),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_analysis_5a_html(data, 17, 9),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_team_decision_5a_html(data, 18, 36),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_git_gate_5a_html(data, 19),
render_session_ai_bibliography_5a_html(data, 20),
]
),
},
]
def render_5b_point_section_numbers_html(markup: str) -> str:
return re.sub(r'(<div class="section-head"><span>)§([0-9]+)(</span>)', r"\1\2.\3", markup)
def render_5b_page_html(data: dict, center: str, page: int, total: int) -> str:
return (
f'<div class="page" data-screen-label="5b strona {page}">'
f'{render_page_header_html(data, page, total)}'
'<div class="page-grid">'
f'{render_margin_tree_panel_5b_html(data, "zawodowe")}'
'<div class="tree-gap-left" aria-hidden="true"></div>'
f'<main class="page-main">{center}</main>'
'<div class="tree-gap-right" aria-hidden="true"></div>'
f'{render_margin_tree_panel_5b_html(data, "ogolne")}'
'</div>'
f'{footer_5a_html(data, page, total)}'
'</div>'
)
def render_5b_technical_page_html(data: dict, center: str, page: int, total: int) -> str:
return (
f'<div class="page page-technical" data-screen-label="5b strona {page} techniczna">'
f'{render_page_header_html(data, page, total)}'
'<div class="page-grid technical-page-grid">'
f'{render_margin_tree_panel_5b_html(data, "zawodowe")}'
'<div class="tree-gap-left" aria-hidden="true"></div>'
f'<main class="page-main technical-main">{center}</main>'
'<div class="tree-gap-right" aria-hidden="true"></div>'
f'{render_margin_tree_panel_5b_html(data, "ogolne")}'
'</div>'
f'{footer_5a_html(data, page, total)}'
'</div>'
)
def render_5b_document_page_html(data: dict, page_data: dict, page: int, total: int) -> str:
center = render_5b_point_section_numbers_html(str(page_data.get("center", "")))
if page_data.get("kind") == "technical_schematic":
return render_5b_technical_page_html(data, center, page, total)
return render_5b_page_html(data, center, page, total)
def render_main_html_5b_css(template: dict | None = None) -> str:
css = r"""
*{-webkit-print-color-adjust:exact;print-color-adjust:exact;box-sizing:border-box}
body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items:center;gap:24px;padding:24px}
.page{position:relative;--page-rule-left:119px;--page-rule-width:556px;width:794px;height:1123px;background:#fefefc;color:#1c1c1a;border:1px solid rgba(0,0,0,.1);box-shadow:0 2px 10px rgba(0,0,0,.08);padding:20px 21px 95px;display:flex;flex-direction:column;font-family:'STIX Two Text',serif;font-size:11px;line-height:1.42}.page>.page-resource-header{flex:none;margin-bottom:8px}
.page-grid{flex:1;display:grid;grid-template-columns:77px 21px minmax(0,1fr) 21px 77px;column-gap:0;min-height:0}
.margin-tree{align-self:start;font:400 7.2px/1.55 'JetBrains Mono',monospace;color:#1c1c1a;white-space:nowrap;overflow:hidden;padding-top:2px}
.tree-gap-left,.tree-gap-right{min-width:0}.tree-we{font-weight:600;color:#3b5a8f;margin-top:2px}.tree-we:first-child{margin-top:0}.tree-line.level-1{padding-left:5px}.tree-line.level-2{padding-left:10px;color:#4a4944}
.page-main{display:flex;flex-direction:column;min-height:0;justify-content:space-between}
.page.page-technical{--page-rule-left:104px;--page-rule-width:915px;width:1123px;height:794px}.page-technical .technical-page-grid{grid-template-columns:77px 21px minmax(0,1fr) 21px 77px}.technical-main{display:flex;min-height:0}.technical-section{flex:1;display:flex;flex-direction:column;margin-top:0}.technical-section .section-body{flex:1;min-height:0;padding:6px 8px}.technical-sheet-figure{height:100%;min-height:0;margin:0;display:flex;flex-direction:column;gap:4px}.technical-sheet-link{flex:1;min-height:0;display:flex;align-items:center;justify-content:center}.technical-sheet-figure img{width:100%;height:100%;object-fit:contain;border:1px solid #d8d6d0;background:white}.technical-sheet-figure figcaption{display:flex;justify-content:space-between;align-items:baseline;gap:10px;font-size:7.2px;line-height:1.18;color:#4a4944}.technical-sheet-figure figcaption span{min-width:0}.technical-sheet-missing{flex:1;display:flex;align-items:center;justify-content:center;border:1px solid #d8d6d0;color:#8a887f;font:600 8px 'JetBrains Mono',monospace;text-transform:uppercase}
.metric-box{border:1.4px solid #1c1c1a}.title-block{display:grid;grid-template-columns:1.0fr 2.35fr 1.24fr 52px;min-height:82px;border-bottom:1px solid #1c1c1a}.title-signers,.title-document{display:grid;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-signers{border-right:1px solid #1c1c1a}.title-document{border-right:1px solid #1c1c1a}.title-meta{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-cell,.title-name,.title-url{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:2px 4px;border-bottom:1px solid #d8d6d0}.title-signers .title-cell:last-child,.title-document>div:last-child,.title-meta .title-cell:nth-last-child(-n+2){border-bottom:0}.title-meta .title-cell:nth-child(odd){border-right:1px solid #d8d6d0}.title-cell span,.title-name span,.title-url span{font:600 5.4px/1.12 'JetBrains Mono',monospace;letter-spacing:.07em;text-transform:uppercase;color:#8a887f}.title-cell strong,.title-name strong{font-size:6.9px;line-height:1.1;font-weight:500;overflow-wrap:anywhere}.title-cell em{font:400 5.2px/1.05 'JetBrains Mono',monospace;color:#8a887f;font-style:normal}.title-category{display:flex;align-items:center;justify-content:space-between;gap:4px;padding:2px 4px;background:#3b5a8f;color:#fefefc;font-size:5.9px;letter-spacing:.04em;overflow:hidden}.title-category strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.title-category span{font:400 5.1px 'JetBrains Mono',monospace;white-space:nowrap;color:#fefefc}.title-name strong{font-size:7.6px;font-weight:700}.title-url code{font:400 5.4px/1.12 'JetBrains Mono',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#4a4944}.title-qr-cell{display:flex;align-items:center;justify-content:center;padding:4px;border-left:1px solid #1c1c1a;background:#fefefc}.title-qr{width:100%;height:100%;display:block}.title-qr-empty{flex-direction:column;text-align:center;color:#8a887f}.title-qr-empty span{font:600 6px 'JetBrains Mono',monospace}.title-qr-empty strong{font:400 5px 'JetBrains Mono',monospace}.metric-title{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:7px 10px;border-bottom:1px solid #1c1c1a}.metric-title div{font-size:12.5px;font-weight:700}.metric-title span{font:500 8px 'JetBrains Mono',monospace;color:#4a4944;white-space:nowrap}
.metric-grid{display:grid;grid-template-columns:1fr 1fr;font-size:9.4px}.metric-grid div{display:flex;gap:6px;padding:3px 10px;border-bottom:1px solid #d8d6d0}.metric-grid div:nth-child(odd){border-right:1px solid #d8d6d0}.metric-grid div:nth-last-child(-n+2){border-bottom:0}.metric-grid span{font:500 7.2px/1.7 'JetBrains Mono',monospace;color:#8a887f;text-transform:uppercase;flex:none}.metric-grid strong{font-weight:400}.metric-grid i{flex:1;border-bottom:1px dotted #9a988f}
.plain-box{border:1px solid #1c1c1a;padding:6px 10px;margin-top:7px}.plain-title{font-weight:700;font-size:9.8px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:1px}.plain-box p{margin:0;text-align:justify}
.section-box{border:1px solid #1c1c1a;margin-top:7px}.section-box:first-child{margin-top:0}.section-box.dense{margin-top:6px}.section-head{display:flex;align-items:baseline;gap:6px;padding:4px 10px;background:#f4f3ef;border-bottom:1px solid #1c1c1a}.section-head span{font:600 7.8px 'JetBrains Mono',monospace;color:#8a887f}.section-head strong{font-weight:700;font-size:9.6px;letter-spacing:.12em;text-transform:uppercase}.section-body{padding:6px 10px}.dense .section-body{padding:5px 9px}
.objectives{display:flex;flex-direction:column;gap:3px}.objective-row{display:grid;grid-template-columns:14px 1fr 92px;column-gap:7px;align-items:baseline}.objective-row span:nth-child(2){text-align:justify}.objective-row code{font:500 6.8px 'JetBrains Mono',monospace;color:#3b5a8f;text-align:right}
.box-check{display:inline-block;width:10px;height:10px;border:1.1px solid #1c1c1a;align-self:center}
.agenda-grid{display:grid;grid-template-columns:46px 1fr;font-size:9.6px;line-height:1.35;padding-top:4px;padding-bottom:4px}.agenda-grid div{padding:1px 0;border-bottom:1px solid #f0efe9}.agenda-grid div:nth-last-child(-n+2){border-bottom:0}.agenda-time{font:500 7.6px/1.55 'JetBrains Mono',monospace;color:#3b5a8f;padding-right:6px!important}
.gate{display:flex;flex-direction:column;gap:3px;font-size:9.6px}.gate-row{display:grid;grid-template-columns:14px 1fr;column-gap:7px;align-items:center}.sign-row{grid-template-columns:14px 1fr 112px}.sign-row code{display:flex;align-items:baseline;gap:5px;font:400 7px 'JetBrains Mono',monospace;color:#8a887f}.sign-row i{flex:1;border-bottom:1px dotted #9a988f;height:10px}
.running-card-head{position:relative;display:flex;justify-content:space-between;align-items:baseline;padding:4px 0 6px;border-bottom:0;font-size:9.6px}.running-card-head::after{content:"";position:absolute;left:0;right:0;bottom:0;border-bottom:1.4px solid #1c1c1a}.running-card-head span{font-weight:600}.running-card-head code{font:500 7.8px 'JetBrains Mono',monospace;color:#8a887f}
.theory-table{width:100%;border-collapse:collapse;font-size:9.4px}.theory-table th{text-align:left;padding:2px 7px;border:1px solid #1c1c1a;background:#f4f3ef}.theory-table th:first-child{width:31%}.theory-table td{padding:2px 7px;vertical-align:top;border:1px solid #d8d6d0}.theory-table code{font:400 8px 'JetBrains Mono',monospace}
.tech-stack{display:grid;grid-template-columns:1fr 1fr;column-gap:10px;align-items:start}.stack-column h3{font:600 7.2px 'JetBrains Mono',monospace;letter-spacing:.08em;color:#4a4944;text-transform:uppercase;padding-bottom:2px;border-bottom:1px solid #1c1c1a;margin:0}.stack-grid{display:grid;grid-template-columns:70px 1fr;font-size:8.6px;line-height:1.25}.stack-grid div{padding:2px 0;border-bottom:1px solid #e4e2dc}.stack-grid div:nth-last-child(-n+2){border-bottom:0}.stack-grid code{font:400 7.7px/1.35 'JetBrains Mono',monospace}
.part-banner{border:1.4px solid #1c1c1a;background:#f4f3ef;padding:5px 10px;display:grid;grid-template-columns:126px 1fr;column-gap:9px;align-items:baseline}.part-banner strong{font:700 9px 'JetBrains Mono',monospace;letter-spacing:.08em}.part-banner span{font-size:9.4px;text-align:justify}
.compact-list{margin:2px 0 0 13px;padding:0;font-size:8.7px;line-height:1.25}.compact-list li{margin:1px 0}.code-subblock{border:1px solid #d8d6d0;border-left:3px solid #3b5a8f;padding:4px 5px;margin-top:4px}.code-subblock:first-child{margin-top:0}.code-subblock h3{margin:0 0 2px}.code-subblock>p{margin:0 0 3px}.three-col,.connection-notes{display:grid;grid-template-columns:1fr 1fr 1fr;column-gap:8px}.connection-notes{grid-template-columns:1fr 1fr;margin-top:4px}.three-col h3,.model-grid h3,.connection-notes h3{font:600 7.2px 'JetBrains Mono',monospace;text-transform:uppercase;color:#4a4944;margin:0 0 2px}.three-col p,.model-grid p{margin:0 0 3px;font-size:8.8px}.pass-line{margin:4px 0 0;padding-top:3px;border-top:1px solid #e4e2dc;font-size:8.6px}
.math-task{display:grid;grid-template-columns:1fr 126px;column-gap:8px;align-items:start;border-bottom:1px solid #f0efe9;padding:3px 0;font-size:9px}.math-task:last-child{border-bottom:0}.math-task code{font:400 7.5px 'JetBrains Mono',monospace;color:#3b5a8f}.math-task em{grid-column:1 / -1;color:#8a887f;font-size:7.8px}.answer-line{display:block;border-bottom:1px dotted #9a988f;height:12px;margin-top:2px}
.model-grid{display:grid;grid-template-columns:1.25fr 1fr;column-gap:10px}.compact-table{width:100%;border-collapse:collapse;font-size:8.4px;line-height:1.18}.compact-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:2px 4px;text-align:left}.compact-table td{border:1px solid #d8d6d0;padding:2px 4px;vertical-align:top}.compact-table code{font:400 7.5px 'JetBrains Mono',monospace}.constants td:first-child{width:66%}.check-table td:first-child,.procedure-table td:first-child,.git-gate td:first-child{width:20px;text-align:center}.check-table td:nth-child(2){width:70px}.procedure-table td{height:25px}
.command-list{margin:0 0 0 18px;padding:0;font-size:8.8px;line-height:1.32}.command-list code{font:400 7.5px 'JetBrains Mono',monospace}.safety-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}.safety-card{border:1px solid #d8d6d0;padding:4px 5px}.safety-card strong{display:block;font:600 7.5px 'JetBrains Mono',monospace;color:#3b5a8f;margin-bottom:1px}.safety-card span{font-size:8.4px;line-height:1.22;text-align:justify}
.figure-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:5px}.figure-grid figure{margin:0}.figure-grid img{width:100%;max-height:165px;object-fit:contain;border:1px solid #d8d6d0;background:white}.figure-grid figcaption{font-size:7px;line-height:1.16;color:#4a4944;margin-top:1px}.connection-figure img{width:100%;max-height:310px;object-fit:contain;border:1px solid #d8d6d0;background:white}.figure-pdf-link{display:inline-block;margin-top:3px;font:600 7.2px 'JetBrains Mono',monospace;color:#3b5a8f;text-decoration:none;text-transform:uppercase}
.obs-table{width:100%;border-collapse:collapse;font-size:7.4px;line-height:1.16}.obs-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:2px 3px;text-align:left}.obs-table td{border:1px solid #d8d6d0;padding:3px;vertical-align:top;height:185px}.write-box{display:block;height:173px;border-bottom:1px dotted #9a988f}.write-box.small{height:76px}.procedure-table td{height:56px}.trouble-table td{height:92px}
.analysis-list{display:grid;grid-template-columns:1fr;gap:7px}.analysis-item{display:grid;grid-template-columns:18px 1fr;column-gap:5px;font-size:9px}.analysis-item i{grid-column:2;border-bottom:1px dotted #9a988f;height:14px}
.adr-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px 10px}.adr-field strong{display:block;font-size:8.8px}.adr-field span{display:block;height:15px;border-bottom:1px dotted #9a988f}.signature-line{display:block;height:18px;border-bottom:1px dotted #9a988f}.git-gate td:last-child{width:58px}.git-gate td{height:34px}.bib-list{margin:2px 0 0 16px;padding:0;font-size:7.4px;line-height:1.18}.bib-list code{font:400 6.8px 'JetBrains Mono',monospace}
.page-footer{position:absolute;left:var(--page-rule-left);bottom:95px;width:var(--page-rule-width);display:flex;justify-content:space-between;border-top:0;padding-top:6px;margin-top:0;font:400 8.2px 'JetBrains Mono',monospace;color:#8a887f;gap:10px}.page-footer::before{content:"";position:absolute;left:0;right:0;top:0;border-top:1.4px solid #1c1c1a}
@media print{@page{size:A4 portrait;margin:0}body{background:#fff;padding:0;gap:0}.page{box-shadow:none!important;border:none!important;break-after:page}}
"""
return css + page_resource_header_css()
def render_main_html_5b(data: dict, template: dict) -> str:
pages = build_5b_pages_html(data)
total_pages = len(pages)
card = data["card"]
return f"""<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<title>{html_escape(card_title_line_5a(card))} — karta pracy</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
{"".join(render_5b_document_page_html(data, page, index, total_pages) for index, page in enumerate(pages, start=1))}
</body>
</html>
"""
def tex_5a(value: object) -> str:
text = str(value)
text = text.replace("<->", "<->")
text = text.replace("", "->").replace("", "<->")
text = text.replace("", "--").replace("", "--")
text = text.replace("·", "*")
text = " ".join(text.split())
return tex_escape(text)
def render_page_header_5x_tex(data: dict, page: int, total: int) -> list[str]:
"""Render an exact 30 mm header inside explicit 5a/5b page wrappers."""
card = data["card"]
fields = title_block_fields(data)
url = str(fields["url"])
source_url = str(fields["repository_url"])
dual_qr = bool(fields["show_repository_qr"])
if fields["show_qr"] and not url:
raise RuntimeError("Nie można wygenerować QR bez kanonicznego URL")
if dual_qr and not source_url:
raise RuntimeError("Nie można wygenerować QR bez adresu repozytorium")
for field_name, target in (("url", url), ("repository_url", source_url)):
if target and any(char in target for char in "{}\\\n\r%#"):
raise RuntimeError(
f"title_block.{field_name} zawiera znaki nieobsługiwane przez nagłówek TeX"
)
page_qr = (
rf"\qrcode[level=L,height=22mm]{{{url}}}"
if fields["show_qr"]
else r"{\tiny QR wyłączony}"
)
source_qr = rf"\qrcode[level=L,height=22mm]{{{source_url}}}" if dual_qr else ""
title = f"{card_prefix(card)}: {card.get('title', '')}"
card_number = f"{card.get('number', '')}/{card.get('count', '')}"
primary_width = r"\dimexpr\linewidth-111mm\relax" if dual_qr else r"\dimexpr\linewidth-85mm\relax"
separator = r"\hspace{0.85mm}\vrule width0.3pt height26mm\hspace{0.85mm}%"
source_cell = (
[
r"\begin{minipage}[c][26mm][c]{24mm}\centering",
source_qr + r"\par",
r"\vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily GITEA}",
r"\end{minipage}" + separator,
]
if dual_qr
else []
)
source_url_line = (
rf"{{\fontsize{{4.2}}{{4.8}}\selectfont\ttfamily GITEA: \href{{{source_url}}}{{\nolinkurl{{{source_url}}}}}}}\par"
if source_url
else ""
)
return [
r"\begin{tcolorbox}[enhanced,height=30mm,valign=center,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=1.2mm,right=1.2mm,top=0.8mm,bottom=0.8mm]",
r"\setlength{\parskip}{0pt}%",
*source_cell,
rf"\begin{{minipage}}[c][26mm][c]{{{primary_width}}}",
rf"{{\fontsize{{5.2}}{{5.9}}\selectfont\ttfamily\textcolor{{ESCMuted}}{{{tex_5a(fields['category'])}}}}}\par",
rf"{{\fontsize{{8.6}}{{9.5}}\selectfont\bfseries {tex_5a(title)}}}\par",
rf"{{\fontsize{{5.8}}{{6.6}}\selectfont Projekt: \texttt{{{tex_5a(card.get('project', ''))}}}\quad Seria: \texttt{{{tex_5a(card.get('series_title', card.get('series', '')))}}}}}\par",
rf"{{\fontsize{{5.8}}{{6.6}}\selectfont Przedmiot: \texttt{{{tex_5a(card.get('subject', ''))}}}\quad Poziom: \texttt{{{tex_5a(card.get('level', ''))}}}}}\par",
source_url_line,
rf"{{\fontsize{{4.2}}{{4.8}}\selectfont\ttfamily HTML: \href{{{url}}}{{\nolinkurl{{{url}}}}}}}",
r"\end{minipage}" + separator,
r"\begin{minipage}[c][26mm][c]{55mm}",
rf"{{\fontsize{{5.5}}{{6.2}}\selectfont Data: \texttt{{{tex_5a(card.get('revision_date', ''))}}}\hfill Status: \texttt{{{tex_5a(card.get('status', ''))}}}}}\par",
rf"{{\fontsize{{5.5}}{{6.2}}\selectfont Karta: \texttt{{{tex_5a(card_number)}}}\hfill Wersja: \texttt{{{tex_5a(card.get('version', ''))}}}}}\par",
rf"{{\fontsize{{5.5}}{{6.2}}\selectfont Autor: \texttt{{{tex_5a(card.get('author', ''))}}}\hfill Arkusz: \texttt{{{page}/{total}}}}}\par",
r"\vspace{0.4mm}\hrule\vspace{0.4mm}",
rf"{{\fontsize{{4.6}}{{5.2}}\selectfont\ttfamily UUID: {tex_5a(card.get('uuid', ''))}}}",
r"\end{minipage}" + separator,
r"\begin{minipage}[c][26mm][c]{24mm}\centering",
page_qr + r"\par",
r"\vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily HTML}",
r"\end{minipage}",
r"\end{tcolorbox}",
r"\vspace{1.5mm}",
]
def render_preamble_5a(card: dict) -> list[str]:
return [
r"\documentclass[10pt,a4paper]{article}",
r"\usepackage[T1]{fontenc}",
r"\usepackage[utf8]{inputenc}",
r"\usepackage[polish]{babel}",
r"\usepackage{array}",
r"\usepackage{tabularx}",
r"\usepackage{multirow}",
r"\usepackage{xcolor}",
r"\usepackage{amssymb}",
r"\usepackage{graphicx}",
r"\usepackage{microtype}",
r"\usepackage[a4paper,landscape,left=1.0cm,right=1.0cm,top=1.0cm,bottom=0.8cm]{geometry}",
r"\IfFileExists{stix2.sty}{\usepackage{stix2}}{\usepackage{lmodern}}",
r"\IfFileExists{inconsolata.sty}{\usepackage{inconsolata}}{\usepackage{lmodern}}",
r"\usepackage[most]{tcolorbox}",
r"\usepackage{qrcode}",
r"\usepackage{hyperref}",
r"\hypersetup{hidelinks}",
r"\IfFileExists{build-meta.tex}{\input{build-meta.tex}}{\newcommand{\BuildCommit}{local}}",
macro("CardNumber", card["number"]),
macro("CardCount", card["count"]),
macro("CardVersion", card["version"]),
macro("DocumentUUID", card["uuid"]),
r"\definecolor{ESCPaper}{HTML}{FEFEFC}",
r"\definecolor{ESCInk}{HTML}{1C1C1A}",
r"\definecolor{ESCMuted}{HTML}{8A887F}",
r"\definecolor{ESCSubtle}{HTML}{4A4944}",
r"\definecolor{ESCHeader}{HTML}{F4F3EF}",
r"\definecolor{ESCLine}{HTML}{D8D6D0}",
r"\definecolor{ESCSoftLine}{HTML}{E4E2DC}",
r"\definecolor{ESCAccent}{HTML}{3B5A8F}",
r"\pagecolor{ESCPaper}",
r"\color{ESCInk}",
r"\setlength{\parindent}{0pt}",
r"\setlength{\parskip}{0.25em}",
r"\setlength{\emergencystretch}{3em}",
r"\setlength{\tabcolsep}{3pt}",
r"\renewcommand{\arraystretch}{1.12}",
r"\newcolumntype{Y}{>{\raggedright\arraybackslash}X}",
r"\newcommand{\FiveACheck}{\(\square\)}",
r"\newcommand{\FiveAFill}{\dotfill}",
r"\newcommand{\FiveAWriteLine}{\par\noindent\dotfill}",
r"\newtcolorbox{FiveASection}[2]{enhanced,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=2.8mm,right=2.8mm,top=1.2mm,bottom=1.2mm,colbacktitle=ESCHeader,coltitle=ESCInk,fonttitle=\bfseries\footnotesize,title={\ttfamily\textcolor{ESCMuted}{\S#1}\quad #2}}",
r"\newtcolorbox{FiveAPart}{enhanced,arc=0pt,boxrule=0.6pt,colback=ESCHeader,colframe=ESCInk,boxsep=0pt,left=2.8mm,right=2.8mm,top=1.2mm,bottom=1.2mm}",
r"\newtcolorbox{CardContentBlock}{enhanced,arc=0pt,boxrule=0.28pt,colback=ESCPaper,colframe=ESCLine,boxsep=0pt,left=1.4mm,right=1.4mm,top=0.8mm,bottom=0.8mm}",
"",
]
def render_margin_tree_panel_5a_tex(data: dict, column: str) -> list[str]:
is_tech = column == "zawodowe"
tags = learning_tree_tags(
data,
column=column,
effect_color="orange!85!black" if is_tech else "green!50!black",
)
title = "TECH -- zawodowe" if is_tech else "OG -- ogólne"
subtitle = "WE -> EK -> KW" if is_tech else "WE -> EN -> KW"
dominance = "dominanta zawodowa: ELE/AUT/ELM/INF" if is_tech else "dominanta ogólna: FIZ/MAT/INF"
lines = [
r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.3pt,colback=ESCPaper,colframe=ESCLine,boxsep=0pt,left=0.8mm,right=0.8mm,top=0.8mm,bottom=0.8mm]",
rf"{{\scriptsize\bfseries {tex_5a(title)}\par}}",
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(subtitle)}}}\par}}",
r"\vspace{0.3em}{\tiny\ttfamily\raggedright",
]
for group in margin_tree_groups_5a(tags):
if group.get("we"):
lines.append(rf"\textcolor{{ESCAccent}}{{\textbf{{{tex_escape(group['we'])}}}}}\par")
for item in group.get("items", []):
lines.append(rf"\hspace*{{0.6em}}{tex_escape(item['code'])}\par")
for kw_code in item.get("kw", []):
lines.append(rf"\hspace*{{1.2em}}\textcolor{{ESCSubtle}}{{{tex_escape(kw_code)}}}\par")
lines.extend(
[
r"}\vspace{0.25em}",
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{KW wspólne:}}\par {tex_escape(common_kw_5a(tags))}\par}}",
r"\tcblower",
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_5a(dominance)}}}}}",
r"\end{tcolorbox}",
]
)
return lines
def render_metric_header_5a_tex(data: dict) -> list[str]:
card = data["card"]
fields = data.get("metric_fill_fields") or [{"label": "Uczeń"}, {"label": "Klasa · data"}]
first_label = fields[0].get("label", "Uczeń") if fields else "Uczeń"
second_label = fields[1].get("label", "Klasa · data") if len(fields) > 1 else "Klasa · data"
return [
r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.6pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=0pt,right=0pt,top=0.7mm,bottom=0.7mm]",
r"\hspace{2.0mm}{\tiny\ttfamily\textcolor{ESCMuted}{PROJEKT}}\hspace{0.8mm}{\scriptsize " + tex_5a(card["project"]) + r"}\hfill{\tiny\ttfamily\textcolor{ESCMuted}{PRZEDMIOT}}\hspace{0.8mm}{\scriptsize " + tex_5a(card["subject"]) + r"}\hspace{2.0mm}\par",
rf"\hspace{{2.0mm}}{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(first_label.upper())}}}}}\hspace{{0.8mm}}\FiveAFill\hfill{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(second_label.upper())}}}}}\hspace{{0.8mm}}\FiveAFill\hspace{{2.0mm}}\par",
r"\end{tcolorbox}",
]
def section_5a_tex(number: int, title: str, body: list[str]) -> list[str]:
return [
rf"\begin{{FiveASection}}{{{number}}}{{{tex_5a(title.upper())}}}",
*body,
r"\end{FiveASection}",
]
def render_mission_5a_tex(data: dict) -> list[str]:
return [
r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,left=2.8mm,right=2.8mm,top=1.5mm,bottom=1.5mm]",
r"{\bfseries\footnotesize MISJA}\par",
rf"\scriptsize {tex_5a(data.get('mission', ''))}",
r"\end{tcolorbox}",
]
def render_objectives_5a_tex(data: dict) -> list[str]:
refs = data.get("objective_refs", [])
body = [
r"\scriptsize",
r"\begin{tabularx}{\linewidth}{@{}p{0.45cm}X>{\raggedleft\arraybackslash}p{3.45cm}@{}}",
]
for index, objective in enumerate(data.get("objectives", []), start=1):
ref = refs[index - 1] if index - 1 < len(refs) else {}
ref_text = f"<- TECH {ref.get('tech', f'{index:02d}')} · OG {ref.get('og', f'{index:02d}')} ->"
body.append(rf"\FiveACheck & {index}. {tex_5a(objective)} & {{\tiny\ttfamily\textcolor{{ESCAccent}}{{{tex_5a(ref_text)}}}}} \\")
body.append(r"\end{tabularx}")
return section_5a_tex(1, "Cele operacyjne", body)
def render_agenda_5a_tex(data: dict) -> list[str]:
body = [
r"\scriptsize",
r"\begin{tabularx}{\linewidth}{@{}>{\ttfamily\textcolor{ESCAccent}}p{1.3cm}X@{}}",
]
for row in data.get("agenda", []):
body.append(rf"{tex_5a(agenda_time_5a(row.get('time', '')))} & {tex_5a(row.get('work', ''))} \\")
body.append(r"\end{tabularx}")
return section_5a_tex(2, "Agenda 45 min", body)
def render_hw_gate_5a_tex(data: dict) -> list[str]:
body = [
r"\scriptsize",
r"\begin{tabularx}{\linewidth}{@{}p{0.45cm}X@{}}",
]
for criterion in data.get("hw_gate", {}).get("criteria", []):
text = criterion.get("check") or criterion.get("criterion", "")
body.append(rf"\FiveACheck & {tex_5a(text)} \\")
body.extend(
[
r"\end{tabularx}",
r"\hfill{\tiny\ttfamily\textcolor{ESCMuted}{podpis nauczyciela:}}\quad\rule{3.4cm}{0.3pt}",
]
)
return section_5a_tex(3, "Warunki wejścia na sprzęt (bramka A -> B)", body)
def render_theory_5a_tex(data: dict) -> list[str]:
theory = data.get("theory_refs", {})
body = [
r"\scriptsize",
]
if theory.get("intro"):
body.extend([tex_5a(theory.get("intro", "")), r"\par\vspace{0.35em}"])
body.extend([
r"\begin{tabularx}{\linewidth}{|>{\ttfamily}p{4.0cm}|X|}",
r"\hline",
r"\textbf{Karta / źródło} & \textbf{Użycie w symulacji} \\",
r"\hline",
])
for index, item in enumerate(theory.get("known_refs", []), start=1):
body.append(rf"{tex_5a(item.get('id', ''))} [{index}] & {tex_5a(item.get('note') or item.get('label', ''))} \\")
body.append(r"\hline")
body.append(r"\end{tabularx}")
concepts = theory.get("new_concepts", [])
if concepts:
body.extend([
r"\vspace{0.35em}",
r"\begin{tabularx}{\linewidth}{|p{4.0cm}|X|}",
r"\hline",
r"\textbf{Nowe pojęcie} & \textbf{Definicja robocza} \\",
r"\hline",
])
for concept in concepts:
body.append(rf"{tex_5a(concept.get('term', ''))} & {tex_5a(concept.get('definition', ''))} \\")
body.append(r"\hline")
body.append(r"\end{tabularx}")
return section_5a_tex(4, "Teoria pod model i symulację", body)
def render_stack_column_5a_tex(title: str, rows: list[dict]) -> list[str]:
lines = [
rf"{{\scriptsize\ttfamily\bfseries {tex_escape(title.upper())}}}\par",
r"\vspace{0.1em}\hrule\vspace{0.2em}",
r"\tiny",
r"\begin{tabularx}{\linewidth}{@{}>{\ttfamily}p{2.0cm}X@{}}",
]
for row in rows:
lines.append(rf"{tex_5a(row.get('name', ''))} & {tex_5a(row.get('use', ''))} \\")
lines.append(r"\end{tabularx}")
return lines
def render_tech_stack_5a_tex(data: dict) -> list[str]:
stack = data.get("tech_stack", {})
body = [
r"\begin{minipage}[t]{0.48\linewidth}",
*render_stack_column_5a_tex("Software", stack.get("software", [])),
r"\end{minipage}\hfill",
r"\begin{minipage}[t]{0.48\linewidth}",
*render_stack_column_5a_tex("Hardware", stack.get("hardware", [])),
r"\end{minipage}",
]
return section_5a_tex(5, "Technologie stanowiska", body)
def render_part_banner_5a_tex(part: dict) -> list[str]:
return [
r"\begin{FiveAPart}",
rf"{{\scriptsize\ttfamily\bfseries {tex_5a(part.get('title', ''))}}}\quad {{\tiny {tex_5a(part.get('body', ''))}}}",
r"\end{FiveAPart}",
]
def tex_compact_items_5a(items: Iterable[object], size: str = r"\tiny") -> list[str]:
items = list(items)
if not items:
return []
lines = [rf"\begin{{itemize}}\setlength\itemsep{{0pt}}\setlength\topsep{{0pt}}{size}"]
for item in items:
lines.append(rf"\item {tex_5a(item)}")
lines.append(r"\end{itemize}")
return lines
def render_code_columns_5a_tex(block: dict) -> list[str]:
body: list[str] = []
columns = code_block_columns_5a(block)
width = "0.32"
for index, column in enumerate(columns[:3]):
if index > 0:
body.append(r"\hfill")
body.append(rf"\begin{{minipage}}[t]{{{width}\linewidth}}{{\tiny\ttfamily\bfseries {tex_5a(column.get('title', ''))}}}\par")
body.extend(tex_compact_items_5a(column.get("items", [])))
body.append(r"\end{minipage}")
return body
def render_code_subblock_5a_tex(block: dict) -> list[str]:
body = [
r"\begin{CardContentBlock}",
rf"\textbf{{{tex_5a(block.get('title', ''))}}}\par",
tex_5a(block.get("context", "")),
r"\par\vspace{0.15em}",
*render_code_columns_5a_tex(block),
]
if block.get("pass_condition"):
body.append(rf"\par{{\tiny\ttfamily\textcolor{{ESCAccent}}{{PASS: {tex_5a(block.get('pass_condition', ''))}}}}}")
body.extend([r"\end{CardContentBlock}", r"\vspace{0.25em}"])
return body
def render_math_5a_tex(data: dict) -> list[str]:
block = data.get("math_block", {})
body: list[str] = [r"\scriptsize"]
for index, task in enumerate(block.get("tasks", []), start=1):
body.append(rf"\textbf{{{index}.}} {tex_5a(task.get('prompt', ''))}\par")
equation = task.get("equation_tex", "")
if equation:
body.append(rf"{{\scriptsize\[{equation}\]}}")
body.extend([r"\FiveAWriteLine", r"\FiveAWriteLine"])
if task.get("answer_hint"):
body.append(rf"{{\tiny\textcolor{{ESCMuted}}{{{tex_5a(task.get('answer_hint', ''))}}}}}\par")
return section_5a_tex(6, block.get("title", "Do policzenia"), body)
def render_model_5a_tex(data: dict) -> list[str]:
model = data.get("model_block", {})
body = [
r"\tiny",
r"\begin{minipage}[t]{0.60\linewidth}",
rf"Notebook: \texttt{{{tex_mono_body(model.get('notebook', ''))}}}\par",
rf"Kontrakt: \texttt{{{tex_mono_body(model.get('output_constants', ''))}}}\par",
*tex_compact_items_5a(model.get("steps", [])),
r"\end{minipage}\hfill",
r"\begin{minipage}[t]{0.36\linewidth}",
r"\tiny\begin{tabularx}{\linewidth}{|>{\ttfamily}Y|Y|}",
r"\hline",
]
for item in model.get("constants", []):
body.append(rf"{tex_5a(item.get('name', ''))} & {tex_5a(item.get('value', ''))} \\ \hline")
body.extend([r"\end{tabularx}", r"\end{minipage}"])
return section_5a_tex(7, model.get("title", "Model w notebooku"), body)
def render_code_block_5a_tex(data: dict) -> list[str]:
code = data.get("code_block", {})
container = data.get("container_test", {})
note = code.get("next_cards_note", "")
body = [
r"\tiny",
]
if code.get("context"):
body.extend([tex_5a(code.get("context", "")), r"\par\vspace{0.2em}"])
blocks = code.get("blocks", [])
if blocks:
for block in blocks:
body.extend(render_code_subblock_5a_tex(block))
else:
body.extend(render_code_columns_5a_tex(code))
if note:
body.append(rf"\par{{\tiny\ttfamily\textcolor{{ESCAccent}}{{NOTATKA: {tex_5a(note)}}}}}")
body.append(rf"\par{{\tiny\ttfamily\textcolor{{ESCAccent}}{{PASS: {tex_5a(container.get('pass_condition', ''))}}}}}")
return section_5a_tex(8, code.get("title", "Bloczek RP2350"), body)
def render_container_test_5a_tex(data: dict) -> list[str]:
container = data.get("container_test", {})
body = [r"\tiny\begin{enumerate}\setlength\itemsep{0pt}\setlength\topsep{0pt}"]
for command in container.get("commands", []):
body.append(rf"\item \texttt{{{tex_mono_body(command)}}}")
body.append(r"\end{enumerate}")
return section_5a_tex(9, container.get("title", "Test w kontenerze"), body)
def render_bom_5a_tex(data: dict) -> list[str]:
body = [
r"\tiny\begin{tabularx}{\linewidth}{|p{0.45cm}|p{2.3cm}|Y|}",
r"\hline OK & Element & Kontrola \\ \hline",
]
for item in data.get("bom", []):
body.append(rf"\FiveACheck & \texttt{{{tex_5a(item.get('item', ''))}}} & {tex_5a(item.get('check', ''))} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(10, "BOM i checklista stanowiska", body)
def render_safety_5a_tex(data: dict) -> list[str]:
body = [r"\tiny\begin{tabularx}{\linewidth}{|Y|Y|}", r"\hline"]
rules = data.get("safety_rules", [])
for index in range(0, len(rules), 2):
left = rules[index]
right = rules[index + 1] if index + 1 < len(rules) else {}
left_text = rf"\textbf{{{tex_5a(left.get('title', ''))}}}\par {tex_5a(left.get('body', ''))}"
right_text = rf"\textbf{{{tex_5a(right.get('title', ''))}}}\par {tex_5a(right.get('body', ''))}" if right else ""
body.append(rf"{left_text} & {right_text} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(11, "BHP i złote zasady", body)
def render_settings_figures_5a_tex(data: dict) -> list[str]:
body = [r"\scriptsize"]
figures = data.get("figures", [])
if figures:
body.append(r"\begin{minipage}[t]{0.49\linewidth}\centering")
fig = figures[0]
body.append(rf"\includegraphics[height=2.35cm,width=\linewidth,keepaspectratio]{{{tex_escape(fig.get('path', ''))}}}\par")
body.append(rf"{{\tiny {tex_5a(fig.get('caption', ''))}}}")
body.append(r"\end{minipage}\hfill")
body.append(r"\begin{minipage}[t]{0.49\linewidth}\centering")
if len(figures) > 1:
fig = figures[1]
body.append(rf"\includegraphics[height=2.35cm,width=\linewidth,keepaspectratio]{{{tex_escape(fig.get('path', ''))}}}\par")
body.append(rf"{{\tiny {tex_5a(fig.get('caption', ''))}}}")
body.append(r"\end{minipage}\par\vspace{0.2em}")
body.extend(
[
r"\tiny\begin{tabularx}{\linewidth}{|Y|p{3.2cm}|Y|}",
r"\hline Test & DPS3010U & XY6020L \\ \hline",
]
)
for row in data.get("reference_settings", []):
body.append(rf"{tex_5a(row.get('test', ''))} & {tex_5a(row.get('dps', ''))} & {tex_5a(row.get('xy', ''))} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(12, data.get("settings_section_title", "Kaskada i nastawy referencyjne"), body)
def render_connection_5a_tex(data: dict, section_number: int = 13) -> list[str]:
connection = data.get("connection_section", {})
if not connection:
return []
body = [
r"\tiny\renewcommand{\arraystretch}{1.08}",
]
figure = connection.get("figure", {})
if figure.get("path"):
body.extend(
[
r"\begin{center}",
rf"\includegraphics[height=5.0cm,width=\linewidth,keepaspectratio]{{{tex_escape(figure.get('path', ''))}}}\par",
rf"{{\fontsize{{4.7}}{{5.2}}\selectfont {tex_5a(figure.get('caption', ''))}}}",
r"\end{center}\vspace{-0.25em}",
]
)
body.extend(
[
tex_5a(connection.get("intro", "")) + r"\par\vspace{0.12em}",
r"\begin{tabularx}{\linewidth}{|p{2.35cm}|Y|Y|p{2.45cm}|}",
r"\hline RP2350 & OptoIzolator strona A & OptoIzolator strona B & XY6020L \\ \hline",
]
)
for row in connection.get("pin_table", []):
body.append(
rf"{tex_5a(row.get('rp2350', ''))} & "
rf"{tex_5a(row.get('isolator_a', ''))} & "
rf"{tex_5a(row.get('isolator_b', ''))} & "
rf"{tex_5a(row.get('xy', ''))} \\ \hline"
)
body.extend(
[
r"\end{tabularx}",
r"\vspace{0.12em}",
rf"\textbf{{Parametry:}} {tex_5a('; '.join(connection.get('parameters', [])))}\par",
rf"\textbf{{Zakazy/kontrola:}} {tex_5a('; '.join(connection.get('warnings', [])))}\par",
]
)
if connection.get("source_note"):
body.append(rf"\textcolor{{ESCMuted}}{{Źródła: {tex_5a(connection.get('source_note', ''))}}}")
return section_5a_tex(section_number, connection.get("title", "Podłączenie RP2350 ↔ XY6020L"), body)
def render_procedure_5a_tex(data: dict) -> list[str]:
body = [
r"\tiny\renewcommand{\arraystretch}{1.18}",
r"\begin{tabularx}{\linewidth}{|p{0.45cm}|p{0.7cm}|Y|Y|}",
r"\hline OK & Krok & Działanie & Warunek przejścia \\ \hline",
]
for step in data.get("hardware_procedure", []):
body.append(rf"\FiveACheck & \texttt{{{tex_5a(step.get('step', ''))}}} & {tex_5a(step.get('action', ''))} & {tex_5a(step.get('condition', ''))} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(13, "Procedura sprzętowa krok po kroku", body)
def render_observation_5a_tex(data: dict) -> list[str]:
obs = data.get("observation_table", {})
headers = obs.get("columns", [])
body = [
r"\tiny\renewcommand{\arraystretch}{2.2}",
r"\begin{tabularx}{\linewidth}{|p{2.0cm}|p{3.6cm}|p{3.0cm}|p{3.4cm}|Y|p{1.5cm}|}",
r"\hline " + " & ".join(tex_5a(header) for header in headers) + r" \\ \hline",
]
for row in obs.get("rows", []):
cells = list(row.get("cells", []))
cells += [""] * max(0, len(headers) - len(cells))
rendered = [tex_5a(cell) if cell else r"\rule{0pt}{9.0em}\dotfill" for cell in cells[: len(headers)]]
body.append(" & ".join(rendered) + r" \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(14, obs.get("title", "Karta obserwacji"), body)
def render_troubleshooting_5a_tex(data: dict) -> list[str]:
body = [
r"\tiny\renewcommand{\arraystretch}{1.25}",
r"\begin{tabularx}{\linewidth}{|p{3.0cm}|Y|Y|p{3.0cm}|}",
r"\hline Objaw & Hipoteza & Akcja & Wynik \\ \hline",
]
for row in data.get("troubleshooting", []):
result = tex_5a(row.get("result", "")) if row.get("result") else r"\rule{0pt}{2.4em}\dotfill"
body.append(rf"{tex_5a(row.get('symptom', ''))} & {tex_5a(row.get('hypothesis', ''))} & {tex_5a(row.get('action', ''))} & {result} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(15, "Troubleshooting log", body)
def render_analysis_5a_tex(data: dict) -> list[str]:
body = [r"\scriptsize\begin{enumerate}\setlength\itemsep{0.15em}\setlength\topsep{0pt}"]
for task in data.get("analysis_tasks", []):
body.append(rf"\item {tex_5a(task)}\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine")
body.append(r"\end{enumerate}")
return section_5a_tex(16, "Model vs pomiar i analiza danych", body)
def render_team_decision_5a_tex(data: dict) -> list[str]:
decision = data.get("team_decision", {})
body = [r"\scriptsize\begin{tabularx}{\linewidth}{|Y|Y|}", r"\hline"]
questions = decision.get("questions", [])
for index in range(0, len(questions), 2):
left = rf"\textbf{{{tex_5a(questions[index])}}}\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine"
right = rf"\textbf{{{tex_5a(questions[index + 1])}}}\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine" if index + 1 < len(questions) else ""
body.append(rf"{left} & {right} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(17, decision.get("title", "Decyzja zespołu"), body)
def render_git_gate_5a_tex(data: dict) -> list[str]:
gate = data.get("git_gate", {})
body = [
r"\tiny\begin{tabularx}{\linewidth}{|p{0.45cm}|Y|p{5.0cm}|p{2.3cm}|}",
r"\hline OK & Kryterium & Dowód & Podpis \\ \hline",
]
for item in gate.get("criteria", []):
body.append(rf"\FiveACheck & {tex_5a(item.get('criterion', ''))} & \texttt{{{tex_mono_body(item.get('evidence', ''))}}} & \dotfill \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(18, gate.get("title", "Brama wyjścia"), body)
def render_session_ai_bibliography_5a_tex(
data: dict,
section_number: int = 19,
font_size: str = r"\tiny",
) -> list[str]:
record = data.get("session_record", {})
ai = data.get("ai_rules", {})
entries = bibliography_entries(data)
bib_items = [f"{ref}: {entries.get(ref, {}).get('title', ref)}" for ref in data.get("references", [])]
body = [
font_size,
r"\begin{minipage}[t]{0.32\linewidth}{\ttfamily\bfseries Rekord zajęć}\par",
*tex_compact_items_5a([f"{item.get('path', '')}: {item.get('purpose', '')}" for item in record.get("files", [])], font_size),
r"\end{minipage}\hfill",
r"\begin{minipage}[t]{0.32\linewidth}{\ttfamily\bfseries Agent AI}\par",
rf"\texttt{{{tex_mono_body(ai.get('log_path', ''))}}}\par",
r"\textbf{Wolno:}",
*tex_compact_items_5a(ai.get("allowed", []), font_size),
r"\textbf{Zakaz:}",
*tex_compact_items_5a(ai.get("forbidden", []), font_size),
r"\end{minipage}\hfill",
r"\begin{minipage}[t]{0.32\linewidth}{\ttfamily\bfseries Bibliografia}\par",
*tex_compact_items_5a(bib_items, font_size),
r"\end{minipage}",
]
return section_5a_tex(section_number, "Rekord, AI i bibliografia", body)
def build_5a_pages_tex(data: dict) -> list[list[str]]:
parts = data.get("parts", {})
return [
[
*render_metric_header_5a_tex(data),
r"\vspace{0.25em}",
*render_mission_5a_tex(data),
r"\vspace{0.25em}",
*render_objectives_5a_tex(data),
r"\vspace{0.25em}",
*render_agenda_5a_tex(data),
r"\vspace{0.25em}",
*render_hw_gate_5a_tex(data),
],
[
rf"{{\footnotesize\bfseries {tex_5a(card_title_line_5a(data['card']))}}}\hfill{{\scriptsize\ttfamily KARTA {tex_escape(data['card']['number'])}/{tex_escape(data['card']['count'])}}}\par",
r"\vspace{0.2em}\hrule\vspace{0.6em}",
*render_theory_5a_tex(data),
r"\vspace{0.25em}",
*render_tech_stack_5a_tex(data),
r"\vspace{0.25em}",
*render_math_5a_tex(data),
],
[
*render_part_banner_5a_tex(parts.get("a", {})),
r"\vspace{0.1em}",
*render_model_5a_tex(data),
r"\vspace{0.1em}",
*render_code_block_5a_tex(data),
r"\vspace{0.1em}",
*render_container_test_5a_tex(data),
r"\vspace{0.1em}",
*render_bom_5a_tex(data),
],
[
*render_part_banner_5a_tex(parts.get("b", {})),
r"\vspace{0.25em}",
*render_safety_5a_tex(data),
r"\vspace{0.25em}",
*render_settings_figures_5a_tex(data),
r"\vspace{0.25em}",
*render_procedure_5a_tex(data),
],
[
*render_observation_5a_tex(data),
],
[
*render_troubleshooting_5a_tex(data),
r"\vspace{0.25em}",
*render_analysis_5a_tex(data),
],
[
*render_team_decision_5a_tex(data),
r"\vspace{0.25em}",
*render_git_gate_5a_tex(data),
r"\vspace{0.25em}",
*render_session_ai_bibliography_5a_tex(data, 20),
],
]
def footer_5a_tex(data: dict, page: int, total: int) -> list[str]:
card = data["card"]
return [
r"\vfill",
r"\noindent\textcolor{ESCInk}{\rule{\linewidth}{0.5pt}}\par",
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{commit \BuildCommit\ \textperiodcentered\ {tex_escape(card['uuid'])}\ \textperiodcentered\ {tex_escape(card['version'])}\ \textperiodcentered\ {tex_escape(card['revision_date'])}\hfill strona {page} / {total}\hfill {tex_5a(card['status'])}}}}}",
]
def footer_layer_5b_tex(data: dict, page: int, total: int, width: str = r"\linewidth", x_shift: str = "0pt") -> list[str]:
card = data["card"]
return [
rf"\noindent\hspace*{{{x_shift}}}\begin{{minipage}}{{{width}}}",
r"\textcolor{ESCInk}{\rule{\linewidth}{0.5pt}}\par",
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{commit \BuildCommit\ \textperiodcentered\ {tex_escape(card['uuid'])}\ \textperiodcentered\ {tex_escape(card['version'])}\ \textperiodcentered\ {tex_escape(card['revision_date'])}\hfill strona {page} / {total}\hfill {tex_5a(card['status'])}}}}}",
r"\end{minipage}",
]
def running_card_header_5b_tex(data: dict) -> list[str]:
card = data["card"]
return [
rf"{{\footnotesize\bfseries {tex_5a(card_title_line_5a(card))}}}\hfill{{\scriptsize\ttfamily KARTA {tex_escape(card['number'])}/{tex_escape(card['count'])}}}\par",
r"\vspace{0.15em}",
r"\noindent\textcolor{ESCInk}{\rule{\linewidth}{0.5pt}}\par",
r"\vspace{0.45em}",
]
def render_5a_page_tex(data: dict, center: list[str], page: int, total: int) -> list[str]:
lines = [
*render_page_header_5x_tex(data, page, total),
r"\noindent\begin{minipage}[t]{2.85cm}",
r"\vspace{0pt}",
*render_margin_tree_panel_5a_tex(data, "zawodowe"),
r"\end{minipage}\hfill",
r"\begin{minipage}[t]{21.2cm}",
r"\vspace{0pt}",
*center,
*footer_5a_tex(data, page, total),
r"\end{minipage}\hfill",
r"\begin{minipage}[t]{2.85cm}",
r"\vspace{0pt}",
*render_margin_tree_panel_5a_tex(data, "ogolne"),
r"\end{minipage}",
]
return lines
def render_main_tex_5a(data: dict, template: dict) -> str:
pages = build_5a_pages_tex(data)
page_lines: list[str] = []
for index, page in enumerate(pages, start=1):
if index > 1:
page_lines.append(r"\clearpage")
page_lines.extend(render_5a_page_tex(data, page, index, len(pages)))
lines = [
"% Generated from json/card_source.json by tools/render_card.py.",
"% Do not edit manually; update the JSON and regenerate.",
f"% Template: {template.get('_path', template.get('id', ''))}",
"",
*render_preamble_5a(data["card"]),
r"\begin{document}",
r"\sloppy",
*page_lines,
r"\end{document}",
"",
]
return "\n".join(lines)
def render_preamble_5b(card: dict) -> list[str]:
return [
r"\documentclass[10pt,a4paper]{article}",
r"\usepackage[T1]{fontenc}",
r"\usepackage[utf8]{inputenc}",
r"\usepackage[polish]{babel}",
r"\usepackage{array}",
r"\usepackage{tabularx}",
r"\usepackage{multirow}",
r"\usepackage{xcolor}",
r"\usepackage{amssymb}",
r"\usepackage{graphicx}",
r"\usepackage{microtype}",
r"\usepackage{pdflscape}",
r"\usepackage[a4paper,portrait,left=0.55cm,right=0.55cm,top=2.50cm,bottom=2.50cm]{geometry}",
r"\IfFileExists{stix2.sty}{\usepackage{stix2}}{\usepackage{lmodern}}",
r"\IfFileExists{inconsolata.sty}{\usepackage{inconsolata}}{\usepackage{lmodern}}",
r"\usepackage[most]{tcolorbox}",
r"\usepackage{qrcode}",
r"\usepackage{hyperref}",
r"\hypersetup{hidelinks}",
r"\IfFileExists{build-meta.tex}{\input{build-meta.tex}}{\newcommand{\BuildCommit}{local}}",
macro("CardNumber", card["number"]),
macro("CardCount", card["count"]),
macro("CardVersion", card["version"]),
macro("DocumentUUID", card["uuid"]),
r"\definecolor{ESCPaper}{HTML}{FEFEFC}",
r"\definecolor{ESCInk}{HTML}{1C1C1A}",
r"\definecolor{ESCMuted}{HTML}{8A887F}",
r"\definecolor{ESCSubtle}{HTML}{4A4944}",
r"\definecolor{ESCHeader}{HTML}{F4F3EF}",
r"\definecolor{ESCLine}{HTML}{D8D6D0}",
r"\definecolor{ESCSoftLine}{HTML}{E4E2DC}",
r"\definecolor{ESCAccent}{HTML}{3B5A8F}",
r"\pagecolor{ESCPaper}",
r"\color{ESCInk}",
r"\setlength{\parindent}{0pt}",
r"\setlength{\parskip}{0.20em}",
r"\setlength{\emergencystretch}{4em}",
r"\setlength{\tabcolsep}{2.4pt}",
r"\renewcommand{\arraystretch}{1.10}",
r"\newcolumntype{Y}{>{\raggedright\arraybackslash}X}",
r"\newcommand{\FiveACheck}{\(\square\)}",
r"\newcommand{\FiveAFill}{\dotfill}",
r"\newcommand{\FiveAWriteLine}{\par\noindent\dotfill}",
r"\newtcolorbox{FiveASection}[2]{enhanced,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=2.0mm,right=2.0mm,top=1.0mm,bottom=1.0mm,colbacktitle=ESCHeader,coltitle=ESCInk,fonttitle=\bfseries\scriptsize,title={\ttfamily\textcolor{ESCMuted}{#1.}\quad #2}}",
r"\newtcolorbox{FiveAPart}{enhanced,arc=0pt,boxrule=0.55pt,colback=ESCHeader,colframe=ESCInk,boxsep=0pt,left=2.0mm,right=2.0mm,top=1.0mm,bottom=1.0mm}",
r"\newtcolorbox{CardContentBlock}{enhanced,arc=0pt,boxrule=0.28pt,colback=ESCPaper,colframe=ESCLine,boxsep=0pt,left=1.1mm,right=1.1mm,top=0.7mm,bottom=0.7mm}",
"",
]
def render_margin_tree_panel_5b_tex(data: dict, column: str) -> list[str]:
is_tech = column == "zawodowe"
tags = learning_tree_tags(
data,
column=column,
effect_color="orange!85!black" if is_tech else "green!50!black",
)
lines = [r"{\fontsize{5.6}{6.6}\selectfont\ttfamily\raggedright"]
for group in margin_tree_groups_5b(tags):
if group.get("we"):
lines.append(rf"\textcolor{{ESCAccent}}{{\textbf{{{tex_escape(group['we'])}}}}}\par")
for item in group.get("items", []):
lines.append(rf"\hspace*{{0.55em}}{tex_escape(item['code'])}\par")
lines.append(r"}")
return lines
def render_landscape_margin_tree_panel_5b_tex(data: dict, column: str, label: str) -> list[str]:
return [
r"{\fontsize{6.2}{7.0}\selectfont\ttfamily\textcolor{ESCMuted}{"
+ tex_escape(label)
+ r"}\par\vspace{1.2mm}",
*render_margin_tree_panel_5b_tex(data, column),
r"}",
]
def render_landscape_section_bar_5b_tex(section_number: int, title: str) -> list[str]:
return [
r"\noindent\hspace*{27.5mm}\begin{minipage}{242mm}",
r"{\setlength{\fboxsep}{0pt}\noindent\fcolorbox{ESCInk}{ESCHeader}{%",
r"\begin{minipage}[c][8mm][c]{\dimexpr\linewidth-2\fboxrule\relax}",
rf"\hspace{{2.4mm}}{{\ttfamily\scriptsize\textcolor{{ESCMuted}}{{{section_number}.}}}}\quad"
rf"{{\bfseries\scriptsize {tex_5a(title.upper())}}}",
r"\end{minipage}}}\par",
r"\end{minipage}\par",
]
def render_technical_schematic_page_5b_tex(data: dict, section_number: int, page: int, total: int) -> list[str]:
connection = data.get("connection_section", {})
figure = connection.get("figure", {})
figure_path = figure.get("path", "")
title = connection.get("title", "Podłączenie RP2350 ↔ XY6020L")
image = (
rf"\includegraphics[width=242mm,height=120mm,keepaspectratio]{{{tex_escape(figure_path)}}}"
if figure_path
else r"\vfill"
)
return [
r"\newgeometry{left=0mm,right=0mm,top=0mm,bottom=0mm}",
r"\begin{landscape}",
r"\thispagestyle{empty}",
r"\vspace*{5mm}",
*render_page_header_5x_tex(data, page, total),
*render_landscape_section_bar_5b_tex(section_number, title),
r"\vspace{2mm}",
r"\noindent\begin{minipage}[t][120mm][t]{27.5mm}",
r"\vspace{2mm}\hspace{2.0mm}\begin{minipage}[t]{23.0mm}",
*render_landscape_margin_tree_panel_5b_tex(data, "zawodowe", "TECH"),
r"\end{minipage}",
r"\end{minipage}%",
r"\begin{minipage}[t][120mm][c]{242mm}",
r"\centering",
image,
r"\end{minipage}%",
r"\begin{minipage}[t][120mm][t]{27.5mm}",
r"\vspace{2mm}\hspace{2.0mm}\begin{minipage}[t]{23.0mm}",
*render_landscape_margin_tree_panel_5b_tex(data, "ogolne", "OG"),
r"\end{minipage}",
r"\end{minipage}",
r"\vfill",
*footer_layer_5b_tex(data, page, total, "242mm", "27.5mm"),
r"\vspace*{5mm}",
r"\end{landscape}",
r"\restoregeometry",
]
def render_observation_5b_tex(data: dict, section_number: int = 14) -> list[str]:
obs = data.get("observation_table", {})
headers = obs.get("columns", [])
body = [
r"\tiny\renewcommand{\arraystretch}{1.35}",
r"\begin{tabularx}{\linewidth}{|p{1.55cm}|p{2.55cm}|p{2.20cm}|p{2.45cm}|Y|p{1.25cm}|}",
r"\hline " + " & ".join(tex_5a(header) for header in headers) + r" \\ \hline",
]
for row in obs.get("rows", []):
cells = list(row.get("cells", []))
cells += [""] * max(0, len(headers) - len(cells))
rendered = [tex_5a(cell) if cell else r"\rule{0pt}{26.0em}\dotfill" for cell in cells[: len(headers)]]
body.append(" & ".join(rendered) + r" \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(section_number, obs.get("title", "Karta obserwacji"), body)
def render_troubleshooting_5b_tex(data: dict, section_number: int = 15) -> list[str]:
body = [
r"\tiny\renewcommand{\arraystretch}{1.35}",
r"\begin{tabularx}{\linewidth}{|p{2.35cm}|Y|Y|p{2.35cm}|}",
r"\hline Objaw & Hipoteza & Akcja & Wynik \\ \hline",
]
for row in data.get("troubleshooting", []):
result = tex_5a(row.get("result", "")) if row.get("result") else r"\rule{0pt}{12.0em}\dotfill"
body.append(rf"{tex_5a(row.get('symptom', ''))} & {tex_5a(row.get('hypothesis', ''))} & {tex_5a(row.get('action', ''))} & {result} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(section_number, "Troubleshooting log", body)
def render_analysis_5b_tex(data: dict, section_number: int = 16) -> list[str]:
body = [r"\scriptsize\begin{enumerate}\setlength\itemsep{0.35em}\setlength\topsep{0pt}"]
for task in data.get("analysis_tasks", []):
body.append(rf"\item {tex_5a(task)}\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine")
body.append(r"\end{enumerate}")
return section_5a_tex(section_number, "Model vs pomiar i analiza danych", body)
def render_team_decision_5b_tex(data: dict, section_number: int = 17) -> list[str]:
decision = data.get("team_decision", {})
body = [r"\scriptsize\begin{tabularx}{\linewidth}{|Y|Y|}", r"\hline"]
questions = decision.get("questions", [])
lines = r"\FiveAWriteLine" * 36
for index in range(0, len(questions), 2):
left = rf"\textbf{{{tex_5a(questions[index])}}}{lines}"
right = rf"\textbf{{{tex_5a(questions[index + 1])}}}{lines}" if index + 1 < len(questions) else ""
body.append(rf"{left} & {right} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(section_number, decision.get("title", "Decyzja zespołu"), body)
def render_git_gate_5b_tex(data: dict, section_number: int = 18) -> list[str]:
gate = data.get("git_gate", {})
body = [
r"\tiny\renewcommand{\arraystretch}{2.60}",
r"\begin{tabularx}{\linewidth}{|p{0.38cm}|Y|p{3.70cm}|p{1.55cm}|}",
r"\hline OK & Kryterium & Dowód & Podpis \\ \hline",
]
for item in gate.get("criteria", []):
signature = r"\rule{0pt}{1.8em}\dotfill"
body.append(rf"\FiveACheck & {tex_5a(item.get('criterion', ''))} & \texttt{{{tex_mono_body(item.get('evidence', ''))}}} & {signature} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(section_number, gate.get("title", "Brama wyjścia"), body)
def render_settings_figures_5b_tex(data: dict) -> list[str]:
body = [r"\scriptsize"]
figures = data.get("figures", [])
if figures:
body.append(r"\begin{minipage}[t]{0.49\linewidth}\centering")
fig = figures[0]
body.append(rf"\includegraphics[height=3.25cm,width=\linewidth,keepaspectratio]{{{tex_escape(fig.get('path', ''))}}}\par")
body.append(rf"{{\tiny {tex_5a(fig.get('caption', ''))}}}")
body.append(r"\end{minipage}\hfill")
body.append(r"\begin{minipage}[t]{0.49\linewidth}\centering")
if len(figures) > 1:
fig = figures[1]
body.append(rf"\includegraphics[height=3.25cm,width=\linewidth,keepaspectratio]{{{tex_escape(fig.get('path', ''))}}}\par")
body.append(rf"{{\tiny {tex_5a(fig.get('caption', ''))}}}")
body.append(r"\end{minipage}\par\vspace{0.25em}")
body.extend(
[
r"\tiny\begin{tabularx}{\linewidth}{|Y|p{3.0cm}|Y|}",
r"\hline Test & DPS3010U & XY6020L \\ \hline",
]
)
for row in data.get("reference_settings", []):
body.append(rf"{tex_5a(row.get('test', ''))} & {tex_5a(row.get('dps', ''))} & {tex_5a(row.get('xy', ''))} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(12, data.get("settings_section_title", "Kaskada i nastawy referencyjne"), body)
def render_connection_details_5b_tex(data: dict, section_number: int = 13) -> list[str]:
connection = data.get("connection_section", {})
if not connection:
return []
body = [
r"\tiny\renewcommand{\arraystretch}{1.18}",
tex_5a(connection.get("intro", "")) + r"\par\vspace{0.18em}",
r"\begin{tabularx}{\linewidth}{|p{2.35cm}|Y|Y|p{2.45cm}|}",
r"\hline RP2350 & OptoIzolator strona A & OptoIzolator strona B & XY6020L \\ \hline",
]
for row in connection.get("pin_table", []):
body.append(
rf"{tex_5a(row.get('rp2350', ''))} & "
rf"{tex_5a(row.get('isolator_a', ''))} & "
rf"{tex_5a(row.get('isolator_b', ''))} & "
rf"{tex_5a(row.get('xy', ''))} \\ \hline"
)
body.extend(
[
r"\end{tabularx}",
r"\vspace{0.18em}",
rf"\textbf{{Parametry:}} {tex_5a('; '.join(connection.get('parameters', [])))}\par",
rf"\textbf{{Zakazy/kontrola:}} {tex_5a('; '.join(connection.get('warnings', [])))}\par",
]
)
if connection.get("source_note"):
body.append(rf"\textcolor{{ESCMuted}}{{Źródła: {tex_5a(connection.get('source_note', ''))}}}")
return section_5a_tex(section_number, connection.get("title", "Podłączenie RP2350 ↔ XY6020L") + " -- tabela pinów i kontrola", body)
def render_procedure_5b_tex(data: dict, section_number: int = 13) -> list[str]:
body = [
r"\tiny\renewcommand{\arraystretch}{3.35}",
r"\begin{tabularx}{\linewidth}{|p{0.40cm}|p{0.62cm}|Y|Y|}",
r"\hline OK & Krok & Działanie & Warunek przejścia \\ \hline",
]
for step in data.get("hardware_procedure", []):
body.append(rf"\FiveACheck & \texttt{{{tex_5a(step.get('step', ''))}}} & {tex_5a(step.get('action', ''))} & {tex_5a(step.get('condition', ''))} \\ \hline")
body.append(r"\end{tabularx}")
return section_5a_tex(section_number, "Procedura sprzętowa krok po kroku", body)
def build_5b_pages_tex(data: dict) -> list[list[str]]:
parts = data.get("parts", {})
return [
{
"kind": "portrait",
"center": [
*render_metric_header_5a_tex(data),
r"\vfill",
*render_objectives_5a_tex(data),
r"\vfill",
*render_agenda_5a_tex(data),
],
},
{
"kind": "portrait",
"center": [
r"\vfill",
*render_mission_5a_tex(data),
r"\vfill",
*render_theory_5a_tex(data),
r"\vfill",
*render_tech_stack_5a_tex(data),
r"\vfill",
*render_hw_gate_5a_tex(data),
r"\vfill",
*render_math_5a_tex(data),
],
},
{
"kind": "portrait",
"center": [
*render_part_banner_5a_tex(parts.get("a", {})),
r"\vfill",
*render_model_5a_tex(data),
r"\vfill",
*render_code_block_5a_tex(data),
r"\vfill",
*render_container_test_5a_tex(data),
r"\vfill",
*render_bom_5a_tex(data),
],
},
{
"kind": "portrait",
"center": [
*render_part_banner_5a_tex(parts.get("b", {})),
r"\vfill",
*render_safety_5a_tex(data),
r"\vfill",
*render_settings_figures_5b_tex(data),
],
},
{
"kind": "landscape_schematic",
"section_number": 13,
},
{
"kind": "portrait",
"center": [
*render_connection_details_5b_tex(data, 13),
r"\vfill",
*render_procedure_5b_tex(data, 14),
],
},
{
"kind": "portrait",
"center": [
*render_observation_5b_tex(data, 15),
],
},
{
"kind": "portrait",
"center": [
*render_troubleshooting_5b_tex(data, 16),
],
},
{
"kind": "portrait",
"center": [
*render_analysis_5b_tex(data, 17),
],
},
{
"kind": "portrait",
"center": [
*render_team_decision_5b_tex(data, 18),
],
},
{
"kind": "portrait",
"center": [
*render_git_gate_5b_tex(data, 19),
r"\vfill",
*render_session_ai_bibliography_5a_tex(data, 20, r"\fontsize{4.7}{5.0}\selectfont"),
],
},
]
def render_5b_document_page_tex(data: dict, page_data: dict, page: int, total: int) -> list[str]:
if page_data.get("kind") == "landscape_schematic":
return render_technical_schematic_page_5b_tex(
data,
int(page_data.get("section_number", 13)),
page,
total,
)
return render_5b_page_tex(data, list(page_data.get("center", [])), page, total)
def render_5b_page_tex(data: dict, center: list[str], page: int, total: int) -> list[str]:
lines = [
*render_page_header_5x_tex(data, page, total),
r"\noindent\makebox[\textwidth][l]{%",
r"\begin{minipage}[t][\dimexpr\textheight-35mm\relax][t]{2.05cm}",
r"\vspace{0pt}",
*render_margin_tree_panel_5b_tex(data, "zawodowe"),
r"\end{minipage}\hspace{0.55cm}%",
r"\begin{minipage}[t][\dimexpr\textheight-35mm\relax][t]{14.70cm}",
r"\vspace{0pt}",
*center,
r"\vfill",
*footer_layer_5b_tex(data, page, total),
r"\end{minipage}\hspace{0.55cm}%",
r"\begin{minipage}[t][\dimexpr\textheight-35mm\relax][t]{2.05cm}",
r"\vspace{0pt}",
*render_margin_tree_panel_5b_tex(data, "ogolne"),
r"\end{minipage}%",
r"}",
]
return lines
def render_main_tex_5b(data: dict, template: dict) -> str:
pages = build_5b_pages_tex(data)
page_lines: list[str] = []
for index, page in enumerate(pages, start=1):
if index > 1:
page_lines.append(r"\clearpage")
page_lines.extend(render_5b_document_page_tex(data, page, index, len(pages)))
lines = [
"% Generated from json/card_source.json by tools/render_card.py.",
"% Do not edit manually; update the JSON and regenerate.",
f"% Template: {template.get('_path', template.get('id', ''))}",
"",
*render_preamble_5b(data["card"]),
r"\begin{document}",
r"\sloppy",
*page_lines,
r"\end{document}",
"",
]
return "\n".join(lines)
def render_tex_output(data: dict, template: dict) -> str:
if template_emitter(template, "latex") == "5b":
return render_main_tex_5b(data, template)
if template_emitter(template, "latex") == "5a":
return render_main_tex_5a(data, template)
return render_main_tex(data)
def render_html_output(data: dict, template: dict) -> str:
if template_emitter(template, "html") == "5b":
return render_main_html_5b(data, template)
if template_emitter(template, "html") == "5a":
return render_main_html_5a(data, template)
return render_main_html(data)
def render_html_css_output(data: dict, template: dict) -> str:
if template_emitter(template, "html") == "5b":
return render_main_html_5b_css(template)
return ""
def slugify_web(value: object, fallback: str = "bloczek") -> str:
text = unicodedata.normalize("NFKD", str(value))
text = "".join(ch for ch in text if not unicodedata.combining(ch))
text = text.lower()
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
return text or fallback
def build_meta_value(text: str, name: str) -> str | None:
patterns = [
rf"\\renewcommand\{{\\{re.escape(name)}\}}\{{([^}}]*)\}}",
rf"\\newcommand\{{\\{re.escape(name)}\}}\{{([^}}]*)\}}",
rf"\\providecommand\{{\\{re.escape(name)}\}}\{{([^}}]*)\}}",
]
value: str | None = None
for pattern in patterns:
for match in re.finditer(pattern, text):
candidate = match.group(1).strip()
if candidate:
value = candidate
return value
def read_build_meta(data: dict) -> dict[str, str]:
card = data.get("card", {})
meta = {
"commit": "local",
"generated": str(card.get("revision_date", "")),
}
path = CARD_ROOT / "doc" / "build-meta.tex"
if not path.exists():
return meta
text = path.read_text(encoding="utf-8")
commit = build_meta_value(text, "BuildCommit")
generated = (
build_meta_value(text, "BuildGenerated")
or build_meta_value(text, "BuildDate")
or build_meta_value(text, "BuildTimestamp")
)
if commit:
meta["commit"] = commit
if generated:
meta["generated"] = generated
return meta
def should_emit_web_blocks(data: dict, template: dict, force_web: bool) -> bool:
blocks = data.get("code_block", {}).get("blocks") or []
if not blocks:
return False
if force_web:
return True
return template_emitter(template, "html") == "5b" or template_emitter(template, "latex") == "5b"
def web_block_slug(block: dict, index: int) -> str:
return slugify_web(block.get("slug") or block.get("title") or f"bloczek-{index}", f"bloczek-{index}")
def web_column_items(block: dict, titles: Iterable[str]) -> list[str]:
wanted = {text_5a(title).lower() for title in titles}
for column in code_block_columns_5a(block):
if text_5a(column.get("title", "")).lower() in wanted:
return [text_5a(item) for item in column.get("items", [])]
return []
def web_block_steps(block: dict) -> list[str]:
steps = block.get("web_steps") or block.get("steps")
if steps:
return [text_5a(step) for step in steps]
return web_column_items(block, ["Procedura", "Uczeń odpowiada za"])
def web_block_files(block: dict) -> list[str]:
files = block.get("web_files")
if files:
return [text_5a(item) for item in files]
return web_column_items(block, ["Artefakty", "Pliki", "Pliki szkieletu"])
def web_block_evidence(block: dict) -> list[str]:
evidence = block.get("web_evidence") or block.get("evidence")
if evidence:
return [text_5a(item) for item in evidence]
return web_column_items(block, ["Uczeń oddaje"])
def web_block_commands(block: dict) -> list[dict]:
rows: list[dict] = []
for index, item in enumerate(block.get("web_commands") or block.get("commands") or [], start=1):
if isinstance(item, dict):
label = text_5a(item.get("label", f"Komenda {index}"))
command = str(item.get("command", "")).strip()
else:
label = f"Komenda {index}"
command = str(item).strip()
if command:
rows.append({"label": label, "command": command})
return rows
def web_tokens(template: dict) -> dict[str, str]:
colors = template.get("tokens", {}).get("colors", {})
page = template.get("tokens", {}).get("page", {})
return {
"paper": colors.get("paper") or page.get("paper") or "#fefefc",
"desk": colors.get("desk") or page.get("desk") or "#eceae4",
"ink": colors.get("ink") or page.get("ink") or "#1c1c1a",
"muted": colors.get("muted") or "#8a887f",
"subtle": colors.get("subtle") or "#4a4944",
"header": colors.get("header") or "#f4f3ef",
"line": colors.get("line") or "#d8d6d0",
"line_soft": colors.get("line_soft") or "#e4e2dc",
"hairline": colors.get("hairline") or "#f0efe9",
"accent": colors.get("accent") or "#3b5a8f",
}
def render_web_block_css(template: dict) -> str:
tokens = web_tokens(template)
variables = "\n".join(f" --{name}: {value};" for name, value in tokens.items())
return "\n".join(
[
":root {",
variables,
"}",
r"""
* { box-sizing: border-box; }
html { color-scheme: light; }
body {
margin: 0;
background: var(--desk);
color: var(--ink);
font-family: "STIX Two Text", "STIX Two", "Times New Roman", Georgia, serif;
font-size: 17px;
line-height: 1.48;
}
button,
code,
pre,
.mono,
.step-index {
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.shell {
width: min(1040px, calc(100vw - 28px));
margin: 28px auto;
background: var(--paper);
border: 1px solid color-mix(in srgb, var(--ink) 14%, transparent);
box-shadow: 0 12px 34px rgba(0, 0, 0, .10);
}
.top {
border-bottom: 1.5px solid var(--ink);
padding: 22px 28px 18px;
}
.kicker {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
justify-content: space-between;
color: var(--muted);
font: 600 12px/1.4 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
letter-spacing: .08em;
text-transform: uppercase;
}
h1 {
margin: 10px 0 8px;
font-size: clamp(28px, 4.5vw, 46px);
line-height: 1.04;
letter-spacing: 0;
}
.goal {
max-width: 78ch;
margin: 0;
color: var(--subtle);
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr);
gap: 18px;
padding: 22px 28px 26px;
}
.panel {
border: 1px solid var(--line);
background: color-mix(in srgb, var(--paper) 88%, white);
}
.panel h2 {
margin: 0;
padding: 10px 14px;
background: var(--header);
border-bottom: 1px solid var(--ink);
font: 700 13px/1.25 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
letter-spacing: .10em;
text-transform: uppercase;
}
.panel-body { padding: 14px; }
.progress-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
progress {
width: 100%;
height: 12px;
accent-color: var(--accent);
}
.progress-label {
color: var(--accent);
font: 700 13px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
}
.step-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.step-item {
display: grid;
grid-template-columns: 24px 42px 1fr;
align-items: start;
gap: 10px;
padding: 10px 0;
border-top: 1px solid var(--hairline);
}
.step-item:first-child { border-top: 0; }
.step-check {
width: 18px;
height: 18px;
margin-top: 2px;
accent-color: var(--accent);
}
.step-index {
color: var(--muted);
font-size: 12px;
}
.command-card {
border-top: 1px solid var(--line_soft);
padding-top: 12px;
margin-top: 12px;
}
.command-card:first-child {
border-top: 0;
padding-top: 0;
margin-top: 0;
}
.command-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
margin-bottom: 7px;
}
.command-label {
color: var(--subtle);
font: 700 12px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
text-transform: uppercase;
letter-spacing: .08em;
}
.copy-button,
.reset-button {
border: 1px solid var(--ink);
background: var(--paper);
color: var(--ink);
cursor: pointer;
font-size: 12px;
padding: 5px 9px;
}
.copy-button:hover,
.reset-button:hover,
.copy-button:focus-visible,
.reset-button:focus-visible {
background: var(--ink);
color: var(--paper);
}
pre {
margin: 0;
overflow-x: auto;
padding: 11px 12px;
background: #111;
color: #f7f7f3;
font-size: 13px;
line-height: 1.45;
}
.plain-list {
margin: 0;
padding-left: 20px;
}
.plain-list li { margin: 5px 0; }
.proof {
display: grid;
gap: 10px;
}
.pass {
border-left: 4px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--paper));
padding: 10px 12px;
}
.warning-list {
display: grid;
gap: 9px;
}
.warning {
border: 1px solid var(--ink);
padding: 10px 12px;
background: color-mix(in srgb, var(--header) 65%, var(--paper));
}
.footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px 18px;
border-top: 1.5px solid var(--ink);
padding: 13px 28px 16px;
color: var(--muted);
font: 12px/1.45 "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
}
@media (max-width: 820px) {
.shell { width: 100%; margin: 0; border-left: 0; border-right: 0; }
.grid { grid-template-columns: 1fr; padding: 18px 16px 22px; }
.top { padding: 19px 16px 16px; }
.footer { padding-left: 16px; padding-right: 16px; }
}
""".strip(),
"",
]
)
def render_web_block_js() -> str:
return r"""
(function () {
const root = document.querySelector("[data-storage-key]");
if (!root) return;
const storageKey = root.dataset.storageKey;
const checks = Array.from(document.querySelectorAll(".step-check"));
const progress = document.querySelector("[data-progress]");
const progressText = document.querySelector("[data-progress-text]");
const reset = document.querySelector("[data-reset]");
function loadState() {
try {
const value = JSON.parse(localStorage.getItem(storageKey) || "[]");
if (Array.isArray(value)) {
checks.forEach((check, index) => {
check.checked = Boolean(value[index]);
});
}
} catch (_error) {
localStorage.removeItem(storageKey);
}
}
function saveState() {
const value = checks.map((check) => check.checked);
localStorage.setItem(storageKey, JSON.stringify(value));
}
function updateProgress() {
const done = checks.filter((check) => check.checked).length;
const total = checks.length;
if (progress) {
progress.max = total;
progress.value = done;
}
if (progressText) {
progressText.textContent = done + "/" + total;
}
}
checks.forEach((check) => {
check.addEventListener("change", () => {
saveState();
updateProgress();
});
});
if (reset) {
reset.addEventListener("click", () => {
checks.forEach((check) => {
check.checked = false;
});
saveState();
updateProgress();
});
}
async function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
document.body.appendChild(area);
area.select();
document.execCommand("copy");
document.body.removeChild(area);
}
document.querySelectorAll("[data-copy-target]").forEach((button) => {
button.addEventListener("click", async () => {
const target = document.getElementById(button.dataset.copyTarget);
if (!target) return;
const original = button.textContent;
try {
await copyText(target.textContent);
button.textContent = "skopiowano";
} catch (_error) {
button.textContent = "błąd";
}
window.setTimeout(() => {
button.textContent = original;
}, 1400);
});
});
loadState();
updateProgress();
})();
""".strip() + "\n"
def render_web_list(items: Iterable[str], class_name: str = "plain-list") -> str:
rows = "".join(f"<li>{html_escape(text_5a(item))}</li>" for item in items)
return f'<ul class="{html_escape(class_name)}">{rows}</ul>'
def render_web_commands(block: dict, slug: str) -> str:
commands = web_block_commands(block)
if not commands:
return ""
rows = []
for index, item in enumerate(commands, start=1):
code_id = f"{slug}-cmd-{index}"
rows.append(
'<article class="command-card">'
'<div class="command-head">'
f'<span class="command-label">{html_escape(item["label"])}</span>'
f'<button class="copy-button" type="button" data-copy-target="{html_escape(code_id)}">kopiuj</button>'
'</div>'
f'<pre><code id="{html_escape(code_id)}">{html_escape(item["command"])}</code></pre>'
'</article>'
)
return (
'<section class="panel">'
'<h2>Komendy</h2>'
f'<div class="panel-body">{"".join(rows)}</div>'
'</section>'
)
def render_web_block_index(data: dict, block: dict, block_index: int, template: dict, build_meta: dict[str, str]) -> str:
card = data["card"]
slug = web_block_slug(block, block_index)
steps = web_block_steps(block)
files = web_block_files(block)
evidence = web_block_evidence(block)
safety = [text_5a(item) for item in block.get("web_safety", [])]
goal = text_5a(block.get("goal") or block.get("context", ""))
storage_key = f'{card.get("uuid", card.get("id", "card"))}:{slug}:steps'
step_rows = "".join(
'<li class="step-item">'
f'<input class="step-check" type="checkbox" id="step-{index}">'
f'<span class="step-index">{index:02d}</span>'
f'<label for="step-{index}">{html_escape(step)}</label>'
'</li>'
for index, step in enumerate(steps, start=1)
)
safety_html = ""
if safety:
safety_html = (
'<section class="panel">'
'<h2>Ostrzeżenia BHP</h2>'
'<div class="panel-body warning-list">'
+ "".join(f'<div class="warning">{html_escape(item)}</div>' for item in safety)
+ '</div></section>'
)
evidence_html = render_web_list(evidence) if evidence else ""
pass_html = ""
if block.get("pass_condition"):
pass_html = f'<div class="pass"><strong>PASS:</strong> {html_escape(text_5a(block.get("pass_condition", "")))}</div>'
return f"""<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html_escape(block.get("title", f"Bloczek {block_index}"))} · {html_escape(card_prefix(card))}</title>
<link rel="stylesheet" href="style.css">
<script src="app.js" defer></script>
</head>
<body data-storage-key="{html_escape(storage_key)}">
<main class="shell">
<header class="top">
<div class="kicker"><span>Karta {html_escape(card_prefix(card))}</span><span>{html_escape(card.get("uuid", ""))}</span></div>
<h1>{html_escape(block.get("title", f"Bloczek {block_index}"))}</h1>
<p class="goal">{html_escape(goal)}</p>
</header>
<div class="grid">
<section class="panel">
<h2>Checklista kroków</h2>
<div class="panel-body">
<div class="progress-row"><progress data-progress max="{len(steps)}" value="0"></progress><span class="progress-label" data-progress-text>0/{len(steps)}</span></div>
<ol class="step-list">{step_rows}</ol>
<p><button class="reset-button" type="button" data-reset>wyczyść checklistę</button></p>
</div>
</section>
<aside class="panel">
<h2>Pliki szkieletu</h2>
<div class="panel-body">{render_web_list(files)}</div>
</aside>
{render_web_commands(block, slug)}
<section class="panel">
<h2>Dowód</h2>
<div class="panel-body proof">{evidence_html}{pass_html}</div>
</section>
{safety_html}
</div>
<footer class="footer">
<span>uuid {html_escape(card.get("uuid", ""))}</span>
<span>wersja {html_escape(card.get("version", ""))}</span>
<span>build {html_escape(build_meta.get("generated", ""))}</span>
<span>commit {html_escape(build_meta.get("commit", "local"))}</span>
</footer>
</main>
</body>
</html>
"""
def write_web_outputs(data: dict, template: dict, force_web: bool = False) -> list[Path]:
if not should_emit_web_blocks(data, template, force_web):
return []
build_meta = read_build_meta(data)
paths: list[Path] = []
css = render_web_block_css(template)
js = render_web_block_js()
web_root = CARD_ROOT / "web"
for index, block in enumerate(data.get("code_block", {}).get("blocks") or [], start=1):
slug = web_block_slug(block, index)
block_dir = web_root / slug
block_dir.mkdir(parents=True, exist_ok=True)
files = {
"index.html": render_web_block_index(data, block, index, template, build_meta),
"style.css": css,
"app.js": js,
}
for name, content in files.items():
path = block_dir / name
path.write_text(content, encoding="utf-8")
paths.append(path)
return paths
def curriculum_refs_for_task(data: dict, effect_refs: Iterable[str], criterion_ref: str) -> list[dict]:
refs: list[dict] = []
seen: set[tuple[str, str]] = set()
for effect_ref in effect_refs:
for item in data.get("learning_effects", {}).get(effect_ref, {}).get("curriculum_refs", []):
key = (item.get("domain", ""), item.get("ref", ""))
if key in seen:
continue
seen.add(key)
refs.append(item)
criterion = data.get("assessment_criteria", {}).get(criterion_ref, {})
for item in criterion.get("curriculum_refs", []):
key = (item.get("domain", ""), item.get("ref", ""))
if key in seen:
continue
seen.add(key)
refs.append(item)
return refs
def report_task_rows(data: dict) -> list[dict]:
rows: list[dict] = []
for task_ref in data["tasks_order"]:
task = data["tasks"][task_ref]
report = task.get("report", {})
if not report.get("include", False):
continue
rows.append(
{
"id": task_ref,
"number": task["number"],
"stage": task["stage"],
"stage_title": task["stage_title"],
"label": report.get("label", task["stage_title"]),
"prompt_tex": task["prompt_tex"],
"criterion": task["criterion"],
"educational_requirement_refs": task.get("educational_requirement_refs", []),
"learning_effect_refs": task.get("learning_effect_refs", []),
"assessment_criterion_ref": task.get("assessment_criterion_ref", ""),
"curriculum_refs": curriculum_refs_for_task(
data,
task.get("learning_effect_refs", []),
task.get("assessment_criterion_ref", ""),
),
"links": task.get("links", {}),
"evidence": report.get("evidence", ""),
}
)
return rows
def render_report_tasks_json(data: dict) -> str:
payload = {
"schema": "esc-report-task-map.v1",
"card": data["card"]["id"],
"title": data["card"]["title"],
"educational_model": data.get("educational_model", {}),
"educational_requirements": data.get("educational_requirements", {}),
"learning_effects": data.get("learning_effects", {}),
"assessment_criteria": data.get("assessment_criteria", {}),
"tasks": report_task_rows(data),
}
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
def format_refs(values: Iterable[str]) -> str:
return ", ".join(values) if values else "-"
def format_curriculum_domains(values: Iterable[dict]) -> str:
domains: list[str] = []
for item in values:
domain = item.get("domain", "")
if domain and domain not in domains:
domains.append(domain)
return ", ".join(domains) if domains else "-"
def render_report_tasks_tex(data: dict) -> str:
lines = [
"% Generated from json/card_source.json by src/render_from_dictionary.py.",
"% Do not edit manually; update the JSON and regenerate.",
r"\section*{Mapa zadań do raportu}",
r"\begin{tabularx}{\textwidth}{|p{1.0cm}|p{2.6cm}|p{3.0cm}|X|p{2.7cm}|}",
r"\hline",
r"Zad. & Etap & WE/EN/KW & Co ma trafić do raportu & Linki \\",
r"\hline",
]
for row in report_task_rows(data):
links = row["links"]
refs = []
if links.get("equations"):
refs.append("wzory: " + format_refs(links["equations"]))
if links.get("figures"):
refs.append("rys.: " + format_refs(links["figures"]))
if links.get("sections"):
refs.append("sekcje: " + format_refs(links["sections"]))
lines.append(
rf"{row['number']} & {tex_escape(row['stage_title'])} & "
rf"{tex_escape(', '.join(row['educational_requirement_refs']))}\newline "
rf"{tex_escape(', '.join(row['learning_effect_refs']))}\newline "
rf"{tex_escape(row['assessment_criterion_ref'])}\newline "
rf"Domeny: {tex_escape(format_curriculum_domains(row['curriculum_refs']))} & "
rf"{row['prompt_tex']} & {tex_escape('; '.join(refs))} \\"
)
lines.append(r"\hline")
lines.append(r"\end{tabularx}")
lines.append("")
return "\n".join(lines)
def html_output_dir(data: dict) -> Path:
output_ref = data.get("generated", {}).get("html", "html/index.html") or "html/index.html"
return (CARD_ROOT / output_ref).parent
def tex_output_dir(data: dict) -> Path:
output_ref = data.get("generated", {}).get("tex", "tex/main.tex") or "tex/main.tex"
return (CARD_ROOT / output_ref).parent
def tex_asset_path(data: dict, asset_path: str) -> str:
source = resolve_web_asset_source(asset_path)
return os.path.relpath(source, tex_output_dir(data)).replace("\\", "/")
def render_section_assets_tex(section: dict, data: dict) -> list[str]:
lines: list[str] = []
for asset in section.get("assets", []) or []:
path = tex_asset_path(data, str(asset.get("path", "")))
width = float(asset.get("width", 1.0))
caption = tex_escape(str(asset.get("caption", "")))
label = str(asset.get("label", ""))
lines.extend(
[
r"\begin{figure}[H]",
r" \centering",
rf" \includegraphics[width={width:.3g}\linewidth]{{\detokenize{{{path}}}}}",
rf" \caption{{{caption}}}",
rf" \label{{{label}}}",
r"\end{figure}",
]
)
return lines
def prepare_html_figure_assets(data: dict) -> None:
output_dir = html_output_dir(data) / "figures"
output_dir.mkdir(parents=True, exist_ok=True)
pdftoppm = shutil.which("pdftoppm")
graphics: dict[str, int] = {}
def add_graphic(path: str, dpi: int = 160) -> None:
if not path:
return
graphics[path] = max(dpi, graphics.get(path, 0))
for graphic in graphics_paths(data.get("sections", [])):
add_graphic(graphic)
for asset in section_assets(data.get("sections", [])):
add_graphic(str(asset.get("path", "")))
for figure in data.get("figures", []):
add_graphic(figure.get("path", ""))
pdf_links: list[str] = []
connection_figure = data.get("connection_section", {}).get("figure", {})
if connection_figure.get("path"):
add_graphic(connection_figure.get("path", ""))
if connection_figure.get("full_pdf_path"):
pdf_links.append(connection_figure.get("full_pdf_path", ""))
if connection_figure.get("web_carrier_pdf_path"):
try:
web_dpi = int(connection_figure.get("web_carrier_png_dpi", 220))
except (TypeError, ValueError):
web_dpi = 220
add_graphic(connection_figure.get("web_carrier_pdf_path", ""), web_dpi)
for graphic, dpi in graphics.items():
source = resolve_web_asset_source(graphic)
if not source.exists():
continue
target_name = Path(html_figure_asset(graphic)).name
target = output_dir / target_name
if source.suffix.lower() == ".pdf" and pdftoppm:
subprocess.run(
[pdftoppm, "-singlefile", "-png", "-r", str(dpi), str(source), str(target.with_suffix(""))],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
elif source.suffix.lower() == ".pdf":
shutil.copy2(source, output_dir / source.name)
else:
shutil.copy2(source, target)
for graphic in dict.fromkeys(path for path in pdf_links if path):
source = resolve_web_asset_source(graphic)
if source.exists():
shutil.copy2(source, output_dir / source.name)
def write_outputs(data: dict, template: dict, force_web: bool = False) -> list[Path]:
prepare_html_figure_assets(data)
outputs = {
"tex": render_tex_output(data, template),
"html": render_html_output(data, template),
}
if "html_css" in data.get("generated", {}):
outputs["html_css"] = render_html_css_output(data, template)
if "report_tasks_json" in data.get("generated", {}):
outputs["report_tasks_json"] = render_report_tasks_json(data)
if "report_tasks_tex" in data.get("generated", {}):
outputs["report_tasks_tex"] = render_report_tasks_tex(data)
paths: list[Path] = []
for key, content in outputs.items():
default_path = "html/index.html" if key == "html" else ""
output_ref = data["generated"].get(key, default_path)
if not output_ref:
continue
path = CARD_ROOT / output_ref
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
paths.append(path)
paths.extend(write_web_outputs(data, template, force_web))
return paths
def parse_render_args(argv: list[str]) -> tuple[str | None, str | None, bool]:
card_root: str | None = None
template_ref: str | None = None
force_web = False
index = 0
while index < len(argv):
arg = argv[index]
if arg == "--web":
force_web = True
index += 1
continue
if arg == "--template":
if index + 1 >= len(argv):
raise SystemExit("Brak wartości po --template")
template_ref = argv[index + 1]
index += 2
continue
if arg.startswith("--template="):
template_ref = arg.split("=", 1)[1]
index += 1
continue
if arg.startswith("-"):
raise SystemExit(f"Nieznany argument: {arg}")
if card_root is not None:
raise SystemExit(f"Nadmiarowy argument ścieżki karty: {arg}")
card_root = arg
index += 1
return card_root, template_ref, force_web
def main(argv: list[str] | None = None) -> None:
args = list(sys.argv[1:] if argv is None else argv)
card_root, template_ref, force_web = parse_render_args(args)
if card_root:
configure_card_root(Path(card_root))
source_data = load_source()
template = load_template(source_data, template_ref)
data = prepare_data(source_data)
validate_source(data)
for path in write_outputs(data, template, force_web):
print(path)
if __name__ == "__main__":
main()