diff --git a/README.md b/README.md index 306742a..fd93b0b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ card-layouts/ ├── schemas/ │ ├── card-source.schema.json │ ├── card-template.schema.json +│ ├── reference-registry.schema.json │ └── series.schema.json ├── templates/ │ ├── karta-klasyczna.json @@ -107,6 +108,11 @@ WE (wymaganie edukacyjne) 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. +Sekcje mogą także deklarować strukturalne `assets` (najczęściej zrzuty ekranu +z `doc/assets`). Generator wstawia je do TeX/PDF i HTML, zachowuje podpis, +tekst alternatywny i stabilną etykietę oraz przygotowuje link do pełnego +obrazu w wersji ekranowej. + Szczegóły modelu karty opisuje [docs/CARD.md](docs/CARD.md), a manifestu serii [docs/SERIES.md](docs/SERIES.md). @@ -126,6 +132,14 @@ ich katalog przez: Brak słowników nie blokuje generowania, jeśli karta zawiera kompletne pola `display` i `text` we własnych drzewkach. +## Rejestry referencji + +Wspólnej strategii, innej karty, treści ani wzoru nie kopiujemy do karty. +`card_source.json` przechowuje stabilne UUID-y, a generator rozwiązuje je przez +lokalny kanoniczny rejestr JSON. Widocznym hiperłączem jest kod strategii +(`Mxx`), nazwa karty albo etykieta treści/wzoru. Model danych i przykład są w +[docs/CARD.md](docs/CARD.md#referencje-zewnętrzne-bez-kopiowania-treści). + ## Zasada zmian - zmiana treści należy do `card_source.json`; diff --git a/docs/CARD.md b/docs/CARD.md index 41eae31..ed374b3 100644 --- a/docs/CARD.md +++ b/docs/CARD.md @@ -23,11 +23,25 @@ Obiekt `card` identyfikuje serię, numer, wersję, UUID, autora i status. Obiekt `generated` wskazuje ścieżki wyjściowe TeX/HTML/CSS. Pole `template` wybiera layout. -Opcjonalny obiekt `title_block` dodaje do pierwszego nagłówka tekstową -tabliczkę dokumentu: osoby odpowiedzialne, kategorię, tytuł, URL, rewizję, -datę wydania, serię, typ, narzędzie i arkusz. Pole `url` jest kodowane także -lokalnie w QR (SVG w HTML i pakiet `qrcode` w TeX). Gdy URL nie jest podany, -generator pokazuje w tej komórce informację `brak URL`. +Opcjonalny obiekt `title_block` włącza tabliczkę zasobu o wysokości dokładnie +3 cm na każdej stronie, także pierwszej, bibliografii, słownika i stronach +kontynuacji. Tabliczka zastępuje dużą tabelę metadanych strony tytułowej. +Zawiera kategorię, tytuł, metadane karty, numer strony oraz widoczny, +kanoniczny URL. Ten sam URL jest kodowany lokalnie w QR: jako SVG w HTML i +przez pakiet `qrcode` w TeX. Opcjonalny `repository_url` dodaje drugi QR do +repozytorium. Przy dwóch kodach komórki mają po 27 mm: Gitea znajduje się po +lewej, a wyrenderowany zasób po prawej. Jedna zamknięta ramka biegnie dokładnie +między marginesami strony. W HTML widok ekranowy ma rozmiar A4 (210 × 297 mm) i +nagłówek na każdej logicznej karcie, a wydruk używa `@page { size: A4; }` oraz +pól marginesu, dlatego nagłówek powtarza się również po automatycznym podziale +długiej sekcji. + +Pola `height_cm: 3`, `repeat_on_every_page: true` i +`replace_front_matter: true` dokumentują ten kontrakt. `show_qr: true` wymaga +kanonicznego adresu HTTP(S) w `url` albo pary `url_host_uuid`/`doc_uuid`, z +której powstaje `https:///`. +`show_repository_qr: true` wymaga `repository_url`. Adres podglądu `localhost` +nie powinien być publikowany na karcie. ### Efekty i kryteria @@ -49,6 +63,42 @@ Pole `sections[]` porządkuje treść. Kroki `sections[].steps[]` mają: - `tree_refs` wskazujące drzewka z marginesów; - opcjonalne odwołania do wymagań, efektów, kryteriów, równań i figur. +### Referencje zewnętrzne bez kopiowania treści + +Karta może ładować kanoniczne rejestry współdzielonych strategii, kart, treści +i wzorów. Lokalny plik JSON służy do walidacji oraz deterministycznego buildu: + +```json +{ + "reference_registries": [ + { + "uuid": "03403a8a-e20d-5b17-9004-26d9fe6c3003", + "source": "../../../tools/debugging-strategies/json/reference_registry.json" + } + ] +} +``` + +Krok przechowuje tylko rodzaj, UUID rejestru i UUID wpisu: + +```json +{ + "references": [ + { + "kind": "strategy", + "registry_uuid": "03403a8a-e20d-5b17-9004-26d9fe6c3003", + "uuid": "1ecfc474-4fbd-5a56-8ef1-80ae557761d0" + } + ] +} +``` + +Generator pobiera z rejestru etykietę, tytuł, ścieżkę i publiczny adres. +Strategię pokazuje jako kod `Mxx`, kartę jako jej nazwę, a treść lub wzór jako +kanoniczną etykietę. W TeX/PDF i HTML etykieta jest hiperłączem. Nazw ani URL-i +nie kopiujemy do `card_source.json`; brak rejestru, UUID albo lokalnego pliku +przerywa generowanie. + Layouty 5a/5b obsługują również: - `model_block.steps` — kroki modelowania; @@ -60,12 +110,57 @@ renderowany jako osobna, opisana ramka w sekcji implementacyjnej, a jego `web_steps` zasila niezależną checklistę HTML. Kolejność głównych bloków strony jest opisana przez `block_map` wybranego szablonu. +### Zrzuty ekranu i inne assety sekcji + +Zrzutów nie wpisujemy jako ręcznego `\\includegraphics` w wygenerowanym TeX. +Sekcja może deklarować listę `assets`, a generator umieszcza każdy element w +TeX/PDF i HTML, kopiuje plik do `figures/` strony oraz dodaje link do pełnego +rozmiaru: + +```json +{ + "title": "Pomiar w debuggerze", + "content_tex": "Zatrzymaj program przed pierwszą alokacją.", + "assets": [ + { + "path": "assets/task04-first-allocation-annotated.png", + "caption": "Task04 przed pierwszym przydziałem pamięci.", + "label": "fig:task04-first-allocation", + "alt": "Termdebug: kod C, listing, rejestry i allocbuf", + "kind": "screenshot", + "width": 1.0 + } + ] +} +``` + +- `path` jest liczony względem katalogu `doc/` karty; standardem jest + `doc/assets/`; +- obsługiwane są PNG, JPG/JPEG i PDF; +- `width` jest ułamkiem szerokości kolumny od `0.1` do `1.0`; +- `label` jest stabilnym identyfikatorem używanym przez `figure_refs`; +- `full_size_link` domyślnie włącza otwieranie zrzutu HTML w pełnym rozmiarze. + ### 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. +Rozbudowane zadanie przechowuje przebieg dydaktyczny w uporządkowanym +`tasks..flow[]`: + +- `kind: "block"` grupuje jedną część objaśnienia lub demonstracji; +- opcjonalne `block.steps[]` zachowują kolejność czynności wewnątrz bloku; +- `kind: "exercise"` jest samodzielną pracą ucznia należącą bezpośrednio do + zadania i podaje `prompt_tex`, `evidence_tex` oraz `criterion`; +- `exercise.based_on[]` może wskazać wcześniejsze bloki lub ich kroki. + +Bloki i ćwiczenia są rodzeństwem w jednym `flow`, dzięki czemu renderer nie +gubi kolejności nauczania. `steps` nie może występować bezpośrednio w obiekcie +zadania. Dawne `prompt_tex` i `criterion` pozostają wymagane dla zgodności ze +starszymi konsumentami; gdy istnieje `flow`, renderer używa jego treści. + ## Zalecany README karty README konkretnej karty powinien zawierać: @@ -89,4 +184,4 @@ 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. +zadań, rejestrów UUID i odwołań, a dopiero potem zapisuje oba formaty. diff --git a/examples/card/json/card_source.json b/examples/card/json/card_source.json index 1186a2f..ac3e1a6 100644 --- a/examples/card/json/card_source.json +++ b/examples/card/json/card_source.json @@ -34,13 +34,18 @@ "checked_by": "—", "approved_by": "—", "url": "https://demo.example.edu/cards/demo-01", + "repository_url": "https://gitea.example.edu/edu/cards/demo-01", "revision": "1", "issued_on": "15.07.2026", "series": "DEMO-01", "document_type": "karta pracy", "tool": "card-layouts", "sheet": "1 / 11", - "show_qr": true + "show_qr": true, + "show_repository_qr": true, + "height_cm": 3, + "repeat_on_every_page": true, + "replace_front_matter": true }, "front_page_break": false, "metric_fill_fields": [ diff --git a/schemas/card-source.schema.json b/schemas/card-source.schema.json index b9d11e6..4d74416 100644 --- a/schemas/card-source.schema.json +++ b/schemas/card-source.schema.json @@ -47,6 +47,23 @@ "side_margin_tree_layout": { "$ref": "#/$defs/sideMarginLayout" }, + "reference_registries": { + "type": "array", + "items": { + "type": "object", + "required": ["uuid", "source"], + "properties": { + "uuid": { + "$ref": "#/$defs/uuid" + }, + "source": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + }, "learning_effects": { "type": "object", "minProperties": 1, @@ -147,6 +164,26 @@ } }, "$defs": { + "uuid": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "externalReference": { + "type": "object", + "required": ["kind", "registry_uuid", "uuid"], + "properties": { + "kind": { + "enum": ["strategy", "card", "content", "formula"] + }, + "registry_uuid": { + "$ref": "#/$defs/uuid" + }, + "uuid": { + "$ref": "#/$defs/uuid" + } + }, + "additionalProperties": false + }, "stringList": { "type": "array", "items": { @@ -174,6 +211,10 @@ "type": "string", "minLength": 1 }, + "series_title": { + "type": "string", + "minLength": 1 + }, "number": { "type": "string", "minLength": 1 @@ -242,18 +283,65 @@ "checked_by": { "type": "string" }, "approved_by": { "type": "string" }, "title": { "type": "string" }, - "url": { "type": "string" }, - "url_host_uuid": { "type": "string" }, + "url": { + "type": "string", + "pattern": "^https?://" + }, + "repository_url": { + "type": "string", + "pattern": "^https?://" + }, + "url_host_uuid": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, "url_port": { "type": ["string", "integer"] }, - "doc_uuid": { "type": "string" }, + "doc_uuid": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, "revision": { "type": "string" }, "issued_on": { "type": "string" }, "series": { "type": "string" }, "document_type": { "type": "string" }, "tool": { "type": "string" }, - "sheet": { "type": "string" }, - "show_qr": { "type": "boolean" } + "sheet": { + "type": "string", + "description": "Legacy static sheet label. Repeated page headers compute page/total dynamically." + }, + "show_qr": { "type": "boolean" }, + "show_repository_qr": { "type": "boolean" }, + "height_cm": { "const": 3 }, + "repeat_on_every_page": { "const": true }, + "replace_front_matter": { "const": true } }, + "allOf": [ + { + "if": { + "required": ["show_qr"], + "properties": { + "show_qr": { "const": true } + } + }, + "then": { + "anyOf": [ + { "required": ["url"] }, + { "required": ["url_host_uuid", "doc_uuid"] } + ] + } + }, + { + "if": { + "required": ["show_repository_qr"], + "properties": { + "show_repository_qr": { "const": true } + } + }, + "then": { + "required": ["repository_url"] + } + } + ], "additionalProperties": false }, "sideMarginLayout": { @@ -469,10 +557,49 @@ "items": { "$ref": "#/$defs/sectionStep" } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/$defs/sectionAsset" + } } }, "additionalProperties": true }, + "sectionAsset": { + "type": "object", + "required": ["path", "caption", "label"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "caption": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9:.-]*$" + }, + "alt": { + "type": "string" + }, + "kind": { + "enum": ["screenshot", "figure", "diagram"] + }, + "width": { + "type": "number", + "minimum": 0.1, + "maximum": 1.0 + }, + "full_size_link": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "sectionStep": { "type": "object", "required": ["id", "title", "tree_refs"], @@ -506,6 +633,12 @@ }, "figure_refs": { "$ref": "#/$defs/stringList" + }, + "references": { + "type": "array", + "items": { + "$ref": "#/$defs/externalReference" + } } }, "additionalProperties": true @@ -520,6 +653,21 @@ "criterion": { "type": "string" }, + "title": { + "type": "string", + "minLength": 1 + }, + "uuid": { + "type": "string", + "minLength": 1 + }, + "flow": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/taskFlowItem" + } + }, "assessment_criterion_ref": { "type": "string" }, @@ -532,6 +680,56 @@ }, "additionalProperties": true }, + "taskFlowItem": { + "oneOf": [ + { "$ref": "#/$defs/taskBlock" }, + { "$ref": "#/$defs/taskExercise" } + ] + }, + "taskBlock": { + "type": "object", + "required": ["kind", "id", "title"], + "properties": { + "kind": { "const": "block" }, + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "content_tex": { "type": "string" }, + "steps": { + "type": "array", + "items": { "$ref": "#/$defs/taskBlockStep" } + } + }, + "additionalProperties": false + }, + "taskBlockStep": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "content_tex": { "type": "string" }, + "tree_refs": { "$ref": "#/$defs/stringList" }, + "references": { + "type": "array", + "items": { "$ref": "#/$defs/externalReference" } + } + }, + "additionalProperties": false + }, + "taskExercise": { + "type": "object", + "required": ["kind", "id", "title", "prompt_tex", "criterion"], + "properties": { + "kind": { "const": "exercise" }, + "id": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "prompt_tex": { "type": "string", "minLength": 1 }, + "evidence_tex": { "type": "string" }, + "criterion": { "type": "string", "minLength": 1 }, + "based_on": { "$ref": "#/$defs/stringList" } + }, + "additionalProperties": false + }, "procedureStep": { "type": "object", "required": ["step", "action", "condition"], diff --git a/schemas/reference-registry.schema.json b/schemas/reference-registry.schema.json new file mode 100644 index 0000000..6a41d99 --- /dev/null +++ b/schemas/reference-registry.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "reference-registry.schema.json", + "title": "Rejestr współdzielonych referencji dydaktycznych", + "type": "object", + "required": ["schema", "uuid", "name", "title", "root_path", "base_url", "entries"], + "properties": { + "$schema": { + "type": "string" + }, + "schema": { + "const": "esc-reference-registry.v1" + }, + "uuid": { + "$ref": "#/$defs/uuid" + }, + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "root_path": { + "type": "string", + "minLength": 1 + }, + "base_url": { + "type": "string", + "format": "uri", + "minLength": 1 + }, + "entries": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/entry" + } + } + }, + "additionalProperties": false, + "$defs": { + "uuid": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "entry": { + "type": "object", + "required": ["uuid", "kind", "title", "path"], + "properties": { + "uuid": { + "$ref": "#/$defs/uuid" + }, + "kind": { + "enum": ["strategy", "card", "content", "formula"] + }, + "code": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + } +} diff --git a/scripts/check_repository.py b/scripts/check_repository.py index 52d4eba..858dc27 100644 --- a/scripts/check_repository.py +++ b/scripts/check_repository.py @@ -2,9 +2,12 @@ from __future__ import annotations import json +import base64 import os +import shutil import subprocess import sys +import tempfile from pathlib import Path @@ -86,18 +89,174 @@ def check_render() -> None: raise AssertionError(f"TeX nie zawiera znacznika {marker!r}") if marker not in html: raise AssertionError(f"HTML nie zawiera znacznika {marker!r}") - - if r"\qrcode[height=" not in tex: + if "\x00" in html: + raise AssertionError("HTML zawiera bajt NUL po rozwinięciu placeholderów") + if not any(r"\qrcode[" in line and "height=" in line for line in tex.splitlines()): raise AssertionError("TeX nie zawiera kodu QR z tabliczki") + for expected_url in ( + "https://demo.example.edu/cards/demo-01", + "https://gitea.example.edu/edu/cards/demo-01", + ): + if expected_url not in tex or expected_url not in html: + raise AssertionError(f"brak adresu nagłówka {expected_url!r}") + if tex.count(r"\qrcode[level=L,height=22mm]") < 2: + raise AssertionError("TeX nie zawiera obu kodów QR nagłówka") + for marker in ("resource-qr-repository", "resource-qr-page"): + if marker not in html: + raise AssertionError(f"HTML nie zawiera komórki {marker!r}") for marker in ('class="title-qr"', 'class="code-subblock"'): if marker not in html: raise AssertionError(f"HTML nie zawiera znacznika {marker!r}") +def check_section_assets() -> None: + with tempfile.TemporaryDirectory(prefix="card-layouts-assets-") as temp_dir: + card_root = Path(temp_dir) / "card" + shutil.copytree(EXAMPLE_CARD, card_root, ignore=shutil.ignore_patterns("build")) + source_path = card_root / "json" / "card_source.json" + source = load_json(source_path) + source["template"] = str(ROOT / "templates" / "karta-klasyczna.json") + source["generated"] = { + "tex": "build/tex/main.tex", + "html": "build/html/index.html", + "html_css": "build/html/style.css", + } + source["sections"][0]["content_tex"] += ( + r" \textbf{Widok \texttt{GDB}.}" + "\\begin{verbatim}stemctl test demo\\end{verbatim}" + ) + source["sections"][0]["assets"] = [ + { + "path": "assets/debug-screen.png", + "caption": "Kontrolny zrzut debuggera.", + "label": "fig:debug-screen", + "alt": "Zrzut debuggera", + "kind": "screenshot", + "width": 0.96, + } + ] + source["sections"][0]["steps"][0]["figure_refs"] = ["fig:debug-screen"] + source_path.write_text(json.dumps(source, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + asset_path = card_root / "doc" / "assets" / "debug-screen.png" + asset_path.parent.mkdir(parents=True, exist_ok=True) + asset_path.write_bytes( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + ) + ) + subprocess.run( + [sys.executable, str(ROOT / "tools" / "render_card.py"), str(card_root)], + cwd=ROOT, + check=True, + stdout=subprocess.DEVNULL, + ) + tex = (card_root / "build" / "tex" / "main.tex").read_text(encoding="utf-8") + html = (card_root / "build" / "html" / "index.html").read_text(encoding="utf-8") + copied_asset = card_root / "build" / "html" / "figures" / "debug-screen.png" + for marker in ("fig:debug-screen", "debug-screen.png", "Kontrolny zrzut debuggera"): + if marker not in tex: + raise AssertionError(f"TeX assetu nie zawiera znacznika {marker!r}") + if marker not in html: + raise AssertionError(f"HTML assetu nie zawiera znacznika {marker!r}") + if "\x00" in html: + raise AssertionError("HTML assetu zawiera bajt NUL") + if r"\begin{verbatim}" in html or r"\end{verbatim}" in html: + raise AssertionError("HTML zawiera surowe środowisko verbatim") + if "
stemctl test demo
" not in html: + raise AssertionError("HTML nie zawiera wyrenderowanego bloku verbatim") + if not copied_asset.is_file(): + raise AssertionError(f"asset nie został skopiowany: {copied_asset}") + + +def check_external_references() -> None: + with tempfile.TemporaryDirectory(prefix="card-layouts-references-") as temp_dir: + root = Path(temp_dir) + card_root = root / "card" + registry_root = root / "registry" + shutil.copytree(EXAMPLE_CARD, card_root, ignore=shutil.ignore_patterns("build")) + (registry_root / "json").mkdir(parents=True) + (registry_root / "docs").mkdir() + + registry_uuid = "03403a8a-e20d-5b17-9004-26d9fe6c3003" + strategy_uuid = "1ecfc474-4fbd-5a56-8ef1-80ae557761d0" + card_uuid = "4abec2d9-79e9-5dca-9040-c60669f51ff8" + registry = { + "schema": "esc-reference-registry.v1", + "uuid": registry_uuid, + "name": "test-registry", + "title": "Rejestr testowy", + "root_path": "..", + "base_url": "https://example.test/materials/", + "entries": [ + { + "uuid": strategy_uuid, + "kind": "strategy", + "code": "M02", + "title": "Breakpointy", + "path": "docs/M02.md", + }, + { + "uuid": card_uuid, + "kind": "card", + "title": "Wskaźniki i tablice", + "path": "docs/card.md", + }, + ], + } + registry_path = registry_root / "json" / "reference_registry.json" + registry_path.write_text( + json.dumps(registry, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + (registry_root / "docs" / "M02.md").write_text("# M02\n", encoding="utf-8") + (registry_root / "docs" / "card.md").write_text("# Karta\n", encoding="utf-8") + + source_path = card_root / "json" / "card_source.json" + source = load_json(source_path) + source["template"] = str(ROOT / "templates" / "karta-klasyczna.json") + source["reference_registries"] = [ + {"uuid": registry_uuid, "source": str(registry_path)} + ] + source["sections"][0]["steps"][0]["references"] = [ + {"kind": "strategy", "registry_uuid": registry_uuid, "uuid": strategy_uuid}, + {"kind": "card", "registry_uuid": registry_uuid, "uuid": card_uuid}, + ] + source_path.write_text( + json.dumps(source, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + subprocess.run( + [sys.executable, str(ROOT / "tools" / "render_card.py"), str(card_root)], + cwd=ROOT, + check=True, + stdout=subprocess.DEVNULL, + ) + tex = (card_root / "build" / "tex" / "main.tex").read_text(encoding="utf-8") + html = (card_root / "build" / "html" / "index.html").read_text(encoding="utf-8") + expected = ( + "https://example.test/materials/docs/M02.md", + "https://example.test/materials/docs/card.md", + "M02", + "Wskaźniki i tablice", + ) + for marker in expected: + if marker not in tex: + raise AssertionError(f"TeX referencji nie zawiera {marker!r}") + if marker not in html: + raise AssertionError(f"HTML referencji nie zawiera {marker!r}") + if f'data-reference-uuid="{strategy_uuid}"' not in html: + raise AssertionError("HTML nie zachowuje UUID rozwiązanej strategii") + if "M02" not in html: + raise AssertionError("HTML nie pokazuje kodu strategii jako etykiety") + def main() -> int: check_json_files() check_optional_json_schema() check_render() + check_section_assets() + check_external_references() print("PASS: schematy, layouty, przykład serii oraz render TeX/HTML są spójne.") return 0 diff --git a/tools/render_card.py b/tools/render_card.py index aa7a9a2..fa83b2f 100644 --- a/tools/render_card.py +++ b/tools/render_card.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import base64 import json import html as html_lib import os @@ -8,11 +9,13 @@ import re import shutil import subprocess import sys +import tempfile import textwrap import unicodedata from functools import lru_cache from pathlib import Path from typing import Callable, Iterable +from urllib.parse import urljoin REPO_ROOT = Path(__file__).resolve().parents[1] @@ -23,6 +26,19 @@ PP_ROOT = EDU_ROOT / "pp" / "json" SOURCE_PATH = CARD_ROOT / "json" / "card_source.json" TOOLTIP_WRAP_CHARS = 92 DEFAULT_TEMPLATE_PATH = REPO_ROOT / "templates" / "karta-klasyczna.json" +REFERENCE_KINDS = {"strategy", "card", "content", "formula"} +REFERENCE_KIND_LABELS = { + "strategy": "strategie", + "card": "karty", + "content": "treści", + "formula": "wzory", +} +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) +UUID_PATH_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +) def configure_card_root(card_root: Path) -> None: @@ -44,6 +60,124 @@ def load_source(path: Path | None = None) -> dict: return json.loads(source.read_text(encoding="utf-8")) +def build_reference_registry_index(data: dict, errors: list[str] | None = None) -> dict[str, dict]: + cached = data.get("_reference_registry_index") + cached_errors = data.get("_reference_registry_errors") + if isinstance(cached, dict) and isinstance(cached_errors, list): + if errors is not None: + errors.extend(cached_errors) + return cached + + registry_index: dict[str, dict] = {} + registry_errors: list[str] = [] + configs = data.get("reference_registries", []) or [] + if not isinstance(configs, list): + registry_errors.append("reference_registries musi być listą") + configs = [] + + for position, config in enumerate(configs, start=1): + prefix = f"reference_registries[{position}]" + if not isinstance(config, dict): + registry_errors.append(f"{prefix}: wpis musi być obiektem") + continue + expected_uuid = str(config.get("uuid", "")).strip() + source = str(config.get("source", "")).strip() + if not UUID_RE.fullmatch(expected_uuid): + registry_errors.append(f"{prefix}: niepoprawny UUID rejestru {expected_uuid or '-'}") + continue + if expected_uuid in registry_index: + registry_errors.append(f"{prefix}: zduplikowany rejestr {expected_uuid}") + continue + if not source: + registry_errors.append(f"{prefix}: brak source") + continue + + source_path = resolve_card_path(source) + if not source_path.is_file(): + registry_errors.append(f"{prefix}: brak źródła rejestru {source}") + continue + try: + registry = load_json_file(str(source_path)) + except (OSError, json.JSONDecodeError) as exc: + registry_errors.append(f"{prefix}: nie można odczytać {source}: {exc}") + continue + if registry.get("schema") != "esc-reference-registry.v1": + registry_errors.append( + f"{prefix}: {source} nie używa schema esc-reference-registry.v1" + ) + continue + actual_uuid = str(registry.get("uuid", "")).strip() + if actual_uuid != expected_uuid: + registry_errors.append( + f"{prefix}: oczekiwano rejestru {expected_uuid}, źródło zawiera {actual_uuid or '-'}" + ) + continue + + entries = registry.get("entries", []) + if not isinstance(entries, list): + registry_errors.append(f"{prefix}: entries musi być listą") + continue + base_url = str(registry.get("base_url", "")).strip() + root_path = str(registry.get("root_path", "")).strip() + if not base_url: + registry_errors.append(f"{prefix}: brak base_url") + if not root_path: + registry_errors.append(f"{prefix}: brak root_path") + local_root = (source_path.parent / (root_path or ".")).resolve() + entries_by_uuid: dict[str, dict] = {} + for entry_position, raw_entry in enumerate(entries, start=1): + entry_prefix = f"{prefix}.entries[{entry_position}]" + if not isinstance(raw_entry, dict): + registry_errors.append(f"{entry_prefix}: wpis musi być obiektem") + continue + entry_uuid = str(raw_entry.get("uuid", "")).strip() + kind = str(raw_entry.get("kind", "")).strip() + title = str(raw_entry.get("title", "")).strip() + path = str(raw_entry.get("path", "")).strip() + if not UUID_RE.fullmatch(entry_uuid): + registry_errors.append(f"{entry_prefix}: niepoprawny UUID {entry_uuid or '-'}") + continue + if entry_uuid in entries_by_uuid: + registry_errors.append(f"{entry_prefix}: zduplikowany UUID {entry_uuid}") + continue + if kind not in REFERENCE_KINDS: + registry_errors.append(f"{entry_prefix}: nieznany rodzaj {kind or '-'}") + continue + if not title: + registry_errors.append(f"{entry_prefix}: brak title") + if kind == "strategy" and not str(raw_entry.get("code", "")).strip(): + registry_errors.append(f"{entry_prefix}: strategia nie ma code") + if not path: + registry_errors.append(f"{entry_prefix}: brak path") + elif not (local_root / path).is_file(): + registry_errors.append(f"{entry_prefix}: brak lokalnego dokumentu {path}") + entry = dict(raw_entry) + entry["url"] = urljoin(base_url, path) if base_url and path else "" + entries_by_uuid[entry_uuid] = entry + + registry_index[expected_uuid] = { + "uuid": expected_uuid, + "name": str(registry.get("name", "")), + "title": str(registry.get("title", "")), + "base_url": base_url, + "source_path": str(source_path), + "entries": entries_by_uuid, + } + + data["_reference_registry_index"] = registry_index + data["_reference_registry_errors"] = registry_errors + if errors is not None: + errors.extend(registry_errors) + return registry_index + + +def resolve_external_reference(data: dict, reference: dict) -> dict: + registry_uuid = str(reference.get("registry_uuid", "")) + entry_uuid = str(reference.get("uuid", "")) + registry = build_reference_registry_index(data).get(registry_uuid, {}) + return registry.get("entries", {}).get(entry_uuid, {}) + + def resolve_template_path(value: str | None) -> Path: if not value: return DEFAULT_TEMPLATE_PATH @@ -112,6 +246,15 @@ def graphics_paths(sections: list[dict]) -> set[str]: return paths +def section_assets(sections: list[dict]) -> list[dict]: + return [ + asset + for section in sections + for asset in section.get("assets", []) or [] + if isinstance(asset, dict) + ] + + def build_learning_tree_index(data: dict) -> tuple[dict[str, dict], list[str]]: index: dict[str, dict] = {} duplicates: list[str] = [] @@ -154,16 +297,47 @@ def validate_source(data: dict) -> None: 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", [])) + sections = data.get("sections", []) + labels = label_set(sections) for graphic in graphics_paths(data.get("sections", [])): if not (CARD_ROOT / "doc" / graphic).exists(): errors.append(f"brak grafiki użytej w treści: {graphic}") + html_asset_targets: dict[str, str] = {} + supported_asset_suffixes = {".png", ".jpg", ".jpeg", ".pdf"} + for asset in section_assets(sections): + path = str(asset.get("path", "")).strip() + label = str(asset.get("label", "")).strip() + if not path: + errors.append("asset sekcji bez path") + continue + source = resolve_web_asset_source(path) + if not source.is_file(): + errors.append(f"brak assetu sekcji: {path}") + if source.suffix.lower() not in supported_asset_suffixes: + errors.append( + f"nieobsługiwany format assetu {path}; dozwolone: PNG, JPG/JPEG, PDF" + ) + if not label: + errors.append(f"asset {path}: brak label") + elif label in labels: + errors.append(f"zduplikowana etykieta assetu: {label}") + else: + labels.add(label) + target_name = Path(html_figure_asset(path)).name + previous = html_asset_targets.get(target_name) + if previous and previous != path: + errors.append( + f"kolizja nazw assetów HTML {target_name}: {previous} oraz {path}" + ) + html_asset_targets[target_name] = path + tasks = data.get("tasks", {}) educational_requirements = data.get("educational_requirements", {}) learning_effects = data.get("learning_effects", {}) assessment_criteria = data.get("assessment_criteria", {}) educational_model = data.get("educational_model", {}) + reference_registries = build_reference_registry_index(data, errors) margin_source = data.get("side_margin_tree_layout") or educational_model.get("side_margin_tree_layout", {}) validate_side_margin_tree_layout(margin_source, errors) curriculum_domains = set(educational_model.get("curriculum_domains", {})) @@ -217,6 +391,83 @@ def validate_source(data: dict) -> None: if task_ref not in tasks: errors.append(f"tasks_order wskazuje brakujące zadanie: {task_ref}") + title_block = data.get("title_block") or {} + if title_block: + resource_url = str(title_block.get("url", "")).strip() + repository_url = str(title_block.get("repository_url", "")).strip() + host_uuid = str(title_block.get("url_host_uuid", "")).strip() + document_uuid = str(title_block.get("doc_uuid", "")).strip() + url_port = str(title_block.get("url_port", "")).strip() + if resource_url and not re.match(r"^https?://", resource_url): + errors.append("title_block.url musi być kanonicznym adresem HTTP(S)") + if repository_url and not re.match(r"^https?://", repository_url): + errors.append("title_block.repository_url musi być adresem HTTP(S)") + if title_block.get("show_repository_qr") is True and not repository_url: + errors.append( + "title_block.show_repository_qr=true wymaga title_block.repository_url" + ) + if bool(host_uuid) != bool(document_uuid): + errors.append("title_block.url_host_uuid i doc_uuid muszą występować razem") + if host_uuid and not UUID_PATH_RE.fullmatch(host_uuid): + errors.append("title_block.url_host_uuid musi być UUID w zapisie 8-4-4-4-12") + if document_uuid and not UUID_PATH_RE.fullmatch(document_uuid): + errors.append("title_block.doc_uuid musi być UUID w zapisie 8-4-4-4-12") + authority = f"{host_uuid}:{url_port}" if host_uuid and url_port else host_uuid + derived_url = f"https://{authority}/{document_uuid}" if authority and document_uuid else "" + if resource_url and derived_url and resource_url != derived_url: + errors.append( + "title_block.url musi odpowiadać url_host_uuid, opcjonalnemu url_port i doc_uuid" + ) + if title_block.get("show_qr") is True and not (resource_url or derived_url): + errors.append( + "title_block.show_qr=true wymaga title_block.url albo pary url_host_uuid/doc_uuid" + ) + if title_block.get("height_cm", 3) not in (3, 3.0): + errors.append("title_block.height_cm musi wynosić dokładnie 3") + if title_block.get("repeat_on_every_page", True) is not True: + errors.append("title_block.repeat_on_every_page musi być true") + if title_block.get("replace_front_matter", True) is not True: + errors.append("title_block.replace_front_matter musi być true") + + for task_ref, task in tasks.items(): + if "steps" in task: + errors.append( + f"zadanie {task_ref}: steps nie może należeć bezpośrednio do task; użyj flow[].kind=block" + ) + flow = task.get("flow", []) or [] + if not flow: + continue + known_ids: set[str] = set() + exercise_refs: list[tuple[str, str]] = [] + for item in flow: + kind = str(item.get("kind", "")) + item_id = str(item.get("id", "")).strip() + if not item_id: + errors.append(f"zadanie {task_ref}: element flow bez id") + elif item_id in known_ids: + errors.append(f"zadanie {task_ref}: zduplikowane id flow {item_id}") + else: + known_ids.add(item_id) + if kind == "block": + for step in item.get("steps", []) or []: + step_id = str(step.get("id", "")).strip() + if not step_id: + errors.append(f"zadanie {task_ref}: blok {item_id} ma step bez id") + elif step_id in known_ids: + errors.append(f"zadanie {task_ref}: zduplikowane id {step_id}") + else: + known_ids.add(step_id) + elif kind == "exercise": + for based_on in item.get("based_on", []) or []: + exercise_refs.append((item_id, str(based_on))) + else: + errors.append(f"zadanie {task_ref}: nieznany kind flow {kind or '-'}") + for exercise_id, based_on in exercise_refs: + if based_on not in known_ids: + errors.append( + f"zadanie {task_ref}: exercise {exercise_id} wskazuje brakujące based_on {based_on}" + ) + for section in data.get("sections", []): section_title = section.get("title", "") section_area_tree_refs = section.get("area_tree_refs", []) or [] @@ -270,6 +521,46 @@ def validate_source(data: dict) -> None: for label in step.get("equation_refs", []) + step.get("figure_refs", []): if label not in labels: errors.append(f"sekcja {section_title}: step {step_id} link do nieistniejącej etykiety {label}") + for reference in step.get("references", []) or []: + if not isinstance(reference, dict): + errors.append( + f"sekcja {section_title}: step {step_id} referencja nie jest obiektem" + ) + continue + kind = str(reference.get("kind", "")) + registry_uuid = str(reference.get("registry_uuid", "")) + entry_uuid = str(reference.get("uuid", "")) + if kind not in REFERENCE_KINDS: + errors.append( + f"sekcja {section_title}: step {step_id} nieznany rodzaj referencji {kind or '-'}" + ) + continue + if not UUID_RE.fullmatch(registry_uuid): + errors.append( + f"sekcja {section_title}: step {step_id} niepoprawny UUID rejestru {registry_uuid or '-'}" + ) + continue + if not UUID_RE.fullmatch(entry_uuid): + errors.append( + f"sekcja {section_title}: step {step_id} niepoprawny UUID wpisu {entry_uuid or '-'}" + ) + continue + registry = reference_registries.get(registry_uuid) + if not registry: + errors.append( + f"sekcja {section_title}: step {step_id} link do niezaładowanego rejestru {registry_uuid}" + ) + continue + entry = registry.get("entries", {}).get(entry_uuid) + if not entry: + errors.append( + f"sekcja {section_title}: step {step_id} link do nieistniejącego wpisu {entry_uuid}" + ) + continue + if entry.get("kind") != kind: + errors.append( + f"sekcja {section_title}: step {step_id} rodzaj {kind} nie zgadza się z {entry.get('kind')} dla {entry_uuid}" + ) for tree_ref in section_step_tree_refs: if section_area_tree_refs and tree_ref not in section_area_tree_refs: errors.append(f"sekcja {section_title}: area_tree_refs nie zawiera drzewka ze stepów {tree_ref}") @@ -379,7 +670,8 @@ def macro(name: str, value: str) -> str: return rf"\newcommand{{\{name}}}{{{tex_escape(value)}}}" -def render_preamble(card: dict) -> list[str]: +def render_preamble(data: dict) -> list[str]: + card = data["card"] return [ r"\documentclass[12pt,a4paper]{article}", "", @@ -391,7 +683,7 @@ def render_preamble(card: dict) -> list[str]: 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[a4paper,left=2.15cm,right=2.15cm,top=3.75cm,bottom=2.2cm,includehead]{geometry}", r"\usepackage{eso-pic}", r"\usepackage{marginnote}", r"\usepackage{array}", @@ -406,6 +698,7 @@ def render_preamble(card: dict) -> list[str]: r"\usepackage{fancyhdr}", r"\usepackage{lastpage}", r"\usepackage{microtype}", + r"\usepackage{qrcode}", r"\IfFileExists{references.bib}{%", r" \usepackage[backend=biber,style=numeric,sorting=none]{biblatex}%", r" \addbibresource{references.bib}%", @@ -416,7 +709,7 @@ def render_preamble(card: dict) -> list[str]: r"\IfFileExists{build-meta.tex}{%", r" \input{build-meta.tex}%", r"}{%", - r" \newcommand{\BuildCommit}{local}%", + r" \newcommand{\BuildCommit}{lokalna}%", r"}", "", macro("DocumentAuthor", card["author"]), @@ -433,21 +726,22 @@ def render_preamble(card: dict) -> list[str]: r"\newcommand{\EscWriteRow}[1][2.8em]{\rule{0pt}{#1}}", r"\setlength{\parindent}{0pt}", r"\setlength{\parskip}{0.45em}", - r"\setlength{\headheight}{18pt}", + r"\setlength{\headheight}{30mm}", + r"\setlength{\headsep}{3mm}", r"\setlength{\footskip}{24pt}", r"\setlength{\marginparwidth}{1.5cm}", r"\setlength{\marginparsep}{0.25cm}", r"\renewcommand{\arraystretch}{1.22}", r"% Margin separator lines disabled for layout trial.", "", + *render_page_header_tex_macro(data), r"\pagestyle{fancy}", r"\fancyhf{}", - 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"\fancyhead[C]{\ESCPageResourceHeader}", + r"\lfoot{\scriptsize rewizja \BuildCommit}", r"\cfoot{\scriptsize \thepage/\pageref{LastPage}}", r"\rfoot{\scriptsize \DocumentUUID}", - r"\renewcommand{\headrulewidth}{0.4pt}", + r"\renewcommand{\headrulewidth}{0pt}", "", r"\newcommand{\ESCMarginTag}[4]{%", r" \hspace*{#1}\textcolor{#2}{#3~#4}%", @@ -520,17 +814,17 @@ def render_tree_macro(data: dict) -> list[str]: 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"]), + ("Projekt", card["project"]), + ("Seria", card.get("series_title", card["series"])), + ("Karta", card["title"]), + ("Przedmiot", card["subject"]), + ("Poziom", card["level"]), + ("Data aktualizacji", card["revision_date"]), ("Status", card["status"]), - ("Card in series", r"\small\texttt{\CardNumber/\CardCount}"), - ("Card version", r"\small\texttt{\CardVersion}"), + ("Numer karty", r"\small\texttt{\CardNumber/\CardCount}"), + ("Wersja karty", r"\small\texttt{\CardVersion}"), ("UUID", r"\small\texttt{\DocumentUUID}"), - ("Commit", r"\small\texttt{\BuildCommit}"), + ("Rewizja źródła", r"\small\texttt{\BuildCommit}"), ] lines = [r"\noindent\strut\ESCLearningTreeWidget%", r"\begin{tabular}{@{}lp{0.72\textwidth}@{}}"] for label, value in rows: @@ -568,12 +862,62 @@ def render_front_page_scope(data: dict) -> list[str]: return lines +def render_task_flow_tex(task_ref: str, task: dict) -> list[str]: + title = task.get("title") or task_ref + lines = [ + rf"\subsection*{{{tex_escape(task_ref.upper())} · {tex_escape(title)}}}", + ] + if task.get("uuid"): + lines.append(rf"{{\scriptsize\ttfamily {tex_escape(task['uuid'])}}}\par") + for item in task.get("flow", []) or []: + kind = item.get("kind") + if kind == "block": + lines.extend( + [ + r"\par\medskip\noindent\textcolor{black!35}{\rule{\linewidth}{0.35pt}}\par", + rf"\noindent{{\large\bfseries Block · {tex_escape(item.get('title', ''))}}}\par", + ] + ) + if item.get("content_tex"): + lines.append(str(item["content_tex"])) + steps = item.get("steps", []) or [] + if steps: + lines.append(r"\begin{enumerate}[label=\textbf{K\arabic*.},leftmargin=*]") + for step in steps: + content = str(step.get("content_tex", "")) + suffix = f" {content}" if content else "" + lines.append(rf" \item \textbf{{{tex_escape(step.get('title', ''))}}}{suffix}") + lines.append(r"\end{enumerate}") + elif kind == "exercise": + lines.extend( + [ + r"\par\medskip\noindent\textcolor{black!55}{\rule{\linewidth}{0.6pt}}\par", + rf"\noindent{{\large\bfseries Exercise · {tex_escape(item.get('title', ''))}}}\par", + str(item.get("prompt_tex", "")), + ] + ) + if item.get("evidence_tex"): + lines.append(rf"\par\textbf{{Evidence:}} {item['evidence_tex']}") + lines.append(rf"\par\textbf{{Acceptance:}} {tex_escape(item.get('criterion', ''))}") + lines.append(rf"\par\textbf{{Task acceptance:}} {tex_escape(task.get('criterion', ''))}") + return lines + + def render_tasks(section: dict, data: dict) -> list[str]: tasks = data["tasks"] - lines = [r"\begin{enumerate}[label=\textbf{\arabic*.}]"] + lines: list[str] = [] + legacy_refs: list[str] = [] for task_ref in section["task_refs"]: - lines.append(rf" \item {tasks[task_ref]['prompt_tex']}") - lines.append(r"\end{enumerate}") + task = tasks[task_ref] + if task.get("flow"): + lines.extend(render_task_flow_tex(task_ref, task)) + else: + legacy_refs.append(task_ref) + if legacy_refs: + lines.append(r"\begin{enumerate}[label=\textbf{\arabic*.}]") + for task_ref in legacy_refs: + lines.append(rf" \item {tasks[task_ref]['prompt_tex']}") + lines.append(r"\end{enumerate}") return lines @@ -1290,6 +1634,39 @@ def render_learning_tree_token(alias: str, tree_ref: str, tree_index: dict[str, return tooltip_tex(alias, learning_tree_tooltip(tree_ref, tree)) +def external_reference_label(reference: dict, entry: dict) -> str: + kind = str(reference.get("kind", entry.get("kind", ""))) + if kind == "strategy": + return str(entry.get("code", entry.get("title", ""))) + if kind == "card": + return str(entry.get("title", entry.get("code", ""))) + return str(entry.get("code") or entry.get("title") or reference.get("uuid", "")) + + +def grouped_external_references(references: list[dict]) -> list[tuple[str, list[dict]]]: + groups: dict[str, list[dict]] = {} + for reference in references: + if not isinstance(reference, dict): + continue + kind = str(reference.get("kind", "")) + groups.setdefault(kind, []).append(reference) + return [(kind, groups[kind]) for kind in REFERENCE_KIND_LABELS if groups.get(kind)] + + +def render_external_reference_tex(data: dict, reference: dict) -> str: + entry = resolve_external_reference(data, reference) + label = external_reference_label(reference, entry) + kind = str(reference.get("kind", "")) + if kind == "strategy": + visible = rf"\texttt{{{tex_escape(label)}}}" + else: + visible = tex_escape(label) + url = str(entry.get("url", "")) + if not url: + return visible + return rf"\href{{{tex_escape(url)}}}{{{visible}}}" + + def section_tree_refs(section: dict) -> list[str]: refs = list(section.get("area_tree_refs", []) or []) if refs: @@ -1332,12 +1709,19 @@ def render_section_step_tree_map(section: dict, data: dict) -> list[str]: for index, step in enumerate(steps, start=1): if index > 1: lines.append(r"\ESCTinyStepSeparator") - tokens = " ".join( + tree_tokens = " ".join( tex_escape(aliases.get(tree_ref, "?")) for tree_ref in step.get("tree_refs", []) or [] ) + reference_chunks = [] + for kind, references in grouped_external_references(step.get("references", []) or []): + reference_tokens = " ".join( + render_external_reference_tex(data, reference) for reference in references + ) + reference_chunks.append(f"{REFERENCE_KIND_LABELS[kind]}: {reference_tokens}") + reference_suffix = rf"\quad {'; '.join(reference_chunks)}" if reference_chunks else "" step_text = f"K{index}: {step.get('title', '')}" - lines.append(rf"\noindent {tex_escape(step_text)}\quad {tokens}\par") + lines.append(rf"\noindent {tex_escape(step_text)}\quad {tree_tokens}{reference_suffix}\par") lines.extend([r"\par\vspace{0.18em}%", r"\endgroup", ""]) return lines @@ -1377,6 +1761,7 @@ def render_sections(data: dict) -> list[str]: lines.extend(render_tasks(section, data)) else: lines.append(section.get("content_tex", "")) + lines.extend(render_section_assets_tex(section, data)) lines.append(r"\ESCSectionBlockEnd") lines.append("") return lines @@ -1786,7 +2171,7 @@ def render_main_tex(data: dict) -> str: "% Do not edit manually; update the JSON and regenerate.", "", ] - lines.extend(render_preamble(data["card"])) + lines.extend(render_preamble(data)) lines.extend(render_learning_tree_macro(data)) lines.extend( [ @@ -1795,7 +2180,9 @@ def render_main_tex(data: dict) -> str: "", ] ) - lines.extend(render_front_matter(data["card"])) + title_block = data.get("title_block") or {} + if not title_block or title_block.get("replace_front_matter", True) is not True: + lines.extend(render_front_matter(data["card"])) lines.extend(render_front_page_scope(data)) if data.get("front_page_break", True) is not False: lines.append(r"\clearpage") @@ -1833,6 +2220,11 @@ def collect_numbered_labels(data: dict) -> tuple[dict[str, int], dict[str, int]] else: figure_no += 1 figure_numbers[match.group("label")] = figure_no + for asset in section.get("assets", []) or []: + label = str(asset.get("label", "")) + if label: + figure_no += 1 + figure_numbers[label] = figure_no return equation_numbers, figure_numbers @@ -1917,7 +2309,11 @@ def latex_text_segment_to_html(segment: str, equation_numbers: dict[str, int], f segment = segment.replace(old, new) escaped = html_text(segment) - for index, value in enumerate(placeholders): + # Polecenia mogą być zagnieżdżone, np. \textbf{...\texttt{...}}. + # Zewnętrzny placeholder powstaje po wewnętrznym, dlatego rozwijamy je + # w odwrotnej kolejności. W przeciwnym razie znaczniki NUL wyciekały do HTML. + for index in reversed(range(len(placeholders))): + value = placeholders[index] escaped = escaped.replace(html_escape(f"\u0000{index}\u0000"), value) return escaped @@ -2008,6 +2404,31 @@ def render_figure_html(body: str, equation_numbers: dict[str, int], figure_numbe ) +def render_section_assets_html(section: dict, figure_numbers: dict[str, int]) -> str: + figures: list[str] = [] + for asset in section.get("assets", []) or []: + path = str(asset.get("path", "")) + label = str(asset.get("label", "")) + caption = str(asset.get("caption", "")) + alt = str(asset.get("alt") or caption) + kind = str(asset.get("kind") or "screenshot") + width = float(asset.get("width", 1.0)) + href = html_figure_asset(path) + image = ( + f'{html_escape(alt)}' + ) + if asset.get("full_size_link", True): + image = f'{image}' + number = figure_numbers.get(label) + prefix = f"Rys. {number}. " if number else "" + figures.append( + f'
' + f'{image}
{html_escape(prefix + caption)}
' + ) + return "".join(figures) + + def split_latex_rows(body: str) -> list[str]: body = re.sub(r"\\hline", "", body) return [row.strip() for row in re.split(r"\\\\", body) if row.strip()] @@ -2049,6 +2470,7 @@ def render_latex_fragment_html(text: str, equation_numbers: dict[str, int], figu "tabularx": r"\\begin\{tabularx\}.*?\n(.*?)\\end\{tabularx\}", "itemize": r"\\begin\{itemize\}(?:\[[^\]]+\])?(.*?)\\end\{itemize\}", "enumerate": r"\\begin\{enumerate\}(?:\[[^\]]+\])?(.*?)\\end\{enumerate\}", + "verbatim": r"\\begin\{verbatim\}(.*?)\\end\{verbatim\}", } combined = re.compile("|".join(f"(?P<{name}>{pattern})" for name, pattern in patterns.items()), re.S) parts: list[str] = [] @@ -2072,6 +2494,9 @@ def render_latex_fragment_html(text: str, equation_numbers: dict[str, int], figu elif kind == "enumerate": body = re.match(patterns["enumerate"], body, re.S).group(1) # type: ignore[union-attr] parts.append(render_enumerate_html(body, equation_numbers, figure_numbers)) + elif kind == "verbatim": + body = re.match(patterns["verbatim"], body, re.S).group(1) # type: ignore[union-attr] + parts.append(f"
{html_escape(body.strip())}
") cursor = match.end() parts.extend(render_paragraphs_html(text[cursor:], equation_numbers, figure_numbers)) return "\n".join(parts) @@ -2110,6 +2535,26 @@ def render_tree_button(alias: str, tree_ref: str, tree_index: dict[str, dict]) - ) +def render_external_reference_html(data: dict, reference: dict) -> str: + entry = resolve_external_reference(data, reference) + label = external_reference_label(reference, entry) + kind = str(reference.get("kind", "")) + title = str(entry.get("title", label)) + registry_uuid = str(reference.get("registry_uuid", "")) + entry_uuid = str(reference.get("uuid", "")) + visible = f"{html_escape(label)}" if kind == "strategy" else html_escape(label) + url = str(entry.get("url", "")) + if not url: + return visible + return ( + f'{visible}' + ) + + def render_step_map_html(section: dict, data: dict) -> str: if section.get("render_step_map") is False: return "" @@ -2127,6 +2572,9 @@ def render_step_map_html(section: dict, data: dict) -> str: refs = [] refs.extend(f'{html_escape(label)}' for label in step.get("equation_refs", []) or []) refs.extend(f'{html_escape(label)}' for label in step.get("figure_refs", []) or []) + for kind, references in grouped_external_references(step.get("references", []) or []): + refs.append(f"{html_escape(REFERENCE_KIND_LABELS[kind])}:") + refs.extend(render_external_reference_html(data, reference) for reference in references) refs_html = f'
{" ".join(refs)}
' if refs else "" rows.append( f'
K{index} ' @@ -2201,15 +2649,74 @@ def render_running_header_html(card: dict) -> str: ) +def render_task_flow_html( + task_ref: str, + task: dict, + equation_numbers: dict[str, int], + figure_numbers: dict[str, int], +) -> str: + title = task.get("title") or task_ref + uuid_html = f'{html_escape(task["uuid"])}' if task.get("uuid") else "" + flow: list[str] = [] + for item in task.get("flow", []) or []: + item_id = f'{task_ref}-{item.get("id", "item")}' + if item.get("kind") == "block": + content = render_latex_fragment_html( + item.get("content_tex", ""), equation_numbers, figure_numbers + ) + steps = [] + for step in item.get("steps", []) or []: + step_content = render_latex_fragment_html( + step.get("content_tex", ""), equation_numbers, figure_numbers + ) + steps.append( + f'
  • ' + f'{html_escape(step.get("title", ""))}{step_content}
  • ' + ) + steps_html = f'
      {"".join(steps)}
    ' if steps else "" + flow.append( + f'
    ' + f'
    Block

    {html_escape(item.get("title", ""))}

    ' + f'{content}{steps_html}
    ' + ) + elif item.get("kind") == "exercise": + prompt = render_latex_fragment_html( + item.get("prompt_tex", ""), equation_numbers, figure_numbers + ) + evidence = render_latex_fragment_html( + item.get("evidence_tex", ""), equation_numbers, figure_numbers + ) + evidence_html = f'
    Evidence{evidence}
    ' if evidence else "" + refs = " ".join(item.get("based_on", []) or []) + refs_html = f'based on: {html_escape(refs)}' if refs else "" + flow.append( + f'
    ' + f'
    Exercise

    {html_escape(item.get("title", ""))}

    {refs_html}
    ' + f'{prompt}{evidence_html}

    Acceptance: ' + f'{html_escape(item.get("criterion", ""))}

    ' + ) + return ( + f'
    ' + f'
    {html_escape(task_ref.upper())}' + f'

    {html_escape(title)}

    {uuid_html}
    {"".join(flow)}' + f'
    Task acceptance: {html_escape(task.get("criterion", ""))}
    ' + '
    ' + ) + + def render_tasks_html(section: dict, data: dict, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str: items = [] for task_ref in section.get("task_refs", []) or []: task = data["tasks"][task_ref] - items.append( - f'
  • ' - f'{latex_inline_to_html(task.get("prompt_tex", ""), equation_numbers, figure_numbers)}
  • ' - ) - return f'
      {"".join(items)}
    ' + if task.get("flow"): + items.append(render_task_flow_html(task_ref, task, equation_numbers, figure_numbers)) + else: + items.append( + f'
    ' + f'{html_escape(task_ref.upper())}' + f'{latex_inline_to_html(task.get("prompt_tex", ""), equation_numbers, figure_numbers)}
    ' + ) + return f'
    {"".join(items)}
    ' def render_learning_tree_overview_html(data: dict) -> str: @@ -2280,6 +2787,7 @@ def render_bibliography_section_html(data: dict) -> str: def render_main_html(data: dict) -> str: equation_numbers, figure_numbers = collect_numbered_labels(data) card = data["card"] + use_resource_header = bool(data.get("title_block")) show_tree_inspector = data.get("generated", {}).get("html_tree_inspector", True) tree_data = ( json.dumps(tree_html_data(data), ensure_ascii=False).replace(" str: f'{index}. {html_escape(section.get("title", ""))}' for index, section in enumerate(data.get("sections", []), start=1) ) + bibliography_html = render_bibliography_section_html(data) + total_pages = 2 + len(data.get("sections", [])) + (1 if bibliography_html else 0) rows = [ - ("Project", card["project"]), - ("Subject", card["subject"]), - ("Level", card["level"]), - ("Dominanta", card["dominant"]), - ("Topic", card["title"]), - ("Revision date", card["revision_date"]), + ("Projekt", card["project"]), + ("Seria", card.get("series_title", card["series"])), + ("Karta", card["title"]), + ("Przedmiot", card["subject"]), + ("Poziom", card["level"]), + ("Data aktualizacji", card["revision_date"]), ("Status", card["status"]), - ("Card in series", f"{card['number']}/{card['count']}"), - ("Card version", card["version"]), + ("Numer karty", f"{card['number']}/{card['count']}"), + ("Wersja karty", card["version"]), ("UUID", card["uuid"]), ] metadata = "".join(f"
    {html_escape(label)}
    {html_escape(value)}
    " for label, value in rows) @@ -2310,11 +2820,13 @@ def render_main_html(data: dict) -> str: ) sections_html: list[str] = [] for index, section in enumerate(data.get("sections", []), start=1): + page_number = index + 1 content = ( render_tasks_html(section, data, equation_numbers, figure_numbers) if section.get("content_kind") == "tasks" else render_latex_fragment_html(section.get("content_tex", ""), equation_numbers, figure_numbers) ) + content += render_section_assets_html(section, figure_numbers) left_margin, right_margin = render_section_margins_html(section, data) task_identity = section.get("task_identity", {}) or {} identity_html = "" @@ -2332,18 +2844,20 @@ def render_main_html(data: dict) -> str: ) sections_html.append( f'
    ' + f'{render_page_header_html(data, page_number, total_pages) if use_resource_header else ""}' f'' - f'
    {render_running_header_html(card)}' + f'
    {render_running_header_html(card) if not use_resource_header else ""}' f'{heading}' f'{render_step_map_html(section, data)}{content}
    ' f'
    ' ) - bibliography_html = render_bibliography_section_html(data) if bibliography_html: + bibliography_page = len(data.get("sections", [])) + 2 sections_html.append( f'
    ' + f'{render_page_header_html(data, bibliography_page, total_pages) if use_resource_header else ""}' f'' - f'
    {render_running_header_html(card)}' + f'
    {render_running_header_html(card) if not use_resource_header else ""}' f'

    Bibliografia i źródła

    {bibliography_html}
    ' f'
    ' ) @@ -2355,13 +2869,23 @@ def render_main_html(data: dict) -> str: if scope else "" ) + front_intro_html = ( + scope_html + if use_resource_header + else ( + f'

    {html_escape(card_prefix(card))}: {html_escape(card["title"])}

    ' + f'' + f'
    {metadata}
    {scope_html}' + ) + ) + dictionary_page = total_pages css = r""" :root{--ink:#111;--muted:#666;--line:#b9b9b9;--line-soft:#dddddd;--paper:#fff;--desk:#eeeeee;--og:#008000;--tech:#d36b00;--kw:#0018c8;--we:#e00000} *{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--desk);color:var(--ink);font-family:Georgia,"Times New Roman",serif;line-height:1.42} .topbar{position:sticky;top:0;z-index:20;background:rgba(245,245,245,.94);border-bottom:1px solid #d0d0d0;padding:8px 14px;font-family:system-ui,-apple-system,Segoe UI,sans-serif} .topbar details{max-width:1120px;margin:0 auto}.topbar summary{cursor:pointer;font-weight:650}.toc-links{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.toc-links a{font-size:13px;color:#333;text-decoration:none;border:1px solid #ccc;background:white;border-radius:4px;padding:3px 7px} -.paper{padding:18px 12px 44px}.sheet{width:min(1120px,calc(100vw - 24px));min-height:calc(1120px * 1.414);margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:grid;grid-template-columns:116px minmax(0,1fr) 116px;column-gap:18px;padding:52px 32px 42px} +.paper{padding:18px 12px 44px;overflow-x:auto}.sheet{width:210mm;min-height:297mm;margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:grid;grid-template-columns:22mm minmax(0,1fr) 22mm;column-gap:4mm;align-content:start;padding:13mm 9mm 11mm}.resource-header-enabled .sheet{padding-top:9mm;row-gap:6mm} .pdf-main{min-width:0}.running-header{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;border-bottom:1px solid #111;padding-bottom:3px;margin-bottom:26px;font-size:15px}.running-header span{white-space:nowrap} .hero h1{font-size:24px;line-height:1.15;margin:0 0 4px;border-bottom:1px solid #111;padding-bottom:4px}.hero .byline{float:right;margin-top:-31px;font-size:15px} .meta{clear:both;display:grid;grid-template-columns:148px minmax(0,1fr);gap:4px 18px;padding:34px 0 24px;border-bottom:1px solid #111;font-size:17px}.meta dt{color:#333}.meta dd{margin:0;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace} @@ -2374,14 +2898,17 @@ p{font-size:17px;margin:0 0 12px}.pdf-main ul{font-size:17px;margin:8px 0 14px 2 .tree-legend{display:flex;flex-wrap:wrap;gap:4px;margin:7px 0}.steps{display:grid;gap:6px}.step-row{border-top:1px dashed #c9c9c9;padding-top:6px}.step-row:first-child{border-top:0}.step-title span{font-weight:700;margin-right:7px}.step-trees{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px}.step-refs{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}.step-refs a{font-size:10px;color:#666} .tree-token{font:10px/1 "Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid #c8c8c8;background:#fff;border-radius:3px;padding:3px 5px;cursor:pointer}.tree-token.active{background:#111;color:white;border-color:#111} .equation{display:grid;grid-template-columns:1fr auto;align-items:center;gap:14px;margin:18px 0;padding:4px 0;background:transparent;border:0}.equation .display{font-size:17px}.equation-number{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:15px} -figure{margin:24px 0;text-align:center}figure img{max-width:100%;display:block;margin:0 auto;border:0;border-radius:0}figcaption{font-size:14px;color:#444;margin-top:8px;text-align:left} +figure{margin:24px 0;text-align:center}figure img{max-width:100%;display:block;margin:0 auto;border:0;border-radius:0}figcaption{font-size:14px;color:#444;margin-top:8px;text-align:left}.card-asset.asset-screenshot img{border:1px solid #aaa}.card-asset a{display:block;cursor:zoom-in} .table-wrap{overflow:auto;margin:14px 0}table{border-collapse:collapse;width:100%;font-size:16px}th,td{border:1px solid #111;padding:5px 8px;text-align:left;vertical-align:top}th{font-weight:400;background:white} .empty-check{display:inline-block;width:1em;height:1em;border:1px solid #111;vertical-align:-.12em}.answer-line,.dotfill{display:block;border-bottom:1px dotted #111;height:1.45em;min-width:10em}.write-row{display:block;min-height:2.8em} -code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:17px}.tasks li{margin:10px 0}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.dictionary-sheet .pdf-main{grid-column:2}.dictionary-sheet .pdf-margin{display:none} +code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:17px}.task-legacy{margin:10px 0}.task-legacy>strong{display:block;font:700 11px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #aaa;margin:8px 0 18px}.task-flow-header{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:10px;padding:8px 11px;background:#f3f3f3;border-bottom:1px solid #aaa}.task-flow-header span{font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow-header h3{font-size:18px;margin:0}.task-flow-header code{font-size:8px;color:#777}.task-block,.task-exercise{margin:10px 12px;padding:0 0 10px;border-bottom:1px solid #ddd}.task-block header,.task-exercise header{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.task-block header span,.task-exercise header span{font:700 9px/1.2 "Latin Modern Mono",ui-monospace,monospace;text-transform:uppercase;color:#666}.task-block h4,.task-exercise h4{font-size:16px;margin:0}.block-steps{counter-reset:block-step;margin:5px 0 0 24px!important}.block-steps li{margin:7px 0}.block-steps li::marker{font-family:"Latin Modern Mono",ui-monospace,monospace}.task-exercise{border:1px solid #777;border-left:4px solid #111;padding:9px 11px;background:#fafafa}.task-exercise header{flex-wrap:wrap}.exercise-based-on{margin-left:auto;font-size:8px;color:#777}.exercise-evidence{border-top:1px dashed #bbb;margin-top:8px;padding-top:6px}.exercise-evidence>strong{font-size:11px;text-transform:uppercase}.exercise-criterion{font-size:13px;margin:7px 0 0}.task-flow>footer{padding:8px 11px;border-top:1px solid #aaa;font-size:12px}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.dictionary-sheet .pdf-main{grid-column:2}.dictionary-sheet .pdf-margin{display:none} .inspector{position:fixed;right:14px;bottom:14px;width:min(360px,calc(100vw - 28px));max-height:46vh;overflow:auto;background:white;border:1px solid #cfcfcf;box-shadow:0 4px 18px rgba(0,0,0,.18);border-radius:6px;padding:12px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:13px;z-index:30}.inspector h2{font-size:14px;margin:0 0 8px}.inspector .empty{color:#777}.tree-card h3{font-size:15px;margin:6px 0}.tree-card code{display:inline-block;margin:4px 0}.tree-card .line{font-weight:700}.tree-card .line.OG{color:var(--og)}.tree-card .line.TECH{color:var(--tech)}.kw-list{padding-left:18px}.kw-list code{color:var(--kw)} @media(max-width:900px){.sheet{display:block;min-height:0;padding:26px 18px}.pdf-margin{position:static;max-height:none;overflow:visible;margin:10px 0;padding:8px;border:1px solid #ddd}.pdf-margin.left{border-left:3px solid var(--tech)}.pdf-margin.right{border-left:3px solid var(--og)}.hero .byline{float:none;margin:0 0 14px}.meta{grid-template-columns:1fr}.section-heading{flex-wrap:wrap}.task-identity{width:100%;padding:6px 0 0}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:static}} -@media print{body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{width:210mm;min-height:297mm;margin:0;box-shadow:none;break-after:page;padding:18mm 9mm 15mm}.pdf-margin{position:static;max-height:none;overflow:hidden}} +@media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{display:grid;grid-template-columns:22mm minmax(0,1fr) 22mm;column-gap:4mm;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;row-gap:6mm}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet{padding:0;row-gap:6mm}.sheet>.page-resource-header{display:none!important}.pdf-margin{position:static;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.task-block,.task-exercise,figure{break-inside:avoid}} """ + css += page_resource_header_css() + if use_resource_header: + css += page_resource_print_css(data) js = r""" const treeData = JSON.parse(document.getElementById('tree-data').textContent); const inspector = document.getElementById('tree-inspector'); @@ -2436,20 +2963,20 @@ document.addEventListener('click', event => { - +
    + {render_page_header_html(data, 1, total_pages) if use_resource_header else ''}
    -

    {html_escape(card_prefix(card))}: {html_escape(card['title'])}

    {metadata}
    - {scope_html} + {front_intro_html}
    {''.join(sections_html)} -
    {render_running_header_html(card)}

    Słownik WE/EN/EK/KW

    {render_learning_tree_overview_html(data)}
    +
    {render_page_header_html(data, dictionary_page, total_pages) if use_resource_header else ''}
    {render_running_header_html(card) if not use_resource_header else ''}

    Słownik WE/EN/EK/KW

    {render_learning_tree_overview_html(data)}
    {inspector_html} @@ -2508,11 +3035,26 @@ def publication_url(data: dict) -> str: return "" +def repository_url(data: dict) -> str: + """Return the optional source-repository URL used by the second QR cell.""" + title_block = data.get("title_block") or {} + card = data.get("card") or {} + for value in ( + title_block.get("repository_url"), + data.get("repository_url"), + card.get("repository_url"), + ): + if value: + return str(value).strip() + return "" + + def title_block_fields(data: dict) -> dict[str, str | bool]: """Resolve the ISO-7200-like header while keeping card metadata canonical.""" card = data["card"] configured = data.get("title_block") or {} url = publication_url(data) + source_url = repository_url(data) def value(name: str, default: object = "") -> str: current = configured.get(name, default) @@ -2528,6 +3070,7 @@ def title_block_fields(data: dict) -> dict[str, str | bool]: "approved_by": value("approved_by", "—") or "—", "title": value("title", card.get("title", "")), "url": url, + "repository_url": source_url, "revision": value("revision", card.get("version", "")), "issued_on": value("issued_on", revision_date), "series": value("series", card_prefix(card)), @@ -2535,6 +3078,9 @@ def title_block_fields(data: dict) -> dict[str, str | bool]: "tool": value("tool", "card-layouts"), "sheet": value("sheet", "1 / 1"), "show_qr": bool(configured.get("show_qr", bool(url))), + "show_repository_qr": bool( + configured.get("show_repository_qr", bool(source_url)) + ), } @@ -2552,7 +3098,7 @@ def qr_svg(value: str) -> str: try: from reportlab.graphics.barcode.qr import QrCodeWidget except ImportError: - return "" + return qr_svg_from_tex(value) widget = QrCodeWidget(value=value, barLevel="L") widget.qr.make() @@ -2581,6 +3127,329 @@ def qr_svg(value: str) -> str: ) +def qr_svg_from_tex(value: str) -> str: + """Use the local TeX QR encoder when the optional Python module is absent.""" + latex = shutil.which("latex") + dvisvgm = shutil.which("dvisvgm") + if not latex or not dvisvgm or any(char in value for char in "{}\\\n\r"): + return "" + source = ( + r"\documentclass{standalone}" "\n" + r"\usepackage{qrcode}" "\n" + r"\pagestyle{empty}" "\n" + r"\begin{document}" "\n" + rf"\qrcode[level=L,height=30mm]{{{value}}}" "\n" + r"\end{document}" "\n" + ) + with tempfile.TemporaryDirectory(prefix="card-qr-") as temporary_dir: + workdir = Path(temporary_dir) + source_path = workdir / "qr.tex" + source_path.write_text(source, encoding="utf-8") + latex_result = subprocess.run( + [latex, "-interaction=nonstopmode", "-halt-on-error", source_path.name], + cwd=workdir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=20, + check=False, + ) + if latex_result.returncode != 0: + return "" + svg_path = workdir / "qr.svg" + svg_result = subprocess.run( + [dvisvgm, "--no-fonts", "--exact-bbox", f"--output={svg_path.name}", "qr.dvi"], + cwd=workdir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=20, + check=False, + ) + if svg_result.returncode != 0 or not svg_path.is_file(): + return "" + svg = svg_path.read_text(encoding="utf-8") + svg_start = svg.find(" str: + """Render the canonical 30 mm resource header used by every page wrapper.""" + card = data["card"] + fields = title_block_fields(data) + url = str(fields["url"]) + source_url = str(fields["repository_url"]) + + def qr_cell(label: str, target: str, enabled: bool, modifier: str) -> str: + qr = qr_svg(target) if enabled else "" + if enabled and not qr: + raise RuntimeError( + "Nie można wygenerować lokalnego QR: zainstaluj reportlab albo TeX z pakietem qrcode i dvisvgm" + ) + classes = f"resource-qr resource-qr-{modifier}" + if qr: + return ( + f'' + f'{qr}{html_escape(label)}' + ) + return ( + f'
    ' + f'QR{html_escape(label)} wyłączony
    ' + ) + + page_qr_html = qr_cell("HTML", url, bool(fields["show_qr"]), "page") + repository_qr_html = ( + qr_cell("GITEA", source_url, True, "repository") + if fields["show_repository_qr"] + else "" + ) + + def datum(label: str, value: object, class_name: str = "") -> str: + suffix = f" {class_name}" if class_name else "" + return ( + f'
    {html_escape(label)}' + f'{html_escape(value or "—")}
    ' + ) + + card_number = f"{card.get('number', '')}/{card.get('count', '')}" + sheet_number = f"{page}/{total}" + header_class = "page-resource-header page-resource-header--dual" if repository_qr_html else "page-resource-header" + source_link = ( + f'' + f'GITEA{html_escape(source_url)}' + if source_url + else "" + ) + + return ( + f'
    ' + f'{repository_qr_html}' + '
    ' + f'
    {html_escape(fields["category"])}' + f'{html_escape(card_prefix(card))}: {html_escape(card.get("title", ""))}
    ' + '
    ' + f'{datum("Projekt", card.get("project", ""))}' + f'{datum("Seria", card.get("series_title", card.get("series", "")))}' + f'{datum("Przedmiot", card.get("subject", ""))}' + f'{datum("Poziom", card.get("level", ""), "resource-level")}' + '
    ' + '
    ' + f'{source_link}' + f'HTML{html_escape(url or "brak URL")}' + '
    ' + '
    ' + '
    ' + f'{datum("Data", card.get("revision_date", ""))}' + f'{datum("Status", card.get("status", ""))}' + f'{datum("Karta", card_number)}' + f'{datum("Wersja", card.get("version", ""))}' + f'{datum("Arkusz", sheet_number)}' + f'{datum("Autor", card.get("author", ""))}' + f'{datum("UUID", card.get("uuid", ""), "resource-uuid")}' + '
    ' + f'{page_qr_html}' + '
    ' + ) + + +def page_resource_header_css() -> str: + return r""" +.page-resource-header{grid-column:1/-1;width:100%;height:30mm;min-height:30mm;max-height:30mm;border:1px solid #1c1c1a;display:grid;grid-template-columns:minmax(0,1fr) 49mm 27mm;background:#fff;color:#111;overflow:hidden;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.12}.page-resource-header--dual{grid-template-columns:27mm minmax(0,1fr) 49mm 27mm} +.resource-qr{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35mm;border-right:1px solid #aaa;padding:.65mm .8mm;color:#555;text-decoration:none;font-size:7px}.resource-qr-page{border-right:0}.resource-qr .title-qr{display:block;width:24mm;height:24mm;max-width:24mm;max-height:24mm;flex:none}.resource-qr-label{font:700 5px/1 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.08em;color:#444}.resource-qr-empty{text-align:center}.resource-qr-empty span,.resource-qr-empty strong{display:block;font:600 5.5px/1.15 ui-monospace,SFMono-Regular,Consolas,monospace} +.resource-primary{min-width:0;display:grid;grid-template-rows:auto 1fr auto;padding:1.4mm 2mm;border-right:1px solid #aaa}.resource-title{display:flex;align-items:baseline;justify-content:space-between;gap:3mm;border-bottom:1px solid #ddd;padding-bottom:.8mm;min-width:0}.resource-title span{font:600 6.5px/1.1 ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:.06em;color:#555;white-space:nowrap}.resource-title strong{font:700 12px/1.1 Georgia,"Times New Roman",serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right} +.resource-details{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-content:center;column-gap:3mm;row-gap:.6mm;min-width:0}.resource-datum{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1mm;align-items:baseline;min-width:0}.resource-datum span{font:500 6px/1.1 ui-monospace,SFMono-Regular,Consolas,monospace;text-transform:uppercase;color:#666;white-space:nowrap}.resource-datum strong{font:600 7.5px/1.12 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.resource-level{grid-column:1/-1}.resource-url{font:500 6.5px/1.1 ui-monospace,SFMono-Regular,Consolas,monospace;color:#222;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-top:1px solid #eee;padding-top:.7mm} +.resource-urls{min-width:0}.resource-url{display:block}.resource-url span{display:inline-block;width:9mm;font-weight:700;color:#666}.resource-url+.resource-url{padding-top:.35mm}.resource-meta{padding:1.4mm 1.7mm;border-right:1px solid #aaa;display:grid;grid-template-columns:1fr 1fr;gap:.65mm 1.6mm;align-content:center;min-width:0}.resource-meta .resource-datum{display:block}.resource-meta .resource-datum span,.resource-meta .resource-datum strong{display:block}.resource-meta .resource-datum strong{font-size:7px}.resource-meta .resource-uuid{grid-column:1/-1;border-top:1px solid #ddd;padding-top:.7mm}.resource-meta .resource-uuid strong{font-size:5.8px;overflow-wrap:anywhere;white-space:normal} +""" + + +def css_paged_string(value: object) -> str: + return ( + str(value) + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\r", "") + .replace("\n", "\\A ") + ) + + +def page_resource_print_css(data: dict) -> str: + """Render a real 30 mm repeated header in CSS paged-media margin boxes.""" + card = data["card"] + fields = title_block_fields(data) + url = str(fields["url"]) + source_url = str(fields["repository_url"]) + + def qr_background(target: str, enabled: bool) -> str: + qr = qr_svg(target) if enabled else "" + if enabled and not qr: + raise RuntimeError("Nie można wygenerować QR do nagłówka wydruku HTML") + qr_data = base64.b64encode(qr.encode("utf-8")).decode("ascii") if qr else "" + return f'url("data:image/svg+xml;base64,{qr_data}")' if qr_data else "none" + + page_qr_background = qr_background(url, bool(fields["show_qr"])) + repository_qr_enabled = bool(fields["show_repository_qr"]) + repository_qr_background = qr_background(source_url, repository_qr_enabled) + middle = "\n".join( + [ + f"{fields['category']} | {card_prefix(card)}: {card.get('title', '')}", + f"Projekt: {card.get('project', '')} · Seria: {card.get('series_title', card.get('series', ''))}", + f"Przedmiot: {card.get('subject', '')}", + f"Poziom: {card.get('level', '')}", + url, + ] + ) + meta_before_page = "\n".join( + [ + f"DATA {card.get('revision_date', '')} · STATUS {card.get('status', '')}", + f"KARTA {card.get('number', '')}/{card.get('count', '')} · WERSJA {card.get('version', '')}", + "STRONA ", + ] + ) + meta_after_page = "\n".join( + [ + f" · AUTOR {card.get('author', '')}", + f"UUID {card.get('uuid', '')}", + ] + ) + qr_box = ( + "height:30mm;box-sizing:border-box;padding:25.3mm .8mm .5mm;" + "border:1px solid #111;background-color:#fff;background-repeat:no-repeat;" + "background-position:center .7mm;background-size:24mm 24mm;" + "vertical-align:middle;text-align:center;" + "font:700 5px/1 ui-monospace,SFMono-Regular,Consolas,monospace;" + "letter-spacing:.08em;color:#444" + ) + if repository_qr_enabled: + center_before_page = "\n".join( + [ + f"{fields['category']} | {card_prefix(card)}: {card.get('title', '')}", + f"PROJEKT {card.get('project', '')} · SERIA {card.get('series_title', card.get('series', ''))} · PRZEDMIOT {card.get('subject', '')}", + f"POZIOM {card.get('level', '')}", + f"DATA {card.get('revision_date', '')} · STATUS {card.get('status', '')} · KARTA {card.get('number', '')}/{card.get('count', '')} · WERSJA {card.get('version', '')}", + "STRONA ", + ] + ) + center_after_page = "\n".join( + [ + f" · AUTOR {card.get('author', '')} · UUID {card.get('uuid', '')}", + f"GITEA {source_url}", + f"HTML {url}", + ] + ) + return f""" +@page{{ + size:A4; + margin:35mm 9mm 15mm; + @top-left{{content:"GITEA";width:27mm;{qr_box};background-image:{repository_qr_background}}} + @top-center{{content:"{css_paged_string(center_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(center_after_page)}";width:138mm;height:30mm;box-sizing:border-box;padding:1.2mm 2mm;border-block:1px solid #111;white-space:pre-wrap;vertical-align:middle;text-align:left;font:600 7px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111}} + @top-right{{content:"HTML";width:27mm;{qr_box};background-image:{page_qr_background}}} +}} +""" + + return f""" +@page{{ + size:A4; + margin:35mm 9mm 15mm; + @top-left{{content:"{css_paged_string(meta_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(meta_after_page)}";width:49mm;height:30mm;box-sizing:border-box;padding:1.5mm 1.7mm;border:1px solid #111;white-space:pre-wrap;vertical-align:middle;text-align:left;font:600 5.7px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111}} + @top-center{{content:"{css_paged_string(middle)}";width:116mm;height:30mm;box-sizing:border-box;padding:1.5mm 2mm;border-block:1px solid #111;white-space:pre-wrap;vertical-align:middle;text-align:left;font:600 6.2px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111}} + @top-right{{content:"HTML";width:27mm;{qr_box};background-image:{page_qr_background}}} +}} +""" + + +def render_page_header_tex_macro(data: dict) -> list[str]: + """Define the repeated 30 mm TeX header for the classic emitter.""" + card = data["card"] + fields = title_block_fields(data) + url = str(fields["url"]) + source_url = str(fields["repository_url"]) + dual_qr = bool(fields["show_repository_qr"]) + if fields["show_qr"] and not url: + raise RuntimeError("Nie można wygenerować QR bez kanonicznego URL") + if dual_qr and not source_url: + raise RuntimeError("Nie można wygenerować QR bez adresu repozytorium") + for field_name, target in (("url", url), ("repository_url", source_url)): + if target and any(char in target for char in "{}\\\n\r%#"): + raise RuntimeError( + f"title_block.{field_name} zawiera znaki nieobsługiwane przez nagłówek TeX" + ) + page_qr = ( + rf"\qrcode[level=L,height=22mm]{{{url}}}" + if fields["show_qr"] + else r"{\tiny QR wyłączony}" + ) + source_qr = rf"\qrcode[level=L,height=22mm]{{{source_url}}}" if dual_qr else "" + title = f"{card_prefix(card)}: {card.get('title', '')}" + card_number = f"{card.get('number', '')}/{card.get('count', '')}" + primary_width = "76mm" if dual_qr else "89mm" + meta_width = "39mm" if dual_qr else "47mm" + repository_meta_line = ( + rf" {{\fontsize{{3.8}}{{4.3}}\selectfont\ttfamily GITEA: \href{{{source_url}}}{{\nolinkurl{{{source_url}}}}}}}%" + if source_url + else "" + ) + source_cell = ( + [ + r" \begin{minipage}[c][29mm][c]{24mm}\centering%", + " " + source_qr + r"\par%", + r" \vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily GITEA}%", + r" \end{minipage}%", + r" \vrule width0.35pt\hspace{1mm}%", + ] + if dual_qr + else [] + ) + return [ + r"\newcommand{\ESCPageResourceHeader}{%", + r" \begingroup%", + r" \setlength{\parskip}{0pt}%", + r" \setlength{\fboxsep}{0pt}%", + r" \setlength{\fboxrule}{0.35pt}%", + r" \noindent\fbox{%", + r" \begin{minipage}[c][29.5mm][c]{\dimexpr\textwidth-2\fboxrule\relax}%", + *source_cell, + rf" \begin{{minipage}}[c][29mm][c]{{{primary_width}}}%", + rf" {{\fontsize{{5.5}}{{6.2}}\selectfont\ttfamily\textcolor{{black!58}}{{{tex_escape(fields['category'])}}}}}\par%", + rf" {{\fontsize{{9.2}}{{10.1}}\selectfont\bfseries {tex_escape(title)}}}\par%", + r" \vspace{0.1mm}%", + rf" {{\fontsize{{5.6}}{{6.3}}\selectfont Projekt: \texttt{{{tex_escape(card.get('project', ''))}}}\quad Seria: \texttt{{{tex_escape(card.get('series_title', card.get('series', '')))}}}}}\par%", + rf" {{\fontsize{{5.6}}{{6.3}}\selectfont Przedmiot: \texttt{{{tex_escape(card.get('subject', ''))}}}}}\par%", + rf" {{\fontsize{{5.0}}{{5.7}}\selectfont Poziom: \texttt{{{tex_escape(card.get('level', ''))}}}}}\par%", + rf" {{\fontsize{{4.2}}{{4.8}}\selectfont\ttfamily HTML: \href{{{url}}}{{\nolinkurl{{{url}}}}}}}%", + r" \end{minipage}%", + r" \hfill\vrule width0.35pt\hspace{1mm}%", + rf" \begin{{minipage}}[c][29mm][c]{{{meta_width}}}%", + rf" {{\fontsize{{5.7}}{{6.5}}\selectfont Data: \texttt{{{tex_escape(card.get('revision_date', ''))}}}\hfill Status: \texttt{{{tex_escape(card.get('status', ''))}}}}}\par%", + rf" {{\fontsize{{5.7}}{{6.5}}\selectfont Karta: \texttt{{{tex_escape(card_number)}}}\hfill Wersja: \texttt{{{tex_escape(card.get('version', ''))}}}}}\par%", + rf" {{\fontsize{{5.7}}{{6.5}}\selectfont Autor: \texttt{{{tex_escape(card.get('author', ''))}}}}}\par%", + r" {\fontsize{5.7}{6.5}\selectfont Arkusz: \texttt{\thepage/\pageref{LastPage}}}\par%", + r" \vspace{0.5mm}\hrule\vspace{0.5mm}%", + rf" {{\fontsize{{4.7}}{{5.3}}\selectfont\ttfamily UUID: {tex_escape(card.get('uuid', ''))}}}\par%", + repository_meta_line, + r" \end{minipage}%", + r" \hfill\vrule width0.35pt\hspace{1mm}%", + r" \begin{minipage}[c][29mm][c]{24mm}\centering%", + " " + page_qr + r"\par%", + r" \vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily HTML}%", + r" \end{minipage}%", + r" \end{minipage}%", + r" }%", + r" \endgroup%", + r"}", + "", + ] + + def margin_code_5a(prefix: str, official: str, local: str) -> str: if prefix == "WE": return " ".join(part for part in ("WE", official) if part) @@ -2753,7 +3622,6 @@ def render_metric_header_5a_html(data: dict) -> str: second_label = fields[1].get("label", "Klasa · data") if len(fields) > 1 else "Klasa · data" return ( '
    ' - f'{render_title_block_html(data)}' '
    ' f'
    Projekt{html_escape(card["project"])}
    ' f'
    Przedmiot{html_escape(card["subject"])}
    ' @@ -3332,7 +4200,6 @@ def build_5a_pages_html(data: dict) -> list[str]: ), "".join( [ - render_running_card_header_5a_html(data), render_theory_5a_html(data), render_tech_stack_5a_html(data), render_math_5a_html(data), @@ -3400,6 +4267,7 @@ def render_running_card_header_5a_html(data: dict) -> str: def render_5a_page_html(data: dict, center: str, page: int, total: int) -> str: return ( f'
    ' + f'{render_page_header_html(data, page, total)}' '
    ' f'{render_margin_tree_panel_5a_html(data, "zawodowe")}' f'
    {center}
    ' @@ -3416,7 +4284,7 @@ def render_main_html_5a(data: dict, template: dict) -> str: css = r""" *{-webkit-print-color-adjust:exact;print-color-adjust:exact;box-sizing:border-box} body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items:center;gap:24px;padding:24px} -.page{width:1123px;height:794px;background:#fefefc;color:#1c1c1a;border:1px solid rgba(0,0,0,.1);box-shadow:0 2px 10px rgba(0,0,0,.08);padding:38px 38px 30px;display:flex;flex-direction:column;font-family:'STIX Two Text',serif;font-size:13px;line-height:1.55} +.page{width:1123px;height:794px;background:#fefefc;color:#1c1c1a;border:1px solid rgba(0,0,0,.1);box-shadow:0 2px 10px rgba(0,0,0,.08);padding:10px 38px 30px;display:flex;flex-direction:column;font-family:'STIX Two Text',serif;font-size:13px;line-height:1.55}.page>.page-resource-header{flex:none;margin-bottom:8px} .page-grid{flex:1;display:grid;grid-template-columns:112px 1fr 112px;column-gap:19px;min-height:0} .margin-panel{display:flex;flex-direction:column;border:1px solid #d8d6d0;align-self:start;overflow:hidden} .margin-panel-head{padding:5px 8px;background:#f4f3ef;border-bottom:1px solid #d8d6d0} @@ -3451,6 +4319,7 @@ body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items: .page-footer{display:flex;justify-content:space-between;border-top:1.5px solid #1c1c1a;padding-top:8px;margin-top:14px;font:400 9.5px 'JetBrains Mono',monospace;color:#8a887f;gap:16px} @media print{@page{size:A4 landscape;margin:0}body{background:#fff;padding:0;gap:0}.page{box-shadow:none!important;border:none!important;break-after:page}} """ + css += page_resource_header_css() card = data["card"] return f""" @@ -3501,7 +4370,6 @@ def build_5b_pages_html(data: dict) -> list[dict]: "kind": "portrait", "center": "".join( [ - render_running_card_header_5a_html(data), render_mission_5a_html(data), render_theory_5a_html(data), render_tech_stack_5a_html(data), @@ -3596,6 +4464,7 @@ def render_5b_point_section_numbers_html(markup: str) -> str: def render_5b_page_html(data: dict, center: str, page: int, total: int) -> str: return ( f'
    ' + f'{render_page_header_html(data, page, total)}' '
    ' f'{render_margin_tree_panel_5b_html(data, "zawodowe")}' '' @@ -3611,6 +4480,7 @@ def render_5b_page_html(data: dict, center: str, page: int, total: int) -> str: def render_5b_technical_page_html(data: dict, center: str, page: int, total: int) -> str: return ( f'
    ' + f'{render_page_header_html(data, page, total)}' '
    ' f'{render_margin_tree_panel_5b_html(data, "zawodowe")}' '' @@ -3631,10 +4501,10 @@ def render_5b_document_page_html(data: dict, page_data: dict, page: int, total: def render_main_html_5b_css(template: dict | None = None) -> str: - return r""" + css = r""" *{-webkit-print-color-adjust:exact;print-color-adjust:exact;box-sizing:border-box} body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items:center;gap:24px;padding:24px} -.page{position:relative;--page-rule-left:119px;--page-rule-width:556px;width:794px;height:1123px;background:#fefefc;color:#1c1c1a;border:1px solid rgba(0,0,0,.1);box-shadow:0 2px 10px rgba(0,0,0,.08);padding:95px 21px;display:flex;flex-direction:column;font-family:'STIX Two Text',serif;font-size:11px;line-height:1.42} +.page{position:relative;--page-rule-left:119px;--page-rule-width:556px;width:794px;height:1123px;background:#fefefc;color:#1c1c1a;border:1px solid rgba(0,0,0,.1);box-shadow:0 2px 10px rgba(0,0,0,.08);padding:20px 21px 95px;display:flex;flex-direction:column;font-family:'STIX Two Text',serif;font-size:11px;line-height:1.42}.page>.page-resource-header{flex:none;margin-bottom:8px} .page-grid{flex:1;display:grid;grid-template-columns:77px 21px minmax(0,1fr) 21px 77px;column-gap:0;min-height:0} .margin-tree{align-self:start;font:400 7.2px/1.55 'JetBrains Mono',monospace;color:#1c1c1a;white-space:nowrap;overflow:hidden;padding-top:2px} .tree-gap-left,.tree-gap-right{min-width:0}.tree-we{font-weight:600;color:#3b5a8f;margin-top:2px}.tree-we:first-child{margin-top:0}.tree-line.level-1{padding-left:5px}.tree-line.level-2{padding-left:10px;color:#4a4944} @@ -3663,6 +4533,7 @@ body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items: .page-footer{position:absolute;left:var(--page-rule-left);bottom:95px;width:var(--page-rule-width);display:flex;justify-content:space-between;border-top:0;padding-top:6px;margin-top:0;font:400 8.2px 'JetBrains Mono',monospace;color:#8a887f;gap:10px}.page-footer::before{content:"";position:absolute;left:0;right:0;top:0;border-top:1.4px solid #1c1c1a} @media print{@page{size:A4 portrait;margin:0}body{background:#fff;padding:0;gap:0}.page{box-shadow:none!important;border:none!important;break-after:page}} """ + return css + page_resource_header_css() def render_main_html_5b(data: dict, template: dict) -> str: @@ -3693,6 +4564,75 @@ def tex_5a(value: object) -> str: return tex_escape(text) +def render_page_header_5x_tex(data: dict, page: int, total: int) -> list[str]: + """Render an exact 30 mm header inside explicit 5a/5b page wrappers.""" + card = data["card"] + fields = title_block_fields(data) + url = str(fields["url"]) + source_url = str(fields["repository_url"]) + dual_qr = bool(fields["show_repository_qr"]) + if fields["show_qr"] and not url: + raise RuntimeError("Nie można wygenerować QR bez kanonicznego URL") + if dual_qr and not source_url: + raise RuntimeError("Nie można wygenerować QR bez adresu repozytorium") + for field_name, target in (("url", url), ("repository_url", source_url)): + if target and any(char in target for char in "{}\\\n\r%#"): + raise RuntimeError( + f"title_block.{field_name} zawiera znaki nieobsługiwane przez nagłówek TeX" + ) + page_qr = ( + rf"\qrcode[level=L,height=22mm]{{{url}}}" + if fields["show_qr"] + else r"{\tiny QR wyłączony}" + ) + source_qr = rf"\qrcode[level=L,height=22mm]{{{source_url}}}" if dual_qr else "" + title = f"{card_prefix(card)}: {card.get('title', '')}" + card_number = f"{card.get('number', '')}/{card.get('count', '')}" + primary_width = r"\dimexpr\linewidth-111mm\relax" if dual_qr else r"\dimexpr\linewidth-85mm\relax" + separator = r"\hspace{0.85mm}\vrule width0.3pt height26mm\hspace{0.85mm}%" + source_cell = ( + [ + r"\begin{minipage}[c][26mm][c]{24mm}\centering", + source_qr + r"\par", + r"\vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily GITEA}", + r"\end{minipage}" + separator, + ] + if dual_qr + else [] + ) + source_url_line = ( + rf"{{\fontsize{{4.2}}{{4.8}}\selectfont\ttfamily GITEA: \href{{{source_url}}}{{\nolinkurl{{{source_url}}}}}}}\par" + if source_url + else "" + ) + return [ + r"\begin{tcolorbox}[enhanced,height=30mm,valign=center,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=1.2mm,right=1.2mm,top=0.8mm,bottom=0.8mm]", + r"\setlength{\parskip}{0pt}%", + *source_cell, + rf"\begin{{minipage}}[c][26mm][c]{{{primary_width}}}", + rf"{{\fontsize{{5.2}}{{5.9}}\selectfont\ttfamily\textcolor{{ESCMuted}}{{{tex_5a(fields['category'])}}}}}\par", + rf"{{\fontsize{{8.6}}{{9.5}}\selectfont\bfseries {tex_5a(title)}}}\par", + rf"{{\fontsize{{5.8}}{{6.6}}\selectfont Projekt: \texttt{{{tex_5a(card.get('project', ''))}}}\quad Seria: \texttt{{{tex_5a(card.get('series_title', card.get('series', '')))}}}}}\par", + rf"{{\fontsize{{5.8}}{{6.6}}\selectfont Przedmiot: \texttt{{{tex_5a(card.get('subject', ''))}}}\quad Poziom: \texttt{{{tex_5a(card.get('level', ''))}}}}}\par", + source_url_line, + rf"{{\fontsize{{4.2}}{{4.8}}\selectfont\ttfamily HTML: \href{{{url}}}{{\nolinkurl{{{url}}}}}}}", + r"\end{minipage}" + separator, + r"\begin{minipage}[c][26mm][c]{55mm}", + rf"{{\fontsize{{5.5}}{{6.2}}\selectfont Data: \texttt{{{tex_5a(card.get('revision_date', ''))}}}\hfill Status: \texttt{{{tex_5a(card.get('status', ''))}}}}}\par", + rf"{{\fontsize{{5.5}}{{6.2}}\selectfont Karta: \texttt{{{tex_5a(card_number)}}}\hfill Wersja: \texttt{{{tex_5a(card.get('version', ''))}}}}}\par", + rf"{{\fontsize{{5.5}}{{6.2}}\selectfont Autor: \texttt{{{tex_5a(card.get('author', ''))}}}\hfill Arkusz: \texttt{{{page}/{total}}}}}\par", + r"\vspace{0.4mm}\hrule\vspace{0.4mm}", + rf"{{\fontsize{{4.6}}{{5.2}}\selectfont\ttfamily UUID: {tex_5a(card.get('uuid', ''))}}}", + r"\end{minipage}" + separator, + r"\begin{minipage}[c][26mm][c]{24mm}\centering", + page_qr + r"\par", + r"\vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily HTML}", + r"\end{minipage}", + r"\end{tcolorbox}", + r"\vspace{1.5mm}", + ] + + def render_preamble_5a(card: dict) -> list[str]: return [ r"\documentclass[10pt,a4paper]{article}", @@ -3781,43 +4721,11 @@ def render_margin_tree_panel_5a_tex(data: dict, column: str) -> list[str]: def render_metric_header_5a_tex(data: dict) -> list[str]: card = data["card"] - title_fields = title_block_fields(data) fields = data.get("metric_fill_fields") or [{"label": "Uczeń"}, {"label": "Klasa · data"}] first_label = fields[0].get("label", "Uczeń") if fields else "Uczeń" second_label = fields[1].get("label", "Klasa · data") if len(fields) > 1 else "Klasa · data" - - def title_cell(label: str, value: object, mono: bool = False) -> str: - content = tex_escape(str(value)) if mono else tex_5a(value) - face = r"\ttfamily" if mono else "" - return rf"{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(label.upper())}}}}}\par{{\scriptsize {face} {content}}}" - - url = str(title_fields["url"]) - safe_qr_url = re.fullmatch(r"https?://[A-Za-z0-9._~:/?&=+\-]+", url) - qr = ( - rf"\qrcode[height=15.5mm,level=L]{{{url}}}" - if title_fields["show_qr"] and safe_qr_url - else r"{\tiny\ttfamily\textcolor{ESCMuted}{QR: brak URL}}" - ) return [ - r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.6pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=0pt,right=0pt,top=0pt,bottom=0pt]", - r"\renewcommand{\arraystretch}{1.08}\scriptsize", - r"\noindent\begin{minipage}[t]{12.40cm}", - r"\begin{tabularx}{\linewidth}{|p{2.55cm}|Y|p{1.55cm}|p{1.75cm}|}", - r"\hline", - rf"{title_cell('Opracował', title_fields['prepared_by'])}\newline{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(str(title_fields['prepared_on']))}}}}} & " - rf"{{\scriptsize\bfseries {tex_5a(title_fields['category'])}}}\hfill{{\tiny\ttfamily {tex_escape(str(title_fields['standard']))}}} & " - rf"{title_cell('Rewizja', title_fields['revision'], True)} & {title_cell('Data wydania', title_fields['issued_on'], True)} \\\hline", - rf"{title_cell('Sprawdził', title_fields['checked_by'])} & " - rf"{title_cell('Tytuł', title_fields['title'])} & " - rf"{title_cell('Seria', title_fields['series'], True)} & {title_cell('Typ', title_fields['document_type'])} \\\hline", - rf"{title_cell('Zatwierdził', title_fields['approved_by'])} & " - rf"{title_cell('URL', url or '—', True)} & " - rf"{title_cell('Narzędzie', title_fields['tool'])} & {title_cell('Arkusz', title_fields['sheet'], True)} \\\hline", - r"\end{tabularx}", - r"\end{minipage}\hfill", - r"\begin{minipage}[t][15.5mm][c]{1.95cm}\centering", - qr, - r"\end{minipage}", + r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.6pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=0pt,right=0pt,top=0.7mm,bottom=0.7mm]", r"\hspace{2.0mm}{\tiny\ttfamily\textcolor{ESCMuted}{PROJEKT}}\hspace{0.8mm}{\scriptsize " + tex_5a(card["project"]) + r"}\hfill{\tiny\ttfamily\textcolor{ESCMuted}{PRZEDMIOT}}\hspace{0.8mm}{\scriptsize " + tex_5a(card["subject"]) + r"}\hspace{2.0mm}\par", rf"\hspace{{2.0mm}}{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(first_label.upper())}}}}}\hspace{{0.8mm}}\FiveAFill\hfill{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(second_label.upper())}}}}}\hspace{{0.8mm}}\FiveAFill\hspace{{2.0mm}}\par", r"\end{tcolorbox}", @@ -4340,6 +5248,7 @@ def running_card_header_5b_tex(data: dict) -> list[str]: def render_5a_page_tex(data: dict, center: list[str], page: int, total: int) -> list[str]: lines = [ + *render_page_header_5x_tex(data, page, total), r"\noindent\begin{minipage}[t]{2.85cm}", r"\vspace{0pt}", *render_margin_tree_panel_5a_tex(data, "zawodowe"), @@ -4476,7 +5385,7 @@ def render_technical_schematic_page_5b_tex(data: dict, section_number: int, page figure_path = figure.get("path", "") title = connection.get("title", "Podłączenie RP2350 ↔ XY6020L") image = ( - rf"\includegraphics[width=242mm,height=135mm,keepaspectratio]{{{tex_escape(figure_path)}}}" + rf"\includegraphics[width=242mm,height=120mm,keepaspectratio]{{{tex_escape(figure_path)}}}" if figure_path else r"\vfill" ) @@ -4484,26 +5393,27 @@ def render_technical_schematic_page_5b_tex(data: dict, section_number: int, page r"\newgeometry{left=0mm,right=0mm,top=0mm,bottom=0mm}", r"\begin{landscape}", r"\thispagestyle{empty}", - r"\vspace*{25mm}", + r"\vspace*{5mm}", + *render_page_header_5x_tex(data, page, total), *render_landscape_section_bar_5b_tex(section_number, title), r"\vspace{2mm}", - r"\noindent\begin{minipage}[t][135mm][t]{27.5mm}", + r"\noindent\begin{minipage}[t][120mm][t]{27.5mm}", r"\vspace{2mm}\hspace{2.0mm}\begin{minipage}[t]{23.0mm}", *render_landscape_margin_tree_panel_5b_tex(data, "zawodowe", "TECH"), r"\end{minipage}", r"\end{minipage}%", - r"\begin{minipage}[t][135mm][c]{242mm}", + r"\begin{minipage}[t][120mm][c]{242mm}", r"\centering", image, r"\end{minipage}%", - r"\begin{minipage}[t][135mm][t]{27.5mm}", + r"\begin{minipage}[t][120mm][t]{27.5mm}", r"\vspace{2mm}\hspace{2.0mm}\begin{minipage}[t]{23.0mm}", *render_landscape_margin_tree_panel_5b_tex(data, "ogolne", "OG"), r"\end{minipage}", r"\end{minipage}", r"\vfill", *footer_layer_5b_tex(data, page, total, "242mm", "27.5mm"), - r"\vspace*{25mm}", + r"\vspace*{5mm}", r"\end{landscape}", r"\restoregeometry", ] @@ -4659,7 +5569,6 @@ def build_5b_pages_tex(data: dict) -> list[list[str]]: { "kind": "portrait", "center": [ - *running_card_header_5b_tex(data), r"\vfill", *render_mission_5a_tex(data), r"\vfill", @@ -4756,18 +5665,19 @@ def render_5b_document_page_tex(data: dict, page_data: dict, page: int, total: i def render_5b_page_tex(data: dict, center: list[str], page: int, total: int) -> list[str]: lines = [ + *render_page_header_5x_tex(data, page, total), r"\noindent\makebox[\textwidth][l]{%", - r"\begin{minipage}[t][\dimexpr\textheight-5pt\relax][t]{2.05cm}", + r"\begin{minipage}[t][\dimexpr\textheight-35mm\relax][t]{2.05cm}", r"\vspace{0pt}", *render_margin_tree_panel_5b_tex(data, "zawodowe"), r"\end{minipage}\hspace{0.55cm}%", - r"\begin{minipage}[t][\dimexpr\textheight-5pt\relax][t]{14.70cm}", + r"\begin{minipage}[t][\dimexpr\textheight-35mm\relax][t]{14.70cm}", r"\vspace{0pt}", *center, r"\vfill", *footer_layer_5b_tex(data, page, total), r"\end{minipage}\hspace{0.55cm}%", - r"\begin{minipage}[t][\dimexpr\textheight-5pt\relax][t]{2.05cm}", + r"\begin{minipage}[t][\dimexpr\textheight-35mm\relax][t]{2.05cm}", r"\vspace{0pt}", *render_margin_tree_panel_5b_tex(data, "ogolne"), r"\end{minipage}%", @@ -5498,6 +6408,36 @@ def html_output_dir(data: dict) -> Path: return (CARD_ROOT / output_ref).parent +def tex_output_dir(data: dict) -> Path: + output_ref = data.get("generated", {}).get("tex", "tex/main.tex") or "tex/main.tex" + return (CARD_ROOT / output_ref).parent + + +def tex_asset_path(data: dict, asset_path: str) -> str: + source = resolve_web_asset_source(asset_path) + return os.path.relpath(source, tex_output_dir(data)).replace("\\", "/") + + +def render_section_assets_tex(section: dict, data: dict) -> list[str]: + lines: list[str] = [] + for asset in section.get("assets", []) or []: + path = tex_asset_path(data, str(asset.get("path", ""))) + width = float(asset.get("width", 1.0)) + caption = tex_escape(str(asset.get("caption", ""))) + label = str(asset.get("label", "")) + lines.extend( + [ + r"\begin{figure}[H]", + r" \centering", + rf" \includegraphics[width={width:.3g}\linewidth]{{\detokenize{{{path}}}}}", + rf" \caption{{{caption}}}", + rf" \label{{{label}}}", + r"\end{figure}", + ] + ) + return lines + + def prepare_html_figure_assets(data: dict) -> None: output_dir = html_output_dir(data) / "figures" output_dir.mkdir(parents=True, exist_ok=True) @@ -5511,6 +6451,8 @@ def prepare_html_figure_assets(data: dict) -> None: for graphic in graphics_paths(data.get("sections", [])): add_graphic(graphic) + for asset in section_assets(data.get("sections", [])): + add_graphic(str(asset.get("path", ""))) for figure in data.get("figures", []): add_graphic(figure.get("path", "")) pdf_links: list[str] = []