commit 65299458eda346aa27511034e84541ff40421693 Author: mpabi Date: Wed Jul 15 08:41:20 2026 +0200 Add shared card schemas layouts and renderer diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..283c7f7 --- /dev/null +++ b/.gitea/workflows/check.yml @@ -0,0 +1,19 @@ +name: Check card layouts + +on: + push: + branches: + - main + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check schemas and render example + run: | + python3 -m pip install -r requirements-dev.txt + CARD_LAYOUTS_REQUIRE_JSONSCHEMA=1 python3 scripts/check_repository.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1cbe2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ + +examples/card/build/ +examples/card/web/ +examples/card/doc/*.aux +examples/card/doc/*.fdb_latexmk +examples/card/doc/*.fls +examples/card/doc/*.log +examples/card/doc/*.out +examples/card/doc/*.pdf diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..003c005 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: check render-example pdf-example + +PYTHON ?= python3 +EXAMPLE_CARD := examples/card +EXAMPLE_TEX_DIR := $(EXAMPLE_CARD)/build/tex + +render-example: + $(PYTHON) tools/render_card.py $(EXAMPLE_CARD) + +pdf-example: render-example + cd $(EXAMPLE_TEX_DIR) && latexmk -pdf -interaction=nonstopmode -halt-on-error main.tex + +check: + $(PYTHON) scripts/check_repository.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..683efb7 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# card-layouts + +Wspólne źródło schematów, layoutów i generatora kart pracy używanych przez +serie edukacyjne. Jeden plik `card_source.json` opisuje treść oraz mapowanie +edukacyjne, a `tools/render_card.py` generuje z niego równolegle LaTeX i HTML. + +Repozytorium rozdziela trzy warstwy: + +1. **Treść karty** — `card_source.json`: cele, efekty nauczania, kryteria, + drzewka marginesowe, kroki oraz zadania. +2. **Layout** — `templates/*.json`: geometria strony, role marginesów, + komponenty i wybór emitera TeX/HTML. +3. **Seria** — `series.json`: kolejność kart, repozytoria, wersje i kontrakt + uruchamiania zadań. + +## Zawartość + +```text +card-layouts/ +├── schemas/ +│ ├── card-source.schema.json +│ ├── card-template.schema.json +│ └── series.schema.json +├── templates/ +│ ├── karta-klasyczna.json +│ ├── karta-5a.json +│ └── karta-5b.json +├── tools/render_card.py +├── docs/ +│ ├── CARD.md +│ └── SERIES.md +└── examples/ + ├── card/ + └── series/ +``` + +## Layouty + +| Layout | Przeznaczenie | Marginesy | Emitery | +|---|---|---|---| +| `karta-klasyczna` | elastyczna karta sekcyjna | dwa panele drzewek | TeX + HTML | +| `karta-5a` | wielostronicowa karta A4 landscape | TECH po lewej, OG po prawej | TeX + HTML | +| `karta-5b` | referencyjna karta A4 portrait | gołe drzewka TECH/OG | TeX + HTML | + +`karta-5b` jest obecnym layoutem referencyjnym. Używa geometrii +`0,55 / 2,05 / 0,55 / 14,70 / 0,55 / 2,05 / 0,55 cm`; lewy margines +zawiera efekty zawodowe `TECH`, a prawy efekty ogólne `OG`. + +## Szybki start + +Wygenerowanie przykładowej karty: + +```bash +make render-example +``` + +Powstaną: + +```text +examples/card/build/tex/main.tex +examples/card/build/html/index.html +examples/card/build/html/style.css +``` + +Złożenie kontrolnego PDF-u wymaga `latexmk` i pakietów LaTeX używanych przez +generator: + +```bash +make pdf-example +``` + +Pełna kontrola repozytorium: + +```bash +make check +``` + +Walidacja względem JSON Schema jest wykonywana, gdy dostępny jest pakiet +`jsonschema`; CI instaluje go z `requirements-dev.txt`. + +Generator można też wywołać bezpośrednio dla dowolnego katalogu karty: + +```bash +python3 tools/render_card.py /sciezka/do/karty +python3 tools/render_card.py /sciezka/do/karty --template templates/karta-5b.json +``` + +Katalog karty musi zawierać `json/card_source.json`. Ścieżki wyjściowe +określa obiekt `generated` w tym pliku. + +## Model edukacyjny + +Źródłem marginesów nie jest luźny tekst. Relacje są jawne: + +```text +WE (wymaganie edukacyjne) +└── EN/EK (efekt nauczania lub efekt kształcenia) + └── KW (kryterium weryfikacji) +``` + +Każda sekcja i każdy krok mogą wskazywać te same drzewka przez `tree_refs`. +Dzięki temu TeX i HTML pokazują identyczne powiązanie treści z efektami. + +Szczegóły modelu karty opisuje [docs/CARD.md](docs/CARD.md), a manifestu +serii [docs/SERIES.md](docs/SERIES.md). + +## Słowniki podstaw programowych + +Generator może rozszerzać skróty z zewnętrznych słowników JSON. Karta podaje +ich katalog przez: + +```json +{ + "dictionary_refs": { + "pp_json_root": "../../pp/json" + } +} +``` + +Brak słowników nie blokuje generowania, jeśli karta zawiera kompletne pola +`display` i `text` we własnych drzewkach. + +## Zasada zmian + +- zmiana treści należy do `card_source.json`; +- zmiana geometrii i stylu należy do `templates/*.json` oraz emitera; +- wygenerowanych plików nie edytujemy ręcznie; +- nowa wersja layoutu wymaga aktualizacji numeru `version`, przykładu i testu + obu wyjść. diff --git a/docs/CARD.md b/docs/CARD.md new file mode 100644 index 0000000..b407942 --- /dev/null +++ b/docs/CARD.md @@ -0,0 +1,81 @@ +# Opis repozytorium pojedynczej karty + +Każda karta jest osobnym repozytorium lub samodzielnym katalogiem. Jej źródłem +prawdy jest `json/card_source.json`; pliki TeX i HTML są artefaktami +generowanymi. + +## Minimalne drzewo + +```text +moja-karta/ +├── README.md +├── json/card_source.json +├── doc/ +├── web/ +└── src/ +``` + +## Obowiązkowe warstwy danych + +### Metadane + +Obiekt `card` identyfikuje serię, numer, wersję, UUID, autora i status. +Obiekt `generated` wskazuje ścieżki wyjściowe TeX/HTML/CSS. Pole `template` +wybiera layout. + +### Efekty i kryteria + +- `educational_requirements` — wymagania `WE`; +- `learning_effects` — efekty `EN` lub `EK`; +- `assessment_criteria` — mierzalne kryteria `KW`; +- `educational_requirements.*.learning_tree` — połączenia renderowane na + marginesach. + +Każdy wpis `learning_tree.ogolne[]` lub `learning_tree.zawodowe[]` ma stabilne +`tree_id`, odwołanie `effect_ref`, etykietę `display` oraz co najmniej jedno +powiązane `KW`. + +### Sekcje i kroki + +Pole `sections[]` porządkuje treść. Kroki `sections[].steps[]` mają: + +- `id` i `title`; +- `tree_refs` wskazujące drzewka z marginesów; +- opcjonalne odwołania do wymagań, efektów, kryteriów, równań i figur. + +Layouty 5a/5b obsługują również: + +- `model_block.steps` — kroki modelowania; +- `code_block.blocks[].web_steps` — interaktywne kroki HTML; +- `hardware_procedure[]` — tabela `step/action/condition` w TeX i HTML. + +### Zadania + +`tasks` jest słownikiem zadań, a `tasks_order` określa ich kolejność. Każde +zadanie posiada polecenie `prompt_tex`, kryterium zaliczenia i odwołania do +modelu edukacyjnego. + +## Zalecany README karty + +README konkretnej karty powinien zawierać: + +1. nazwę, numer i miejsce w serii; +2. cel oraz rezultat obserwowalny; +3. mapowanie do źródeł i efektów nauczania; +4. listę kroków/zadań; +5. polecenia generowania i testowania; +6. warunek `PASS`; +7. informację, które pliki są źródłowe, a które generowane. + +Gotowy przykład znajduje się w `examples/card/README.md`. + +## Generowanie + +Z katalogu repozytorium `card-layouts`: + +```bash +python3 tools/render_card.py /sciezka/do/mojej-karty +``` + +Generator najpierw sprawdza spójność identyfikatorów WE/EN/EK/KW, kroków, +zadań i odwołań, a dopiero potem zapisuje oba formaty. diff --git a/docs/SERIES.md b/docs/SERIES.md new file mode 100644 index 0000000..ea3a67e --- /dev/null +++ b/docs/SERIES.md @@ -0,0 +1,40 @@ +# Opis repozytorium i README serii + +Seria jest uporządkowaną ścieżką kart. Jej manifest nie kopiuje treści kart; +przechowuje kolejność, adres repozytorium, gałąź, wersję dokumentu i kontrakt +uruchamiania zadań. + +## Manifest `series.json` + +Najważniejsze pola: + +- `version` — wersja formatu manifestu; +- `series`, `org`, `title` — identyfikacja serii; +- `default_branch` — domyślna gałąź kart; +- `cards[]` — uporządkowane wpisy kart; +- `cards[].build` — cele budowania i czyszczenia; +- `cards[].tasks[]` — cele uruchamiane przez narzędzia ucznia. + +Schemat znajduje się w `schemas/series.schema.json`, a minimalny manifest w +`examples/series/series.json`. + +## Zalecany README serii + +README serii powinien odpowiadać na pięć pytań: + +1. Jaki jest rezultat całej ścieżki? +2. Jakie są wymagania wejściowe? +3. W jakiej kolejności realizujemy karty i dlaczego? +4. Z jakich książek, dokumentacji i przykładów korzystamy? +5. Jaki wspólny kontrakt `PASS` obowiązuje wszystkie karty? + +Zalecana tabela mapuje: + +```text +karta → lekcja → źródło → eksperyment → rezultat → stan +``` + +README nie zastępuje `series.json`: Markdown opisuje narrację i decyzje, +natomiast JSON jest kontraktem maszynowym dla narzędzi. + +Przykład znajduje się w `examples/series/README.md`. diff --git a/examples/card/README.md b/examples/card/README.md new file mode 100644 index 0000000..edde196 --- /dev/null +++ b/examples/card/README.md @@ -0,0 +1,36 @@ +# demo-01 / pierwszy krok + +Karta `01/02` demonstracyjnej serii `demo`. Pokazuje minimalny kontrakt +generatora: jedno wymaganie edukacyjne, efekty ogólne i zawodowe na +marginesach, kryterium weryfikacji oraz kroki obecne w TeX i HTML. + +## Cel + +Uczeń uruchamia generator, rozpoznaje pliki źródłowe oraz potrafi wskazać, +który krok realizuje dane wymaganie i kryterium. + +## Kroki + +1. Sprawdź `json/card_source.json`. +2. Wygeneruj TeX i HTML. +3. Potwierdź obecność marginesów `TECH` i `OG`. +4. Zapisz dowód wykonania polecenia kontrolnego. + +## Generowanie + +Z katalogu głównego repozytorium `card-layouts`: + +```bash +make render-example +make pdf-example +``` + +## Warunek PASS + +- generator kończy pracę kodem `0`; +- istnieją `build/tex/main.tex`, `build/html/index.html` i `style.css`; +- oba wyjścia zawierają tytuł karty i identyfikatory drzewek; +- PDF składa się bez błędu. + +Źródłem prawdy jest `json/card_source.json`. Plików w `build/` nie edytujemy +ręcznie. diff --git a/examples/card/json/card_source.json b/examples/card/json/card_source.json new file mode 100644 index 0000000..5082596 --- /dev/null +++ b/examples/card/json/card_source.json @@ -0,0 +1,586 @@ +{ + "$schema": "../../../schemas/card-source.schema.json", + "schema": "esc-card-source.v1", + "card": { + "id": "demo-01", + "series": "demo", + "number": "01", + "count": "02", + "slug": "pierwszy-krok", + "title": "DEMO-01: Jedno źródło, dwa formaty", + "topic": "Generowanie TeX i HTML z mapą efektów nauczania", + "project": "System kart pracy", + "subject": "Zajęcia projektowe", + "level": "przykład techniczny", + "dominant": "informatyka i kompetencje techniczne", + "revision_date": "15.07.2026", + "status": "Przykład", + "version": "v0.1", + "uuid": "00000000-0000-4000-8000-000000000001", + "author": "Zespół edukacyjny", + "year": "2026" + }, + "generated": { + "tex": "build/tex/main.tex", + "html": "build/html/index.html", + "html_css": "build/html/style.css" + }, + "template": "templates/karta-5b.json", + "front_page_break": false, + "metric_fill_fields": [ + { + "label": "Uczeń", + "kind": "fill_in_line" + }, + { + "label": "Klasa · data", + "kind": "fill_in_line" + } + ], + "side_margin_tree_layout": { + "columns": [ + { + "id": "zawodowe", + "label": "TECH", + "side": "left", + "tree": "WE -> EK -> KW", + "description": "Efekty techniczne po lewej stronie." + }, + { + "id": "ogolne", + "label": "OG", + "side": "right", + "tree": "WE -> EN -> KW", + "description": "Efekty ogólne po prawej stronie." + } + ] + }, + "learning_effects": { + "DEMO.EN01": { + "bloom_level": "Zastosowanie", + "label": "Generowanie", + "text": "Uczeń generuje TeX i HTML z jednego pliku JSON.", + "assessment_criteria": [ + "DEMO.KW01" + ] + }, + "DEMO.EK01": { + "bloom_level": "Analiza", + "label": "Spójność", + "text": "Uczeń rozpoznaje wspólny model danych i layoutu.", + "assessment_criteria": [ + "DEMO.KW01" + ] + } + }, + "assessment_criteria": { + "DEMO.KW01": { + "text": "Generator kończy pracę kodem 0, a oba wyjścia zawierają tytuł, kroki i drzewka marginesowe.", + "learning_effects": [ + "DEMO.EN01", + "DEMO.EK01" + ] + } + }, + "educational_requirements": { + "DEMO.WE01": { + "text": "Wygenerowanie spójnej karty pracy z jednego źródła.", + "label": "Jedno źródło", + "learning_effects": [ + "DEMO.EN01", + "DEMO.EK01" + ], + "learning_tree": { + "schema": "we-learning-tree.v1", + "policy": "Każdy efekt ma co najmniej jedno mierzalne kryterium.", + "ogolne": [ + { + "effect_ref": "DEMO.EN01", + "display": "EN IP WO 01.01.01", + "source": "IP", + "official": "01.01", + "local": "01", + "text": "Uczeń generuje TeX i HTML z jednego pliku JSON.", + "kind": "WO", + "tree_id": "DEMO.WE01.OG.IP.01.01.01", + "kw": [ + { + "criterion_ref": "DEMO.KW01", + "display": "KW IP WS 01.01.01", + "source": "IP", + "kind": "WS", + "official": "01.01", + "local": "01", + "text": "Oba artefakty zawierają te same metadane i kroki." + } + ] + } + ], + "zawodowe": [ + { + "effect_ref": "DEMO.EK01", + "display": "EK INF04 01.01.01", + "source": "I4", + "official": "01.01", + "local": "01", + "text": "Uczeń rozpoznaje wspólny model danych i layoutu.", + "kind": "EK", + "tree_id": "DEMO.WE01.TECH.I4.01.01.01", + "kw": [ + { + "criterion_ref": "DEMO.KW01", + "display": "KW INF04 01.01.01.01", + "source": "I4", + "kind": "KW", + "official": "01.01.01", + "local": "01", + "text": "Uczeń wskazuje źródło treści, layout i oba artefakty." + } + ] + } + ] + } + } + }, + "sections": [ + { + "title": "Uruchomienie generatora", + "content_kind": "prose", + "content_tex": "Jedno źródło JSON jest renderowane do dwóch formatów.", + "educational_requirement_refs": [ + "DEMO.WE01" + ], + "learning_effect_refs": [ + "DEMO.EN01", + "DEMO.EK01" + ], + "assessment_criterion_refs": [ + "DEMO.KW01" + ], + "area_tree_refs": [ + "DEMO.WE01.OG.IP.01.01.01", + "DEMO.WE01.TECH.I4.01.01.01" + ], + "steps": [ + { + "id": "K1", + "title": "Otwórz card_source.json i odczytaj metadane.", + "tree_refs": [ + "DEMO.WE01.OG.IP.01.01.01", + "DEMO.WE01.TECH.I4.01.01.01" + ], + "educational_requirement_refs": [ + "DEMO.WE01" + ], + "learning_effect_refs": [ + "DEMO.EN01", + "DEMO.EK01" + ], + "assessment_criterion_refs": [ + "DEMO.KW01" + ] + }, + { + "id": "K2", + "title": "Uruchom generator i sprawdź oba wyjścia.", + "tree_refs": [ + "DEMO.WE01.OG.IP.01.01.01", + "DEMO.WE01.TECH.I4.01.01.01" + ], + "educational_requirement_refs": [ + "DEMO.WE01" + ], + "learning_effect_refs": [ + "DEMO.EN01", + "DEMO.EK01" + ], + "assessment_criterion_refs": [ + "DEMO.KW01" + ] + } + ] + } + ], + "tasks": { + "task01": { + "prompt_tex": "Wygeneruj TeX i HTML oraz porównaj tytuł i identyfikatory efektów.", + "criterion": "Oba pliki istnieją i pochodzą z tego samego źródła.", + "educational_requirement_refs": [ + "DEMO.WE01" + ], + "learning_effect_refs": [ + "DEMO.EN01", + "DEMO.EK01" + ], + "assessment_criterion_ref": "DEMO.KW01", + "links": { + "equations": [], + "figures": [] + } + } + }, + "tasks_order": [ + "task01" + ], + "mission": "Wygeneruj kartę pracy do TeX i HTML z jednego źródła, a następnie potwierdź, że oba formaty pokazują tę samą mapę efektów i te same kroki.", + "objectives": [ + "Rozpoznasz rozdzielenie treści, layoutu i manifestu serii.", + "Wygenerujesz TeX i HTML jednym poleceniem.", + "Powiążesz krok z wymaganiem, efektem i kryterium." + ], + "objective_refs": [ + { + "tech": "01", + "og": "01" + }, + { + "tech": "01", + "og": "01" + }, + { + "tech": "01", + "og": "01" + } + ], + "agenda": [ + { + "time": "0--5 min", + "work": "Odczyt źródła i drzewek edukacyjnych." + }, + { + "time": "5--10 min", + "work": "Generowanie TeX i HTML." + }, + { + "time": "10--15 min", + "work": "Porównanie rezultatów i zapis PASS." + } + ], + "hw_gate": { + "title": "Bramka wejścia", + "criteria": [ + { + "check": "Python 3 jest dostępny.", + "evidence": "python3 --version" + }, + { + "check": "Źródło JSON jest poprawne składniowo.", + "evidence": "python3 -m json.tool json/card_source.json" + } + ] + }, + "tech_stack": { + "software": [ + { + "name": "Python", + "use": "walidacja i generowanie" + }, + { + "name": "LaTeX", + "use": "skład PDF" + }, + { + "name": "HTML/CSS", + "use": "wersja ekranowa i druk z przeglądarki" + } + ], + "hardware": [ + { + "name": "Komputer", + "use": "uruchomienie przykładu" + } + ] + }, + "theory_refs": { + "intro": "Treść i layout są osobnymi warstwami. Identyfikatory WE, EN/EK i KW tworzą graf powiązań, który renderer umieszcza na marginesach.", + "known_refs": [], + "new_concepts": [ + { + "term": "źródło prawdy", + "definition": "Plik, z którego odtwarzamy wszystkie artefakty bez ręcznych poprawek." + }, + { + "term": "layout", + "definition": "Kontrakt geometrii, komponentów i emiterów niezależny od treści karty." + } + ] + }, + "math_block": { + "title": "Kontrola liczby wyjść", + "tasks": [ + { + "prompt": "Policz formaty generowane z jednego źródła.", + "equation_tex": "N_{wyjsc} = N_{TeX} + N_{HTML} = 1 + 1 = 2", + "answer_hint": "Dwa formaty, jedno źródło." + } + ] + }, + "parts": { + "a": { + "title": "CZĘŚĆ A -- model", + "body": "Sprawdź identyfikatory i zależności przed generowaniem." + }, + "b": { + "title": "CZĘŚĆ B -- weryfikacja", + "body": "Porównaj TeX, HTML i wynik PDF." + } + }, + "model_block": { + "title": "Model danych", + "notebook": "nie dotyczy", + "output_constants": "json/card_source.json", + "steps": [ + "Odczytaj metadane karty.", + "Przejdź po relacji WE -> EN/EK -> KW.", + "Sprawdź tree_refs dla każdego kroku." + ], + "constants": [ + { + "name": "SOURCE_COUNT", + "value": "1" + }, + { + "name": "OUTPUT_COUNT", + "value": "2" + } + ] + }, + "code_block": { + "title": "Do wykonania: render przykładu", + "context": "Pracujemy wyłącznie na plikach przykładowych w tym repozytorium.", + "blocks": [ + { + "title": "Bloczek 1 -- GENERATOR", + "slug": "generator", + "goal": "Wytworzyć oba formaty i sprawdzić ich spójność.", + "context": "Kroki są zapisywane w JSON i emitowane do interaktywnego HTML.", + "web_steps": [ + "Uruchom make render-example.", + "Otwórz build/html/index.html.", + "Sprawdź build/tex/main.tex.", + "Potwierdź obecność TECH, OG i kroku K2." + ], + "web_commands": [ + { + "label": "Render", + "command": "make render-example" + }, + { + "label": "Kontrola", + "command": "make check" + } + ], + "web_files": [ + "json/card_source.json", + "build/tex/main.tex", + "build/html/index.html" + ], + "web_evidence": [ + "Polecenie kończy się kodem 0.", + "Oba wyjścia zawierają tytuł karty." + ], + "web_safety": [ + "Nie edytuj ręcznie plików w build/." + ], + "columns": [ + { + "title": "Artefakty", + "items": [ + "build/tex/main.tex", + "build/html/index.html", + "build/html/style.css" + ] + }, + { + "title": "Procedura", + "items": [ + "render", + "kontrola marginesów", + "kontrola kroków" + ] + }, + { + "title": "Uczeń oddaje", + "items": [ + "wynik PASS", + "krótkie porównanie obu formatów" + ] + } + ], + "pass_condition": "TeX i HTML istnieją oraz zawierają te same metadane." + } + ] + }, + "container_test": { + "title": "Test bez sprzętu", + "commands": [ + "make render-example", + "make check" + ], + "pass_condition": "Wszystkie kontrole kończą się kodem 0." + }, + "bom": [ + { + "item": "Python 3", + "check": "Interpreter dostępny." + }, + { + "item": "latexmk", + "check": "Wymagany tylko dla PDF." + } + ], + "safety_rules": [ + { + "title": "Jedno źródło", + "body": "Nie poprawiaj osobno TeX i HTML; popraw card_source.json lub layout." + }, + { + "title": "Stabilne identyfikatory", + "body": "Nie zmieniaj tree_id po opublikowaniu karty bez migracji odwołań." + } + ], + "reference_settings": [ + { + "test": "layout", + "dps": "karta-5b", + "xy": "TeX + HTML" + } + ], + "figures": [], + "connection_section": { + "title": "Przepływ danych: JSON -> renderer -> TeX/HTML", + "intro": "Źródło treści i layout są wczytywane osobno, a następnie przekazywane do dwóch emiterów.", + "pin_table": [ + { + "rp2350": "card_source.json", + "isolator_a": "walidacja", + "isolator_b": "emiter", + "xy": "main.tex / index.html" + } + ], + "parameters": [ + "Treść jest niezależna od geometrii.", + "Oba emitery korzystają z przygotowanego modelu danych." + ], + "warnings": [ + "Ręczna zmiana artefaktu znika przy kolejnym generowaniu." + ], + "source_note": "Repozytorium card-layouts jest źródłem generatora, schematów i layoutów." + }, + "hardware_procedure": [ + { + "step": "1", + "action": "Uruchom make render-example.", + "condition": "Generator zwraca kod 0." + }, + { + "step": "2", + "action": "Otwórz HTML i sprawdź marginesy TECH/OG oraz kroki.", + "condition": "Widoczne są oba drzewka i lista kroków." + }, + { + "step": "3", + "action": "Złóż PDF poleceniem make pdf-example.", + "condition": "Powstaje main.pdf bez błędu LaTeX." + } + ], + "observation_table": { + "title": "Karta kontroli artefaktów", + "columns": [ + "Artefakt", + "Oczekiwany element", + "Wynik" + ], + "rows": [ + { + "cells": [ + "HTML", + "tytuł, TECH, OG, kroki", + "" + ] + }, + { + "cells": [ + "TeX/PDF", + "tytuł, TECH, OG, procedura", + "" + ] + } + ] + }, + "troubleshooting": [ + { + "symptom": "Brak layoutu", + "hypothesis": "Błędna ścieżka template.", + "action": "Sprawdź pole template i katalog templates/.", + "result": "" + }, + { + "symptom": "Błąd tree_ref", + "hypothesis": "Krok wskazuje nieistniejące drzewko.", + "action": "Porównaj tree_refs z educational_requirements.*.learning_tree.", + "result": "" + } + ], + "analysis_tasks": [ + "Wyjaśnij, dlaczego treść i layout są osobnymi plikami.", + "Wskaż relację między DEMO.WE01, DEMO.EN01 i DEMO.KW01.", + "Porównaj elementy obecne w TeX i HTML." + ], + "team_decision": { + "title": "Decyzja zespołu", + "questions": [ + "Który layout wybieramy?", + "Jakie były alternatywy?", + "Jak potwierdziliśmy oba formaty?", + "Który commit dokumentuje decyzję?" + ] + }, + "git_gate": { + "title": "Brama wyjścia", + "criteria": [ + { + "criterion": "JSON przechodzi walidację generatora.", + "evidence": "make check" + }, + { + "criterion": "TeX i HTML zostały wygenerowane.", + "evidence": "examples/card/build/" + }, + { + "criterion": "PDF składa się bez błędu.", + "evidence": "main.pdf" + } + ] + }, + "session_record": { + "title": "Rekord pracy", + "files": [ + { + "path": "README.md", + "purpose": "opis wyniku i poleceń" + } + ], + "timeline_sources": [ + "git log --oneline", + "wynik make check" + ] + }, + "ai_rules": { + "allowed": [ + "wyjaśnienie błędu walidacji", + "review spójności identyfikatorów" + ], + "forbidden": [ + "fabrykowanie wyniku PASS", + "ręczna poprawa wygenerowanego pliku zamiast źródła" + ], + "student_owns": [ + "treść kroków", + "mapowanie efektów", + "wynik kontroli" + ], + "log_path": "ai-log.md" + }, + "references": [] +} diff --git a/examples/series/README.md b/examples/series/README.md new file mode 100644 index 0000000..21ffde1 --- /dev/null +++ b/examples/series/README.md @@ -0,0 +1,27 @@ +# Seria demo + +Minimalna seria pokazująca, jak opisywać kolejność kart niezależnie od ich +layoutu i generatora. + +## Rezultat ścieżki + +Po przejściu serii uczeń potrafi wygenerować kartę z JSON, odczytać marginesy +efektów nauczania oraz wykonać kroki zakończone jednoznacznym `PASS`. + +## Mapa + +| Karta | Temat | Rezultat | Stan | +|---:|---|---|---| +| 01 | źródło JSON i layout 5b | TeX, HTML i PDF z jednego źródła | gotowa | +| 02 | własna karta | poprawny model WE → EN/EK → KW | planowana | + +## Kontrakt wspólny + +Każda karta: + +- posiada `json/card_source.json` zgodny ze schematem; +- generuje TeX i HTML tym samym narzędziem; +- ma jawne kroki oraz kryterium `PASS`; +- nie wymaga ręcznej edycji artefaktów wygenerowanych. + +Maszynowym opisem tej kolejności jest `series.json`. diff --git a/examples/series/series.json b/examples/series/series.json new file mode 100644 index 0000000..1b8b27b --- /dev/null +++ b/examples/series/series.json @@ -0,0 +1,41 @@ +{ + "$schema": "../../schemas/series.schema.json", + "version": 1, + "series": "demo", + "org": "edu-demo", + "title": "Demonstracja generatora kart", + "default_branch": "deploy", + "cards": [ + { + "id": "01", + "repo": "lab-demo-card-layout", + "branch": "deploy", + "title": "demo / pierwszy krok", + "summary": "Jedno źródło JSON generujące TeX i HTML z marginesami efektów nauczania.", + "kind": "lab", + "status": "example", + "area": "demo", + "card_series": "demo", + "card_number": "01", + "card_count": "02", + "slug": "pierwszy-krok", + "version": "v0.1", + "document_uuid": "00000000-0000-4000-8000-000000000001", + "pdf": "doc/pdf/demo-01-pierwszy-krok-v0.1.pdf", + "build": { + "driver": "make", + "all_target": "check", + "clean_target": "clean" + }, + "tasks": [ + { + "id": "task01", + "title": "Wygeneruj oba formaty", + "target": "render", + "source": "json/card_source.json", + "build_dir": "build" + } + ] + } + ] +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f3ad13d --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +jsonschema>=4,<5 diff --git a/schemas/card-source.schema.json b/schemas/card-source.schema.json new file mode 100644 index 0000000..ab90be6 --- /dev/null +++ b/schemas/card-source.schema.json @@ -0,0 +1,524 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Źródło karty pracy", + "description": "Schemat wspólnego źródła treści generowanego do TeX i HTML.", + "type": "object", + "required": [ + "schema", + "card", + "generated", + "template", + "learning_effects", + "assessment_criteria", + "educational_requirements", + "sections", + "tasks", + "tasks_order" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema": { + "const": "esc-card-source.v1" + }, + "card": { + "$ref": "#/$defs/card" + }, + "generated": { + "$ref": "#/$defs/generated" + }, + "template": { + "type": "string", + "minLength": 1 + }, + "dictionary_refs": { + "type": "object", + "properties": { + "pp_json_root": { + "type": "string" + } + }, + "additionalProperties": true + }, + "side_margin_tree_layout": { + "$ref": "#/$defs/sideMarginLayout" + }, + "learning_effects": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/learningEffect" + } + }, + "assessment_criteria": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/assessmentCriterion" + } + }, + "educational_requirements": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/educationalRequirement" + } + }, + "sections": { + "type": "array", + "items": { + "$ref": "#/$defs/section" + } + }, + "tasks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/task" + } + }, + "tasks_order": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "model_block": { + "type": "object", + "properties": { + "steps": { + "$ref": "#/$defs/stringList" + } + }, + "additionalProperties": true + }, + "code_block": { + "type": "object", + "properties": { + "blocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "goal": { + "type": "string" + }, + "web_steps": { + "$ref": "#/$defs/stringList" + }, + "web_commands": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "command"], + "properties": { + "label": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "hardware_procedure": { + "type": "array", + "items": { + "$ref": "#/$defs/procedureStep" + } + } + }, + "$defs": { + "stringList": { + "type": "array", + "items": { + "type": "string" + } + }, + "card": { + "type": "object", + "required": [ + "id", + "series", + "number", + "count", + "slug", + "title", + "version", + "uuid" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "series": { + "type": "string", + "minLength": 1 + }, + "number": { + "type": "string", + "minLength": 1 + }, + "count": { + "type": "string", + "minLength": 1 + }, + "slug": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "uuid": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + }, + "generated": { + "type": "object", + "required": ["tex", "html"], + "properties": { + "tex": { + "type": "string", + "minLength": 1 + }, + "html": { + "type": "string", + "minLength": 1 + }, + "html_css": { + "type": "string" + }, + "bibliography": { + "type": "string" + }, + "report_tasks_json": { + "type": "string" + }, + "report_tasks_tex": { + "type": "string" + } + }, + "additionalProperties": false + }, + "sideMarginLayout": { + "type": "object", + "required": ["columns"], + "properties": { + "columns": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": ["id", "label", "side", "tree"], + "properties": { + "id": { + "enum": ["ogolne", "zawodowe"] + }, + "label": { + "type": "string" + }, + "side": { + "enum": ["left", "right"] + }, + "tree": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + }, + "learningEffect": { + "type": "object", + "required": ["text"], + "properties": { + "text": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string" + }, + "bloom_level": { + "type": "string" + }, + "assessment_criteria": { + "$ref": "#/$defs/stringList" + } + }, + "additionalProperties": true + }, + "assessmentCriterion": { + "type": "object", + "required": ["text", "learning_effects"], + "properties": { + "text": { + "type": "string", + "minLength": 1 + }, + "learning_effects": { + "$ref": "#/$defs/stringList" + } + }, + "additionalProperties": true + }, + "educationalRequirement": { + "type": "object", + "required": ["text", "learning_effects", "learning_tree"], + "properties": { + "text": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string" + }, + "learning_effects": { + "$ref": "#/$defs/stringList" + }, + "learning_tree": { + "type": "object", + "required": ["ogolne", "zawodowe"], + "properties": { + "schema": { + "type": "string" + }, + "policy": { + "type": "string" + }, + "ogolne": { + "type": "array", + "items": { + "$ref": "#/$defs/treeItem" + } + }, + "zawodowe": { + "type": "array", + "items": { + "$ref": "#/$defs/treeItem" + } + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "treeItem": { + "type": "object", + "required": ["tree_id", "effect_ref", "display", "text", "kw"], + "properties": { + "tree_id": { + "type": "string", + "minLength": 1 + }, + "effect_ref": { + "type": "string", + "minLength": 1 + }, + "display": { + "type": "string", + "minLength": 1 + }, + "text": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string" + }, + "official": { + "type": "string" + }, + "local": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "kw": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/treeCriterion" + } + } + }, + "additionalProperties": true + }, + "treeCriterion": { + "type": "object", + "required": ["criterion_ref", "display", "text"], + "properties": { + "criterion_ref": { + "type": "string", + "minLength": 1 + }, + "display": { + "type": "string", + "minLength": 1 + }, + "text": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string" + }, + "official": { + "type": "string" + }, + "local": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "additionalProperties": true + }, + "section": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "minLength": 1 + }, + "content_kind": { + "type": "string" + }, + "content_tex": { + "type": "string" + }, + "area_tree_refs": { + "$ref": "#/$defs/stringList" + }, + "educational_requirement_refs": { + "$ref": "#/$defs/stringList" + }, + "learning_effect_refs": { + "$ref": "#/$defs/stringList" + }, + "assessment_criterion_refs": { + "$ref": "#/$defs/stringList" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/sectionStep" + } + } + }, + "additionalProperties": true + }, + "sectionStep": { + "type": "object", + "required": ["id", "title", "tree_refs"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "tree_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "educational_requirement_refs": { + "$ref": "#/$defs/stringList" + }, + "learning_effect_refs": { + "$ref": "#/$defs/stringList" + }, + "assessment_criterion_refs": { + "$ref": "#/$defs/stringList" + }, + "equation_refs": { + "$ref": "#/$defs/stringList" + }, + "figure_refs": { + "$ref": "#/$defs/stringList" + } + }, + "additionalProperties": true + }, + "task": { + "type": "object", + "required": ["prompt_tex", "criterion"], + "properties": { + "prompt_tex": { + "type": "string" + }, + "criterion": { + "type": "string" + }, + "assessment_criterion_ref": { + "type": "string" + }, + "educational_requirement_refs": { + "$ref": "#/$defs/stringList" + }, + "learning_effect_refs": { + "$ref": "#/$defs/stringList" + } + }, + "additionalProperties": true + }, + "procedureStep": { + "type": "object", + "required": ["step", "action", "condition"], + "properties": { + "step": { + "type": "string" + }, + "action": { + "type": "string", + "minLength": 1 + }, + "condition": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": true +} diff --git a/schemas/card-template.schema.json b/schemas/card-template.schema.json new file mode 100644 index 0000000..e85850f --- /dev/null +++ b/schemas/card-template.schema.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Layout karty pracy", + "description": "Schemat pliku templates/karta-*.json używanego przez render_card.py.", + "type": "object", + "required": [ + "schema", + "id", + "name", + "version", + "description", + "tokens", + "emitters" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema": { + "const": "esc-card-template.v1" + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "approved": { + "type": "string" + }, + "description": { + "type": "string", + "minLength": 1 + }, + "tokens": { + "type": "object", + "properties": { + "page": { + "type": "object", + "properties": { + "format": { + "type": "string" + }, + "orientation": { + "enum": ["portrait", "landscape"] + }, + "width_px": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height_px": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": true + }, + "grid": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "minItems": 3, + "items": { + "type": "string" + } + }, + "left_role": { + "type": "string" + }, + "right_role": { + "type": "string" + }, + "geometry_cm": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + }, + "additionalProperties": true + }, + "typography": { + "type": "object", + "additionalProperties": true + }, + "colors": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": true + }, + "components": { + "type": "object", + "additionalProperties": true + }, + "block_map": { + "type": "object", + "additionalProperties": true + }, + "emitters": { + "type": "object", + "required": ["html", "latex"], + "properties": { + "html": { + "enum": ["classic", "5a", "5b"] + }, + "latex": { + "enum": ["classic", "5a", "5b"] + } + }, + "additionalProperties": false + }, + "limitations": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "additionalProperties": true +} diff --git a/schemas/series.schema.json b/schemas/series.schema.json new file mode 100644 index 0000000..8cb958d --- /dev/null +++ b/schemas/series.schema.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Manifest serii kart", + "description": "Maszynowy opis kolejności repozytoriów kart i ich zadań.", + "type": "object", + "required": [ + "version", + "series", + "org", + "title", + "default_branch", + "cards" + ], + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "const": 1 + }, + "series": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "org": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "default_branch": { + "type": "string", + "minLength": 1 + }, + "cards": { + "type": "array", + "items": { + "$ref": "#/$defs/cardEntry" + } + } + }, + "$defs": { + "cardEntry": { + "type": "object", + "required": [ + "id", + "repo", + "branch", + "title", + "summary", + "status", + "card_series", + "card_number", + "card_count", + "slug", + "version", + "build", + "tasks" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "repo": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "branch": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "summary": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "status": { + "type": "string" + }, + "area": { + "type": "string" + }, + "card_series": { + "type": "string" + }, + "card_number": { + "type": "string" + }, + "card_count": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "document_uuid": { + "type": "string" + }, + "pdf": { + "type": "string" + }, + "build": { + "type": "object", + "required": ["driver", "all_target", "clean_target"], + "properties": { + "driver": { + "const": "make" + }, + "all_target": { + "type": "string" + }, + "clean_target": { + "type": "string" + } + }, + "additionalProperties": true + }, + "tasks": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "target"], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "target": { + "type": "string" + }, + "source": { + "type": "string" + }, + "build_dir": { + "type": "string" + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/scripts/check_repository.py b/scripts/check_repository.py new file mode 100644 index 0000000..6b169db --- /dev/null +++ b/scripts/check_repository.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCHEMAS = ROOT / "schemas" +TEMPLATES = ROOT / "templates" +EXAMPLE_CARD = ROOT / "examples" / "card" +EXAMPLE_SERIES = ROOT / "examples" / "series" / "series.json" + + +def load_json(path: Path) -> dict: + with path.open(encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise AssertionError(f"{path}: korzeń JSON musi być obiektem") + return value + + +def check_json_files() -> None: + for path in sorted(SCHEMAS.glob("*.json")): + schema = load_json(path) + assert schema.get("$schema", "").endswith("2020-12/schema"), path + + for path in sorted(TEMPLATES.glob("*.json")): + template = load_json(path) + assert template.get("schema") == "esc-card-template.v1", path + assert template.get("emitters", {}).get("latex"), path + assert template.get("emitters", {}).get("html"), path + + card = load_json(EXAMPLE_CARD / "json" / "card_source.json") + assert card.get("schema") == "esc-card-source.v1" + assert card.get("sections") + assert card.get("educational_requirements") + + series = load_json(EXAMPLE_SERIES) + assert series.get("version") == 1 + assert series.get("cards") + + +def check_optional_json_schema() -> None: + try: + import jsonschema + except ModuleNotFoundError: + if os.environ.get("CARD_LAYOUTS_REQUIRE_JSONSCHEMA") == "1": + raise AssertionError("brak pakietu jsonschema wymaganego przez pełną kontrolę") + print("INFO: jsonschema nie jest zainstalowany; wykonuję walidację generatora.") + return + + pairs = [ + (SCHEMAS / "card-source.schema.json", EXAMPLE_CARD / "json" / "card_source.json"), + (SCHEMAS / "series.schema.json", EXAMPLE_SERIES), + ] + pairs.extend( + (SCHEMAS / "card-template.schema.json", path) + for path in sorted(TEMPLATES.glob("*.json")) + ) + for schema_path, document_path in pairs: + jsonschema.Draft202012Validator(load_json(schema_path)).validate(load_json(document_path)) + + +def check_render() -> None: + subprocess.run( + [sys.executable, str(ROOT / "tools" / "render_card.py"), str(EXAMPLE_CARD)], + cwd=ROOT, + check=True, + ) + + tex_path = EXAMPLE_CARD / "build" / "tex" / "main.tex" + html_path = EXAMPLE_CARD / "build" / "html" / "index.html" + css_path = EXAMPLE_CARD / "build" / "html" / "style.css" + for path in (tex_path, html_path, css_path): + if not path.is_file() or path.stat().st_size == 0: + raise AssertionError(f"brak wygenerowanego pliku: {path}") + + tex = tex_path.read_text(encoding="utf-8") + html = html_path.read_text(encoding="utf-8") + for marker in ("DEMO-01", "IP WO", "INF04"): + if marker not in tex: + raise AssertionError(f"TeX nie zawiera znacznika {marker!r}") + if marker not in html: + raise AssertionError(f"HTML nie zawiera znacznika {marker!r}") + + +def main() -> int: + check_json_files() + check_optional_json_schema() + check_render() + print("PASS: schematy, layouty, przykład serii oraz render TeX/HTML są spójne.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/karta-5a.json b/templates/karta-5a.json new file mode 100644 index 0000000..49f14a3 --- /dev/null +++ b/templates/karta-5a.json @@ -0,0 +1,184 @@ +{ + "$schema": "../schemas/card-template.schema.json", + "schema": "esc-card-template.v1", + "id": "karta-5a", + "name": "Karta 5a", + "version": "1.0", + "description": "Wielostronicowy layout A4 landscape wyekstrahowany z referencyjnego pliku series/esc/html/ESC-04 karta.html; pierwsze dwie strony zachowują konwencję wzorca, dalsze strony dokładają pełną treść karty.", + "tokens": { + "page": { + "format": "a4", + "orientation": "landscape", + "width_px": 1123, + "height_px": 794, + "padding_px": { + "top": 38, + "right": 38, + "bottom": 30, + "left": 38 + }, + "paper": "#fefefc", + "desk": "#eceae4", + "ink": "#1c1c1a" + }, + "grid": { + "columns": [ + "112px", + "1fr", + "112px" + ], + "gap_px": 19, + "left_role": "TECH", + "right_role": "OG" + }, + "typography": { + "text": "STIX Two Text", + "mono": "JetBrains Mono", + "body_px": 13, + "line_height": 1.55, + "margin_tree_px": 7.5, + "label_px": 8.5, + "footer_px": 9.5 + }, + "colors": { + "paper": "#fefefc", + "desk": "#eceae4", + "ink": "#1c1c1a", + "muted": "#8a887f", + "subtle": "#4a4944", + "header": "#f4f3ef", + "line": "#d8d6d0", + "line_soft": "#e4e2dc", + "tree_line": "#c9c7bf", + "hairline": "#f0efe9", + "accent": "#3b5a8f" + } + }, + "components": { + "section_box": { + "border": "1px solid #1c1c1a", + "header": "§N + uppercase title, letter-spacing .12em, background #f4f3ef" + }, + "metric_header": { + "border": "1.5px solid #1c1c1a", + "title_bar": true, + "grid": "2x2", + "fill_fields": [ + "Uczeń", + "Klasa · data" + ] + }, + "margin_tree_panel": { + "left": "TECH — zawodowe; WE → EK → KW", + "right": "OG — ogólne; WE → EN → KW", + "footer": "KW wspólne + dominanta" + }, + "checkbox_row": { + "size_px": 11, + "border": "1.2px solid #1c1c1a" + }, + "agenda_grid": { + "columns": [ + "56px", + "1fr" + ], + "time_color": "#3b5a8f" + }, + "theory_table": { + "border": "1px solid #d8d6d0", + "id_font": "JetBrains Mono" + }, + "tech_stack_table": { + "columns": [ + "88px", + "1fr" + ], + "groups": [ + "Software", + "Hardware" + ] + }, + "footer": { + "rule": "1.5px solid #1c1c1a", + "format": "commit · uuid · wersja · data | strona N / M | status" + }, + "fill_in_line": { + "style": "1px dotted #9a988f" + } + }, + "block_map": { + "page_1": [ + "metric_header", + "mission", + "objectives", + "agenda", + "hw_gate" + ], + "page_2": [ + "running_card_header", + "theory_refs", + "tech_stack", + "math_block" + ], + "page_3": [ + "part_a_banner", + "model_block", + "code_block", + "container_test", + "bom" + ], + "page_4": [ + "part_b_banner", + "safety_rules", + "reference_settings", + "figures", + "hardware_procedure" + ], + "page_5": [ + "observation_table" + ], + "page_6": [ + "troubleshooting", + "analysis_tasks" + ], + "page_7": [ + "team_decision", + "git_gate", + "session_record", + "ai_rules", + "references" + ], + "objectives": "checkbox_row + objective_refs", + "agenda": "agenda_grid", + "hw_gate": "checkbox_row + teacher_signature", + "theory_refs": "theory_table", + "tech_stack": "tech_stack_table", + "math_block": "equations + answer_lines", + "model_block": "notebook + constants_table + manual_inputs", + "code_block": "skeleton_files + interfaces + responsibilities + pass_condition", + "container_test": "command_list + evidence_line", + "bom": "checkbox_table", + "safety_rules": "golden_rules_grid", + "reference_settings": "compact_reference_table", + "figures": "kicad_includegraphics", + "hardware_procedure": "checkbox_steps", + "observation_table": "spacious_grid", + "troubleshooting": "symptom_hypothesis_action_result_log", + "analysis_tasks": "answer_lines", + "team_decision": "mini_adr", + "git_gate": "checkbox_criterion_evidence_signature", + "ai_rules": "compact_policy_box", + "references": "compact_mono_list" + }, + "emitters": { + "html": "5a", + "latex": "5a" + }, + "limitations": { + "latex": [ + "pdfLaTeX nie używa fontów Google Fonts; renderer wybiera stix2, jeśli jest dostępny, oraz mono z pakietu inconsolata/beramono/lmodern jako fallback.", + "HTML odwzorowuje pikselowe rozmiary 1123x794; LaTeX mapuje je na A4 landscape w centymetrach.", + "Panele drzewek w LaTeX są statycznymi minipage, bez interaktywnych tooltipów HTML." + ] + } +} diff --git a/templates/karta-5b.json b/templates/karta-5b.json new file mode 100644 index 0000000..4a02cd2 --- /dev/null +++ b/templates/karta-5b.json @@ -0,0 +1,215 @@ +{ + "$schema": "../schemas/card-template.schema.json", + "schema": "esc-card-template.v1", + "id": "karta-5b", + "name": "Karta 5b", + "version": "1.3", + "approved": "04.07.2026 — layout referencyjny zatwierdzony przez prowadzącego (symetria 0,55/2,05/0,55/14,70; gołe drzewka); v1.1 — nagłówki punktowe zamiast §, 05.07.2026; v1.2 — linie nagłówka/stopki jako warstwa strony ograniczona do bloku treści, 06.07.2026; v1.3 — margines gorny/dolny 2,5 cm, 06.07.2026", + "description": "Wielostronicowy layout A4 portrait: charakter 5a z ramkami sekcji, ale z golymi drzewkami WE->EN/EK jako marginalia bez paneli i bez luznych KW. Linie strony sa warstwa overlay i nie wychodza pod drzewka. Blok tresci zaczyna sie 2,5 cm od gornej krawedzi i konczy 2,5 cm od dolnej.", + "tokens": { + "page": { + "format": "a4", + "orientation": "portrait", + "width_px": 794, + "height_px": 1123, + "padding_px": { + "top": 95, + "right": 21, + "bottom": 95, + "left": 21 + }, + "paper": "#fefefc", + "desk": "#eceae4", + "ink": "#1c1c1a" + }, + "grid": { + "columns": [ + "77px", + "21px", + "1fr", + "21px", + "77px" + ], + "gap_px": 0, + "left_gap_px": 21, + "right_gap_px": 21, + "geometry_cm": { + "top": 2.5, + "bottom": 2.5, + "outer_margin_left": 0.55, + "left_tree": 2.05, + "left_gap": 0.55, + "content": 14.7, + "content_height": 24.7, + "right_gap": 0.55, + "right_tree": 2.05, + "outer_margin_right": 0.55 + }, + "left_role": "TECH", + "right_role": "OG" + }, + "page_rules": { + "layer": "overlay", + "portrait_content_width_cm": 14.7, + "portrait_content_height_cm": 24.7, + "portrait_rule_scope": "kolumna tresci; linia nie wchodzi pod drzewka ani w przerwy marginesowe", + "landscape_schematic_slot_mm": 242, + "landscape_schematic_slot_height_mm": 160, + "landscape_schematic_figure_height_mm": 135, + "landscape_rule_scope": "slot schematu technicznego; pasek sekcji i stopka maja ta sama szerokosc co arkusz", + "line_weight_pt": 0.5 + }, + "typography": { + "text": "STIX Two Text", + "mono": "JetBrains Mono", + "body_px": 11, + "line_height": 1.42, + "margin_tree_px": 7.2, + "label_px": 7.8, + "footer_px": 8.2 + }, + "colors": { + "paper": "#fefefc", + "desk": "#eceae4", + "ink": "#1c1c1a", + "muted": "#8a887f", + "subtle": "#4a4944", + "header": "#f4f3ef", + "line": "#d8d6d0", + "line_soft": "#e4e2dc", + "tree_line": "#c9c7bf", + "hairline": "#f0efe9", + "accent": "#3b5a8f" + } + }, + "components": { + "section_box": { + "border": "1px solid #1c1c1a", + "header": "N. + uppercase title, letter-spacing .12em, background #f4f3ef" + }, + "metric_header": { + "border": "1.5px solid #1c1c1a", + "title_bar": true, + "grid": "2x2", + "fill_fields": [ + "Uczen", + "Klasa · data" + ] + }, + "margin_tree_panel": { + "left": "TECH jako gole drzewko tekstowe WE->EK/EN bez KW, bez ramki, tla, naglowka i stopki", + "right": "OG jako gole drzewko tekstowe WE->EK/EN bez KW, bez ramki, tla, naglowka i stopki", + "footer": false + }, + "checkbox_row": { + "size_px": 10, + "border": "1.1px solid #1c1c1a" + }, + "agenda_grid": { + "columns": [ + "46px", + "1fr" + ], + "time_color": "#3b5a8f" + }, + "theory_table": { + "border": "1px solid #d8d6d0", + "id_font": "JetBrains Mono" + }, + "tech_stack_table": { + "columns": [ + "72px", + "1fr" + ], + "groups": [ + "Software", + "Hardware" + ] + }, + "footer": { + "rule": "1.5px solid #1c1c1a", + "layer": "page overlay", + "width": "content block only: 14.7cm portrait, 242mm technical landscape", + "format": "commit · uuid · wersja · data | strona N / M | status" + }, + "running_card_header": { + "rule": "1.4px solid #1c1c1a", + "layer": "page overlay", + "width": "content block only: 14.7cm portrait, 242mm technical landscape" + }, + "fill_in_line": { + "style": "1px dotted #9a988f" + } + }, + "block_map": { + "page_1": [ + "metric_header", + "mission", + "objectives", + "agenda", + "hw_gate", + "theory_refs", + "tech_stack" + ], + "page_2": [ + "running_card_header", + "math_block", + "part_a_banner", + "model_block", + "code_block", + "container_test", + "bom" + ], + "page_3": [ + "part_b_banner", + "safety_rules", + "reference_settings", + "figures", + "hardware_procedure" + ], + "page_4": [ + "observation_table", + "troubleshooting" + ], + "page_5": [ + "analysis_tasks", + "team_decision", + "git_gate", + "session_record", + "ai_rules", + "references" + ], + "objectives": "checkbox_row + objective_refs", + "agenda": "agenda_grid", + "hw_gate": "checkbox_row + teacher_signature", + "theory_refs": "theory_table", + "tech_stack": "tech_stack_table", + "math_block": "equations + answer_lines", + "model_block": "notebook + constants_table + manual_inputs", + "code_block": "skeleton_files + interfaces + responsibilities + pass_condition", + "container_test": "command_list + evidence_line", + "bom": "checkbox_table", + "safety_rules": "golden_rules_grid", + "reference_settings": "compact_reference_table", + "figures": "kicad_includegraphics", + "hardware_procedure": "checkbox_steps", + "observation_table": "spacious_grid", + "troubleshooting": "symptom_hypothesis_action_result_log", + "analysis_tasks": "answer_lines", + "team_decision": "mini_adr", + "git_gate": "checkbox_criterion_evidence_signature", + "ai_rules": "compact_policy_box", + "references": "compact_mono_list" + }, + "emitters": { + "html": "5b", + "latex": "5b" + }, + "limitations": { + "latex": [ + "pdfLaTeX nie uzywa fontow Google Fonts; renderer wybiera stix2, jesli jest dostepny, oraz mono z pakietu inconsolata/beramono/lmodern jako fallback.", + "HTML odwzorowuje pikselowe rozmiary 794x1123; LaTeX mapuje je na A4 portrait z minipage dla marginesow.", + "Drzewka marginesowe sa statycznym tekstem mono: lewy margines TECH, prawy margines OG." + ] + } +} diff --git a/templates/karta-klasyczna.json b/templates/karta-klasyczna.json new file mode 100644 index 0000000..d6beb8b --- /dev/null +++ b/templates/karta-klasyczna.json @@ -0,0 +1,74 @@ +{ + "$schema": "../schemas/card-template.schema.json", + "schema": "esc-card-template.v1", + "id": "karta-klasyczna", + "name": "Karta klasyczna", + "version": "1.0", + "description": "Dotychczasowy portretowy layout generatora: metryczka, marginesy z drzewkami po bokach, sekcje pełnej karty i słownik.", + "tokens": { + "page": { + "format": "a4", + "orientation": "portrait", + "paper": "#ffffff", + "margin_cm": { + "left": 2.15, + "right": 2.15, + "top": 2.2, + "bottom": 2.2 + } + }, + "grid": { + "columns": [ + "116px", + "minmax(0, 1fr)", + "116px" + ], + "gap_px": 18 + }, + "typography": { + "text": "Georgia, Times New Roman, serif", + "mono": "Latin Modern Mono, ui-monospace, SFMono-Regular, Consolas, monospace", + "body_px": 17, + "margin_px": 7 + }, + "colors": { + "paper": "#ffffff", + "desk": "#eeeeee", + "ink": "#111111", + "muted": "#666666", + "line": "#b9b9b9", + "line_soft": "#dddddd", + "we": "#e00000", + "og": "#008000", + "tech": "#d36b00", + "kw": "#0018c8" + } + }, + "components": { + "section_box": "classic_section", + "metric_header": "classic_front_matter", + "margin_tree_panel": "classic_margin_notes", + "checkbox_row": "classic_empty_check", + "agenda_grid": "classic_compact_agenda", + "theory_table": "classic_tabularx", + "tech_stack_table": "classic_tabularx", + "footer": "classic_fancyhdr", + "fill_in_line": "classic_dotfill" + }, + "block_map": { + "front_matter": "metric_header", + "front_page_scope": "classic_scope", + "objectives": "section_box", + "agenda": "section_box", + "hw_gate": "section_box", + "theory_refs": "section_box", + "tech_stack": "section_box", + "sections": "section_box", + "bibliography": "section_box", + "dictionary": "dictionary_page" + }, + "emitters": { + "html": "classic", + "latex": "classic" + } +} diff --git a/tools/render_card.py b/tools/render_card.py new file mode 100644 index 0000000..95f6143 --- /dev/null +++ b/tools/render_card.py @@ -0,0 +1,5411 @@ +#!/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 "" + + 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\{(?Pequation|figure)\}.*?\\label\{(?P