#!/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 (2.6, 3, 3.0): errors.append("title_block.height_cm musi wynosić 2.6 albo 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 "" 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"] header_height_mm = resource_header_height_mm(data) source_url = repository_url(data) doc_uuid = str((data.get("title_block") or {}).get("doc_uuid", "")) card_number = f"{card.get('number', '')}/{card.get('count', '')}" 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}", rf"\usepackage[a4paper,left=2.15cm,right=2.15cm,top=0.5cm,bottom=0.5cm,headheight={header_height_mm:g}mm,headsep=3mm,footskip=8mm,includehead,includefoot]{{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}", rf"\setlength{{\headheight}}{{{header_height_mm:g}mm}}", r"\setlength{\headsep}{3mm}", r"\setlength{\footskip}{8mm}", 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"\fancyfoot[C]{%", r" \begin{minipage}{\textwidth}%", r" \hrule height0.35pt\vspace{0.5mm}%", r" \fontsize{4.2}{4.8}\selectfont\ttfamily%", r" \begin{tabularx}{\textwidth}{@{}p{27mm}p{49mm}Xr@{}}%", rf" \textbf{{KARTA}} {tex_escape(card_number)} & \textbf{{UUID}} {tex_escape(card.get('uuid', ''))} & \textbf{{DOKUMENT}} {tex_escape(doc_uuid)} & \textbf{{ARKUSZ}} \thepage/\pageref{{LastPage}} \\%", rf" \textbf{{WERSJA}} {tex_escape(card.get('version', ''))} · {tex_escape(card.get('revision_date', ''))} & \multicolumn{{2}}{{l}}{{\fontsize{{3.6}}{{4.1}}\selectfont\textbf{{GITEA}} \href{{{source_url}}}{{\nolinkurl{{{source_url}}}}}}} & \textbf{{AUTOR}} {tex_escape(card.get('author', ''))}%", r" \end{tabularx}%", r" \end{minipage}%", r"}", r"\renewcommand{\headrulewidth}{0pt}", r"\renewcommand{\footrulewidth}{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\{(?Pequation|figure)\}.*?\\label\{(?P