5622 lines
248 KiB
Python
5622 lines
248 KiB
Python
#!/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:
|
||
if section.get("render_step_map") is False:
|
||
return ""
|
||
steps = section.get("steps", []) or []
|
||
if not steps:
|
||
return ""
|
||
tree_index, _ = build_learning_tree_index(data)
|
||
tree_refs = section_tree_refs(section)
|
||
aliases = {tree_ref: f"D{index + 1}" for index, tree_ref in enumerate(tree_refs)}
|
||
area = " ".join(section.get("area_refs", []) or [])
|
||
legend = " ".join(render_tree_button(aliases[tree_ref], tree_ref, tree_index) for tree_ref in tree_refs)
|
||
rows = []
|
||
for index, step in enumerate(steps, start=1):
|
||
tokens = " ".join(render_tree_button(aliases.get(tree_ref, "?"), tree_ref, tree_index) for tree_ref in step.get("tree_refs", []) or [])
|
||
refs = []
|
||
refs.extend(f'<a href="#{html_escape(html_id(label))}">{html_escape(label)}</a>' for label in step.get("equation_refs", []) or [])
|
||
refs.extend(f'<a href="#{html_escape(html_id(label))}">{html_escape(label)}</a>' for label in step.get("figure_refs", []) or [])
|
||
refs_html = f'<div class="step-refs">{" ".join(refs)}</div>' if refs else ""
|
||
rows.append(
|
||
f'<div class="step-row"><div class="step-title"><span>K{index}</span> '
|
||
f'{html_escape(step.get("title", ""))}</div><div class="step-trees">{tokens}</div>{refs_html}</div>'
|
||
)
|
||
return (
|
||
f'<details class="step-map" open><summary>Drzewka obszaru {html_escape(area)}</summary>'
|
||
f'<div class="tree-legend">{legend}</div><div class="steps">{"".join(rows)}</div></details>'
|
||
)
|
||
|
||
|
||
def margin_color_class(color: str) -> str:
|
||
if color == "red":
|
||
return "we"
|
||
if "green" in color:
|
||
return "og"
|
||
if "orange" in color:
|
||
return "tech"
|
||
if "blue" in color:
|
||
return "kw"
|
||
return "muted"
|
||
|
||
|
||
def render_margin_tag_html(tag: tuple[str, str, str, str, str, str, str]) -> str:
|
||
indent, color, prefix, official, local, official_tip, local_tip = tag
|
||
visible = " ".join(part for part in (prefix, official) if part)
|
||
local_suffix = f".{local}" if local else ""
|
||
tooltip = " | ".join(part for part in (official_tip, local_tip) if part)
|
||
return (
|
||
f'<button class="margin-tag {margin_color_class(color)}" style="--indent:{html_escape(indent)}" '
|
||
f'type="button" data-margin-display="{html_escape(visible + local_suffix)}" '
|
||
f'data-margin-tip="{html_escape(tooltip)}" title="{html_escape(tooltip)}">'
|
||
f'<span>{html_escape(visible)}</span><span class="local">{html_escape(local_suffix)}</span></button>'
|
||
)
|
||
|
||
|
||
def render_margin_column_html(tags: list[tuple[str, str, str, str, str, str, str]], title: str) -> str:
|
||
body = "".join(render_margin_tag_html(tag) for tag in tags) if tags else '<span class="margin-empty">-</span>'
|
||
return f'<div class="margin-title">{html_escape(title)}</div><div class="margin-list">{body}</div>'
|
||
|
||
|
||
def render_dual_margin_html(
|
||
general_tags: list[tuple[str, str, str, str, str, str, str]],
|
||
vocational_tags: list[tuple[str, str, str, str, str, str, str]],
|
||
data: dict | None = None,
|
||
) -> tuple[str, str]:
|
||
layout = margin_layout(data)
|
||
sides = {"left": "", "right": ""}
|
||
columns = {
|
||
"ogolne": general_tags,
|
||
"zawodowe": vocational_tags,
|
||
}
|
||
for column_id, tags in columns.items():
|
||
column = layout[column_id]
|
||
sides[column["side"]] += render_margin_column_html(tags, column["label"])
|
||
return sides["left"], sides["right"]
|
||
|
||
|
||
def render_section_margins_html(section: dict, data: dict) -> tuple[str, str]:
|
||
return render_dual_margin_html(
|
||
section_margin_tags(section, data, column="ogolne", effect_color="green!50!black"),
|
||
section_margin_tags(section, data, column="zawodowe", effect_color="orange!85!black"),
|
||
data=data,
|
||
)
|
||
|
||
|
||
def render_running_header_html(card: dict) -> str:
|
||
return (
|
||
f'<div class="running-header"><strong>{html_escape(card_prefix(card))}: '
|
||
f'{html_escape(card["title"])}</strong><span>{html_escape(card["author"])}, '
|
||
f'{html_escape(card["year"])}</span></div>'
|
||
)
|
||
|
||
|
||
def render_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"]
|
||
show_tree_inspector = data.get("generated", {}).get("html_tree_inspector", True)
|
||
tree_data = (
|
||
json.dumps(tree_html_data(data), ensure_ascii=False).replace("</", "<\\/")
|
||
if show_tree_inspector
|
||
else "{}"
|
||
)
|
||
toc = "".join(
|
||
f'<a href="#{html_escape(html_id("section-" + str(index)))}">{index}. {html_escape(section.get("title", ""))}</a>'
|
||
for index, section in enumerate(data.get("sections", []), start=1)
|
||
)
|
||
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)
|
||
task_identity = section.get("task_identity", {}) or {}
|
||
identity_html = ""
|
||
if task_identity:
|
||
identity_html = (
|
||
'<span class="task-identity">'
|
||
f'<strong>{html_escape(task_identity.get("label", ""))}</strong>'
|
||
f'<span class="task-identity-name">{html_escape(task_identity.get("name", ""))}</span>'
|
||
f'<code>{html_escape(task_identity.get("uuid", ""))}</code>'
|
||
"</span>"
|
||
)
|
||
heading = (
|
||
f'<h2 class="section-heading"><span class="section-index">{index}</span>'
|
||
f'<span class="section-title">{html_escape(section.get("title", ""))}</span>{identity_html}</h2>'
|
||
)
|
||
sections_html.append(
|
||
f'<article class="sheet section-sheet" id="{html_escape(html_id("section-" + str(index)))}">'
|
||
f'<aside class="pdf-margin left">{left_margin}</aside>'
|
||
f'<section class="pdf-main card-section">{render_running_header_html(card)}'
|
||
f'{heading}'
|
||
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}.section-heading{display:flex;align-items:flex-start;gap:0}.section-heading .section-index{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:26px;margin-right:20px;font-weight:400}.section-heading .section-title{min-width:0}.task-identity{margin-left:auto;padding-left:22px;text-align:right;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;line-height:1.25;color:#555;white-space:nowrap}.task-identity strong,.task-identity-name,.task-identity code{display:block}.task-identity strong{font-size:12px;color:#111}.task-identity-name{max-width:240px;overflow:hidden;text-overflow:ellipsis}.task-identity code{font-size:9px;color:#777}
|
||
p{font-size:17px;margin:0 0 12px}.pdf-main ul{font-size:17px;margin:8px 0 14px 24px;padding:0}.pdf-main li{margin:5px 0}
|
||
.pdf-margin{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:7px;line-height:1.14;color:#333;align-self:start;position:sticky;top:48px;max-height:calc(100vh - 80px);overflow:hidden}.pdf-margin.left{text-align:left}.pdf-margin.right{text-align:left}
|
||
.margin-title{font-weight:700;color:#666;margin-bottom:3px;letter-spacing:.02em}.margin-list{display:flex;flex-direction:column;align-items:flex-start}.margin-tag{display:block;margin:0 0 1px;padding:0;border:0;background:transparent;font:inherit;line-height:inherit;text-align:left;cursor:pointer;margin-left:var(--indent)}.margin-tag.we{color:var(--we)}.margin-tag.og,.margin-tag.tech{color:var(--og)}.margin-tag.kw{color:var(--kw)}.margin-tag .local{color:#777}.margin-tag:hover{text-decoration:underline}.margin-empty{color:#aaa}
|
||
.step-map{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.3;color:#5c5c5c;background:transparent;border:0;border-top:1px solid #cfcfcf;border-bottom:1px solid #e0e0e0;border-radius:0;padding:8px 0;margin:0 0 16px}.step-map summary{cursor:pointer;font-weight:400;color:#555}
|
||
.tree-legend{display:flex;flex-wrap:wrap;gap:4px;margin:7px 0}.steps{display:grid;gap:6px}.step-row{border-top:1px dashed #c9c9c9;padding-top:6px}.step-row:first-child{border-top:0}.step-title span{font-weight:700;margin-right:7px}.step-trees{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}.step-refs{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}.step-refs a{font-size:10px;color:#666}
|
||
.tree-token{font:10px/1 "Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #c8c8c8;background:#fff;border-radius:3px;padding:3px 5px;cursor:pointer}.tree-token.active{background:#111;color:white;border-color:#111}
|
||
.equation{display:grid;grid-template-columns:1fr auto;align-items:center;gap:14px;margin:18px 0;padding:4px 0;background:transparent;border:0}.equation .display{font-size:17px}.equation-number{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:15px}
|
||
figure{margin:24px 0;text-align:center}figure img{max-width:100%;display:block;margin:0 auto;border:0;border-radius:0}figcaption{font-size:14px;color:#444;margin-top:8px;text-align:left}
|
||
.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}.section-heading{flex-wrap:wrap}.task-identity{width:100%;padding:6px 0 0}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:static}}
|
||
@media print{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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
||
}
|
||
function renderTree(ref){
|
||
const item = treeData[ref];
|
||
document.querySelectorAll('.tree-token').forEach(btn => btn.classList.toggle('active', btn.dataset.tree === ref));
|
||
if(!item){ inspector.innerHTML = '<p class="empty">Brak danych drzewka.</p>'; return; }
|
||
const line = item.line === 'TECH' ? 'TECH' : 'OG';
|
||
inspector.innerHTML = `<div class="tree-card">
|
||
<div class="line ${line}">${esc(line)}</div>
|
||
<h3>${esc(item.we)}: ${esc(item.we_label)}</h3>
|
||
<p>${esc(item.we_text)}</p>
|
||
<p><code>${esc(item.effect)}</code><br>${esc(item.effect_text)}</p>
|
||
<h4>KW</h4>
|
||
<ul class="kw-list">${item.kw.map(k => `<li><code>${esc(k.display)}</code> ${esc(k.text)}</li>`).join('')}</ul>
|
||
<p><small>${esc(item.tree_ref)}</small></p>
|
||
</div>`;
|
||
}
|
||
document.addEventListener('click', event => {
|
||
const button = event.target.closest('.tree-token');
|
||
if(button){ renderTree(button.dataset.tree); return; }
|
||
const margin = event.target.closest('.margin-tag');
|
||
if(margin){
|
||
document.querySelectorAll('.tree-token').forEach(btn => btn.classList.remove('active'));
|
||
const display = esc(margin.dataset.marginDisplay || '');
|
||
const tip = esc(margin.dataset.marginTip || 'Brak opisu.');
|
||
inspector.innerHTML = `<div class="tree-card"><h3>${display}</h3><p>${tip}</p></div>`;
|
||
}
|
||
});
|
||
"""
|
||
inspector_html = (
|
||
'<aside class="inspector"><h2>Opis kodu</h2><div id="tree-inspector">'
|
||
'<p class="empty">Kliknij kod z marginesu albo D1, D2 itd., żeby zobaczyć opis.</p>'
|
||
"</div></aside>"
|
||
if show_tree_inspector
|
||
else ""
|
||
)
|
||
if not show_tree_inspector:
|
||
js = ""
|
||
return f"""<!doctype html>
|
||
<html lang="pl">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>{html_escape(card_prefix(card))}: {html_escape(card['title'])}</title>
|
||
<style>{css}</style>
|
||
<script>
|
||
window.MathJax = {{tex: {{inlineMath: [['\\\\(','\\\\)']], displayMath: [['\\\\[','\\\\]']]}}, svg: {{fontCache: 'global'}}}};
|
||
</script>
|
||
<script defer src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
|
||
</head>
|
||
<body>
|
||
<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>
|
||
{inspector_html}
|
||
<script>{js}</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def text_5a(value: object) -> str:
|
||
text = str(value)
|
||
text = text.replace("<->", "↔")
|
||
text = text.replace("->", "→")
|
||
text = text.replace("--", "–")
|
||
return " ".join(text.split())
|
||
|
||
|
||
def agenda_time_5a(value: object) -> str:
|
||
return text_5a(value).replace(" min", "")
|
||
|
||
|
||
def card_title_line_5a(card: dict) -> str:
|
||
title = str(card.get("title", "")).strip()
|
||
prefix = card_prefix(card)
|
||
if title.startswith(f"{prefix}:") or title.startswith(f"{prefix} ·"):
|
||
return title
|
||
return f"{prefix} · {title}" if title else prefix
|
||
|
||
|
||
def card_prefix(card: dict) -> str:
|
||
series = str(card.get("series", "card")).strip().upper() or "CARD"
|
||
number = str(card.get("number", "")).strip()
|
||
return f"{series}-{number}" if number else series
|
||
|
||
|
||
def publication_url(data: dict) -> str:
|
||
"""Return the canonical publication URL used by the title block and QR."""
|
||
title_block = data.get("title_block") or {}
|
||
card = data.get("card") or {}
|
||
for value in (
|
||
title_block.get("url"),
|
||
data.get("publication_url"),
|
||
card.get("publication_url"),
|
||
card.get("url"),
|
||
):
|
||
if value:
|
||
return str(value).strip()
|
||
|
||
host = title_block.get("url_host_uuid") or data.get("url_host_uuid")
|
||
port = title_block.get("url_port") or data.get("url_port")
|
||
document = title_block.get("doc_uuid") or data.get("doc_uuid") or card.get("uuid")
|
||
if host and document:
|
||
authority = str(host).strip()
|
||
if port:
|
||
authority += f":{str(port).strip()}"
|
||
return f"https://{authority}/{str(document).strip()}"
|
||
return ""
|
||
|
||
|
||
def title_block_fields(data: dict) -> dict[str, str | bool]:
|
||
"""Resolve the ISO-7200-like header while keeping card metadata canonical."""
|
||
card = data["card"]
|
||
configured = data.get("title_block") or {}
|
||
url = publication_url(data)
|
||
|
||
def value(name: str, default: object = "") -> str:
|
||
current = configured.get(name, default)
|
||
return str(current).strip() if current is not None else ""
|
||
|
||
revision_date = str(card.get("revision_date", "")).strip()
|
||
return {
|
||
"category": value("category", f"KARTA PRACY · {card.get('subject', '')}").upper(),
|
||
"standard": value("standard", "ISO 7200"),
|
||
"prepared_by": value("prepared_by", card.get("author", "—")) or "—",
|
||
"prepared_on": value("prepared_on", revision_date),
|
||
"checked_by": value("checked_by", "—") or "—",
|
||
"approved_by": value("approved_by", "—") or "—",
|
||
"title": value("title", card.get("title", "")),
|
||
"url": url,
|
||
"revision": value("revision", card.get("version", "")),
|
||
"issued_on": value("issued_on", revision_date),
|
||
"series": value("series", card_prefix(card)),
|
||
"document_type": value("document_type", "karta pracy"),
|
||
"tool": value("tool", "card-layouts"),
|
||
"sheet": value("sheet", "1 / 1"),
|
||
"show_qr": bool(configured.get("show_qr", bool(url))),
|
||
}
|
||
|
||
|
||
@lru_cache(maxsize=32)
|
||
def qr_svg(value: str) -> str:
|
||
"""Generate a self-contained QR SVG without a network dependency.
|
||
|
||
ReportLab ships a standards-compliant QR encoder on the supported
|
||
workstation image. The TeX renderer uses its native qrcode package;
|
||
HTML receives the same payload as inline SVG so the generated card is
|
||
fully portable.
|
||
"""
|
||
if not value:
|
||
return ""
|
||
try:
|
||
from reportlab.graphics.barcode.qr import QrCodeWidget
|
||
except ImportError:
|
||
return ""
|
||
|
||
widget = QrCodeWidget(value=value, barLevel="L")
|
||
widget.qr.make()
|
||
modules = widget.qr.modules
|
||
size = len(modules)
|
||
border = 4
|
||
path_parts: list[str] = []
|
||
for row_index, row in enumerate(modules):
|
||
column = 0
|
||
while column < size:
|
||
if not row[column]:
|
||
column += 1
|
||
continue
|
||
start = column
|
||
while column < size and row[column]:
|
||
column += 1
|
||
length = column - start
|
||
path_parts.append(f"M{start + border} {row_index + border}h{length}v1h-{length}z")
|
||
full_size = size + border * 2
|
||
return (
|
||
f'<svg class="title-qr" viewBox="0 0 {full_size} {full_size}" role="img" '
|
||
f'aria-label="Kod QR: {html_escape(value)}" shape-rendering="crispEdges">'
|
||
'<rect width="100%" height="100%" fill="#fefefc"/>'
|
||
f'<path d="{" ".join(path_parts)}" fill="#1c1c1a"/>'
|
||
"</svg>"
|
||
)
|
||
|
||
|
||
def margin_code_5a(prefix: str, official: str, local: str) -> str:
|
||
if prefix == "WE":
|
||
return " ".join(part for part in ("WE", official) if part)
|
||
stem = re.sub(r"^(EN|EK|KW)\s+", "", prefix).strip()
|
||
code = " ".join(part for part in (stem, official) if part)
|
||
if local:
|
||
code += f".{local}"
|
||
return code
|
||
|
||
|
||
def margin_tree_groups_5a(tags: list[tuple[str, str, str, str, str, str, str]]) -> list[dict]:
|
||
groups: list[dict] = []
|
||
current_group: dict | None = None
|
||
current_item: dict | None = None
|
||
for indent, _color, prefix, official, local, _official_tip, _local_tip in tags:
|
||
code = margin_code_5a(prefix, official, local)
|
||
if not code:
|
||
continue
|
||
if prefix == "WE":
|
||
current_group = {"we": code, "items": []}
|
||
current_item = None
|
||
groups.append(current_group)
|
||
continue
|
||
if current_group is None:
|
||
current_group = {"we": "", "items": []}
|
||
groups.append(current_group)
|
||
if indent.startswith("0.28"):
|
||
current_item = {"code": code, "kw": []}
|
||
current_group["items"].append(current_item)
|
||
continue
|
||
if current_item is None:
|
||
current_item = {"code": code, "kw": []}
|
||
current_group["items"].append(current_item)
|
||
else:
|
||
current_item["kw"].append(code)
|
||
return groups
|
||
|
||
|
||
def margin_tree_groups_5b(tags: list[tuple[str, str, str, str, str, str, str]]) -> list[dict]:
|
||
groups: list[dict] = []
|
||
current_group: dict | None = None
|
||
for indent, _color, prefix, official, local, _official_tip, _local_tip in tags:
|
||
if prefix == "KW" or prefix.startswith("KW "):
|
||
continue
|
||
code = margin_code_5a(prefix, official, local)
|
||
if not code:
|
||
continue
|
||
if prefix == "WE":
|
||
current_group = {"we": code, "items": []}
|
||
groups.append(current_group)
|
||
continue
|
||
if current_group is None:
|
||
current_group = {"we": "", "items": []}
|
||
groups.append(current_group)
|
||
if indent.startswith("0.28"):
|
||
current_group["items"].append({"code": code})
|
||
return groups
|
||
|
||
|
||
def common_kw_5a(tags: list[tuple[str, str, str, str, str, str, str]]) -> str:
|
||
codes: list[str] = []
|
||
for _indent, _color, prefix, _official, local, _official_tip, _local_tip in tags:
|
||
if prefix != "KW":
|
||
continue
|
||
code = local or _official
|
||
if code and code not in codes:
|
||
codes.append(code)
|
||
return " ".join(sorted(codes)) if codes else "-"
|
||
|
||
|
||
def render_margin_tree_panel_5a_html(data: dict, column: str) -> str:
|
||
is_tech = column == "zawodowe"
|
||
tags = learning_tree_tags(
|
||
data,
|
||
column=column,
|
||
effect_color="orange!85!black" if is_tech else "green!50!black",
|
||
)
|
||
title = "TECH — zawodowe" if is_tech else "OG — ogólne"
|
||
subtitle = "WE → EK → KW" if is_tech else "WE → EN → KW"
|
||
dominance = "dominanta zawodowa:<br>ELE/AUT/ELM/INF" if is_tech else "dominanta ogólna:<br>FIZ/MAT/INF"
|
||
tree_parts: list[str] = []
|
||
for group in margin_tree_groups_5a(tags):
|
||
if group.get("we"):
|
||
tree_parts.append(f'<div class="tree-we">{html_escape(group["we"])}</div>')
|
||
for item in group.get("items", []):
|
||
tree_parts.append(
|
||
f'<div class="tree-line level-1"><span></span>{html_escape(item["code"])}</div>'
|
||
)
|
||
for kw_code in item.get("kw", []):
|
||
tree_parts.append(
|
||
f'<div class="tree-line level-2"><span></span>{html_escape(kw_code)}</div>'
|
||
)
|
||
tree_html = "".join(f"<div>{part}</div>" if part.startswith('<div class="tree-we"') else part for part in tree_parts)
|
||
return (
|
||
'<div class="margin-panel">'
|
||
'<div class="margin-panel-head">'
|
||
f'<div class="margin-panel-title">{html_escape(title)}</div>'
|
||
f'<div class="margin-panel-subtitle">{html_escape(subtitle)}</div>'
|
||
'</div>'
|
||
f'<div class="margin-panel-body">{tree_html}'
|
||
f'<div class="kw-common">KW wspólne:<br><span>{html_escape(common_kw_5a(tags))}</span></div>'
|
||
'</div>'
|
||
f'<div class="margin-panel-foot">{dominance}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_5a_section_header(number: int, title: str) -> str:
|
||
return (
|
||
'<div class="section-head">'
|
||
f'<span>§{number}</span><strong>{html_escape(title.upper())}</strong>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_title_block_html(data: dict) -> str:
|
||
fields = title_block_fields(data)
|
||
|
||
def cell(label: str, value: object, extra_class: str = "") -> str:
|
||
classes = "title-cell" + (f" {extra_class}" if extra_class else "")
|
||
shown = html_escape(str(value)) if value else "—"
|
||
return f'<div class="{classes}"><span>{html_escape(label)}</span><strong>{shown}</strong></div>'
|
||
|
||
qr = qr_svg(str(fields["url"])) if fields["show_qr"] else ""
|
||
qr_cell = (
|
||
f'<a class="title-qr-cell" href="{html_escape(str(fields["url"]))}" '
|
||
f'aria-label="Otwórz adres z kodu QR">{qr}</a>'
|
||
if qr
|
||
else '<div class="title-qr-cell title-qr-empty"><span>QR</span><strong>'
|
||
+ ("brak modułu QR" if fields["url"] else "brak URL")
|
||
+ "</strong></div>"
|
||
)
|
||
prepared = (
|
||
'<div class="title-cell signer"><span>Opracował</span>'
|
||
f'<strong>{html_escape(str(fields["prepared_by"]))}</strong>'
|
||
f'<em>{html_escape(str(fields["prepared_on"]))}</em></div>'
|
||
)
|
||
return (
|
||
'<section class="title-block" aria-label="Tabliczka dokumentu">'
|
||
'<div class="title-signers">'
|
||
f'{prepared}'
|
||
f'{cell("Sprawdził", fields["checked_by"], "signer")}'
|
||
f'{cell("Zatwierdził", fields["approved_by"], "signer")}'
|
||
'</div>'
|
||
'<div class="title-document">'
|
||
f'<div class="title-category"><strong>{html_escape(str(fields["category"]))}</strong><span>{html_escape(str(fields["standard"]))}</span></div>'
|
||
f'<div class="title-name"><span>Tytuł</span><strong>{html_escape(str(fields["title"]))}</strong></div>'
|
||
f'<div class="title-url"><span>URL</span><code>{html_escape(str(fields["url"])) if fields["url"] else "—"}</code></div>'
|
||
'</div>'
|
||
'<div class="title-meta">'
|
||
f'{cell("Rewizja", fields["revision"])}'
|
||
f'{cell("Data wydania", fields["issued_on"])}'
|
||
f'{cell("Seria", fields["series"])}'
|
||
f'{cell("Typ", fields["document_type"])}'
|
||
f'{cell("Narzędzie", fields["tool"])}'
|
||
f'{cell("Arkusz", fields["sheet"])}'
|
||
'</div>'
|
||
f'{qr_cell}'
|
||
'</section>'
|
||
)
|
||
|
||
|
||
def render_metric_header_5a_html(data: dict) -> str:
|
||
card = data["card"]
|
||
fields = data.get("metric_fill_fields") or [
|
||
{"label": "Uczeń"},
|
||
{"label": "Klasa · data"},
|
||
]
|
||
first_label = fields[0].get("label", "Uczeń") if fields else "Uczeń"
|
||
second_label = fields[1].get("label", "Klasa · data") if len(fields) > 1 else "Klasa · data"
|
||
return (
|
||
'<div class="metric-box">'
|
||
f'{render_title_block_html(data)}'
|
||
'<div class="metric-grid">'
|
||
f'<div><span>Projekt</span><strong>{html_escape(card["project"])}</strong></div>'
|
||
f'<div><span>Przedmiot</span><strong>{html_escape(card["subject"])}</strong></div>'
|
||
f'<div><span>{html_escape(first_label)}</span><i></i></div>'
|
||
f'<div><span>{html_escape(second_label)}</span><i></i></div>'
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_mission_5a_html(data: dict) -> str:
|
||
return (
|
||
'<div class="plain-box">'
|
||
'<div class="plain-title">Misja</div>'
|
||
f'<p>{html_escape(text_5a(data.get("mission", "")))}</p>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_objectives_5a_html(data: dict) -> str:
|
||
refs = data.get("objective_refs", [])
|
||
rows: list[str] = []
|
||
for index, objective in enumerate(data.get("objectives", []), start=1):
|
||
ref = refs[index - 1] if index - 1 < len(refs) else {}
|
||
ref_text = f"← TECH {ref.get('tech', f'{index:02d}')} · OG {ref.get('og', f'{index:02d}')} →"
|
||
rows.append(
|
||
'<div class="objective-row">'
|
||
'<span class="box-check"></span>'
|
||
f'<span>{index}. {html_escape(text_5a(objective))}</span>'
|
||
f'<code>{html_escape(ref_text)}</code>'
|
||
'</div>'
|
||
)
|
||
return (
|
||
'<div class="section-box">'
|
||
f'{render_5a_section_header(1, "Cele operacyjne")}'
|
||
f'<div class="section-body objectives">{"".join(rows)}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_agenda_5a_html(data: dict) -> str:
|
||
rows = []
|
||
for row in data.get("agenda", []):
|
||
rows.append(
|
||
f'<div class="agenda-time">{html_escape(agenda_time_5a(row.get("time", "")))}</div>'
|
||
f'<div>{html_escape(text_5a(row.get("work", "")))}</div>'
|
||
)
|
||
return (
|
||
'<div class="section-box">'
|
||
f'{render_5a_section_header(2, "Agenda 45 min")}'
|
||
f'<div class="section-body agenda-grid">{"".join(rows)}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_hw_gate_5a_html(data: dict) -> str:
|
||
criteria = data.get("hw_gate", {}).get("criteria", [])
|
||
rows: list[str] = []
|
||
for index, criterion in enumerate(criteria):
|
||
text = criterion.get("check") or criterion.get("criterion", "")
|
||
if index == len(criteria) - 1:
|
||
rows.append(
|
||
'<div class="gate-row sign-row"><span class="box-check"></span>'
|
||
f'<span>{html_escape(text_5a(text))}</span>'
|
||
'<code>podpis<i></i></code></div>'
|
||
)
|
||
else:
|
||
rows.append(
|
||
'<div class="gate-row"><span class="box-check"></span>'
|
||
f'<span>{html_escape(text_5a(text))}</span></div>'
|
||
)
|
||
return (
|
||
'<div class="section-box">'
|
||
f'{render_5a_section_header(3, "Warunki wejścia na sprzęt (bramka A → B)")}'
|
||
f'<div class="section-body gate">{"".join(rows)}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_theory_5a_html(data: dict) -> str:
|
||
theory = data.get("theory_refs", {})
|
||
rows = []
|
||
for index, item in enumerate(theory.get("known_refs", []), start=1):
|
||
rows.append(
|
||
'<tr>'
|
||
f'<td><code>{html_escape(item.get("id", ""))} [{index}]</code></td>'
|
||
f'<td>{html_escape(text_5a(item.get("note") or item.get("label", "")))}</td>'
|
||
'</tr>'
|
||
)
|
||
concept_rows = []
|
||
for concept in theory.get("new_concepts", []):
|
||
concept_rows.append(
|
||
'<tr>'
|
||
f'<td>{html_escape(text_5a(concept.get("term", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(concept.get("definition", "")))}</td>'
|
||
'</tr>'
|
||
)
|
||
intro = theory.get("intro", "")
|
||
intro_html = f'<p>{html_escape(text_5a(intro))}</p>' if intro else ""
|
||
concept_html = (
|
||
'<table class="theory-table"><thead><tr>'
|
||
'<th>Nowe pojęcie</th><th>Definicja robocza</th>'
|
||
f'</tr></thead><tbody>{"".join(concept_rows)}</tbody></table>'
|
||
if concept_rows
|
||
else ""
|
||
)
|
||
return (
|
||
'<div class="section-box">'
|
||
f'{render_5a_section_header(4, "Teoria pod model i symulację")}'
|
||
'<div class="section-body">'
|
||
f'{intro_html}'
|
||
'<table class="theory-table"><thead><tr>'
|
||
'<th>Karta / źródło</th><th>Użycie w symulacji</th>'
|
||
f'</tr></thead><tbody>{"".join(rows)}</tbody></table>'
|
||
f'{concept_html}'
|
||
'</div></div>'
|
||
)
|
||
|
||
|
||
def render_stack_column_5a_html(title: str, rows: list[dict]) -> str:
|
||
cells = []
|
||
for row in rows:
|
||
cells.append(
|
||
f'<div><code>{html_escape(row.get("name", ""))}</code></div>'
|
||
f'<div>{html_escape(text_5a(row.get("use", "")))}</div>'
|
||
)
|
||
return (
|
||
'<div class="stack-column">'
|
||
f'<h3>{html_escape(title)}</h3>'
|
||
f'<div class="stack-grid">{"".join(cells)}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_tech_stack_5a_html(data: dict) -> str:
|
||
stack = data.get("tech_stack", {})
|
||
return (
|
||
'<div class="section-box">'
|
||
f'{render_5a_section_header(5, "Technologie stanowiska")}'
|
||
'<div class="section-body tech-stack">'
|
||
f'{render_stack_column_5a_html("Software", stack.get("software", []))}'
|
||
f'{render_stack_column_5a_html("Hardware", stack.get("hardware", []))}'
|
||
'</div></div>'
|
||
)
|
||
|
||
|
||
def section_box_5a_html(number: int, title: str, body: str, extra_class: str = "") -> str:
|
||
class_attr = f"section-box {extra_class}".strip()
|
||
return (
|
||
f'<div class="{html_escape(class_attr)}">'
|
||
f'{render_5a_section_header(number, title)}'
|
||
f'<div class="section-body">{body}</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_part_banner_5a_html(part: dict) -> str:
|
||
return (
|
||
'<div class="part-banner">'
|
||
f'<strong>{html_escape(text_5a(part.get("title", "")))}</strong>'
|
||
f'<span>{html_escape(text_5a(part.get("body", "")))}</span>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def render_html_list_5a(items: Iterable[object]) -> str:
|
||
return '<ul class="compact-list">' + "".join(f"<li>{html_escape(text_5a(item))}</li>" for item in items) + "</ul>"
|
||
|
||
|
||
def code_block_columns_5a(block: dict) -> list[dict]:
|
||
columns = block.get("columns")
|
||
if columns:
|
||
return [
|
||
{"title": column.get("title", ""), "items": column.get("items", [])}
|
||
for column in columns
|
||
]
|
||
return [
|
||
{"title": "Pliki szkieletu", "items": block.get("files", [])},
|
||
{"title": "Interfejs", "items": block.get("functions", [])},
|
||
{"title": "Uczeń odpowiada za", "items": block.get("student_owns", [])},
|
||
]
|
||
|
||
|
||
def render_code_columns_5a_html(block: dict) -> str:
|
||
columns = code_block_columns_5a(block)
|
||
return (
|
||
'<div class="three-col">'
|
||
+ "".join(
|
||
f'<div><h3>{html_escape(text_5a(column.get("title", "")))}</h3>{render_html_list_5a(column.get("items", []))}</div>'
|
||
for column in columns
|
||
)
|
||
+ '</div>'
|
||
)
|
||
|
||
|
||
def render_code_subblock_5a_html(block: dict) -> str:
|
||
pass_line = ""
|
||
if block.get("pass_condition"):
|
||
pass_line = f'<p class="pass-line">PASS: {html_escape(text_5a(block.get("pass_condition", "")))}</p>'
|
||
return (
|
||
f'<article class="code-subblock" data-block="{html_escape(slugify_web(block.get("slug") or block.get("title")))}">'
|
||
f'<h3>{html_escape(text_5a(block.get("title", "")))}</h3>'
|
||
f'<p>{html_escape(text_5a(block.get("context", "")))}</p>'
|
||
f'{render_code_columns_5a_html(block)}'
|
||
f'{pass_line}'
|
||
'</article>'
|
||
)
|
||
|
||
|
||
def render_math_5a_html(data: dict) -> str:
|
||
block = data.get("math_block", {})
|
||
tasks = []
|
||
for index, task in enumerate(block.get("tasks", []), start=1):
|
||
tasks.append(
|
||
'<div class="math-task">'
|
||
f'<div><strong>{index}.</strong> {html_escape(text_5a(task.get("prompt", "")))}</div>'
|
||
f'<code>{html_escape(task.get("equation_tex", ""))}</code>'
|
||
'<span class="answer-line"></span><span class="answer-line"></span>'
|
||
f'<em>{html_escape(text_5a(task.get("answer_hint", "")))}</em>'
|
||
'</div>'
|
||
)
|
||
return section_box_5a_html(6, block.get("title", "Do policzenia"), "".join(tasks), "dense")
|
||
|
||
|
||
def render_model_5a_html(data: dict) -> str:
|
||
model = data.get("model_block", {})
|
||
rows = "".join(
|
||
f'<tr><td><code>{html_escape(item.get("name", ""))}</code></td><td>{html_escape(text_5a(item.get("value", "")))}</td></tr>'
|
||
for item in model.get("constants", [])
|
||
)
|
||
body = (
|
||
'<div class="model-grid">'
|
||
'<div>'
|
||
f'<p>Notebook: <code>{html_escape(model.get("notebook", ""))}</code></p>'
|
||
f'<p>Kontrakt: <code>{html_escape(model.get("output_constants", ""))}</code></p>'
|
||
f'{render_html_list_5a(model.get("steps", []))}'
|
||
'</div>'
|
||
f'<table class="compact-table constants"><tbody>{rows}</tbody></table>'
|
||
'</div>'
|
||
)
|
||
return section_box_5a_html(7, model.get("title", "Model w notebooku"), body, "dense")
|
||
|
||
|
||
def render_code_block_5a_html(data: dict) -> str:
|
||
code = data.get("code_block", {})
|
||
container = data.get("container_test", {})
|
||
note_html = ""
|
||
if code.get("next_cards_note"):
|
||
note_html = f'<p class="pass-line">NOTATKA: {html_escape(text_5a(code.get("next_cards_note", "")))}</p>'
|
||
intro = f'<p>{html_escape(text_5a(code.get("context", "")))}</p>' if code.get("context") else ""
|
||
blocks = code.get("blocks", [])
|
||
if blocks:
|
||
block_html = "".join(render_code_subblock_5a_html(block) for block in blocks)
|
||
body = intro + block_html + note_html + f'<p class="pass-line">PASS: {html_escape(text_5a(container.get("pass_condition", "")))}</p>'
|
||
else:
|
||
body = (
|
||
intro
|
||
+ render_code_columns_5a_html(code)
|
||
+ note_html
|
||
+ f'<p class="pass-line">PASS: {html_escape(text_5a(container.get("pass_condition", "")))}</p>'
|
||
)
|
||
return section_box_5a_html(8, code.get("title", "Bloczek RP2350"), body, "dense")
|
||
|
||
|
||
def render_container_test_5a_html(data: dict) -> str:
|
||
container = data.get("container_test", {})
|
||
commands = "".join(f'<li><code>{html_escape(command)}</code></li>' for command in container.get("commands", []))
|
||
return section_box_5a_html(
|
||
9,
|
||
container.get("title", "Test w kontenerze"),
|
||
f'<ol class="command-list">{commands}</ol>',
|
||
"dense",
|
||
)
|
||
|
||
|
||
def render_bom_5a_html(data: dict) -> str:
|
||
rows = "".join(
|
||
'<tr>'
|
||
'<td><span class="box-check"></span></td>'
|
||
f'<td><code>{html_escape(item.get("item", ""))}</code></td>'
|
||
f'<td>{html_escape(text_5a(item.get("check", "")))}</td>'
|
||
'</tr>'
|
||
for item in data.get("bom", [])
|
||
)
|
||
return section_box_5a_html(10, "BOM i checklista stanowiska", f'<table class="compact-table check-table"><tbody>{rows}</tbody></table>', "dense")
|
||
|
||
|
||
def render_safety_5a_html(data: dict) -> str:
|
||
cards = "".join(
|
||
'<div class="safety-card">'
|
||
f'<strong>{html_escape(rule.get("title", ""))}</strong>'
|
||
f'<span>{html_escape(text_5a(rule.get("body", "")))}</span>'
|
||
'</div>'
|
||
for rule in data.get("safety_rules", [])
|
||
)
|
||
return section_box_5a_html(11, "BHP i złote zasady", f'<div class="safety-grid">{cards}</div>', "dense")
|
||
|
||
|
||
def render_settings_figures_5a_html(data: dict) -> str:
|
||
figure_html = "".join(
|
||
'<figure>'
|
||
f'<img src="{html_escape(html_figure_asset(figure.get("path", "")))}" alt="{html_escape(figure.get("caption", ""))}">'
|
||
f'<figcaption>{html_escape(text_5a(figure.get("caption", "")))}</figcaption>'
|
||
'</figure>'
|
||
for figure in data.get("figures", [])
|
||
)
|
||
rows = "".join(
|
||
'<tr>'
|
||
f'<td>{html_escape(text_5a(row.get("test", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("dps", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("xy", "")))}</td>'
|
||
'</tr>'
|
||
for row in data.get("reference_settings", [])
|
||
)
|
||
table = (
|
||
'<table class="compact-table"><thead><tr><th>Test</th><th>DPS3010U</th><th>XY6020L</th></tr></thead>'
|
||
f'<tbody>{rows}</tbody></table>'
|
||
)
|
||
return section_box_5a_html(12, data.get("settings_section_title", "Kaskada i nastawy referencyjne"), f'<div class="figure-grid">{figure_html}</div>{table}', "dense")
|
||
|
||
|
||
def render_connection_5a_html(data: dict, section_number: int = 13) -> str:
|
||
connection = data.get("connection_section", {})
|
||
if not connection:
|
||
return ""
|
||
figure = connection.get("figure", {})
|
||
figure_html = ""
|
||
if figure.get("path"):
|
||
full_pdf_path = figure.get("full_pdf_path") or figure.get("path", "")
|
||
image_html = (
|
||
f'<img src="{html_escape(html_figure_asset(figure.get("path", "")))}" '
|
||
f'alt="{html_escape(text_5a(figure.get("caption", "")))}">'
|
||
)
|
||
if full_pdf_path:
|
||
image_html = f'<a href="{html_escape(html_pdf_asset(full_pdf_path))}">{image_html}</a>'
|
||
figure_html = (
|
||
'<figure class="connection-figure">'
|
||
f'{image_html}'
|
||
f'<figcaption>{html_escape(text_5a(figure.get("caption", "")))}</figcaption>'
|
||
f'<a class="figure-pdf-link" href="{html_escape(html_pdf_asset(full_pdf_path))}">pełny PDF</a>'
|
||
'</figure>'
|
||
)
|
||
headers = ["RP2350", "OptoIzolator strona A", "OptoIzolator strona B", "XY6020L"]
|
||
rows = "".join(
|
||
"<tr>"
|
||
f'<td>{html_escape(text_5a(row.get("rp2350", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("isolator_a", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("isolator_b", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("xy", "")))}</td>'
|
||
"</tr>"
|
||
for row in connection.get("pin_table", [])
|
||
)
|
||
table = (
|
||
'<table class="compact-table connection-table"><thead><tr>'
|
||
+ "".join(f"<th>{html_escape(header)}</th>" for header in headers)
|
||
+ f"</tr></thead><tbody>{rows}</tbody></table>"
|
||
)
|
||
details = (
|
||
'<div class="connection-notes">'
|
||
f'<div><h3>Parametry łącza</h3>{render_html_list_5a(connection.get("parameters", []))}</div>'
|
||
f'<div><h3>Zakazy i kontrola</h3>{render_html_list_5a(connection.get("warnings", []))}</div>'
|
||
'</div>'
|
||
)
|
||
source_note = ""
|
||
if connection.get("source_note"):
|
||
source_note = f'<p class="pass-line">ŹRÓDŁA: {html_escape(text_5a(connection.get("source_note", "")))}</p>'
|
||
body = (
|
||
f"{figure_html}"
|
||
f'<p>{html_escape(text_5a(connection.get("intro", "")))}</p>'
|
||
f"{table}{details}{source_note}"
|
||
)
|
||
return section_box_5a_html(section_number, connection.get("title", "Podłączenie RP2350 ↔ XY6020L"), body, "dense")
|
||
|
||
|
||
def technical_schematic_web_path(data: dict) -> str:
|
||
figure = data.get("connection_section", {}).get("figure", {})
|
||
return (
|
||
figure.get("web_carrier_pdf_path")
|
||
or figure.get("full_pdf_path")
|
||
or figure.get("path")
|
||
or ""
|
||
)
|
||
|
||
|
||
def render_technical_schematic_5b_html(data: dict, section_number: int = 13) -> str:
|
||
connection = data.get("connection_section", {})
|
||
if not connection:
|
||
return ""
|
||
figure = connection.get("figure", {})
|
||
sheet_path = technical_schematic_web_path(data)
|
||
caption = text_5a(figure.get("caption", "Schemat blokowy połączenia"))
|
||
if sheet_path:
|
||
image_html = (
|
||
f'<img src="{html_escape(html_figure_asset(sheet_path))}" '
|
||
f'alt="{html_escape(caption)}">'
|
||
)
|
||
pdf_href = html_relative_asset_href(data, sheet_path)
|
||
image_html = (
|
||
f'<a class="technical-sheet-link" href="{html_escape(pdf_href)}">'
|
||
f"{image_html}</a>"
|
||
)
|
||
pdf_link = f'<a class="figure-pdf-link" href="{html_escape(pdf_href)}">pełny PDF</a>'
|
||
else:
|
||
image_html = '<div class="technical-sheet-missing">Brak renderu schematu</div>'
|
||
pdf_link = ""
|
||
body = (
|
||
'<figure class="technical-sheet-figure">'
|
||
f"{image_html}"
|
||
'<figcaption>'
|
||
f'<span>{html_escape(caption)}</span>'
|
||
f"{pdf_link}"
|
||
'</figcaption>'
|
||
'</figure>'
|
||
)
|
||
return section_box_5a_html(section_number, connection.get("title", "Podłączenie RP2350 ↔ XY6020L"), body, "dense technical-section")
|
||
|
||
|
||
def render_connection_details_5b_html(data: dict, section_number: int = 13) -> str:
|
||
connection = data.get("connection_section", {})
|
||
if not connection:
|
||
return ""
|
||
headers = ["RP2350", "OptoIzolator strona A", "OptoIzolator strona B", "XY6020L"]
|
||
rows = "".join(
|
||
"<tr>"
|
||
f'<td>{html_escape(text_5a(row.get("rp2350", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("isolator_a", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("isolator_b", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("xy", "")))}</td>'
|
||
"</tr>"
|
||
for row in connection.get("pin_table", [])
|
||
)
|
||
table = (
|
||
'<table class="compact-table connection-table"><thead><tr>'
|
||
+ "".join(f"<th>{html_escape(header)}</th>" for header in headers)
|
||
+ f"</tr></thead><tbody>{rows}</tbody></table>"
|
||
)
|
||
details = (
|
||
'<div class="connection-notes">'
|
||
f'<div><h3>Parametry łącza</h3>{render_html_list_5a(connection.get("parameters", []))}</div>'
|
||
f'<div><h3>Zakazy i kontrola</h3>{render_html_list_5a(connection.get("warnings", []))}</div>'
|
||
'</div>'
|
||
)
|
||
source_note = ""
|
||
if connection.get("source_note"):
|
||
source_note = f'<p class="pass-line">ŹRÓDŁA: {html_escape(text_5a(connection.get("source_note", "")))}</p>'
|
||
body = (
|
||
f'<p>{html_escape(text_5a(connection.get("intro", "")))}</p>'
|
||
f"{table}{details}{source_note}"
|
||
)
|
||
return section_box_5a_html(
|
||
section_number,
|
||
connection.get("title", "Podłączenie RP2350 ↔ XY6020L") + " -- tabela pinów i kontrola",
|
||
body,
|
||
"dense",
|
||
)
|
||
|
||
|
||
def render_procedure_5a_html(data: dict, section_number: int = 13) -> str:
|
||
rows = "".join(
|
||
'<tr>'
|
||
'<td><span class="box-check"></span></td>'
|
||
f'<td><code>{html_escape(step.get("step", ""))}</code></td>'
|
||
f'<td>{html_escape(text_5a(step.get("action", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(step.get("condition", "")))}</td>'
|
||
'</tr>'
|
||
for step in data.get("hardware_procedure", [])
|
||
)
|
||
table = '<table class="compact-table procedure-table"><thead><tr><th>OK</th><th>Krok</th><th>Działanie</th><th>Warunek przejścia</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
||
return section_box_5a_html(section_number, "Procedura sprzętowa krok po kroku", table, "dense")
|
||
|
||
|
||
def render_observation_5a_html(data: dict, section_number: int = 14) -> str:
|
||
obs = data.get("observation_table", {})
|
||
headers = "".join(f"<th>{html_escape(header)}</th>" for header in obs.get("columns", []))
|
||
rows = []
|
||
for row in obs.get("rows", []):
|
||
cells = row.get("cells", [])
|
||
rows.append("".join(f"<td>{html_or_write_box(cell, 'write-box')}</td>" for cell in cells))
|
||
table = f'<table class="obs-table"><thead><tr>{headers}</tr></thead><tbody>' + "".join(f"<tr>{row}</tr>" for row in rows) + "</tbody></table>"
|
||
return section_box_5a_html(section_number, obs.get("title", "Karta obserwacji"), table, "dense")
|
||
|
||
|
||
def render_troubleshooting_5a_html(data: dict, section_number: int = 15) -> str:
|
||
rows = "".join(
|
||
'<tr>'
|
||
f'<td>{html_escape(text_5a(row.get("symptom", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("hypothesis", "")))}</td>'
|
||
f'<td>{html_escape(text_5a(row.get("action", "")))}</td>'
|
||
f'<td>{html_or_write_box(row.get("result", ""), "write-box small")}</td>'
|
||
'</tr>'
|
||
for row in data.get("troubleshooting", [])
|
||
)
|
||
table = '<table class="compact-table trouble-table"><thead><tr><th>Objaw</th><th>Hipoteza</th><th>Akcja</th><th>Wynik</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
||
return section_box_5a_html(section_number, "Troubleshooting log", table, "dense")
|
||
|
||
|
||
def html_or_write_box(value: object, class_name: str) -> str:
|
||
if value:
|
||
return html_escape(text_5a(value))
|
||
return f'<span class="{html_escape(class_name)}"></span>'
|
||
|
||
|
||
def render_analysis_5a_html(data: dict, section_number: int = 16, line_count: int = 3) -> str:
|
||
answer_lines = "".join("<i></i>" for _ in range(line_count))
|
||
items = "".join(
|
||
'<div class="analysis-item">'
|
||
f'<strong>{index}.</strong><span>{html_escape(text_5a(task))}</span>'
|
||
f'{answer_lines}'
|
||
'</div>'
|
||
for index, task in enumerate(data.get("analysis_tasks", []), start=1)
|
||
)
|
||
return section_box_5a_html(section_number, "Model vs pomiar i analiza danych", f'<div class="analysis-list">{items}</div>', "dense")
|
||
|
||
|
||
def render_team_decision_5a_html(data: dict, section_number: int = 17, line_count: int = 3) -> str:
|
||
decision = data.get("team_decision", {})
|
||
answer_lines = "".join("<span></span>" for _ in range(line_count))
|
||
fields = "".join(
|
||
'<div class="adr-field">'
|
||
f'<strong>{html_escape(text_5a(question))}</strong>'
|
||
f'{answer_lines}'
|
||
'</div>'
|
||
for question in decision.get("questions", [])
|
||
)
|
||
return section_box_5a_html(section_number, decision.get("title", "Decyzja zespołu"), f'<div class="adr-grid">{fields}</div>', "dense")
|
||
|
||
|
||
def render_git_gate_5a_html(data: dict, section_number: int = 18) -> str:
|
||
gate = data.get("git_gate", {})
|
||
rows = "".join(
|
||
'<tr>'
|
||
'<td><span class="box-check"></span></td>'
|
||
f'<td>{html_escape(text_5a(item.get("criterion", "")))}</td>'
|
||
f'<td><code>{html_escape(item.get("evidence", ""))}</code></td>'
|
||
'<td><span class="signature-line"></span></td>'
|
||
'</tr>'
|
||
for item in gate.get("criteria", [])
|
||
)
|
||
table = '<table class="compact-table git-gate"><thead><tr><th>OK</th><th>Kryterium</th><th>Dowód</th><th>Podpis</th></tr></thead><tbody>' + rows + '</tbody></table>'
|
||
return section_box_5a_html(section_number, gate.get("title", "Brama wyjścia"), table, "dense")
|
||
|
||
|
||
def render_session_ai_bibliography_5a_html(data: dict, section_number: int = 19) -> str:
|
||
record = data.get("session_record", {})
|
||
ai = data.get("ai_rules", {})
|
||
entries = bibliography_entries(data)
|
||
file_rows = "".join(
|
||
f'<li><code>{html_escape(item.get("path", ""))}</code> — {html_escape(text_5a(item.get("purpose", "")))}</li>'
|
||
for item in record.get("files", [])
|
||
)
|
||
bib_rows = "".join(
|
||
f'<li><code>{html_escape(ref)}</code> {html_escape(text_5a(entries.get(ref, {}).get("title", ref)))}</li>'
|
||
for ref in data.get("references", [])
|
||
)
|
||
body = (
|
||
'<div class="three-col">'
|
||
f'<div><h3>Rekord zajęć</h3><ul class="compact-list">{file_rows}</ul></div>'
|
||
f'<div><h3>Agent AI</h3><p><code>{html_escape(ai.get("log_path", ""))}</code></p><strong>Wolno</strong>{render_html_list_5a(ai.get("allowed", []))}<strong>Zakaz</strong>{render_html_list_5a(ai.get("forbidden", []))}</div>'
|
||
f'<div><h3>Bibliografia</h3><ol class="bib-list">{bib_rows}</ol></div>'
|
||
'</div>'
|
||
)
|
||
return section_box_5a_html(section_number, "Rekord, AI i bibliografia", body, "dense")
|
||
|
||
|
||
def build_5a_pages_html(data: dict) -> list[str]:
|
||
parts = data.get("parts", {})
|
||
return [
|
||
"".join(
|
||
[
|
||
render_metric_header_5a_html(data),
|
||
render_mission_5a_html(data),
|
||
render_objectives_5a_html(data),
|
||
render_agenda_5a_html(data),
|
||
render_hw_gate_5a_html(data),
|
||
]
|
||
),
|
||
"".join(
|
||
[
|
||
render_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}.title-block{display:grid;grid-template-columns:1.08fr 2.55fr 1.25fr 66px;min-height:96px;border-bottom:1px solid #1c1c1a}.title-signers,.title-document{display:grid;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-signers{border-right:1px solid #1c1c1a}.title-document{border-right:1px solid #1c1c1a}.title-meta{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-cell,.title-name,.title-url{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:3px 6px;border-bottom:1px solid #d8d6d0}.title-signers .title-cell:last-child,.title-document>div:last-child,.title-meta .title-cell:nth-last-child(-n+2){border-bottom:0}.title-meta .title-cell:nth-child(odd){border-right:1px solid #d8d6d0}.title-cell span,.title-name span,.title-url span{font:600 6.7px/1.15 'JetBrains Mono',monospace;letter-spacing:.08em;text-transform:uppercase;color:#8a887f}.title-cell strong,.title-name strong{font-size:8.3px;line-height:1.13;font-weight:500;overflow-wrap:anywhere}.title-cell em{font:400 6.4px/1.1 'JetBrains Mono',monospace;color:#8a887f;font-style:normal}.title-category{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:2px 6px;background:#3b5a8f;color:#fefefc;font-size:7.1px;letter-spacing:.06em;overflow:hidden}.title-category strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.title-category span{font:400 6.1px 'JetBrains Mono',monospace;white-space:nowrap;color:#fefefc}.title-name strong{font-size:9.2px;font-weight:700}.title-url code{font:400 6.4px/1.15 'JetBrains Mono',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#4a4944}.title-qr-cell{display:flex;align-items:center;justify-content:center;padding:5px;border-left:1px solid #1c1c1a;background:#fefefc}.title-qr{width:100%;height:100%;display:block}.title-qr-empty{flex-direction:column;text-align:center;color:#8a887f}.title-qr-empty span{font:600 7px 'JetBrains Mono',monospace}.title-qr-empty strong{font:400 6px 'JetBrains Mono',monospace}.metric-title{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:9px 14px;border-bottom:1px solid #1c1c1a}.metric-title div{font-size:15px;font-weight:700}.metric-title span{font:500 9.5px 'JetBrains Mono',monospace;color:#4a4944;white-space:nowrap}
|
||
.metric-grid{display:grid;grid-template-columns:1fr 1fr;font-size:11px}.metric-grid div{display:flex;gap:8px;padding:4px 14px;border-bottom:1px solid #d8d6d0}.metric-grid div:nth-child(odd){border-right:1px solid #d8d6d0}.metric-grid div:nth-last-child(-n+2){border-bottom:0}.metric-grid span{font:500 8.5px/1.9 'JetBrains Mono',monospace;color:#8a887f;text-transform:uppercase;flex:none}.metric-grid strong{font-weight:400}.metric-grid i{flex:1;border-bottom:1px dotted #9a988f}
|
||
.plain-box{border:1px solid #1c1c1a;padding:8px 14px;margin-top:11px}.plain-title{font-weight:700;font-size:11.5px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:2px}.plain-box p{margin:0;text-align:justify}
|
||
.section-box{border:1px solid #1c1c1a;margin-top:10px}.section-box:first-child{margin-top:0}.section-box.dense{margin-top:8px}.section-head{display:flex;align-items:baseline;gap:8px;padding:5px 14px;background:#f4f3ef;border-bottom:1px solid #1c1c1a}.section-head span{font:600 9px 'JetBrains Mono',monospace;color:#8a887f}.section-head strong{font-weight:700;font-size:11.5px;letter-spacing:.12em;text-transform:uppercase}.section-body{padding:7px 14px}.dense .section-body{padding:6px 12px}
|
||
.objectives{display:flex;flex-direction:column;gap:4px}.objective-row{display:grid;grid-template-columns:16px 1fr 118px;column-gap:10px;align-items:baseline}.objective-row span:nth-child(2){text-align:justify}.objective-row code{font:500 8.5px 'JetBrains Mono',monospace;color:#3b5a8f;text-align:right}
|
||
.box-check{display:inline-block;width:11px;height:11px;border:1.2px solid #1c1c1a;align-self:center}
|
||
.agenda-grid{display:grid;grid-template-columns:56px 1fr;font-size:11px;line-height:1.4;padding-top:6px;padding-bottom:6px}.agenda-grid div{padding:2px 0;border-bottom:1px solid #f0efe9}.agenda-grid div:nth-last-child(-n+2){border-bottom:0}.agenda-time{font:500 9px/1.7 'JetBrains Mono',monospace;color:#3b5a8f;padding-right:8px!important}
|
||
.gate{display:flex;flex-direction:column;gap:4px;font-size:11.5px}.gate-row{display:grid;grid-template-columns:16px 1fr;column-gap:10px;align-items:center}.sign-row{grid-template-columns:16px 1fr 150px}.sign-row code{display:flex;align-items:baseline;gap:6px;font:400 8px 'JetBrains Mono',monospace;color:#8a887f}.sign-row i{flex:1;border-bottom:1px dotted #9a988f;height:12px}
|
||
.running-card-head{display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;border-bottom:1.5px solid #1c1c1a;font-size:11px}.running-card-head span{font-weight:600}.running-card-head code{font:500 9.5px 'JetBrains Mono',monospace;color:#8a887f}
|
||
.theory-table{width:100%;border-collapse:collapse;font-size:11px}.theory-table th{text-align:left;padding:3px 10px;border:1px solid #1c1c1a;background:#f4f3ef}.theory-table th:first-child{width:26%}.theory-table td{padding:3px 10px;vertical-align:top;border:1px solid #d8d6d0}.theory-table code{font:400 10px 'JetBrains Mono',monospace}
|
||
.tech-stack{display:grid;grid-template-columns:1fr 1fr;column-gap:18px;align-items:start}.stack-column h3{font:600 8.5px 'JetBrains Mono',monospace;letter-spacing:.08em;color:#4a4944;text-transform:uppercase;padding-bottom:3px;border-bottom:1px solid #1c1c1a;margin:0}.stack-grid{display:grid;grid-template-columns:88px 1fr;font-size:10px;line-height:1.35}.stack-grid div{padding:3px 0;border-bottom:1px solid #e4e2dc}.stack-grid div:nth-last-child(-n+2){border-bottom:0}.stack-grid code{font:400 9.5px/1.45 'JetBrains Mono',monospace}
|
||
.part-banner{border:1.5px solid #1c1c1a;background:#f4f3ef;padding:7px 14px;display:grid;grid-template-columns:190px 1fr;column-gap:12px;align-items:baseline}.part-banner strong{font:700 12px 'JetBrains Mono',monospace;letter-spacing:.08em}.part-banner span{font-size:12px;text-align:justify}
|
||
.compact-list{margin:3px 0 0 15px;padding:0;font-size:10.5px;line-height:1.32}.compact-list li{margin:1px 0}.code-subblock{border:1px solid #d8d6d0;border-left:3px solid #3b5a8f;padding:5px 7px;margin-top:5px}.code-subblock:first-child{margin-top:0}.code-subblock h3{margin:0 0 2px}.code-subblock>p{margin:0 0 4px}.three-col{display:grid;grid-template-columns:1fr 1fr 1fr;column-gap:12px}.three-col h3,.model-grid h3{font:600 8.5px 'JetBrains Mono',monospace;text-transform:uppercase;color:#4a4944;margin:0 0 3px}.three-col p,.model-grid p{margin:0 0 4px;font-size:10.5px}.pass-line{margin:5px 0 0;padding-top:4px;border-top:1px solid #e4e2dc;font-size:10.5px}
|
||
.math-task{display:grid;grid-template-columns:1fr 190px;column-gap:12px;align-items:start;border-bottom:1px solid #f0efe9;padding:3px 0;font-size:10.5px}.math-task:last-child{border-bottom:0}.math-task code{font:400 9px 'JetBrains Mono',monospace;color:#3b5a8f}.math-task em{grid-column:1 / -1;color:#8a887f;font-size:9.5px}.answer-line{display:block;border-bottom:1px dotted #9a988f;height:12px;margin-top:2px}
|
||
.model-grid{display:grid;grid-template-columns:1.5fr 1fr;column-gap:16px}.compact-table{width:100%;border-collapse:collapse;font-size:10px;line-height:1.25}.compact-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:3px 6px;text-align:left}.compact-table td{border:1px solid #d8d6d0;padding:3px 6px;vertical-align:top}.compact-table code{font:400 9px 'JetBrains Mono',monospace}.constants td:first-child{width:68%}.check-table td:first-child,.procedure-table td:first-child,.git-gate td:first-child{width:24px;text-align:center}.check-table td:nth-child(2){width:95px}
|
||
.command-list{margin:0 0 0 22px;padding:0;font-size:10.5px;line-height:1.45}.command-list code{font:400 9px 'JetBrains Mono',monospace}.safety-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.safety-card{border:1px solid #d8d6d0;padding:5px 7px}.safety-card strong{display:block;font:600 9px 'JetBrains Mono',monospace;color:#3b5a8f;margin-bottom:2px}.safety-card span{font-size:10.5px;line-height:1.32;text-align:justify}
|
||
.figure-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px}.figure-grid figure{margin:0}.figure-grid img{width:100%;max-height:160px;object-fit:contain;border:1px solid #d8d6d0;background:white}.figure-grid figcaption{font-size:8.5px;line-height:1.25;color:#4a4944;margin-top:2px}
|
||
.obs-table{width:100%;border-collapse:collapse;font-size:9px;line-height:1.22}.obs-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:3px 4px;text-align:left}.obs-table td{border:1px solid #d8d6d0;padding:4px;vertical-align:top;height:136px}.write-box{display:block;height:124px;border-bottom:1px dotted #9a988f}.write-box.small{height:32px}.trouble-table td{height:44px}.analysis-list{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px}.analysis-item{display:grid;grid-template-columns:22px 1fr;column-gap:6px;font-size:10.5px}.analysis-item i{grid-column:2;border-bottom:1px dotted #9a988f;height:13px}
|
||
.adr-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 14px}.adr-field strong{display:block;font-size:10.5px}.adr-field span{display:block;height:15px;border-bottom:1px dotted #9a988f}.signature-line{display:block;height:14px;border-bottom:1px dotted #9a988f}.git-gate td:last-child{width:74px}.bib-list{margin:2px 0 0 18px;padding:0;font-size:9px;line-height:1.25}.bib-list code{font:400 8.5px 'JetBrains Mono',monospace}
|
||
.page-footer{display:flex;justify-content:space-between;border-top:1.5px solid #1c1c1a;padding-top:8px;margin-top:14px;font:400 9.5px 'JetBrains Mono',monospace;color:#8a887f;gap:16px}
|
||
@media print{@page{size:A4 landscape;margin:0}body{background:#fff;padding:0;gap:0}.page{box-shadow:none!important;border:none!important;break-after:page}}
|
||
"""
|
||
card = data["card"]
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="pl">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>{html_escape(card_title_line_5a(card))} — karta pracy</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=STIX+Two+Text:ital,wght@0,400;0,600;0,700;1,400;1,600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<style>{css}</style>
|
||
</head>
|
||
<body>
|
||
{"".join(render_5a_page_html(data, page, index, total_pages) for index, page in enumerate(pages, start=1))}
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def render_margin_tree_panel_5b_html(data: dict, column: str) -> str:
|
||
is_tech = column == "zawodowe"
|
||
tags = learning_tree_tags(
|
||
data,
|
||
column=column,
|
||
effect_color="orange!85!black" if is_tech else "green!50!black",
|
||
)
|
||
tree_parts: list[str] = []
|
||
for group in margin_tree_groups_5b(tags):
|
||
if group.get("we"):
|
||
tree_parts.append(f'<div class="tree-we">{html_escape(group["we"])}</div>')
|
||
for item in group.get("items", []):
|
||
tree_parts.append(f'<div class="tree-line level-1">{html_escape(item["code"])}</div>')
|
||
return '<aside class="margin-tree">' + "".join(tree_parts) + "</aside>"
|
||
|
||
|
||
def build_5b_pages_html(data: dict) -> list[dict]:
|
||
parts = data.get("parts", {})
|
||
return [
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_metric_header_5a_html(data),
|
||
render_objectives_5a_html(data),
|
||
render_agenda_5a_html(data),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_running_card_header_5a_html(data),
|
||
render_mission_5a_html(data),
|
||
render_theory_5a_html(data),
|
||
render_tech_stack_5a_html(data),
|
||
render_hw_gate_5a_html(data),
|
||
render_math_5a_html(data),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_part_banner_5a_html(parts.get("a", {})),
|
||
render_model_5a_html(data),
|
||
render_code_block_5a_html(data),
|
||
render_container_test_5a_html(data),
|
||
render_bom_5a_html(data),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_part_banner_5a_html(parts.get("b", {})),
|
||
render_safety_5a_html(data),
|
||
render_settings_figures_5a_html(data),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "technical_schematic",
|
||
"center": render_technical_schematic_5b_html(data, 13),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_connection_details_5b_html(data, 13),
|
||
render_procedure_5a_html(data, 14),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_observation_5a_html(data, 15),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_troubleshooting_5a_html(data, 16),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_analysis_5a_html(data, 17, 9),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_team_decision_5a_html(data, 18, 36),
|
||
]
|
||
),
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": "".join(
|
||
[
|
||
render_git_gate_5a_html(data, 19),
|
||
render_session_ai_bibliography_5a_html(data, 20),
|
||
]
|
||
),
|
||
},
|
||
]
|
||
|
||
|
||
def render_5b_point_section_numbers_html(markup: str) -> str:
|
||
return re.sub(r'(<div class="section-head"><span>)§([0-9]+)(</span>)', r"\1\2.\3", markup)
|
||
|
||
|
||
def render_5b_page_html(data: dict, center: str, page: int, total: int) -> str:
|
||
return (
|
||
f'<div class="page" data-screen-label="5b strona {page}">'
|
||
'<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}.title-block{display:grid;grid-template-columns:1.0fr 2.35fr 1.24fr 52px;min-height:82px;border-bottom:1px solid #1c1c1a}.title-signers,.title-document{display:grid;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-signers{border-right:1px solid #1c1c1a}.title-document{border-right:1px solid #1c1c1a}.title-meta{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-cell,.title-name,.title-url{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:2px 4px;border-bottom:1px solid #d8d6d0}.title-signers .title-cell:last-child,.title-document>div:last-child,.title-meta .title-cell:nth-last-child(-n+2){border-bottom:0}.title-meta .title-cell:nth-child(odd){border-right:1px solid #d8d6d0}.title-cell span,.title-name span,.title-url span{font:600 5.4px/1.12 'JetBrains Mono',monospace;letter-spacing:.07em;text-transform:uppercase;color:#8a887f}.title-cell strong,.title-name strong{font-size:6.9px;line-height:1.1;font-weight:500;overflow-wrap:anywhere}.title-cell em{font:400 5.2px/1.05 'JetBrains Mono',monospace;color:#8a887f;font-style:normal}.title-category{display:flex;align-items:center;justify-content:space-between;gap:4px;padding:2px 4px;background:#3b5a8f;color:#fefefc;font-size:5.9px;letter-spacing:.04em;overflow:hidden}.title-category strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.title-category span{font:400 5.1px 'JetBrains Mono',monospace;white-space:nowrap;color:#fefefc}.title-name strong{font-size:7.6px;font-weight:700}.title-url code{font:400 5.4px/1.12 'JetBrains Mono',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#4a4944}.title-qr-cell{display:flex;align-items:center;justify-content:center;padding:4px;border-left:1px solid #1c1c1a;background:#fefefc}.title-qr{width:100%;height:100%;display:block}.title-qr-empty{flex-direction:column;text-align:center;color:#8a887f}.title-qr-empty span{font:600 6px 'JetBrains Mono',monospace}.title-qr-empty strong{font:400 5px 'JetBrains Mono',monospace}.metric-title{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:7px 10px;border-bottom:1px solid #1c1c1a}.metric-title div{font-size:12.5px;font-weight:700}.metric-title span{font:500 8px 'JetBrains Mono',monospace;color:#4a4944;white-space:nowrap}
|
||
.metric-grid{display:grid;grid-template-columns:1fr 1fr;font-size:9.4px}.metric-grid div{display:flex;gap:6px;padding:3px 10px;border-bottom:1px solid #d8d6d0}.metric-grid div:nth-child(odd){border-right:1px solid #d8d6d0}.metric-grid div:nth-last-child(-n+2){border-bottom:0}.metric-grid span{font:500 7.2px/1.7 'JetBrains Mono',monospace;color:#8a887f;text-transform:uppercase;flex:none}.metric-grid strong{font-weight:400}.metric-grid i{flex:1;border-bottom:1px dotted #9a988f}
|
||
.plain-box{border:1px solid #1c1c1a;padding:6px 10px;margin-top:7px}.plain-title{font-weight:700;font-size:9.8px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:1px}.plain-box p{margin:0;text-align:justify}
|
||
.section-box{border:1px solid #1c1c1a;margin-top:7px}.section-box:first-child{margin-top:0}.section-box.dense{margin-top:6px}.section-head{display:flex;align-items:baseline;gap:6px;padding:4px 10px;background:#f4f3ef;border-bottom:1px solid #1c1c1a}.section-head span{font:600 7.8px 'JetBrains Mono',monospace;color:#8a887f}.section-head strong{font-weight:700;font-size:9.6px;letter-spacing:.12em;text-transform:uppercase}.section-body{padding:6px 10px}.dense .section-body{padding:5px 9px}
|
||
.objectives{display:flex;flex-direction:column;gap:3px}.objective-row{display:grid;grid-template-columns:14px 1fr 92px;column-gap:7px;align-items:baseline}.objective-row span:nth-child(2){text-align:justify}.objective-row code{font:500 6.8px 'JetBrains Mono',monospace;color:#3b5a8f;text-align:right}
|
||
.box-check{display:inline-block;width:10px;height:10px;border:1.1px solid #1c1c1a;align-self:center}
|
||
.agenda-grid{display:grid;grid-template-columns:46px 1fr;font-size:9.6px;line-height:1.35;padding-top:4px;padding-bottom:4px}.agenda-grid div{padding:1px 0;border-bottom:1px solid #f0efe9}.agenda-grid div:nth-last-child(-n+2){border-bottom:0}.agenda-time{font:500 7.6px/1.55 'JetBrains Mono',monospace;color:#3b5a8f;padding-right:6px!important}
|
||
.gate{display:flex;flex-direction:column;gap:3px;font-size:9.6px}.gate-row{display:grid;grid-template-columns:14px 1fr;column-gap:7px;align-items:center}.sign-row{grid-template-columns:14px 1fr 112px}.sign-row code{display:flex;align-items:baseline;gap:5px;font:400 7px 'JetBrains Mono',monospace;color:#8a887f}.sign-row i{flex:1;border-bottom:1px dotted #9a988f;height:10px}
|
||
.running-card-head{position:relative;display:flex;justify-content:space-between;align-items:baseline;padding:4px 0 6px;border-bottom:0;font-size:9.6px}.running-card-head::after{content:"";position:absolute;left:0;right:0;bottom:0;border-bottom:1.4px solid #1c1c1a}.running-card-head span{font-weight:600}.running-card-head code{font:500 7.8px 'JetBrains Mono',monospace;color:#8a887f}
|
||
.theory-table{width:100%;border-collapse:collapse;font-size:9.4px}.theory-table th{text-align:left;padding:2px 7px;border:1px solid #1c1c1a;background:#f4f3ef}.theory-table th:first-child{width:31%}.theory-table td{padding:2px 7px;vertical-align:top;border:1px solid #d8d6d0}.theory-table code{font:400 8px 'JetBrains Mono',monospace}
|
||
.tech-stack{display:grid;grid-template-columns:1fr 1fr;column-gap:10px;align-items:start}.stack-column h3{font:600 7.2px 'JetBrains Mono',monospace;letter-spacing:.08em;color:#4a4944;text-transform:uppercase;padding-bottom:2px;border-bottom:1px solid #1c1c1a;margin:0}.stack-grid{display:grid;grid-template-columns:70px 1fr;font-size:8.6px;line-height:1.25}.stack-grid div{padding:2px 0;border-bottom:1px solid #e4e2dc}.stack-grid div:nth-last-child(-n+2){border-bottom:0}.stack-grid code{font:400 7.7px/1.35 'JetBrains Mono',monospace}
|
||
.part-banner{border:1.4px solid #1c1c1a;background:#f4f3ef;padding:5px 10px;display:grid;grid-template-columns:126px 1fr;column-gap:9px;align-items:baseline}.part-banner strong{font:700 9px 'JetBrains Mono',monospace;letter-spacing:.08em}.part-banner span{font-size:9.4px;text-align:justify}
|
||
.compact-list{margin:2px 0 0 13px;padding:0;font-size:8.7px;line-height:1.25}.compact-list li{margin:1px 0}.code-subblock{border:1px solid #d8d6d0;border-left:3px solid #3b5a8f;padding:4px 5px;margin-top:4px}.code-subblock:first-child{margin-top:0}.code-subblock h3{margin:0 0 2px}.code-subblock>p{margin:0 0 3px}.three-col,.connection-notes{display:grid;grid-template-columns:1fr 1fr 1fr;column-gap:8px}.connection-notes{grid-template-columns:1fr 1fr;margin-top:4px}.three-col h3,.model-grid h3,.connection-notes h3{font:600 7.2px 'JetBrains Mono',monospace;text-transform:uppercase;color:#4a4944;margin:0 0 2px}.three-col p,.model-grid p{margin:0 0 3px;font-size:8.8px}.pass-line{margin:4px 0 0;padding-top:3px;border-top:1px solid #e4e2dc;font-size:8.6px}
|
||
.math-task{display:grid;grid-template-columns:1fr 126px;column-gap:8px;align-items:start;border-bottom:1px solid #f0efe9;padding:3px 0;font-size:9px}.math-task:last-child{border-bottom:0}.math-task code{font:400 7.5px 'JetBrains Mono',monospace;color:#3b5a8f}.math-task em{grid-column:1 / -1;color:#8a887f;font-size:7.8px}.answer-line{display:block;border-bottom:1px dotted #9a988f;height:12px;margin-top:2px}
|
||
.model-grid{display:grid;grid-template-columns:1.25fr 1fr;column-gap:10px}.compact-table{width:100%;border-collapse:collapse;font-size:8.4px;line-height:1.18}.compact-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:2px 4px;text-align:left}.compact-table td{border:1px solid #d8d6d0;padding:2px 4px;vertical-align:top}.compact-table code{font:400 7.5px 'JetBrains Mono',monospace}.constants td:first-child{width:66%}.check-table td:first-child,.procedure-table td:first-child,.git-gate td:first-child{width:20px;text-align:center}.check-table td:nth-child(2){width:70px}.procedure-table td{height:25px}
|
||
.command-list{margin:0 0 0 18px;padding:0;font-size:8.8px;line-height:1.32}.command-list code{font:400 7.5px 'JetBrains Mono',monospace}.safety-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}.safety-card{border:1px solid #d8d6d0;padding:4px 5px}.safety-card strong{display:block;font:600 7.5px 'JetBrains Mono',monospace;color:#3b5a8f;margin-bottom:1px}.safety-card span{font-size:8.4px;line-height:1.22;text-align:justify}
|
||
.figure-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:5px}.figure-grid figure{margin:0}.figure-grid img{width:100%;max-height:165px;object-fit:contain;border:1px solid #d8d6d0;background:white}.figure-grid figcaption{font-size:7px;line-height:1.16;color:#4a4944;margin-top:1px}.connection-figure img{width:100%;max-height:310px;object-fit:contain;border:1px solid #d8d6d0;background:white}.figure-pdf-link{display:inline-block;margin-top:3px;font:600 7.2px 'JetBrains Mono',monospace;color:#3b5a8f;text-decoration:none;text-transform:uppercase}
|
||
.obs-table{width:100%;border-collapse:collapse;font-size:7.4px;line-height:1.16}.obs-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:2px 3px;text-align:left}.obs-table td{border:1px solid #d8d6d0;padding:3px;vertical-align:top;height:185px}.write-box{display:block;height:173px;border-bottom:1px dotted #9a988f}.write-box.small{height:76px}.procedure-table td{height:56px}.trouble-table td{height:92px}
|
||
.analysis-list{display:grid;grid-template-columns:1fr;gap:7px}.analysis-item{display:grid;grid-template-columns:18px 1fr;column-gap:5px;font-size:9px}.analysis-item i{grid-column:2;border-bottom:1px dotted #9a988f;height:14px}
|
||
.adr-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px 10px}.adr-field strong{display:block;font-size:8.8px}.adr-field span{display:block;height:15px;border-bottom:1px dotted #9a988f}.signature-line{display:block;height:18px;border-bottom:1px dotted #9a988f}.git-gate td:last-child{width:58px}.git-gate td{height:34px}.bib-list{margin:2px 0 0 16px;padding:0;font-size:7.4px;line-height:1.18}.bib-list code{font:400 6.8px 'JetBrains Mono',monospace}
|
||
.page-footer{position:absolute;left:var(--page-rule-left);bottom:95px;width:var(--page-rule-width);display:flex;justify-content:space-between;border-top:0;padding-top:6px;margin-top:0;font:400 8.2px 'JetBrains Mono',monospace;color:#8a887f;gap:10px}.page-footer::before{content:"";position:absolute;left:0;right:0;top:0;border-top:1.4px solid #1c1c1a}
|
||
@media print{@page{size:A4 portrait;margin:0}body{background:#fff;padding:0;gap:0}.page{box-shadow:none!important;border:none!important;break-after:page}}
|
||
"""
|
||
|
||
|
||
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{multirow}",
|
||
r"\usepackage{xcolor}",
|
||
r"\usepackage{amssymb}",
|
||
r"\usepackage{graphicx}",
|
||
r"\usepackage{microtype}",
|
||
r"\usepackage[a4paper,landscape,left=1.0cm,right=1.0cm,top=1.0cm,bottom=0.8cm]{geometry}",
|
||
r"\IfFileExists{stix2.sty}{\usepackage{stix2}}{\usepackage{lmodern}}",
|
||
r"\IfFileExists{inconsolata.sty}{\usepackage{inconsolata}}{\usepackage{lmodern}}",
|
||
r"\usepackage[most]{tcolorbox}",
|
||
r"\usepackage{qrcode}",
|
||
r"\usepackage{hyperref}",
|
||
r"\hypersetup{hidelinks}",
|
||
r"\IfFileExists{build-meta.tex}{\input{build-meta.tex}}{\newcommand{\BuildCommit}{local}}",
|
||
macro("CardNumber", card["number"]),
|
||
macro("CardCount", card["count"]),
|
||
macro("CardVersion", card["version"]),
|
||
macro("DocumentUUID", card["uuid"]),
|
||
r"\definecolor{ESCPaper}{HTML}{FEFEFC}",
|
||
r"\definecolor{ESCInk}{HTML}{1C1C1A}",
|
||
r"\definecolor{ESCMuted}{HTML}{8A887F}",
|
||
r"\definecolor{ESCSubtle}{HTML}{4A4944}",
|
||
r"\definecolor{ESCHeader}{HTML}{F4F3EF}",
|
||
r"\definecolor{ESCLine}{HTML}{D8D6D0}",
|
||
r"\definecolor{ESCSoftLine}{HTML}{E4E2DC}",
|
||
r"\definecolor{ESCAccent}{HTML}{3B5A8F}",
|
||
r"\pagecolor{ESCPaper}",
|
||
r"\color{ESCInk}",
|
||
r"\setlength{\parindent}{0pt}",
|
||
r"\setlength{\parskip}{0.25em}",
|
||
r"\setlength{\emergencystretch}{3em}",
|
||
r"\setlength{\tabcolsep}{3pt}",
|
||
r"\renewcommand{\arraystretch}{1.12}",
|
||
r"\newcolumntype{Y}{>{\raggedright\arraybackslash}X}",
|
||
r"\newcommand{\FiveACheck}{\(\square\)}",
|
||
r"\newcommand{\FiveAFill}{\dotfill}",
|
||
r"\newcommand{\FiveAWriteLine}{\par\noindent\dotfill}",
|
||
r"\newtcolorbox{FiveASection}[2]{enhanced,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=2.8mm,right=2.8mm,top=1.2mm,bottom=1.2mm,colbacktitle=ESCHeader,coltitle=ESCInk,fonttitle=\bfseries\footnotesize,title={\ttfamily\textcolor{ESCMuted}{\S#1}\quad #2}}",
|
||
r"\newtcolorbox{FiveAPart}{enhanced,arc=0pt,boxrule=0.6pt,colback=ESCHeader,colframe=ESCInk,boxsep=0pt,left=2.8mm,right=2.8mm,top=1.2mm,bottom=1.2mm}",
|
||
r"\newtcolorbox{CardContentBlock}{enhanced,arc=0pt,boxrule=0.28pt,colback=ESCPaper,colframe=ESCLine,boxsep=0pt,left=1.4mm,right=1.4mm,top=0.8mm,bottom=0.8mm}",
|
||
"",
|
||
]
|
||
|
||
|
||
def render_margin_tree_panel_5a_tex(data: dict, column: str) -> list[str]:
|
||
is_tech = column == "zawodowe"
|
||
tags = learning_tree_tags(
|
||
data,
|
||
column=column,
|
||
effect_color="orange!85!black" if is_tech else "green!50!black",
|
||
)
|
||
title = "TECH -- zawodowe" if is_tech else "OG -- ogólne"
|
||
subtitle = "WE -> EK -> KW" if is_tech else "WE -> EN -> KW"
|
||
dominance = "dominanta zawodowa: ELE/AUT/ELM/INF" if is_tech else "dominanta ogólna: FIZ/MAT/INF"
|
||
lines = [
|
||
r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.3pt,colback=ESCPaper,colframe=ESCLine,boxsep=0pt,left=0.8mm,right=0.8mm,top=0.8mm,bottom=0.8mm]",
|
||
rf"{{\scriptsize\bfseries {tex_5a(title)}\par}}",
|
||
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(subtitle)}}}\par}}",
|
||
r"\vspace{0.3em}{\tiny\ttfamily\raggedright",
|
||
]
|
||
for group in margin_tree_groups_5a(tags):
|
||
if group.get("we"):
|
||
lines.append(rf"\textcolor{{ESCAccent}}{{\textbf{{{tex_escape(group['we'])}}}}}\par")
|
||
for item in group.get("items", []):
|
||
lines.append(rf"\hspace*{{0.6em}}{tex_escape(item['code'])}\par")
|
||
for kw_code in item.get("kw", []):
|
||
lines.append(rf"\hspace*{{1.2em}}\textcolor{{ESCSubtle}}{{{tex_escape(kw_code)}}}\par")
|
||
lines.extend(
|
||
[
|
||
r"}\vspace{0.25em}",
|
||
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{KW wspólne:}}\par {tex_escape(common_kw_5a(tags))}\par}}",
|
||
r"\tcblower",
|
||
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_5a(dominance)}}}}}",
|
||
r"\end{tcolorbox}",
|
||
]
|
||
)
|
||
return lines
|
||
|
||
|
||
def render_metric_header_5a_tex(data: dict) -> list[str]:
|
||
card = data["card"]
|
||
title_fields = title_block_fields(data)
|
||
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"
|
||
|
||
def title_cell(label: str, value: object, mono: bool = False) -> str:
|
||
content = tex_escape(str(value)) if mono else tex_5a(value)
|
||
face = r"\ttfamily" if mono else ""
|
||
return rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(label.upper())}}}}}\par{{\scriptsize {face} {content}}}"
|
||
|
||
url = str(title_fields["url"])
|
||
safe_qr_url = re.fullmatch(r"https?://[A-Za-z0-9._~:/?&=+\-]+", url)
|
||
qr = (
|
||
rf"\qrcode[height=15.5mm,level=L]{{{url}}}"
|
||
if title_fields["show_qr"] and safe_qr_url
|
||
else r"{\tiny\ttfamily\textcolor{ESCMuted}{QR: brak URL}}"
|
||
)
|
||
return [
|
||
r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.6pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=0pt,right=0pt,top=0pt,bottom=0pt]",
|
||
r"\renewcommand{\arraystretch}{1.08}\scriptsize",
|
||
r"\noindent\begin{minipage}[t]{12.40cm}",
|
||
r"\begin{tabularx}{\linewidth}{|p{2.55cm}|Y|p{1.55cm}|p{1.75cm}|}",
|
||
r"\hline",
|
||
rf"{title_cell('Opracował', title_fields['prepared_by'])}\newline{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(str(title_fields['prepared_on']))}}}}} & "
|
||
rf"{{\scriptsize\bfseries {tex_5a(title_fields['category'])}}}\hfill{{\tiny\ttfamily {tex_escape(str(title_fields['standard']))}}} & "
|
||
rf"{title_cell('Rewizja', title_fields['revision'], True)} & {title_cell('Data wydania', title_fields['issued_on'], True)} \\\hline",
|
||
rf"{title_cell('Sprawdził', title_fields['checked_by'])} & "
|
||
rf"{title_cell('Tytuł', title_fields['title'])} & "
|
||
rf"{title_cell('Seria', title_fields['series'], True)} & {title_cell('Typ', title_fields['document_type'])} \\\hline",
|
||
rf"{title_cell('Zatwierdził', title_fields['approved_by'])} & "
|
||
rf"{title_cell('URL', url or '—', True)} & "
|
||
rf"{title_cell('Narzędzie', title_fields['tool'])} & {title_cell('Arkusz', title_fields['sheet'], True)} \\\hline",
|
||
r"\end{tabularx}",
|
||
r"\end{minipage}\hfill",
|
||
r"\begin{minipage}[t][15.5mm][c]{1.95cm}\centering",
|
||
qr,
|
||
r"\end{minipage}",
|
||
r"\hspace{2.0mm}{\tiny\ttfamily\textcolor{ESCMuted}{PROJEKT}}\hspace{0.8mm}{\scriptsize " + tex_5a(card["project"]) + r"}\hfill{\tiny\ttfamily\textcolor{ESCMuted}{PRZEDMIOT}}\hspace{0.8mm}{\scriptsize " + tex_5a(card["subject"]) + r"}\hspace{2.0mm}\par",
|
||
rf"\hspace{{2.0mm}}{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(first_label.upper())}}}}}\hspace{{0.8mm}}\FiveAFill\hfill{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(second_label.upper())}}}}}\hspace{{0.8mm}}\FiveAFill\hspace{{2.0mm}}\par",
|
||
r"\end{tcolorbox}",
|
||
]
|
||
|
||
|
||
def section_5a_tex(number: int, title: str, body: list[str]) -> list[str]:
|
||
return [
|
||
rf"\begin{{FiveASection}}{{{number}}}{{{tex_5a(title.upper())}}}",
|
||
*body,
|
||
r"\end{FiveASection}",
|
||
]
|
||
|
||
|
||
def render_mission_5a_tex(data: dict) -> list[str]:
|
||
return [
|
||
r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,left=2.8mm,right=2.8mm,top=1.5mm,bottom=1.5mm]",
|
||
r"{\bfseries\footnotesize MISJA}\par",
|
||
rf"\scriptsize {tex_5a(data.get('mission', ''))}",
|
||
r"\end{tcolorbox}",
|
||
]
|
||
|
||
|
||
def render_objectives_5a_tex(data: dict) -> list[str]:
|
||
refs = data.get("objective_refs", [])
|
||
body = [
|
||
r"\scriptsize",
|
||
r"\begin{tabularx}{\linewidth}{@{}p{0.45cm}X>{\raggedleft\arraybackslash}p{3.45cm}@{}}",
|
||
]
|
||
for index, objective in enumerate(data.get("objectives", []), start=1):
|
||
ref = refs[index - 1] if index - 1 < len(refs) else {}
|
||
ref_text = f"<- TECH {ref.get('tech', f'{index:02d}')} · OG {ref.get('og', f'{index:02d}')} ->"
|
||
body.append(rf"\FiveACheck & {index}. {tex_5a(objective)} & {{\tiny\ttfamily\textcolor{{ESCAccent}}{{{tex_5a(ref_text)}}}}} \\")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(1, "Cele operacyjne", body)
|
||
|
||
|
||
def render_agenda_5a_tex(data: dict) -> list[str]:
|
||
body = [
|
||
r"\scriptsize",
|
||
r"\begin{tabularx}{\linewidth}{@{}>{\ttfamily\textcolor{ESCAccent}}p{1.3cm}X@{}}",
|
||
]
|
||
for row in data.get("agenda", []):
|
||
body.append(rf"{tex_5a(agenda_time_5a(row.get('time', '')))} & {tex_5a(row.get('work', ''))} \\")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(2, "Agenda 45 min", body)
|
||
|
||
|
||
def render_hw_gate_5a_tex(data: dict) -> list[str]:
|
||
body = [
|
||
r"\scriptsize",
|
||
r"\begin{tabularx}{\linewidth}{@{}p{0.45cm}X@{}}",
|
||
]
|
||
for criterion in data.get("hw_gate", {}).get("criteria", []):
|
||
text = criterion.get("check") or criterion.get("criterion", "")
|
||
body.append(rf"\FiveACheck & {tex_5a(text)} \\")
|
||
body.extend(
|
||
[
|
||
r"\end{tabularx}",
|
||
r"\hfill{\tiny\ttfamily\textcolor{ESCMuted}{podpis nauczyciela:}}\quad\rule{3.4cm}{0.3pt}",
|
||
]
|
||
)
|
||
return section_5a_tex(3, "Warunki wejścia na sprzęt (bramka A -> B)", body)
|
||
|
||
|
||
def render_theory_5a_tex(data: dict) -> list[str]:
|
||
theory = data.get("theory_refs", {})
|
||
body = [
|
||
r"\scriptsize",
|
||
]
|
||
if theory.get("intro"):
|
||
body.extend([tex_5a(theory.get("intro", "")), r"\par\vspace{0.35em}"])
|
||
body.extend([
|
||
r"\begin{tabularx}{\linewidth}{|>{\ttfamily}p{4.0cm}|X|}",
|
||
r"\hline",
|
||
r"\textbf{Karta / źródło} & \textbf{Użycie w symulacji} \\",
|
||
r"\hline",
|
||
])
|
||
for index, item in enumerate(theory.get("known_refs", []), start=1):
|
||
body.append(rf"{tex_5a(item.get('id', ''))} [{index}] & {tex_5a(item.get('note') or item.get('label', ''))} \\")
|
||
body.append(r"\hline")
|
||
body.append(r"\end{tabularx}")
|
||
concepts = theory.get("new_concepts", [])
|
||
if concepts:
|
||
body.extend([
|
||
r"\vspace{0.35em}",
|
||
r"\begin{tabularx}{\linewidth}{|p{4.0cm}|X|}",
|
||
r"\hline",
|
||
r"\textbf{Nowe pojęcie} & \textbf{Definicja robocza} \\",
|
||
r"\hline",
|
||
])
|
||
for concept in concepts:
|
||
body.append(rf"{tex_5a(concept.get('term', ''))} & {tex_5a(concept.get('definition', ''))} \\")
|
||
body.append(r"\hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(4, "Teoria pod model i symulację", body)
|
||
|
||
|
||
def render_stack_column_5a_tex(title: str, rows: list[dict]) -> list[str]:
|
||
lines = [
|
||
rf"{{\scriptsize\ttfamily\bfseries {tex_escape(title.upper())}}}\par",
|
||
r"\vspace{0.1em}\hrule\vspace{0.2em}",
|
||
r"\tiny",
|
||
r"\begin{tabularx}{\linewidth}{@{}>{\ttfamily}p{2.0cm}X@{}}",
|
||
]
|
||
for row in rows:
|
||
lines.append(rf"{tex_5a(row.get('name', ''))} & {tex_5a(row.get('use', ''))} \\")
|
||
lines.append(r"\end{tabularx}")
|
||
return lines
|
||
|
||
|
||
def render_tech_stack_5a_tex(data: dict) -> list[str]:
|
||
stack = data.get("tech_stack", {})
|
||
body = [
|
||
r"\begin{minipage}[t]{0.48\linewidth}",
|
||
*render_stack_column_5a_tex("Software", stack.get("software", [])),
|
||
r"\end{minipage}\hfill",
|
||
r"\begin{minipage}[t]{0.48\linewidth}",
|
||
*render_stack_column_5a_tex("Hardware", stack.get("hardware", [])),
|
||
r"\end{minipage}",
|
||
]
|
||
return section_5a_tex(5, "Technologie stanowiska", body)
|
||
|
||
|
||
def render_part_banner_5a_tex(part: dict) -> list[str]:
|
||
return [
|
||
r"\begin{FiveAPart}",
|
||
rf"{{\scriptsize\ttfamily\bfseries {tex_5a(part.get('title', ''))}}}\quad {{\tiny {tex_5a(part.get('body', ''))}}}",
|
||
r"\end{FiveAPart}",
|
||
]
|
||
|
||
|
||
def tex_compact_items_5a(items: Iterable[object], size: str = r"\tiny") -> list[str]:
|
||
items = list(items)
|
||
if not items:
|
||
return []
|
||
lines = [rf"\begin{{itemize}}\setlength\itemsep{{0pt}}\setlength\topsep{{0pt}}{size}"]
|
||
for item in items:
|
||
lines.append(rf"\item {tex_5a(item)}")
|
||
lines.append(r"\end{itemize}")
|
||
return lines
|
||
|
||
|
||
def render_code_columns_5a_tex(block: dict) -> list[str]:
|
||
body: list[str] = []
|
||
columns = code_block_columns_5a(block)
|
||
width = "0.32"
|
||
for index, column in enumerate(columns[:3]):
|
||
if index > 0:
|
||
body.append(r"\hfill")
|
||
body.append(rf"\begin{{minipage}}[t]{{{width}\linewidth}}{{\tiny\ttfamily\bfseries {tex_5a(column.get('title', ''))}}}\par")
|
||
body.extend(tex_compact_items_5a(column.get("items", [])))
|
||
body.append(r"\end{minipage}")
|
||
return body
|
||
|
||
|
||
def render_code_subblock_5a_tex(block: dict) -> list[str]:
|
||
body = [
|
||
r"\begin{CardContentBlock}",
|
||
rf"\textbf{{{tex_5a(block.get('title', ''))}}}\par",
|
||
tex_5a(block.get("context", "")),
|
||
r"\par\vspace{0.15em}",
|
||
*render_code_columns_5a_tex(block),
|
||
]
|
||
if block.get("pass_condition"):
|
||
body.append(rf"\par{{\tiny\ttfamily\textcolor{{ESCAccent}}{{PASS: {tex_5a(block.get('pass_condition', ''))}}}}}")
|
||
body.extend([r"\end{CardContentBlock}", r"\vspace{0.25em}"])
|
||
return body
|
||
|
||
|
||
def render_math_5a_tex(data: dict) -> list[str]:
|
||
block = data.get("math_block", {})
|
||
body: list[str] = [r"\scriptsize"]
|
||
for index, task in enumerate(block.get("tasks", []), start=1):
|
||
body.append(rf"\textbf{{{index}.}} {tex_5a(task.get('prompt', ''))}\par")
|
||
equation = task.get("equation_tex", "")
|
||
if equation:
|
||
body.append(rf"{{\scriptsize\[{equation}\]}}")
|
||
body.extend([r"\FiveAWriteLine", r"\FiveAWriteLine"])
|
||
if task.get("answer_hint"):
|
||
body.append(rf"{{\tiny\textcolor{{ESCMuted}}{{{tex_5a(task.get('answer_hint', ''))}}}}}\par")
|
||
return section_5a_tex(6, block.get("title", "Do policzenia"), body)
|
||
|
||
|
||
def render_model_5a_tex(data: dict) -> list[str]:
|
||
model = data.get("model_block", {})
|
||
body = [
|
||
r"\tiny",
|
||
r"\begin{minipage}[t]{0.60\linewidth}",
|
||
rf"Notebook: \texttt{{{tex_mono_body(model.get('notebook', ''))}}}\par",
|
||
rf"Kontrakt: \texttt{{{tex_mono_body(model.get('output_constants', ''))}}}\par",
|
||
*tex_compact_items_5a(model.get("steps", [])),
|
||
r"\end{minipage}\hfill",
|
||
r"\begin{minipage}[t]{0.36\linewidth}",
|
||
r"\tiny\begin{tabularx}{\linewidth}{|>{\ttfamily}Y|Y|}",
|
||
r"\hline",
|
||
]
|
||
for item in model.get("constants", []):
|
||
body.append(rf"{tex_5a(item.get('name', ''))} & {tex_5a(item.get('value', ''))} \\ \hline")
|
||
body.extend([r"\end{tabularx}", r"\end{minipage}"])
|
||
return section_5a_tex(7, model.get("title", "Model w notebooku"), body)
|
||
|
||
|
||
def render_code_block_5a_tex(data: dict) -> list[str]:
|
||
code = data.get("code_block", {})
|
||
container = data.get("container_test", {})
|
||
note = code.get("next_cards_note", "")
|
||
body = [
|
||
r"\tiny",
|
||
]
|
||
if code.get("context"):
|
||
body.extend([tex_5a(code.get("context", "")), r"\par\vspace{0.2em}"])
|
||
blocks = code.get("blocks", [])
|
||
if blocks:
|
||
for block in blocks:
|
||
body.extend(render_code_subblock_5a_tex(block))
|
||
else:
|
||
body.extend(render_code_columns_5a_tex(code))
|
||
if note:
|
||
body.append(rf"\par{{\tiny\ttfamily\textcolor{{ESCAccent}}{{NOTATKA: {tex_5a(note)}}}}}")
|
||
body.append(rf"\par{{\tiny\ttfamily\textcolor{{ESCAccent}}{{PASS: {tex_5a(container.get('pass_condition', ''))}}}}}")
|
||
return section_5a_tex(8, code.get("title", "Bloczek RP2350"), body)
|
||
|
||
|
||
def render_container_test_5a_tex(data: dict) -> list[str]:
|
||
container = data.get("container_test", {})
|
||
body = [r"\tiny\begin{enumerate}\setlength\itemsep{0pt}\setlength\topsep{0pt}"]
|
||
for command in container.get("commands", []):
|
||
body.append(rf"\item \texttt{{{tex_mono_body(command)}}}")
|
||
body.append(r"\end{enumerate}")
|
||
return section_5a_tex(9, container.get("title", "Test w kontenerze"), body)
|
||
|
||
|
||
def render_bom_5a_tex(data: dict) -> list[str]:
|
||
body = [
|
||
r"\tiny\begin{tabularx}{\linewidth}{|p{0.45cm}|p{2.3cm}|Y|}",
|
||
r"\hline OK & Element & Kontrola \\ \hline",
|
||
]
|
||
for item in data.get("bom", []):
|
||
body.append(rf"\FiveACheck & \texttt{{{tex_5a(item.get('item', ''))}}} & {tex_5a(item.get('check', ''))} \\ \hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(10, "BOM i checklista stanowiska", body)
|
||
|
||
|
||
def render_safety_5a_tex(data: dict) -> list[str]:
|
||
body = [r"\tiny\begin{tabularx}{\linewidth}{|Y|Y|}", r"\hline"]
|
||
rules = data.get("safety_rules", [])
|
||
for index in range(0, len(rules), 2):
|
||
left = rules[index]
|
||
right = rules[index + 1] if index + 1 < len(rules) else {}
|
||
left_text = rf"\textbf{{{tex_5a(left.get('title', ''))}}}\par {tex_5a(left.get('body', ''))}"
|
||
right_text = rf"\textbf{{{tex_5a(right.get('title', ''))}}}\par {tex_5a(right.get('body', ''))}" if right else ""
|
||
body.append(rf"{left_text} & {right_text} \\ \hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(11, "BHP i złote zasady", body)
|
||
|
||
|
||
def render_settings_figures_5a_tex(data: dict) -> list[str]:
|
||
body = [r"\scriptsize"]
|
||
figures = data.get("figures", [])
|
||
if figures:
|
||
body.append(r"\begin{minipage}[t]{0.49\linewidth}\centering")
|
||
fig = figures[0]
|
||
body.append(rf"\includegraphics[height=2.35cm,width=\linewidth,keepaspectratio]{{{tex_escape(fig.get('path', ''))}}}\par")
|
||
body.append(rf"{{\tiny {tex_5a(fig.get('caption', ''))}}}")
|
||
body.append(r"\end{minipage}\hfill")
|
||
body.append(r"\begin{minipage}[t]{0.49\linewidth}\centering")
|
||
if len(figures) > 1:
|
||
fig = figures[1]
|
||
body.append(rf"\includegraphics[height=2.35cm,width=\linewidth,keepaspectratio]{{{tex_escape(fig.get('path', ''))}}}\par")
|
||
body.append(rf"{{\tiny {tex_5a(fig.get('caption', ''))}}}")
|
||
body.append(r"\end{minipage}\par\vspace{0.2em}")
|
||
body.extend(
|
||
[
|
||
r"\tiny\begin{tabularx}{\linewidth}{|Y|p{3.2cm}|Y|}",
|
||
r"\hline Test & DPS3010U & XY6020L \\ \hline",
|
||
]
|
||
)
|
||
for row in data.get("reference_settings", []):
|
||
body.append(rf"{tex_5a(row.get('test', ''))} & {tex_5a(row.get('dps', ''))} & {tex_5a(row.get('xy', ''))} \\ \hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(12, data.get("settings_section_title", "Kaskada i nastawy referencyjne"), body)
|
||
|
||
|
||
def render_connection_5a_tex(data: dict, section_number: int = 13) -> list[str]:
|
||
connection = data.get("connection_section", {})
|
||
if not connection:
|
||
return []
|
||
body = [
|
||
r"\tiny\renewcommand{\arraystretch}{1.08}",
|
||
]
|
||
figure = connection.get("figure", {})
|
||
if figure.get("path"):
|
||
body.extend(
|
||
[
|
||
r"\begin{center}",
|
||
rf"\includegraphics[height=5.0cm,width=\linewidth,keepaspectratio]{{{tex_escape(figure.get('path', ''))}}}\par",
|
||
rf"{{\fontsize{{4.7}}{{5.2}}\selectfont {tex_5a(figure.get('caption', ''))}}}",
|
||
r"\end{center}\vspace{-0.25em}",
|
||
]
|
||
)
|
||
body.extend(
|
||
[
|
||
tex_5a(connection.get("intro", "")) + r"\par\vspace{0.12em}",
|
||
r"\begin{tabularx}{\linewidth}{|p{2.35cm}|Y|Y|p{2.45cm}|}",
|
||
r"\hline RP2350 & OptoIzolator strona A & OptoIzolator strona B & XY6020L \\ \hline",
|
||
]
|
||
)
|
||
for row in connection.get("pin_table", []):
|
||
body.append(
|
||
rf"{tex_5a(row.get('rp2350', ''))} & "
|
||
rf"{tex_5a(row.get('isolator_a', ''))} & "
|
||
rf"{tex_5a(row.get('isolator_b', ''))} & "
|
||
rf"{tex_5a(row.get('xy', ''))} \\ \hline"
|
||
)
|
||
body.extend(
|
||
[
|
||
r"\end{tabularx}",
|
||
r"\vspace{0.12em}",
|
||
rf"\textbf{{Parametry:}} {tex_5a('; '.join(connection.get('parameters', [])))}\par",
|
||
rf"\textbf{{Zakazy/kontrola:}} {tex_5a('; '.join(connection.get('warnings', [])))}\par",
|
||
]
|
||
)
|
||
if connection.get("source_note"):
|
||
body.append(rf"\textcolor{{ESCMuted}}{{Źródła: {tex_5a(connection.get('source_note', ''))}}}")
|
||
return section_5a_tex(section_number, connection.get("title", "Podłączenie RP2350 ↔ XY6020L"), body)
|
||
|
||
|
||
def render_procedure_5a_tex(data: dict) -> list[str]:
|
||
body = [
|
||
r"\tiny\renewcommand{\arraystretch}{1.18}",
|
||
r"\begin{tabularx}{\linewidth}{|p{0.45cm}|p{0.7cm}|Y|Y|}",
|
||
r"\hline OK & Krok & Działanie & Warunek przejścia \\ \hline",
|
||
]
|
||
for step in data.get("hardware_procedure", []):
|
||
body.append(rf"\FiveACheck & \texttt{{{tex_5a(step.get('step', ''))}}} & {tex_5a(step.get('action', ''))} & {tex_5a(step.get('condition', ''))} \\ \hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(13, "Procedura sprzętowa krok po kroku", body)
|
||
|
||
|
||
def render_observation_5a_tex(data: dict) -> list[str]:
|
||
obs = data.get("observation_table", {})
|
||
headers = obs.get("columns", [])
|
||
body = [
|
||
r"\tiny\renewcommand{\arraystretch}{2.2}",
|
||
r"\begin{tabularx}{\linewidth}{|p{2.0cm}|p{3.6cm}|p{3.0cm}|p{3.4cm}|Y|p{1.5cm}|}",
|
||
r"\hline " + " & ".join(tex_5a(header) for header in headers) + r" \\ \hline",
|
||
]
|
||
for row in obs.get("rows", []):
|
||
cells = list(row.get("cells", []))
|
||
cells += [""] * max(0, len(headers) - len(cells))
|
||
rendered = [tex_5a(cell) if cell else r"\rule{0pt}{9.0em}\dotfill" for cell in cells[: len(headers)]]
|
||
body.append(" & ".join(rendered) + r" \\ \hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(14, obs.get("title", "Karta obserwacji"), body)
|
||
|
||
|
||
def render_troubleshooting_5a_tex(data: dict) -> list[str]:
|
||
body = [
|
||
r"\tiny\renewcommand{\arraystretch}{1.25}",
|
||
r"\begin{tabularx}{\linewidth}{|p{3.0cm}|Y|Y|p{3.0cm}|}",
|
||
r"\hline Objaw & Hipoteza & Akcja & Wynik \\ \hline",
|
||
]
|
||
for row in data.get("troubleshooting", []):
|
||
result = tex_5a(row.get("result", "")) if row.get("result") else r"\rule{0pt}{2.4em}\dotfill"
|
||
body.append(rf"{tex_5a(row.get('symptom', ''))} & {tex_5a(row.get('hypothesis', ''))} & {tex_5a(row.get('action', ''))} & {result} \\ \hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(15, "Troubleshooting log", body)
|
||
|
||
|
||
def render_analysis_5a_tex(data: dict) -> list[str]:
|
||
body = [r"\scriptsize\begin{enumerate}\setlength\itemsep{0.15em}\setlength\topsep{0pt}"]
|
||
for task in data.get("analysis_tasks", []):
|
||
body.append(rf"\item {tex_5a(task)}\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine")
|
||
body.append(r"\end{enumerate}")
|
||
return section_5a_tex(16, "Model vs pomiar i analiza danych", body)
|
||
|
||
|
||
def render_team_decision_5a_tex(data: dict) -> list[str]:
|
||
decision = data.get("team_decision", {})
|
||
body = [r"\scriptsize\begin{tabularx}{\linewidth}{|Y|Y|}", r"\hline"]
|
||
questions = decision.get("questions", [])
|
||
for index in range(0, len(questions), 2):
|
||
left = rf"\textbf{{{tex_5a(questions[index])}}}\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine"
|
||
right = rf"\textbf{{{tex_5a(questions[index + 1])}}}\FiveAWriteLine\FiveAWriteLine\FiveAWriteLine" if index + 1 < len(questions) else ""
|
||
body.append(rf"{left} & {right} \\ \hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(17, decision.get("title", "Decyzja zespołu"), body)
|
||
|
||
|
||
def render_git_gate_5a_tex(data: dict) -> list[str]:
|
||
gate = data.get("git_gate", {})
|
||
body = [
|
||
r"\tiny\begin{tabularx}{\linewidth}{|p{0.45cm}|Y|p{5.0cm}|p{2.3cm}|}",
|
||
r"\hline OK & Kryterium & Dowód & Podpis \\ \hline",
|
||
]
|
||
for item in gate.get("criteria", []):
|
||
body.append(rf"\FiveACheck & {tex_5a(item.get('criterion', ''))} & \texttt{{{tex_mono_body(item.get('evidence', ''))}}} & \dotfill \\ \hline")
|
||
body.append(r"\end{tabularx}")
|
||
return section_5a_tex(18, gate.get("title", "Brama wyjścia"), body)
|
||
|
||
|
||
def render_session_ai_bibliography_5a_tex(
|
||
data: dict,
|
||
section_number: int = 19,
|
||
font_size: str = r"\tiny",
|
||
) -> list[str]:
|
||
record = data.get("session_record", {})
|
||
ai = data.get("ai_rules", {})
|
||
entries = bibliography_entries(data)
|
||
bib_items = [f"{ref}: {entries.get(ref, {}).get('title', ref)}" for ref in data.get("references", [])]
|
||
body = [
|
||
font_size,
|
||
r"\begin{minipage}[t]{0.32\linewidth}{\ttfamily\bfseries Rekord zajęć}\par",
|
||
*tex_compact_items_5a([f"{item.get('path', '')}: {item.get('purpose', '')}" for item in record.get("files", [])], font_size),
|
||
r"\end{minipage}\hfill",
|
||
r"\begin{minipage}[t]{0.32\linewidth}{\ttfamily\bfseries Agent AI}\par",
|
||
rf"\texttt{{{tex_mono_body(ai.get('log_path', ''))}}}\par",
|
||
r"\textbf{Wolno:}",
|
||
*tex_compact_items_5a(ai.get("allowed", []), font_size),
|
||
r"\textbf{Zakaz:}",
|
||
*tex_compact_items_5a(ai.get("forbidden", []), font_size),
|
||
r"\end{minipage}\hfill",
|
||
r"\begin{minipage}[t]{0.32\linewidth}{\ttfamily\bfseries Bibliografia}\par",
|
||
*tex_compact_items_5a(bib_items, font_size),
|
||
r"\end{minipage}",
|
||
]
|
||
return section_5a_tex(section_number, "Rekord, AI i bibliografia", body)
|
||
|
||
|
||
def build_5a_pages_tex(data: dict) -> list[list[str]]:
|
||
parts = data.get("parts", {})
|
||
return [
|
||
[
|
||
*render_metric_header_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_mission_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_objectives_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_agenda_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_hw_gate_5a_tex(data),
|
||
],
|
||
[
|
||
rf"{{\footnotesize\bfseries {tex_5a(card_title_line_5a(data['card']))}}}\hfill{{\scriptsize\ttfamily KARTA {tex_escape(data['card']['number'])}/{tex_escape(data['card']['count'])}}}\par",
|
||
r"\vspace{0.2em}\hrule\vspace{0.6em}",
|
||
*render_theory_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_tech_stack_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_math_5a_tex(data),
|
||
],
|
||
[
|
||
*render_part_banner_5a_tex(parts.get("a", {})),
|
||
r"\vspace{0.1em}",
|
||
*render_model_5a_tex(data),
|
||
r"\vspace{0.1em}",
|
||
*render_code_block_5a_tex(data),
|
||
r"\vspace{0.1em}",
|
||
*render_container_test_5a_tex(data),
|
||
r"\vspace{0.1em}",
|
||
*render_bom_5a_tex(data),
|
||
],
|
||
[
|
||
*render_part_banner_5a_tex(parts.get("b", {})),
|
||
r"\vspace{0.25em}",
|
||
*render_safety_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_settings_figures_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_procedure_5a_tex(data),
|
||
],
|
||
[
|
||
*render_observation_5a_tex(data),
|
||
],
|
||
[
|
||
*render_troubleshooting_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_analysis_5a_tex(data),
|
||
],
|
||
[
|
||
*render_team_decision_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_git_gate_5a_tex(data),
|
||
r"\vspace{0.25em}",
|
||
*render_session_ai_bibliography_5a_tex(data, 20),
|
||
],
|
||
]
|
||
|
||
|
||
def footer_5a_tex(data: dict, page: int, total: int) -> list[str]:
|
||
card = data["card"]
|
||
return [
|
||
r"\vfill",
|
||
r"\noindent\textcolor{ESCInk}{\rule{\linewidth}{0.5pt}}\par",
|
||
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{commit \BuildCommit\ \textperiodcentered\ {tex_escape(card['uuid'])}\ \textperiodcentered\ {tex_escape(card['version'])}\ \textperiodcentered\ {tex_escape(card['revision_date'])}\hfill strona {page} / {total}\hfill {tex_5a(card['status'])}}}}}",
|
||
]
|
||
|
||
|
||
def footer_layer_5b_tex(data: dict, page: int, total: int, width: str = r"\linewidth", x_shift: str = "0pt") -> list[str]:
|
||
card = data["card"]
|
||
return [
|
||
rf"\noindent\hspace*{{{x_shift}}}\begin{{minipage}}{{{width}}}",
|
||
r"\textcolor{ESCInk}{\rule{\linewidth}{0.5pt}}\par",
|
||
rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{commit \BuildCommit\ \textperiodcentered\ {tex_escape(card['uuid'])}\ \textperiodcentered\ {tex_escape(card['version'])}\ \textperiodcentered\ {tex_escape(card['revision_date'])}\hfill strona {page} / {total}\hfill {tex_5a(card['status'])}}}}}",
|
||
r"\end{minipage}",
|
||
]
|
||
|
||
|
||
def running_card_header_5b_tex(data: dict) -> list[str]:
|
||
card = data["card"]
|
||
return [
|
||
rf"{{\footnotesize\bfseries {tex_5a(card_title_line_5a(card))}}}\hfill{{\scriptsize\ttfamily KARTA {tex_escape(card['number'])}/{tex_escape(card['count'])}}}\par",
|
||
r"\vspace{0.15em}",
|
||
r"\noindent\textcolor{ESCInk}{\rule{\linewidth}{0.5pt}}\par",
|
||
r"\vspace{0.45em}",
|
||
]
|
||
|
||
|
||
def render_5a_page_tex(data: dict, center: list[str], page: int, total: int) -> list[str]:
|
||
lines = [
|
||
r"\noindent\begin{minipage}[t]{2.85cm}",
|
||
r"\vspace{0pt}",
|
||
*render_margin_tree_panel_5a_tex(data, "zawodowe"),
|
||
r"\end{minipage}\hfill",
|
||
r"\begin{minipage}[t]{21.2cm}",
|
||
r"\vspace{0pt}",
|
||
*center,
|
||
*footer_5a_tex(data, page, total),
|
||
r"\end{minipage}\hfill",
|
||
r"\begin{minipage}[t]{2.85cm}",
|
||
r"\vspace{0pt}",
|
||
*render_margin_tree_panel_5a_tex(data, "ogolne"),
|
||
r"\end{minipage}",
|
||
]
|
||
return lines
|
||
|
||
|
||
def render_main_tex_5a(data: dict, template: dict) -> str:
|
||
pages = build_5a_pages_tex(data)
|
||
page_lines: list[str] = []
|
||
for index, page in enumerate(pages, start=1):
|
||
if index > 1:
|
||
page_lines.append(r"\clearpage")
|
||
page_lines.extend(render_5a_page_tex(data, page, index, len(pages)))
|
||
lines = [
|
||
"% Generated from json/card_source.json by tools/render_card.py.",
|
||
"% Do not edit manually; update the JSON and regenerate.",
|
||
f"% Template: {template.get('_path', template.get('id', ''))}",
|
||
"",
|
||
*render_preamble_5a(data["card"]),
|
||
r"\begin{document}",
|
||
r"\sloppy",
|
||
*page_lines,
|
||
r"\end{document}",
|
||
"",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
|
||
def render_preamble_5b(card: dict) -> list[str]:
|
||
return [
|
||
r"\documentclass[10pt,a4paper]{article}",
|
||
r"\usepackage[T1]{fontenc}",
|
||
r"\usepackage[utf8]{inputenc}",
|
||
r"\usepackage[polish]{babel}",
|
||
r"\usepackage{array}",
|
||
r"\usepackage{tabularx}",
|
||
r"\usepackage{multirow}",
|
||
r"\usepackage{xcolor}",
|
||
r"\usepackage{amssymb}",
|
||
r"\usepackage{graphicx}",
|
||
r"\usepackage{microtype}",
|
||
r"\usepackage{pdflscape}",
|
||
r"\usepackage[a4paper,portrait,left=0.55cm,right=0.55cm,top=2.50cm,bottom=2.50cm]{geometry}",
|
||
r"\IfFileExists{stix2.sty}{\usepackage{stix2}}{\usepackage{lmodern}}",
|
||
r"\IfFileExists{inconsolata.sty}{\usepackage{inconsolata}}{\usepackage{lmodern}}",
|
||
r"\usepackage[most]{tcolorbox}",
|
||
r"\usepackage{qrcode}",
|
||
r"\usepackage{hyperref}",
|
||
r"\hypersetup{hidelinks}",
|
||
r"\IfFileExists{build-meta.tex}{\input{build-meta.tex}}{\newcommand{\BuildCommit}{local}}",
|
||
macro("CardNumber", card["number"]),
|
||
macro("CardCount", card["count"]),
|
||
macro("CardVersion", card["version"]),
|
||
macro("DocumentUUID", card["uuid"]),
|
||
r"\definecolor{ESCPaper}{HTML}{FEFEFC}",
|
||
r"\definecolor{ESCInk}{HTML}{1C1C1A}",
|
||
r"\definecolor{ESCMuted}{HTML}{8A887F}",
|
||
r"\definecolor{ESCSubtle}{HTML}{4A4944}",
|
||
r"\definecolor{ESCHeader}{HTML}{F4F3EF}",
|
||
r"\definecolor{ESCLine}{HTML}{D8D6D0}",
|
||
r"\definecolor{ESCSoftLine}{HTML}{E4E2DC}",
|
||
r"\definecolor{ESCAccent}{HTML}{3B5A8F}",
|
||
r"\pagecolor{ESCPaper}",
|
||
r"\color{ESCInk}",
|
||
r"\setlength{\parindent}{0pt}",
|
||
r"\setlength{\parskip}{0.20em}",
|
||
r"\setlength{\emergencystretch}{4em}",
|
||
r"\setlength{\tabcolsep}{2.4pt}",
|
||
r"\renewcommand{\arraystretch}{1.10}",
|
||
r"\newcolumntype{Y}{>{\raggedright\arraybackslash}X}",
|
||
r"\newcommand{\FiveACheck}{\(\square\)}",
|
||
r"\newcommand{\FiveAFill}{\dotfill}",
|
||
r"\newcommand{\FiveAWriteLine}{\par\noindent\dotfill}",
|
||
r"\newtcolorbox{FiveASection}[2]{enhanced,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=2.0mm,right=2.0mm,top=1.0mm,bottom=1.0mm,colbacktitle=ESCHeader,coltitle=ESCInk,fonttitle=\bfseries\scriptsize,title={\ttfamily\textcolor{ESCMuted}{#1.}\quad #2}}",
|
||
r"\newtcolorbox{FiveAPart}{enhanced,arc=0pt,boxrule=0.55pt,colback=ESCHeader,colframe=ESCInk,boxsep=0pt,left=2.0mm,right=2.0mm,top=1.0mm,bottom=1.0mm}",
|
||
r"\newtcolorbox{CardContentBlock}{enhanced,arc=0pt,boxrule=0.28pt,colback=ESCPaper,colframe=ESCLine,boxsep=0pt,left=1.1mm,right=1.1mm,top=0.7mm,bottom=0.7mm}",
|
||
"",
|
||
]
|
||
|
||
|
||
def render_margin_tree_panel_5b_tex(data: dict, column: str) -> list[str]:
|
||
is_tech = column == "zawodowe"
|
||
tags = learning_tree_tags(
|
||
data,
|
||
column=column,
|
||
effect_color="orange!85!black" if is_tech else "green!50!black",
|
||
)
|
||
lines = [r"{\fontsize{5.6}{6.6}\selectfont\ttfamily\raggedright"]
|
||
for group in margin_tree_groups_5b(tags):
|
||
if group.get("we"):
|
||
lines.append(rf"\textcolor{{ESCAccent}}{{\textbf{{{tex_escape(group['we'])}}}}}\par")
|
||
for item in group.get("items", []):
|
||
lines.append(rf"\hspace*{{0.55em}}{tex_escape(item['code'])}\par")
|
||
lines.append(r"}")
|
||
return lines
|
||
|
||
|
||
def render_landscape_margin_tree_panel_5b_tex(data: dict, column: str, label: str) -> list[str]:
|
||
return [
|
||
r"{\fontsize{6.2}{7.0}\selectfont\ttfamily\textcolor{ESCMuted}{"
|
||
+ tex_escape(label)
|
||
+ r"}\par\vspace{1.2mm}",
|
||
*render_margin_tree_panel_5b_tex(data, column),
|
||
r"}",
|
||
]
|
||
|
||
|
||
def render_landscape_section_bar_5b_tex(section_number: int, title: str) -> list[str]:
|
||
return [
|
||
r"\noindent\hspace*{27.5mm}\begin{minipage}{242mm}",
|
||
r"{\setlength{\fboxsep}{0pt}\noindent\fcolorbox{ESCInk}{ESCHeader}{%",
|
||
r"\begin{minipage}[c][8mm][c]{\dimexpr\linewidth-2\fboxrule\relax}",
|
||
rf"\hspace{{2.4mm}}{{\ttfamily\scriptsize\textcolor{{ESCMuted}}{{{section_number}.}}}}\quad"
|
||
rf"{{\bfseries\scriptsize {tex_5a(title.upper())}}}",
|
||
r"\end{minipage}}}\par",
|
||
r"\end{minipage}\par",
|
||
]
|
||
|
||
|
||
def render_technical_schematic_page_5b_tex(data: dict, section_number: int, page: int, total: int) -> list[str]:
|
||
connection = data.get("connection_section", {})
|
||
figure = connection.get("figure", {})
|
||
figure_path = figure.get("path", "")
|
||
title = connection.get("title", "Podłączenie RP2350 ↔ XY6020L")
|
||
image = (
|
||
rf"\includegraphics[width=242mm,height=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_objectives_5a_tex(data),
|
||
r"\vfill",
|
||
*render_agenda_5a_tex(data),
|
||
],
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*running_card_header_5b_tex(data),
|
||
r"\vfill",
|
||
*render_mission_5a_tex(data),
|
||
r"\vfill",
|
||
*render_theory_5a_tex(data),
|
||
r"\vfill",
|
||
*render_tech_stack_5a_tex(data),
|
||
r"\vfill",
|
||
*render_hw_gate_5a_tex(data),
|
||
r"\vfill",
|
||
*render_math_5a_tex(data),
|
||
],
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*render_part_banner_5a_tex(parts.get("a", {})),
|
||
r"\vfill",
|
||
*render_model_5a_tex(data),
|
||
r"\vfill",
|
||
*render_code_block_5a_tex(data),
|
||
r"\vfill",
|
||
*render_container_test_5a_tex(data),
|
||
r"\vfill",
|
||
*render_bom_5a_tex(data),
|
||
],
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*render_part_banner_5a_tex(parts.get("b", {})),
|
||
r"\vfill",
|
||
*render_safety_5a_tex(data),
|
||
r"\vfill",
|
||
*render_settings_figures_5b_tex(data),
|
||
],
|
||
},
|
||
{
|
||
"kind": "landscape_schematic",
|
||
"section_number": 13,
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*render_connection_details_5b_tex(data, 13),
|
||
r"\vfill",
|
||
*render_procedure_5b_tex(data, 14),
|
||
],
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*render_observation_5b_tex(data, 15),
|
||
],
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*render_troubleshooting_5b_tex(data, 16),
|
||
],
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*render_analysis_5b_tex(data, 17),
|
||
],
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*render_team_decision_5b_tex(data, 18),
|
||
],
|
||
},
|
||
{
|
||
"kind": "portrait",
|
||
"center": [
|
||
*render_git_gate_5b_tex(data, 19),
|
||
r"\vfill",
|
||
*render_session_ai_bibliography_5a_tex(data, 20, r"\fontsize{4.7}{5.0}\selectfont"),
|
||
],
|
||
},
|
||
]
|
||
|
||
|
||
def render_5b_document_page_tex(data: dict, page_data: dict, page: int, total: int) -> list[str]:
|
||
if page_data.get("kind") == "landscape_schematic":
|
||
return render_technical_schematic_page_5b_tex(
|
||
data,
|
||
int(page_data.get("section_number", 13)),
|
||
page,
|
||
total,
|
||
)
|
||
return render_5b_page_tex(data, list(page_data.get("center", [])), page, total)
|
||
|
||
|
||
def render_5b_page_tex(data: dict, center: list[str], page: int, total: int) -> list[str]:
|
||
lines = [
|
||
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()
|