Files
card-layouts/tools/render_card.py
T
2026-07-15 08:41:20 +02:00

5412 lines
234 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 json
import html as html_lib
import os
import re
import shutil
import subprocess
import sys
import textwrap
import unicodedata
from functools import lru_cache
from pathlib import Path
from typing import Callable, Iterable
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"
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 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 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}")
labels = label_set(data.get("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}")
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", {})
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}")
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 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(card: dict) -> list[str]:
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=2.2cm,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"\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}{local}%",
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}{18pt}",
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.",
"",
r"\pagestyle{fancy}",
r"\fancyhf{}",
rf"\lhead{{\textbf{{{tex_escape(card_prefix(card))}: {tex_escape(card['title'])}}}}}",
rf"\rhead{{\small {tex_escape(card['author'])}, {tex_escape(card['year'])}}}",
r"\lfoot{\scriptsize commit \BuildCommit}",
r"\cfoot{\scriptsize \thepage/\pageref{LastPage}}",
r"\rfoot{\scriptsize \DocumentUUID}",
r"\renewcommand{\headrulewidth}{0.4pt}",
"",
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 = [
("Project", card["project"]),
("Subject", card["subject"]),
("Level", card["level"]),
("Dominanta", card["dominant"]),
("Topic", card["title"]),
("Revision date", card["revision_date"]),
("Status", card["status"]),
("Card in series", r"\small\texttt{\CardNumber/\CardCount}"),
("Card version", r"\small\texttt{\CardVersion}"),
("UUID", r"\small\texttt{\DocumentUUID}"),
("Commit", 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_tasks(section: dict, data: dict) -> list[str]:
tasks = data["tasks"]
lines = [r"\begin{enumerate}[label=\textbf{\arabic*.}]"]
for task_ref in section["task_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 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")
tokens = " ".join(
tex_escape(aliases.get(tree_ref, "?"))
for tree_ref in step.get("tree_refs", []) or []
)
step_text = f"K{index}: {step.get('title', '')}"
lines.append(rf"\noindent {tex_escape(step_text)}\quad {tokens}\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.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["card"]))
lines.extend(render_learning_tree_macro(data))
lines.extend(
[
r"\begin{document}",
r"\sloppy",
"",
]
)
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
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)
for index, value in enumerate(placeholders):
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 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\}",
}
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))
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_step_map_html(section: dict, data: dict) -> str:
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 [])
open_attr = "" if section.get("render_step_map") is False else " open"
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 [])
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_attr}><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_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]
items.append(
f'<li id="{html_escape(html_id(task_ref))}">'
f'{latex_inline_to_html(task.get("prompt_tex", ""), equation_numbers, figure_numbers)}</li>'
)
return f'<ol class="tasks">{"".join(items)}</ol>'
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"]
tree_data = json.dumps(tree_html_data(data), ensure_ascii=False).replace("</", "<\\/")
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)
)
rows = [
("Project", card["project"]),
("Subject", card["subject"]),
("Level", card["level"]),
("Dominanta", card["dominant"]),
("Topic", card["title"]),
("Revision date", card["revision_date"]),
("Status", card["status"]),
("Card in series", f"{card['number']}/{card['count']}"),
("Card version", 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):
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)
)
left_margin, right_margin = render_section_margins_html(section, data)
sections_html.append(
f'<article class="sheet section-sheet" id="{html_escape(html_id("section-" + str(index)))}">'
f'<aside class="pdf-margin left">{left_margin}</aside>'
f'<section class="pdf-main card-section">{render_running_header_html(card)}'
f'<h2><span>{index}</span>{html_escape(section.get("title", ""))}</h2>'
f'{render_step_map_html(section, data)}{content}</section>'
f'<aside class="pdf-margin right">{right_margin}</aside></article>'
)
bibliography_html = render_bibliography_section_html(data)
if bibliography_html:
sections_html.append(
f'<article class="sheet section-sheet" id="bibliografia-i-zrodla">'
f'<aside class="pdf-margin left"></aside>'
f'<section class="pdf-main card-section">{render_running_header_html(card)}'
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 ""
)
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}.sheet{width:min(1120px,calc(100vw - 24px));min-height:calc(1120px * 1.414);margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:grid;grid-template-columns:116px minmax(0,1fr) 116px;column-gap:18px;padding:52px 32px 42px}
.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}.card-section h2 span{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:26px;margin-right:20px;font-weight:400}
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}
.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}.tasks li{margin:10px 0}.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}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:static}}
@media print{body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{width:210mm;min-height:297mm;margin:0;box-shadow:none;break-after:page;padding:18mm 9mm 15mm}.pdf-margin{position:static;max-height:none;overflow:hidden}}
"""
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>`;
}
});
"""
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>
<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">
<aside class="pdf-margin left">{front_left}</aside>
<section class="pdf-main">
<header class="hero"><h1>{html_escape(card_prefix(card))}: {html_escape(card['title'])}</h1><div class="byline">{html_escape(card['author'])}, {html_escape(card['year'])}</div><dl class="meta">{metadata}</dl></header>
{scope_html}
</section>
<aside class="pdf-margin right">{front_right}</aside>
</article>
{''.join(sections_html)}
<article class="sheet dictionary-sheet"><aside class="pdf-margin left"></aside><section class="pdf-main card-section">{render_running_header_html(card)}<h2>Słownik WE/EN/EK/KW</h2>{render_learning_tree_overview_html(data)}</section><aside class="pdf-margin right"></aside></article>
</main>
<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>
<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 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_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-title">'
f'<div>{html_escape(card_title_line_5a(card))}</div>'
f'<span>KARTA {html_escape(card["number"])}/{html_escape(card["count"])}</span>'
'</div>'
'<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 (
'<div class="code-subblock">'
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}'
'</div>'
)
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_escape(text_5a(cell)) if cell else '<span class=\"write-box\"></span>'}</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_escape(text_5a(row.get("result", ""))) if row.get("result") else "<span class=\"write-box small\"></span>"}</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 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_running_card_header_5a_html(data),
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}">'
'<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:38px 38px 30px;display:flex;flex-direction:column;font-family:'STIX Two Text',serif;font-size:13px;line-height:1.55}
.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}.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}.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}}
"""
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_mission_5a_html(data),
render_objectives_5a_html(data),
render_agenda_5a_html(data),
render_hw_gate_5a_html(data),
render_tech_stack_5a_html(data),
]
),
},
{
"kind": "portrait",
"center": "".join(
[
render_running_card_header_5a_html(data),
render_theory_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}">'
'<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">'
'<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:
return 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:95px 21px;display:flex;flex-direction:column;font-family:'STIX Two Text',serif;font-size:11px;line-height:1.42}
.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}.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}.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}}
"""
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_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{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{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}",
"",
]
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=2.8mm,right=2.8mm,top=1.8mm,bottom=1.8mm]",
rf"{{\bfseries {tex_5a(card_title_line_5a(card))}}}\hfill{{\scriptsize\ttfamily KARTA {tex_escape(card['number'])}/{tex_escape(card['count'])}}}\par",
r"\vspace{0.2em}\hrule\vspace{0.2em}",
r"\scriptsize",
r"\begin{tabularx}{\linewidth}{@{}>{\ttfamily\textcolor{ESCMuted}}p{2.2cm}X>{\ttfamily\textcolor{ESCMuted}}p{2.5cm}X@{}}",
rf"Projekt & {tex_5a(card['project'])} & Przedmiot & {tex_5a(card['subject'])} \\",
rf"{tex_5a(first_label)} & \FiveAFill & {tex_5a(second_label)} & \FiveAFill \\",
r"\end{tabularx}",
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 = [
rf"\par\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.append(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 = [
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{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{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}",
"",
]
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=135mm,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*{25mm}",
*render_landscape_section_bar_5b_tex(section_number, title),
r"\vspace{2mm}",
r"\noindent\begin{minipage}[t][135mm][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][135mm][c]{242mm}",
r"\centering",
image,
r"\end{minipage}%",
r"\begin{minipage}[t][135mm][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*{25mm}",
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_mission_5a_tex(data),
r"\vfill",
*render_objectives_5a_tex(data),
r"\vfill",
*render_agenda_5a_tex(data),
r"\vfill",
*render_hw_gate_5a_tex(data),
r"\vfill",
*render_tech_stack_5a_tex(data),
],
},
{
"kind": "portrait",
"center": [
*running_card_header_5b_tex(data),
r"\vfill",
*render_theory_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 = [
r"\noindent\makebox[\textwidth][l]{%",
r"\begin{minipage}[t][\dimexpr\textheight-5pt\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-5pt\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-5pt\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 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 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()