Add QR title block and content blocks

This commit is contained in:
mpabi
2026-07-15 17:07:27 +02:00
parent c66f72f73e
commit 30566840d0
8 changed files with 286 additions and 41 deletions
+11
View File
@@ -23,6 +23,12 @@ 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`.
### Efekty i kryteria
- `educational_requirements` — wymagania `WE`;
@@ -49,6 +55,11 @@ Layouty 5a/5b obsługują również:
- `code_block.blocks[].web_steps` — interaktywne kroki HTML;
- `hardware_procedure[]` — tabela `step/action/condition` w TeX i HTML.
`code_block.blocks[]` jest rzeczywistym podziałem na bloki: każdy wpis jest
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.
### Zadania
`tasks` jest słownikiem zadań, a `tasks_order` określa ich kolejność. Każde
+16
View File
@@ -26,6 +26,22 @@
"html_css": "build/html/style.css"
},
"template": "templates/karta-5b.json",
"title_block": {
"category": "KARTA PRACY · INFORMATYKA",
"standard": "ISO 7200",
"prepared_by": "Zespół edukacyjny",
"prepared_on": "15.07.2026",
"checked_by": "—",
"approved_by": "—",
"url": "https://demo.example.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
},
"front_page_break": false,
"metric_fill_fields": [
{
+1
View File
@@ -1 +1,2 @@
jsonschema>=4,<5
reportlab>=3.6,<5
+28
View File
@@ -32,6 +32,9 @@
"type": "string",
"minLength": 1
},
"title_block": {
"$ref": "#/$defs/titleBlock"
},
"dictionary_refs": {
"type": "object",
"properties": {
@@ -225,6 +228,31 @@
},
"additionalProperties": false
},
"titleBlock": {
"type": "object",
"description": "Tekstowa tabliczka w nagłówku: metadane dokumentu oraz opcjonalny adres kodowany w QR.",
"properties": {
"category": { "type": "string" },
"standard": { "type": "string" },
"prepared_by": { "type": "string" },
"prepared_on": { "type": "string" },
"checked_by": { "type": "string" },
"approved_by": { "type": "string" },
"title": { "type": "string" },
"url": { "type": "string" },
"url_host_uuid": { "type": "string" },
"url_port": { "type": ["string", "integer"] },
"doc_uuid": { "type": "string" },
"revision": { "type": "string" },
"issued_on": { "type": "string" },
"series": { "type": "string" },
"document_type": { "type": "string" },
"tool": { "type": "string" },
"sheet": { "type": "string" },
"show_qr": { "type": "boolean" }
},
"additionalProperties": false
},
"sideMarginLayout": {
"type": "object",
"required": ["columns"],
+7 -1
View File
@@ -81,12 +81,18 @@ def check_render() -> None:
tex = tex_path.read_text(encoding="utf-8")
html = html_path.read_text(encoding="utf-8")
for marker in ("DEMO-01", "IP WO", "INF04"):
for marker in ("DEMO-01", "IP WO", "INF04", "KARTA PRACY"):
if marker not in tex:
raise AssertionError(f"TeX nie zawiera znacznika {marker!r}")
if marker not in html:
raise AssertionError(f"HTML nie zawiera znacznika {marker!r}")
if r"\qrcode[height=" not in tex:
raise AssertionError("TeX nie zawiera kodu QR z tabliczki")
for marker in ('class="title-qr"', 'class="code-subblock"'):
if marker not in html:
raise AssertionError(f"HTML nie zawiera znacznika {marker!r}")
def main() -> int:
check_json_files()
+7 -3
View File
@@ -3,8 +3,8 @@
"schema": "esc-card-template.v1",
"id": "karta-5a",
"name": "Karta 5a",
"version": "1.0",
"description": "Wielostronicowy layout A4 landscape wyekstrahowany z referencyjnego pliku series/esc/html/ESC-04 karta.html; pierwsze dwie strony zachowują konwencję wzorca, dalsze strony dokładają pełną treść karty.",
"version": "1.1",
"description": "Wielostronicowy layout A4 landscape wyekstrahowany z referencyjnego pliku series/esc/html/ESC-04 karta.html; pierwsze dwie strony zachowują konwencję wzorca, dalsze strony dokładają pełną treść karty. Wersja 1.1 dodaje tekstową tabliczkę ISO 7200 i odrębne ramki bloków kodu.",
"tokens": {
"page": {
"format": "a4",
@@ -61,13 +61,17 @@
},
"metric_header": {
"border": "1.5px solid #1c1c1a",
"title_bar": true,
"title_block": "ISO 7200: odpowiedzialni, tytuł, URL+QR, rewizja, data, seria, typ, narzędzie, arkusz",
"grid": "2x2",
"fill_fields": [
"Uczeń",
"Klasa · data"
]
},
"code_content_block": {
"border": "1px solid #d8d6d0; border-left: 3px solid #3b5a8f",
"source": "code_block.blocks[]"
},
"margin_tree_panel": {
"left": "TECH — zawodowe; WE → EK → KW",
"right": "OG — ogólne; WE → EN → KW",
+13 -9
View File
@@ -3,9 +3,9 @@
"schema": "esc-card-template.v1",
"id": "karta-5b",
"name": "Karta 5b",
"version": "1.3",
"approved": "04.07.2026 — layout referencyjny zatwierdzony przez prowadzącego (symetria 0,55/2,05/0,55/14,70; gołe drzewka); v1.1 — nagłówki punktowe zamiast §, 05.07.2026; v1.2 — linie nagłówka/stopki jako warstwa strony ograniczona do bloku treści, 06.07.2026; v1.3 — margines gorny/dolny 2,5 cm, 06.07.2026",
"description": "Wielostronicowy layout A4 portrait: charakter 5a z ramkami sekcji, ale z golymi drzewkami WE->EN/EK jako marginalia bez paneli i bez luznych KW. Linie strony sa warstwa overlay i nie wychodza pod drzewka. Blok tresci zaczyna sie 2,5 cm od gornej krawedzi i konczy 2,5 cm od dolnej.",
"version": "1.4",
"approved": "04.07.2026 — layout referencyjny zatwierdzony przez prowadzącego (symetria 0,55/2,05/0,55/14,70; gołe drzewka); v1.1 — nagłówki punktowe zamiast §, 05.07.2026; v1.2 — linie nagłówka/stopki jako warstwa strony ograniczona do bloku treści, 06.07.2026; v1.3 — margines gorny/dolny 2,5 cm, 06.07.2026; v1.4 — tekstowa tabliczka ISO 7200 + QR oraz ramki bloków kodu, 15.07.2026",
"description": "Wielostronicowy layout A4 portrait: charakter 5a z ramkami sekcji, ale z golymi drzewkami WE->EN/EK jako marginalia bez paneli i bez luznych KW. Linie strony sa warstwa overlay i nie wychodza pod drzewka. Blok tresci zaczyna sie 2,5 cm od gornej krawedzi i konczy 2,5 cm od dolnej. Wersja 1.4 ma tabliczkę dokumentu w nagłówku.",
"tokens": {
"page": {
"format": "a4",
@@ -89,13 +89,17 @@
},
"metric_header": {
"border": "1.5px solid #1c1c1a",
"title_bar": true,
"title_block": "ISO 7200: odpowiedzialni, tytuł, URL+QR, rewizja, data, seria, typ, narzędzie, arkusz",
"grid": "2x2",
"fill_fields": [
"Uczen",
"Klasa · data"
]
},
"code_content_block": {
"border": "1px solid #d8d6d0; border-left: 3px solid #3b5a8f",
"source": "code_block.blocks[]"
},
"margin_tree_panel": {
"left": "TECH jako gole drzewko tekstowe WE->EK/EN bez KW, bez ramki, tla, naglowka i stopki",
"right": "OG jako gole drzewko tekstowe WE->EK/EN bez KW, bez ramki, tla, naglowka i stopki",
@@ -144,15 +148,15 @@
"block_map": {
"page_1": [
"metric_header",
"mission",
"objectives",
"agenda",
"hw_gate",
"theory_refs",
"tech_stack"
"agenda"
],
"page_2": [
"running_card_header",
"mission",
"theory_refs",
"tech_stack",
"hw_gate",
"math_block",
"part_a_banner",
"model_block",
+203 -28
View File
@@ -2455,6 +2455,103 @@ def card_prefix(card: dict) -> str:
return f"{series}-{number}" if number else series
def publication_url(data: dict) -> str:
"""Return the canonical publication URL used by the title block and QR."""
title_block = data.get("title_block") or {}
card = data.get("card") or {}
for value in (
title_block.get("url"),
data.get("publication_url"),
card.get("publication_url"),
card.get("url"),
):
if value:
return str(value).strip()
host = title_block.get("url_host_uuid") or data.get("url_host_uuid")
port = title_block.get("url_port") or data.get("url_port")
document = title_block.get("doc_uuid") or data.get("doc_uuid") or card.get("uuid")
if host and document:
authority = str(host).strip()
if port:
authority += f":{str(port).strip()}"
return f"https://{authority}/{str(document).strip()}"
return ""
def title_block_fields(data: dict) -> dict[str, str | bool]:
"""Resolve the ISO-7200-like header while keeping card metadata canonical."""
card = data["card"]
configured = data.get("title_block") or {}
url = publication_url(data)
def value(name: str, default: object = "") -> str:
current = configured.get(name, default)
return str(current).strip() if current is not None else ""
revision_date = str(card.get("revision_date", "")).strip()
return {
"category": value("category", f"KARTA PRACY · {card.get('subject', '')}").upper(),
"standard": value("standard", "ISO 7200"),
"prepared_by": value("prepared_by", card.get("author", "")) or "",
"prepared_on": value("prepared_on", revision_date),
"checked_by": value("checked_by", "") or "",
"approved_by": value("approved_by", "") or "",
"title": value("title", card.get("title", "")),
"url": url,
"revision": value("revision", card.get("version", "")),
"issued_on": value("issued_on", revision_date),
"series": value("series", card_prefix(card)),
"document_type": value("document_type", "karta pracy"),
"tool": value("tool", "card-layouts"),
"sheet": value("sheet", "1 / 1"),
"show_qr": bool(configured.get("show_qr", bool(url))),
}
@lru_cache(maxsize=32)
def qr_svg(value: str) -> str:
"""Generate a self-contained QR SVG without a network dependency.
ReportLab ships a standards-compliant QR encoder on the supported
workstation image. The TeX renderer uses its native qrcode package;
HTML receives the same payload as inline SVG so the generated card is
fully portable.
"""
if not value:
return ""
try:
from reportlab.graphics.barcode.qr import QrCodeWidget
except ImportError:
return ""
widget = QrCodeWidget(value=value, barLevel="L")
widget.qr.make()
modules = widget.qr.modules
size = len(modules)
border = 4
path_parts: list[str] = []
for row_index, row in enumerate(modules):
column = 0
while column < size:
if not row[column]:
column += 1
continue
start = column
while column < size and row[column]:
column += 1
length = column - start
path_parts.append(f"M{start + border} {row_index + border}h{length}v1h-{length}z")
full_size = size + border * 2
return (
f'<svg class="title-qr" viewBox="0 0 {full_size} {full_size}" role="img" '
f'aria-label="Kod QR: {html_escape(value)}" shape-rendering="crispEdges">'
'<rect width="100%" height="100%" fill="#fefefc"/>'
f'<path d="{" ".join(path_parts)}" fill="#1c1c1a"/>'
"</svg>"
)
def margin_code_5a(prefix: str, official: str, local: str) -> str:
if prefix == "WE":
return " ".join(part for part in ("WE", official) if part)
@@ -2570,6 +2667,53 @@ def render_5a_section_header(number: int, title: str) -> str:
)
def render_title_block_html(data: dict) -> str:
fields = title_block_fields(data)
def cell(label: str, value: object, extra_class: str = "") -> str:
classes = "title-cell" + (f" {extra_class}" if extra_class else "")
shown = html_escape(str(value)) if value else ""
return f'<div class="{classes}"><span>{html_escape(label)}</span><strong>{shown}</strong></div>'
qr = qr_svg(str(fields["url"])) if fields["show_qr"] else ""
qr_cell = (
f'<a class="title-qr-cell" href="{html_escape(str(fields["url"]))}" '
f'aria-label="Otwórz adres z kodu QR">{qr}</a>'
if qr
else '<div class="title-qr-cell title-qr-empty"><span>QR</span><strong>'
+ ("brak modułu QR" if fields["url"] else "brak URL")
+ "</strong></div>"
)
prepared = (
'<div class="title-cell signer"><span>Opracował</span>'
f'<strong>{html_escape(str(fields["prepared_by"]))}</strong>'
f'<em>{html_escape(str(fields["prepared_on"]))}</em></div>'
)
return (
'<section class="title-block" aria-label="Tabliczka dokumentu">'
'<div class="title-signers">'
f'{prepared}'
f'{cell("Sprawdził", fields["checked_by"], "signer")}'
f'{cell("Zatwierdził", fields["approved_by"], "signer")}'
'</div>'
'<div class="title-document">'
f'<div class="title-category"><strong>{html_escape(str(fields["category"]))}</strong><span>{html_escape(str(fields["standard"]))}</span></div>'
f'<div class="title-name"><span>Tytuł</span><strong>{html_escape(str(fields["title"]))}</strong></div>'
f'<div class="title-url"><span>URL</span><code>{html_escape(str(fields["url"])) if fields["url"] else ""}</code></div>'
'</div>'
'<div class="title-meta">'
f'{cell("Rewizja", fields["revision"])}'
f'{cell("Data wydania", fields["issued_on"])}'
f'{cell("Seria", fields["series"])}'
f'{cell("Typ", fields["document_type"])}'
f'{cell("Narzędzie", fields["tool"])}'
f'{cell("Arkusz", fields["sheet"])}'
'</div>'
f'{qr_cell}'
'</section>'
)
def render_metric_header_5a_html(data: dict) -> str:
card = data["card"]
fields = data.get("metric_fill_fields") or [
@@ -2580,10 +2724,7 @@ 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 (
'<div class="metric-box">'
'<div class="metric-title">'
f'<div>{html_escape(card_title_line_5a(card))}</div>'
f'<span>KARTA {html_escape(card["number"])}/{html_escape(card["count"])}</span>'
'</div>'
f'{render_title_block_html(data)}'
'<div class="metric-grid">'
f'<div><span>Projekt</span><strong>{html_escape(card["project"])}</strong></div>'
f'<div><span>Przedmiot</span><strong>{html_escape(card["subject"])}</strong></div>'
@@ -2784,12 +2925,12 @@ def render_code_subblock_5a_html(block: dict) -> str:
if block.get("pass_condition"):
pass_line = f'<p class="pass-line">PASS: {html_escape(text_5a(block.get("pass_condition", "")))}</p>'
return (
'<div class="code-subblock">'
f'<article class="code-subblock" data-block="{html_escape(slugify_web(block.get("slug") or block.get("title")))}">'
f'<h3>{html_escape(text_5a(block.get("title", "")))}</h3>'
f'<p>{html_escape(text_5a(block.get("context", "")))}</p>'
f'{render_code_columns_5a_html(block)}'
f'{pass_line}'
'</div>'
'</article>'
)
@@ -3259,7 +3400,7 @@ body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items:
.kw-common{border-top:1px solid #e4e2dc;margin-top:2px;padding-top:3px;font:400 7px/1.6 'JetBrains Mono',monospace;color:#8a887f}.kw-common span{color:#1c1c1a}
.margin-panel-foot{padding:4px 8px;border-top:1px solid #d8d6d0;font:400 7px 'JetBrains Mono',monospace;color:#8a887f}
.page-main{display:flex;flex-direction:column;min-height:0}
.metric-box{border:1.5px solid #1c1c1a}.metric-title{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:9px 14px;border-bottom:1px solid #1c1c1a}.metric-title div{font-size:15px;font-weight:700}.metric-title span{font:500 9.5px 'JetBrains Mono',monospace;color:#4a4944;white-space:nowrap}
.metric-box{border:1.5px solid #1c1c1a}.title-block{display:grid;grid-template-columns:1.08fr 2.55fr 1.25fr 66px;min-height:96px;border-bottom:1px solid #1c1c1a}.title-signers,.title-document{display:grid;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-signers{border-right:1px solid #1c1c1a}.title-document{border-right:1px solid #1c1c1a}.title-meta{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-cell,.title-name,.title-url{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:3px 6px;border-bottom:1px solid #d8d6d0}.title-signers .title-cell:last-child,.title-document>div:last-child,.title-meta .title-cell:nth-last-child(-n+2){border-bottom:0}.title-meta .title-cell:nth-child(odd){border-right:1px solid #d8d6d0}.title-cell span,.title-name span,.title-url span{font:600 6.7px/1.15 'JetBrains Mono',monospace;letter-spacing:.08em;text-transform:uppercase;color:#8a887f}.title-cell strong,.title-name strong{font-size:8.3px;line-height:1.13;font-weight:500;overflow-wrap:anywhere}.title-cell em{font:400 6.4px/1.1 'JetBrains Mono',monospace;color:#8a887f;font-style:normal}.title-category{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:2px 6px;background:#3b5a8f;color:#fefefc;font-size:7.1px;letter-spacing:.06em;overflow:hidden}.title-category strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.title-category span{font:400 6.1px 'JetBrains Mono',monospace;white-space:nowrap;color:#fefefc}.title-name strong{font-size:9.2px;font-weight:700}.title-url code{font:400 6.4px/1.15 'JetBrains Mono',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#4a4944}.title-qr-cell{display:flex;align-items:center;justify-content:center;padding:5px;border-left:1px solid #1c1c1a;background:#fefefc}.title-qr{width:100%;height:100%;display:block}.title-qr-empty{flex-direction:column;text-align:center;color:#8a887f}.title-qr-empty span{font:600 7px 'JetBrains Mono',monospace}.title-qr-empty strong{font:400 6px 'JetBrains Mono',monospace}.metric-title{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:9px 14px;border-bottom:1px solid #1c1c1a}.metric-title div{font-size:15px;font-weight:700}.metric-title span{font:500 9.5px 'JetBrains Mono',monospace;color:#4a4944;white-space:nowrap}
.metric-grid{display:grid;grid-template-columns:1fr 1fr;font-size:11px}.metric-grid div{display:flex;gap:8px;padding:4px 14px;border-bottom:1px solid #d8d6d0}.metric-grid div:nth-child(odd){border-right:1px solid #d8d6d0}.metric-grid div:nth-last-child(-n+2){border-bottom:0}.metric-grid span{font:500 8.5px/1.9 'JetBrains Mono',monospace;color:#8a887f;text-transform:uppercase;flex:none}.metric-grid strong{font-weight:400}.metric-grid i{flex:1;border-bottom:1px dotted #9a988f}
.plain-box{border:1px solid #1c1c1a;padding:8px 14px;margin-top:11px}.plain-title{font-weight:700;font-size:11.5px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:2px}.plain-box p{margin:0;text-align:justify}
.section-box{border:1px solid #1c1c1a;margin-top:10px}.section-box:first-child{margin-top:0}.section-box.dense{margin-top:8px}.section-head{display:flex;align-items:baseline;gap:8px;padding:5px 14px;background:#f4f3ef;border-bottom:1px solid #1c1c1a}.section-head span{font:600 9px 'JetBrains Mono',monospace;color:#8a887f}.section-head strong{font-weight:700;font-size:11.5px;letter-spacing:.12em;text-transform:uppercase}.section-body{padding:7px 14px}.dense .section-body{padding:6px 12px}
@@ -3271,7 +3412,7 @@ body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items:
.theory-table{width:100%;border-collapse:collapse;font-size:11px}.theory-table th{text-align:left;padding:3px 10px;border:1px solid #1c1c1a;background:#f4f3ef}.theory-table th:first-child{width:26%}.theory-table td{padding:3px 10px;vertical-align:top;border:1px solid #d8d6d0}.theory-table code{font:400 10px 'JetBrains Mono',monospace}
.tech-stack{display:grid;grid-template-columns:1fr 1fr;column-gap:18px;align-items:start}.stack-column h3{font:600 8.5px 'JetBrains Mono',monospace;letter-spacing:.08em;color:#4a4944;text-transform:uppercase;padding-bottom:3px;border-bottom:1px solid #1c1c1a;margin:0}.stack-grid{display:grid;grid-template-columns:88px 1fr;font-size:10px;line-height:1.35}.stack-grid div{padding:3px 0;border-bottom:1px solid #e4e2dc}.stack-grid div:nth-last-child(-n+2){border-bottom:0}.stack-grid code{font:400 9.5px/1.45 'JetBrains Mono',monospace}
.part-banner{border:1.5px solid #1c1c1a;background:#f4f3ef;padding:7px 14px;display:grid;grid-template-columns:190px 1fr;column-gap:12px;align-items:baseline}.part-banner strong{font:700 12px 'JetBrains Mono',monospace;letter-spacing:.08em}.part-banner span{font-size:12px;text-align:justify}
.compact-list{margin:3px 0 0 15px;padding:0;font-size:10.5px;line-height:1.32}.compact-list li{margin:1px 0}.three-col{display:grid;grid-template-columns:1fr 1fr 1fr;column-gap:12px}.three-col h3,.model-grid h3{font:600 8.5px 'JetBrains Mono',monospace;text-transform:uppercase;color:#4a4944;margin:0 0 3px}.three-col p,.model-grid p{margin:0 0 4px;font-size:10.5px}.pass-line{margin:5px 0 0;padding-top:4px;border-top:1px solid #e4e2dc;font-size:10.5px}
.compact-list{margin:3px 0 0 15px;padding:0;font-size:10.5px;line-height:1.32}.compact-list li{margin:1px 0}.code-subblock{border:1px solid #d8d6d0;border-left:3px solid #3b5a8f;padding:5px 7px;margin-top:5px}.code-subblock:first-child{margin-top:0}.code-subblock h3{margin:0 0 2px}.code-subblock>p{margin:0 0 4px}.three-col{display:grid;grid-template-columns:1fr 1fr 1fr;column-gap:12px}.three-col h3,.model-grid h3{font:600 8.5px 'JetBrains Mono',monospace;text-transform:uppercase;color:#4a4944;margin:0 0 3px}.three-col p,.model-grid p{margin:0 0 4px;font-size:10.5px}.pass-line{margin:5px 0 0;padding-top:4px;border-top:1px solid #e4e2dc;font-size:10.5px}
.math-task{display:grid;grid-template-columns:1fr 190px;column-gap:12px;align-items:start;border-bottom:1px solid #f0efe9;padding:3px 0;font-size:10.5px}.math-task:last-child{border-bottom:0}.math-task code{font:400 9px 'JetBrains Mono',monospace;color:#3b5a8f}.math-task em{grid-column:1 / -1;color:#8a887f;font-size:9.5px}.answer-line{display:block;border-bottom:1px dotted #9a988f;height:12px;margin-top:2px}
.model-grid{display:grid;grid-template-columns:1.5fr 1fr;column-gap:16px}.compact-table{width:100%;border-collapse:collapse;font-size:10px;line-height:1.25}.compact-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:3px 6px;text-align:left}.compact-table td{border:1px solid #d8d6d0;padding:3px 6px;vertical-align:top}.compact-table code{font:400 9px 'JetBrains Mono',monospace}.constants td:first-child{width:68%}.check-table td:first-child,.procedure-table td:first-child,.git-gate td:first-child{width:24px;text-align:center}.check-table td:nth-child(2){width:95px}
.command-list{margin:0 0 0 22px;padding:0;font-size:10.5px;line-height:1.45}.command-list code{font:400 9px 'JetBrains Mono',monospace}.safety-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.safety-card{border:1px solid #d8d6d0;padding:5px 7px}.safety-card strong{display:block;font:600 9px 'JetBrains Mono',monospace;color:#3b5a8f;margin-bottom:2px}.safety-card span{font-size:10.5px;line-height:1.32;text-align:justify}
@@ -3322,11 +3463,8 @@ def build_5b_pages_html(data: dict) -> list[dict]:
"center": "".join(
[
render_metric_header_5a_html(data),
render_mission_5a_html(data),
render_objectives_5a_html(data),
render_agenda_5a_html(data),
render_hw_gate_5a_html(data),
render_tech_stack_5a_html(data),
]
),
},
@@ -3335,7 +3473,10 @@ def build_5b_pages_html(data: dict) -> list[dict]:
"center": "".join(
[
render_running_card_header_5a_html(data),
render_mission_5a_html(data),
render_theory_5a_html(data),
render_tech_stack_5a_html(data),
render_hw_gate_5a_html(data),
render_math_5a_html(data),
]
),
@@ -3470,7 +3611,7 @@ body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items:
.tree-gap-left,.tree-gap-right{min-width:0}.tree-we{font-weight:600;color:#3b5a8f;margin-top:2px}.tree-we:first-child{margin-top:0}.tree-line.level-1{padding-left:5px}.tree-line.level-2{padding-left:10px;color:#4a4944}
.page-main{display:flex;flex-direction:column;min-height:0;justify-content:space-between}
.page.page-technical{--page-rule-left:104px;--page-rule-width:915px;width:1123px;height:794px}.page-technical .technical-page-grid{grid-template-columns:77px 21px minmax(0,1fr) 21px 77px}.technical-main{display:flex;min-height:0}.technical-section{flex:1;display:flex;flex-direction:column;margin-top:0}.technical-section .section-body{flex:1;min-height:0;padding:6px 8px}.technical-sheet-figure{height:100%;min-height:0;margin:0;display:flex;flex-direction:column;gap:4px}.technical-sheet-link{flex:1;min-height:0;display:flex;align-items:center;justify-content:center}.technical-sheet-figure img{width:100%;height:100%;object-fit:contain;border:1px solid #d8d6d0;background:white}.technical-sheet-figure figcaption{display:flex;justify-content:space-between;align-items:baseline;gap:10px;font-size:7.2px;line-height:1.18;color:#4a4944}.technical-sheet-figure figcaption span{min-width:0}.technical-sheet-missing{flex:1;display:flex;align-items:center;justify-content:center;border:1px solid #d8d6d0;color:#8a887f;font:600 8px 'JetBrains Mono',monospace;text-transform:uppercase}
.metric-box{border:1.4px solid #1c1c1a}.metric-title{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:7px 10px;border-bottom:1px solid #1c1c1a}.metric-title div{font-size:12.5px;font-weight:700}.metric-title span{font:500 8px 'JetBrains Mono',monospace;color:#4a4944;white-space:nowrap}
.metric-box{border:1.4px solid #1c1c1a}.title-block{display:grid;grid-template-columns:1.0fr 2.35fr 1.24fr 52px;min-height:82px;border-bottom:1px solid #1c1c1a}.title-signers,.title-document{display:grid;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-signers{border-right:1px solid #1c1c1a}.title-document{border-right:1px solid #1c1c1a}.title-meta{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:repeat(3,minmax(0,1fr));min-width:0}.title-cell,.title-name,.title-url{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:2px 4px;border-bottom:1px solid #d8d6d0}.title-signers .title-cell:last-child,.title-document>div:last-child,.title-meta .title-cell:nth-last-child(-n+2){border-bottom:0}.title-meta .title-cell:nth-child(odd){border-right:1px solid #d8d6d0}.title-cell span,.title-name span,.title-url span{font:600 5.4px/1.12 'JetBrains Mono',monospace;letter-spacing:.07em;text-transform:uppercase;color:#8a887f}.title-cell strong,.title-name strong{font-size:6.9px;line-height:1.1;font-weight:500;overflow-wrap:anywhere}.title-cell em{font:400 5.2px/1.05 'JetBrains Mono',monospace;color:#8a887f;font-style:normal}.title-category{display:flex;align-items:center;justify-content:space-between;gap:4px;padding:2px 4px;background:#3b5a8f;color:#fefefc;font-size:5.9px;letter-spacing:.04em;overflow:hidden}.title-category strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.title-category span{font:400 5.1px 'JetBrains Mono',monospace;white-space:nowrap;color:#fefefc}.title-name strong{font-size:7.6px;font-weight:700}.title-url code{font:400 5.4px/1.12 'JetBrains Mono',monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#4a4944}.title-qr-cell{display:flex;align-items:center;justify-content:center;padding:4px;border-left:1px solid #1c1c1a;background:#fefefc}.title-qr{width:100%;height:100%;display:block}.title-qr-empty{flex-direction:column;text-align:center;color:#8a887f}.title-qr-empty span{font:600 6px 'JetBrains Mono',monospace}.title-qr-empty strong{font:400 5px 'JetBrains Mono',monospace}.metric-title{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:7px 10px;border-bottom:1px solid #1c1c1a}.metric-title div{font-size:12.5px;font-weight:700}.metric-title span{font:500 8px 'JetBrains Mono',monospace;color:#4a4944;white-space:nowrap}
.metric-grid{display:grid;grid-template-columns:1fr 1fr;font-size:9.4px}.metric-grid div{display:flex;gap:6px;padding:3px 10px;border-bottom:1px solid #d8d6d0}.metric-grid div:nth-child(odd){border-right:1px solid #d8d6d0}.metric-grid div:nth-last-child(-n+2){border-bottom:0}.metric-grid span{font:500 7.2px/1.7 'JetBrains Mono',monospace;color:#8a887f;text-transform:uppercase;flex:none}.metric-grid strong{font-weight:400}.metric-grid i{flex:1;border-bottom:1px dotted #9a988f}
.plain-box{border:1px solid #1c1c1a;padding:6px 10px;margin-top:7px}.plain-title{font-weight:700;font-size:9.8px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:1px}.plain-box p{margin:0;text-align:justify}
.section-box{border:1px solid #1c1c1a;margin-top:7px}.section-box:first-child{margin-top:0}.section-box.dense{margin-top:6px}.section-head{display:flex;align-items:baseline;gap:6px;padding:4px 10px;background:#f4f3ef;border-bottom:1px solid #1c1c1a}.section-head span{font:600 7.8px 'JetBrains Mono',monospace;color:#8a887f}.section-head strong{font-weight:700;font-size:9.6px;letter-spacing:.12em;text-transform:uppercase}.section-body{padding:6px 10px}.dense .section-body{padding:5px 9px}
@@ -3482,7 +3623,7 @@ body{margin:0;background:#eceae4;display:flex;flex-direction:column;align-items:
.theory-table{width:100%;border-collapse:collapse;font-size:9.4px}.theory-table th{text-align:left;padding:2px 7px;border:1px solid #1c1c1a;background:#f4f3ef}.theory-table th:first-child{width:31%}.theory-table td{padding:2px 7px;vertical-align:top;border:1px solid #d8d6d0}.theory-table code{font:400 8px 'JetBrains Mono',monospace}
.tech-stack{display:grid;grid-template-columns:1fr 1fr;column-gap:10px;align-items:start}.stack-column h3{font:600 7.2px 'JetBrains Mono',monospace;letter-spacing:.08em;color:#4a4944;text-transform:uppercase;padding-bottom:2px;border-bottom:1px solid #1c1c1a;margin:0}.stack-grid{display:grid;grid-template-columns:70px 1fr;font-size:8.6px;line-height:1.25}.stack-grid div{padding:2px 0;border-bottom:1px solid #e4e2dc}.stack-grid div:nth-last-child(-n+2){border-bottom:0}.stack-grid code{font:400 7.7px/1.35 'JetBrains Mono',monospace}
.part-banner{border:1.4px solid #1c1c1a;background:#f4f3ef;padding:5px 10px;display:grid;grid-template-columns:126px 1fr;column-gap:9px;align-items:baseline}.part-banner strong{font:700 9px 'JetBrains Mono',monospace;letter-spacing:.08em}.part-banner span{font-size:9.4px;text-align:justify}
.compact-list{margin:2px 0 0 13px;padding:0;font-size:8.7px;line-height:1.25}.compact-list li{margin:1px 0}.three-col,.connection-notes{display:grid;grid-template-columns:1fr 1fr 1fr;column-gap:8px}.connection-notes{grid-template-columns:1fr 1fr;margin-top:4px}.three-col h3,.model-grid h3,.connection-notes h3{font:600 7.2px 'JetBrains Mono',monospace;text-transform:uppercase;color:#4a4944;margin:0 0 2px}.three-col p,.model-grid p{margin:0 0 3px;font-size:8.8px}.pass-line{margin:4px 0 0;padding-top:3px;border-top:1px solid #e4e2dc;font-size:8.6px}
.compact-list{margin:2px 0 0 13px;padding:0;font-size:8.7px;line-height:1.25}.compact-list li{margin:1px 0}.code-subblock{border:1px solid #d8d6d0;border-left:3px solid #3b5a8f;padding:4px 5px;margin-top:4px}.code-subblock:first-child{margin-top:0}.code-subblock h3{margin:0 0 2px}.code-subblock>p{margin:0 0 3px}.three-col,.connection-notes{display:grid;grid-template-columns:1fr 1fr 1fr;column-gap:8px}.connection-notes{grid-template-columns:1fr 1fr;margin-top:4px}.three-col h3,.model-grid h3,.connection-notes h3{font:600 7.2px 'JetBrains Mono',monospace;text-transform:uppercase;color:#4a4944;margin:0 0 2px}.three-col p,.model-grid p{margin:0 0 3px;font-size:8.8px}.pass-line{margin:4px 0 0;padding-top:3px;border-top:1px solid #e4e2dc;font-size:8.6px}
.math-task{display:grid;grid-template-columns:1fr 126px;column-gap:8px;align-items:start;border-bottom:1px solid #f0efe9;padding:3px 0;font-size:9px}.math-task:last-child{border-bottom:0}.math-task code{font:400 7.5px 'JetBrains Mono',monospace;color:#3b5a8f}.math-task em{grid-column:1 / -1;color:#8a887f;font-size:7.8px}.answer-line{display:block;border-bottom:1px dotted #9a988f;height:12px;margin-top:2px}
.model-grid{display:grid;grid-template-columns:1.25fr 1fr;column-gap:10px}.compact-table{width:100%;border-collapse:collapse;font-size:8.4px;line-height:1.18}.compact-table th{background:#f4f3ef;border:1px solid #1c1c1a;padding:2px 4px;text-align:left}.compact-table td{border:1px solid #d8d6d0;padding:2px 4px;vertical-align:top}.compact-table code{font:400 7.5px 'JetBrains Mono',monospace}.constants td:first-child{width:66%}.check-table td:first-child,.procedure-table td:first-child,.git-gate td:first-child{width:20px;text-align:center}.check-table td:nth-child(2){width:70px}.procedure-table td{height:25px}
.command-list{margin:0 0 0 18px;padding:0;font-size:8.8px;line-height:1.32}.command-list code{font:400 7.5px 'JetBrains Mono',monospace}.safety-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}.safety-card{border:1px solid #d8d6d0;padding:4px 5px}.safety-card strong{display:block;font:600 7.5px 'JetBrains Mono',monospace;color:#3b5a8f;margin-bottom:1px}.safety-card span{font-size:8.4px;line-height:1.22;text-align:justify}
@@ -3531,6 +3672,7 @@ def render_preamble_5a(card: dict) -> list[str]:
r"\usepackage[polish]{babel}",
r"\usepackage{array}",
r"\usepackage{tabularx}",
r"\usepackage{multirow}",
r"\usepackage{xcolor}",
r"\usepackage{amssymb}",
r"\usepackage{graphicx}",
@@ -3539,6 +3681,7 @@ def render_preamble_5a(card: dict) -> list[str]:
r"\IfFileExists{stix2.sty}{\usepackage{stix2}}{\usepackage{lmodern}}",
r"\IfFileExists{inconsolata.sty}{\usepackage{inconsolata}}{\usepackage{lmodern}}",
r"\usepackage[most]{tcolorbox}",
r"\usepackage{qrcode}",
r"\usepackage{hyperref}",
r"\hypersetup{hidelinks}",
r"\IfFileExists{build-meta.tex}{\input{build-meta.tex}}{\newcommand{\BuildCommit}{local}}",
@@ -3567,6 +3710,7 @@ def render_preamble_5a(card: dict) -> list[str]:
r"\newcommand{\FiveAWriteLine}{\par\noindent\dotfill}",
r"\newtcolorbox{FiveASection}[2]{enhanced,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=2.8mm,right=2.8mm,top=1.2mm,bottom=1.2mm,colbacktitle=ESCHeader,coltitle=ESCInk,fonttitle=\bfseries\footnotesize,title={\ttfamily\textcolor{ESCMuted}{\S#1}\quad #2}}",
r"\newtcolorbox{FiveAPart}{enhanced,arc=0pt,boxrule=0.6pt,colback=ESCHeader,colframe=ESCInk,boxsep=0pt,left=2.8mm,right=2.8mm,top=1.2mm,bottom=1.2mm}",
r"\newtcolorbox{CardContentBlock}{enhanced,arc=0pt,boxrule=0.28pt,colback=ESCPaper,colframe=ESCLine,boxsep=0pt,left=1.4mm,right=1.4mm,top=0.8mm,bottom=0.8mm}",
"",
]
@@ -3608,18 +3752,45 @@ 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=2.8mm,right=2.8mm,top=1.8mm,bottom=1.8mm]",
rf"{{\bfseries {tex_5a(card_title_line_5a(card))}}}\hfill{{\scriptsize\ttfamily KARTA {tex_escape(card['number'])}/{tex_escape(card['count'])}}}\par",
r"\vspace{0.2em}\hrule\vspace{0.2em}",
r"\scriptsize",
r"\begin{tabularx}{\linewidth}{@{}>{\ttfamily\textcolor{ESCMuted}}p{2.2cm}X>{\ttfamily\textcolor{ESCMuted}}p{2.5cm}X@{}}",
rf"Projekt & {tex_5a(card['project'])} & Przedmiot & {tex_5a(card['subject'])} \\",
rf"{tex_5a(first_label)} & \FiveAFill & {tex_5a(second_label)} & \FiveAFill \\",
r"\begin{tcolorbox}[enhanced,arc=0pt,boxrule=0.6pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=0pt,right=0pt,top=0pt,bottom=0pt]",
r"\renewcommand{\arraystretch}{1.08}\scriptsize",
r"\noindent\begin{minipage}[t]{12.40cm}",
r"\begin{tabularx}{\linewidth}{|p{2.55cm}|Y|p{1.55cm}|p{1.75cm}|}",
r"\hline",
rf"{title_cell('Opracował', title_fields['prepared_by'])}\newline{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(str(title_fields['prepared_on']))}}}}} & "
rf"{{\scriptsize\bfseries {tex_5a(title_fields['category'])}}}\hfill{{\tiny\ttfamily {tex_escape(str(title_fields['standard']))}}} & "
rf"{title_cell('Rewizja', title_fields['revision'], True)} & {title_cell('Data wydania', title_fields['issued_on'], True)} \\\hline",
rf"{title_cell('Sprawdził', title_fields['checked_by'])} & "
rf"{title_cell('Tytuł', title_fields['title'])} & "
rf"{title_cell('Seria', title_fields['series'], True)} & {title_cell('Typ', title_fields['document_type'])} \\\hline",
rf"{title_cell('Zatwierdził', title_fields['approved_by'])} & "
rf"{title_cell('URL', url or '', True)} & "
rf"{title_cell('Narzędzie', title_fields['tool'])} & {title_cell('Arkusz', title_fields['sheet'], True)} \\\hline",
r"\end{tabularx}",
r"\end{minipage}\hfill",
r"\begin{minipage}[t][15.5mm][c]{1.95cm}\centering",
qr,
r"\end{minipage}",
r"\hspace{2.0mm}{\tiny\ttfamily\textcolor{ESCMuted}{PROJEKT}}\hspace{0.8mm}{\scriptsize " + tex_5a(card["project"]) + r"}\hfill{\tiny\ttfamily\textcolor{ESCMuted}{PRZEDMIOT}}\hspace{0.8mm}{\scriptsize " + tex_5a(card["subject"]) + r"}\hspace{2.0mm}\par",
rf"\hspace{{2.0mm}}{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(first_label.upper())}}}}}\hspace{{0.8mm}}\FiveAFill\hfill{{\tiny\ttfamily\textcolor{{ESCMuted}}{{{tex_escape(second_label.upper())}}}}}\hspace{{0.8mm}}\FiveAFill\hspace{{2.0mm}}\par",
r"\end{tcolorbox}",
]
@@ -3776,14 +3947,15 @@ def render_code_columns_5a_tex(block: dict) -> list[str]:
def render_code_subblock_5a_tex(block: dict) -> list[str]:
body = [
rf"\par\textbf{{{tex_5a(block.get('title', ''))}}}\par",
r"\begin{CardContentBlock}",
rf"\textbf{{{tex_5a(block.get('title', ''))}}}\par",
tex_5a(block.get("context", "")),
r"\par\vspace{0.15em}",
*render_code_columns_5a_tex(block),
]
if block.get("pass_condition"):
body.append(rf"\par{{\tiny\ttfamily\textcolor{{ESCAccent}}{{PASS: {tex_5a(block.get('pass_condition', ''))}}}}}")
body.append(r"\vspace{0.25em}")
body.extend([r"\end{CardContentBlock}", r"\vspace{0.25em}"])
return body
@@ -4186,6 +4358,7 @@ def render_preamble_5b(card: dict) -> list[str]:
r"\usepackage[polish]{babel}",
r"\usepackage{array}",
r"\usepackage{tabularx}",
r"\usepackage{multirow}",
r"\usepackage{xcolor}",
r"\usepackage{amssymb}",
r"\usepackage{graphicx}",
@@ -4195,6 +4368,7 @@ def render_preamble_5b(card: dict) -> list[str]:
r"\IfFileExists{stix2.sty}{\usepackage{stix2}}{\usepackage{lmodern}}",
r"\IfFileExists{inconsolata.sty}{\usepackage{inconsolata}}{\usepackage{lmodern}}",
r"\usepackage[most]{tcolorbox}",
r"\usepackage{qrcode}",
r"\usepackage{hyperref}",
r"\hypersetup{hidelinks}",
r"\IfFileExists{build-meta.tex}{\input{build-meta.tex}}{\newcommand{\BuildCommit}{local}}",
@@ -4223,6 +4397,7 @@ def render_preamble_5b(card: dict) -> list[str]:
r"\newcommand{\FiveAWriteLine}{\par\noindent\dotfill}",
r"\newtcolorbox{FiveASection}[2]{enhanced,arc=0pt,boxrule=0.35pt,colback=ESCPaper,colframe=ESCInk,boxsep=0pt,left=2.0mm,right=2.0mm,top=1.0mm,bottom=1.0mm,colbacktitle=ESCHeader,coltitle=ESCInk,fonttitle=\bfseries\scriptsize,title={\ttfamily\textcolor{ESCMuted}{#1.}\quad #2}}",
r"\newtcolorbox{FiveAPart}{enhanced,arc=0pt,boxrule=0.55pt,colback=ESCHeader,colframe=ESCInk,boxsep=0pt,left=2.0mm,right=2.0mm,top=1.0mm,bottom=1.0mm}",
r"\newtcolorbox{CardContentBlock}{enhanced,arc=0pt,boxrule=0.28pt,colback=ESCPaper,colframe=ESCLine,boxsep=0pt,left=1.1mm,right=1.1mm,top=0.7mm,bottom=0.7mm}",
"",
]
@@ -4447,15 +4622,9 @@ def build_5b_pages_tex(data: dict) -> list[list[str]]:
"center": [
*render_metric_header_5a_tex(data),
r"\vfill",
*render_mission_5a_tex(data),
r"\vfill",
*render_objectives_5a_tex(data),
r"\vfill",
*render_agenda_5a_tex(data),
r"\vfill",
*render_hw_gate_5a_tex(data),
r"\vfill",
*render_tech_stack_5a_tex(data),
],
},
{
@@ -4463,8 +4632,14 @@ def build_5b_pages_tex(data: dict) -> list[list[str]]:
"center": [
*running_card_header_5b_tex(data),
r"\vfill",
*render_mission_5a_tex(data),
r"\vfill",
*render_theory_5a_tex(data),
r"\vfill",
*render_tech_stack_5a_tex(data),
r"\vfill",
*render_hw_gate_5a_tex(data),
r"\vfill",
*render_math_5a_tex(data),
],
},