feat: add React card renderer and interactive UML

This commit is contained in:
user
2026-07-16 13:19:22 +02:00
parent da06a2b81c
commit 41804a8ad1
9 changed files with 2143 additions and 37 deletions
+358 -37
View File
@@ -699,6 +699,7 @@ def render_preamble(data: dict) -> list[str]:
r"\usepackage{fancyhdr}",
r"\usepackage{lastpage}",
r"\usepackage{microtype}",
r"\usepackage[most]{tcolorbox}",
r"\usepackage{qrcode}",
r"\IfFileExists{references.bib}{%",
r" \usepackage[backend=biber,style=numeric,sorting=none]{biblatex}%",
@@ -761,6 +762,9 @@ def render_preamble(data: dict) -> list[str]:
r" \noindent{\color{black!18}\leaders\hbox{\rule{0.60em}{0.22pt}\hspace{0.38em}}\hfill\kern0pt}%",
r" \par\vspace{0.10em}%",
r"}",
r"\newtcolorbox{ESCTaskFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black,boxsep=0pt,left=1.4mm,right=1.4mm,top=0.8mm,bottom=0.8mm,colbacktitle=black!7,coltitle=black,title={\ttfamily\bfseries TASK\quad #1}}",
r"\newtcolorbox{ESCBlockFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!55,boxsep=0pt,left=1.0mm,right=1.0mm,top=0.6mm,bottom=0.6mm,colbacktitle=black!4,coltitle=black,title={\ttfamily\bfseries BLOCK\quad #1}}",
r"\newtcolorbox{ESCStepFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!28,boxsep=0pt,left=0.8mm,right=0.8mm,top=0.45mm,bottom=0.45mm,colbacktitle=black!2,coltitle=black,title={\ttfamily STEP\quad #1}}",
"",
]
@@ -844,6 +848,48 @@ def render_front_matter(card: dict) -> list[str]:
return lines
def render_front_scope_table_tex(scope: dict) -> list[str]:
table = scope.get("scope_table") or {}
rows = table.get("rows") or []
if not rows:
return []
has_task_column = any(str(row.get("task", "")).strip() for row in rows)
default_headers = (
["K&R", "Numer tasku", "Najważniejsza idea", "Priorytet"]
if has_task_column
else ["K&R", "Najważniejsza idea", "Priorytet"]
)
headers = table.get("headers") or default_headers
if len(headers) != len(default_headers):
headers = default_headers
column_spec = (
r"@{}>{\ttfamily}p{1.05cm}>{\ttfamily}p{1.55cm}X>{\raggedright\arraybackslash}p{3.25cm}@{}"
if has_task_column
else r"@{}>{\ttfamily}p{1.25cm}X>{\raggedright\arraybackslash}p{3.55cm}@{}"
)
lines = [
r"\vspace{0.55em}",
rf"\small\begin{{tabularx}}{{\textwidth}}{{{column_spec}}}",
" & ".join(rf"\textbf{{{tex_escape(header)}}}" for header in headers) + r" \\ \hline",
]
for row in rows:
cells = [rf"\texttt{{{tex_escape(row.get('chapter', ''))}}}"]
if has_task_column:
cells.append(rf"\texttt{{{tex_escape(row.get('task', ''))}}}")
cells.extend([row.get("idea_tex", ""), tex_escape(row.get("priority", ""))])
if str(row.get("task", "")).strip().lower() == "task04":
framed_cells = []
for index, cell in enumerate(cells):
alignment = "|l|" if index == 0 else "l|"
framed_cells.append(rf"\multicolumn{{1}}{{{alignment}}}{{\textbf{{{cell}}}}}")
lines.append(r"\hline")
lines.append(" & ".join(framed_cells) + r" \\ \hline")
else:
lines.append(" & ".join(cells) + r" \\ \hline")
lines.extend([r"\end{tabularx}", r"\normalsize", ""])
return lines
def render_front_page_scope(data: dict) -> list[str]:
scope = data.get("front_page_scope")
if not scope:
@@ -853,18 +899,37 @@ def render_front_page_scope(data: dict) -> list[str]:
rf"\noindent{{\Large\bfseries {tex_escape(scope.get('title', 'Zakres i cel opracowania'))}}}\par",
r"\vspace{0.35em}",
scope.get("content_tex", ""),
"",
r"\vspace{0.8em}",
r"\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}",
"",
]
if scope.get("scope_title"):
lines.extend(
[
"",
r"\vspace{0.8em}",
r"\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}",
r"\vspace{0.7em}",
rf"\noindent{{\Large\bfseries {tex_escape(scope['scope_title'])}}}\par",
r"\vspace{0.35em}",
scope.get("scope_content_tex", ""),
]
)
lines.extend(render_front_scope_table_tex(scope))
if scope.get("scope_footer_tex"):
lines.extend([scope["scope_footer_tex"], ""])
lines.extend(
[
"",
r"\vspace{0.8em}",
r"\noindent\textcolor{black!25}{\rule{\textwidth}{0.35pt}}",
"",
]
)
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)}}}",
rf"\begin{{ESCTaskFrame}}{{{tex_escape(task_ref.upper())} · {tex_escape(title)}}}",
]
if task.get("uuid"):
lines.append(rf"{{\scriptsize\ttfamily {tex_escape(task['uuid'])}}}\par")
@@ -873,32 +938,33 @@ def render_task_flow_tex(task_ref: str, task: dict) -> list[str]:
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",
rf"\begin{{ESCBlockFrame}}{{{tex_escape(item.get('title', ''))}}}",
]
)
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}")
lines.append(rf"\begin{{ESCStepFrame}}{{{tex_escape(step.get('id', ''))} · {tex_escape(step.get('title', ''))}}}")
if content:
lines.append(content)
lines.append(r"\end{ESCStepFrame}")
lines.append(r"\end{ESCBlockFrame}")
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",
rf"\begin{{ESCBlockFrame}}{{Ćwiczenie · {tex_escape(item.get('title', ''))}}}",
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', ''))}")
lines.append(r"\end{ESCBlockFrame}")
lines.append(rf"\tcblower\textbf{{Task acceptance:}} {tex_escape(task.get('criterion', ''))}")
lines.append(r"\end{ESCTaskFrame}")
return lines
@@ -906,7 +972,11 @@ def render_tasks(section: dict, data: dict) -> list[str]:
tasks = data["tasks"]
lines: list[str] = []
legacy_refs: list[str] = []
for task_ref in section["task_refs"]:
for index, task_ref in enumerate(section["task_refs"]):
if index:
# Kolejny task jest niezależnym etapem karty: jego ramka ma
# zaczynać nową stronę, a nie być kontynuacją poprzedniego.
lines.append(r"\clearpage")
task = tasks[task_ref]
if task.get("flow"):
lines.extend(render_task_flow_tex(task_ref, task))
@@ -1747,9 +1817,22 @@ def render_section_margin(section: dict, data: dict) -> list[str]:
return render_dual_margin_block(general_tags, vocational_tags, voffset="-3.1em", data=data)
def ordered_sections(data: dict) -> list[dict]:
return [
section
for _, section in sorted(
enumerate(data.get("sections", [])),
key=lambda entry: (entry[1].get("order", 1000), entry[0]),
)
]
def render_sections(data: dict) -> list[str]:
lines: list[str] = []
for section in data["sections"]:
for section in ordered_sections(data):
if section.get("content_kind") == "tasks":
# Pierwszy task danej grupy również dostaje własny początek strony.
lines.append(r"\clearpage")
command = r"\section*" if section.get("starred") else r"\section"
lines.append(r"\ESCSectionBlockStart")
lines.append(rf"{command}{{{tex_escape(section['title'])}}}")
@@ -2206,7 +2289,7 @@ def collect_numbered_labels(data: dict) -> tuple[dict[str, int], dict[str, int]]
figure_numbers: dict[str, int] = {}
equation_no = 0
figure_no = 0
for section in data.get("sections", []):
for section in ordered_sections(data):
content = section.get("content_tex", "")
for match in re.finditer(
r"\\begin\{(?P<kind>equation|figure)\}.*?\\label\{(?P<label>[^}]+)\}.*?\\end\{(?P=kind)\}",
@@ -2785,6 +2868,7 @@ def render_bibliography_section_html(data: dict) -> str:
def render_main_html(data: dict) -> str:
equation_numbers, figure_numbers = collect_numbered_labels(data)
sections = ordered_sections(data)
card = data["card"]
use_resource_header = bool(data.get("title_block"))
show_tree_inspector = data.get("generated", {}).get("html_tree_inspector", True)
@@ -2795,10 +2879,10 @@ def render_main_html(data: dict) -> str:
)
toc = "".join(
f'<a href="#{html_escape(html_id("section-" + str(index)))}">{index}. {html_escape(section.get("title", ""))}</a>'
for index, section in enumerate(data.get("sections", []), start=1)
for index, section in enumerate(sections, start=1)
)
bibliography_html = render_bibliography_section_html(data)
total_pages = 2 + len(data.get("sections", [])) + (1 if bibliography_html else 0)
total_pages = 2 + len(sections) + (1 if bibliography_html else 0)
rows = [
("Projekt", card["project"]),
("Seria", card.get("series_title", card["series"])),
@@ -2818,7 +2902,7 @@ def render_main_html(data: dict) -> str:
data=data,
)
sections_html: list[str] = []
for index, section in enumerate(data.get("sections", []), start=1):
for index, section in enumerate(sections, start=1):
page_number = index + 1
content = (
render_tasks_html(section, data, equation_numbers, figure_numbers)
@@ -2854,7 +2938,7 @@ def render_main_html(data: dict) -> str:
f'</div></article>'
)
if bibliography_html:
bibliography_page = len(data.get("sections", [])) + 2
bibliography_page = len(sections) + 2
sections_html.append(
f'<article class="sheet section-sheet" id="bibliografia-i-zrodla">'
f'<div class="sheet-content">'
@@ -2868,12 +2952,46 @@ def render_main_html(data: dict) -> str:
)
scope = data.get("front_page_scope", {})
scope_html = (
f'<section class="front-scope"><h2>{html_escape(scope.get("title", "Zakres i cel opracowania"))}</h2>'
f'{render_latex_fragment_html(scope.get("content_tex", ""), equation_numbers, figure_numbers)}</section>'
if scope
else ""
)
scope_html = ""
if scope:
scope_html = (
f'<section class="front-scope front-goal"><h2>{html_escape(scope.get("title", "Cel karty"))}</h2>'
f'{render_latex_fragment_html(scope.get("content_tex", ""), equation_numbers, figure_numbers)}</section>'
)
if scope.get("scope_title"):
table = scope.get("scope_table") or {}
rows = table.get("rows") or []
has_task_column = any(str(row.get("task", "")).strip() for row in rows)
default_headers = (
["K&R", "Numer tasku", "Najważniejsza idea", "Priorytet"]
if has_task_column
else ["K&R", "Najważniejsza idea", "Priorytet"]
)
headers = table.get("headers") or default_headers
if len(headers) != len(default_headers):
headers = default_headers
scope_table_html = ""
if rows:
table_rows = "".join(
f'<tr{" class=\"scope-row--key\"" if str(row.get("task", "")).strip().lower() == "task04" else ""}>'
f'<td><code>{html_escape(row.get("chapter", ""))}</code></td>'
+ (f'<td><code>{html_escape(row.get("task", ""))}</code></td>' if has_task_column else "")
+ f'<td>{latex_inline_to_html(row.get("idea_tex", ""), equation_numbers, figure_numbers)}</td>'
+ f'<td>{html_escape(row.get("priority", ""))}</td></tr>'
for row in rows
)
table_class = "scope-table scope-table--task" if has_task_column else "scope-table"
scope_table_html = (
f'<div class="scope-table-wrap"><table class="{table_class}"><thead><tr>'
+ "".join(f'<th>{html_escape(header)}</th>' for header in headers)
+ f'</tr></thead><tbody>{table_rows}</tbody></table></div>'
)
scope_html += (
f'<section class="front-scope front-card-scope"><h2>{html_escape(scope["scope_title"])}</h2>'
f'{render_latex_fragment_html(scope.get("scope_content_tex", ""), equation_numbers, figure_numbers)}'
f'{scope_table_html}'
f'{render_latex_fragment_html(scope.get("scope_footer_tex", ""), equation_numbers, figure_numbers)}</section>'
)
front_intro_html = (
scope_html
if use_resource_header
@@ -2888,14 +3006,15 @@ def render_main_html(data: dict) -> str:
css = r"""
:root{--ink:#111;--muted:#666;--line:#b9b9b9;--line-soft:#dddddd;--paper:#fff;--desk:#eeeeee;--og:#008000;--tech:#d36b00;--kw:#0018c8;--we:#e00000;--viewer-canvas-scale:1;--viewer-content-scale:1}
*{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--desk);color:var(--ink);font-family:"Latin Modern Roman","LM Roman 12",Georgia,"Times New Roman",serif;line-height:1.25}
.topbar{position:sticky;top:0;z-index:20;background:rgba(245,245,245,.94);border-bottom:1px solid #d0d0d0;padding:3px 10px;font-family:system-ui,-apple-system,Segoe UI,sans-serif}
.topbar-inner{max-width:1120px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:12px}.topbar details{min-width:0;flex:1}.topbar summary{cursor:pointer;font-size:12px;line-height:1.35;font-weight:650}.toc-links{display:flex;flex-wrap:wrap;gap:6px;margin-top:5px}.toc-links a{font-size:11px;color:#333;text-decoration:none;border:1px solid #ccc;background:white;border-radius:3px;padding:2px 5px}.viewer-zoom-controls{display:flex;align-items:center;gap:7px;flex:none}.viewer-zoom-status{font:600 10px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:#444;white-space:nowrap}.viewer-zoom-reset{border:1px solid #bbb;border-radius:3px;background:#fff;color:#333;padding:1px 6px;font:600 10px/1.35 system-ui,-apple-system,Segoe UI,sans-serif;cursor:pointer}.viewer-zoom-reset:hover{background:#eee}.viewer-zoom-reset:focus-visible{outline:2px solid #555;outline-offset:1px}
.paper{padding:18px 12px 44px;overflow-x:auto}.sheet{position:relative;width:210mm;height:297mm;min-height:297mm;margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:block;padding:0;overflow:hidden;zoom:var(--viewer-canvas-scale)}.sheet-content{position:relative;width:100%;min-height:297mm;padding:13mm 21.5mm 15mm;transform:scale(var(--sheet-effective-scale,1));transform-origin:top left}.resource-header-enabled .sheet-content{padding-top:5mm}.sheet-content>.page-resource-header{margin-bottom:3mm}
.topbar{position:sticky;top:0;z-index:20;background:rgba(245,245,245,.94);border-bottom:1px solid #d0d0d0;padding:3px 0;font-family:system-ui,-apple-system,Segoe UI,sans-serif}
.topbar-inner{width:calc(100% - 8px);max-width:none;margin:0 4px;display:flex;align-items:center;justify-content:space-between;gap:6px}.topbar details{min-width:0;flex:1}.topbar summary{cursor:pointer;font-size:16px;line-height:1.35;font-weight:650}.toc-links{display:flex;flex-wrap:wrap;gap:6px;margin-top:5px}.toc-links a{font-size:16px;color:#333;text-decoration:none;border:1px solid #ccc;background:white;border-radius:3px;padding:2px 5px}.viewer-zoom-controls{display:flex;align-items:center;gap:7px;flex:none}.viewer-zoom-status{font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:#444;white-space:nowrap}.viewer-zoom-reset{border:1px solid #bbb;border-radius:3px;background:#fff;color:#333;padding:1px 6px;font:600 16px/1.35 system-ui,-apple-system,Segoe UI,sans-serif;cursor:pointer}.viewer-zoom-reset:hover{background:#eee}.viewer-zoom-reset:focus-visible{outline:2px solid #555;outline-offset:1px}
.paper{padding:18px 12px 44px;overflow-x:auto}.sheet{position:relative;width:210mm;min-height:297mm;margin:0 auto 18px;background:var(--paper);box-shadow:0 2px 12px rgba(0,0,0,.13);display:block;padding:0;overflow:visible;zoom:var(--viewer-canvas-scale)}.sheet-content{position:relative;width:100%;min-height:297mm;padding:13mm 21.5mm 15mm;transform:scale(var(--sheet-effective-scale,1));transform-origin:top left}.resource-header-enabled .sheet-content{padding-top:5mm}.sheet-content>.page-resource-header{margin-bottom:3mm}
@media(max-width:900px){.sheet{height:auto}}@media print{.sheet{height:auto}}
.pdf-main{min-width:0;width:100%}.running-header{display:flex;justify-content:space-between;gap:18px;align-items:flex-end;border-bottom:1px solid #111;padding-bottom:3px;margin-bottom:26px;font-size:15px}.running-header span{white-space:nowrap}
.hero h1{font-size:24px;line-height:1.15;margin:0 0 4px;border-bottom:1px solid #111;padding-bottom:4px}.hero .byline{float:right;margin-top:-31px;font-size:15px}
.meta{clear:both;display:grid;grid-template-columns:148px minmax(0,1fr);gap:4px 18px;padding:34px 0 24px;border-bottom:1px solid #111;font-size:17px}.meta dt{color:#333}.meta dd{margin:0;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}
.front-scope{padding:0 0 3mm;border-bottom:1px solid var(--line-soft)}.front-scope h2{font-size:24px;line-height:1.15;margin:0 0 3mm}.front-scope p{font-size:16px}
.scope-table-wrap{margin:3mm 0;overflow:hidden}.scope-table{width:100%;border-collapse:collapse;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;line-height:1.22}.scope-table th,.scope-table td{border:0;border-bottom:1px solid #888;padding:4px 7px;text-align:left;vertical-align:top}.scope-table th{border-bottom:2px solid #555;background:#f3f3f3;font-weight:700}.scope-table th:first-child,.scope-table td:first-child{width:12%}.scope-table th:last-child,.scope-table td:last-child{width:34%}.scope-table--task th:first-child,.scope-table--task td:first-child{width:9%}.scope-table--task th:nth-child(2),.scope-table--task td:nth-child(2){width:14%}.scope-table--task th:last-child,.scope-table--task td:last-child{width:29%}.scope-table tr.scope-row--key td{font-weight:700;background:#f4f4f4;border-top:2px solid #111;border-bottom:2px solid #111}.scope-table tr.scope-row--key td:first-child{border-left:2px solid #111}.scope-table tr.scope-row--key td:last-child{border-right:2px solid #111}.scope-table td p{font:inherit;margin:0}.scope-table code{font-size:inherit}
.card-section h2{font-size:24px;line-height:1.15;margin:0 0 4mm}.section-heading{display:flex;align-items:flex-start;gap:0}.section-heading .section-index{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:23px;margin-right:12mm;font-weight:400}.section-heading .section-title{min-width:0}.task-identity{margin-left:auto;padding-left:22px;text-align:right;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:10px;line-height:1.25;color:#555;white-space:nowrap}.task-identity strong,.task-identity-name,.task-identity code{display:block}.task-identity strong{font-size:12px;color:#111}.task-identity-name{max-width:240px;overflow:hidden;text-overflow:ellipsis}.task-identity code{font-size:9px;color:#777}
p{font-size:16px;margin:0 0 3mm}.pdf-main ul{font-size:16px;margin:2mm 0 3mm 7mm;padding:0}.pdf-main li{margin:1.2mm 0}
.pdf-margin{position:absolute;top:34mm;width:18mm;font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace;font-size:5px;line-height:1.12;color:#333;max-height:245mm;overflow:hidden}.pdf-margin.left{left:2mm;text-align:left}.pdf-margin.right{right:2mm;text-align:left}
@@ -2907,10 +3026,10 @@ p{font-size:16px;margin:0 0 3mm}.pdf-main ul{font-size:16px;margin:2mm 0 3mm 7mm
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:16px}.task-legacy{margin:2.5mm 0}.task-legacy>strong{display:block;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #aaa;margin:2mm 0 4mm}.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-margin{display:none}
code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:16px}.task-legacy{margin:2.5mm 0}.task-legacy>strong{display:block;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #222;margin:1mm 0 2mm;background:#fff}.task-flow-header{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:6px;padding:5px 7px;background:#eaeaea;border-bottom:1px solid #222}.task-flow-header span{font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#222}.task-flow-header h3{font-size:18px;margin:0}.task-flow-header code{font-size:8px;color:#555}.task-block{margin:6px 7px;padding:5px 6px;border:1px solid #777;background:#fff}.task-block header,.task-exercise header{display:flex;align-items:baseline;gap:5px;margin-bottom:3px}.task-block header span,.task-exercise header span{font:700 9px/1.2 "Latin Modern Mono",ui-monospace,monospace;text-transform:uppercase;color:#555}.task-block h4,.task-exercise h4{font-size:16px;margin:0}.block-steps{margin:4px 0 0 16px!important;padding:0;display:grid;gap:3px}.block-steps li{margin:0;border:1px solid #b9b9b9;padding:4px 5px;background:#fff}.block-steps li::marker{font-family:"Latin Modern Mono",ui-monospace,monospace;font-weight:700}.block-steps li p:last-child{margin-bottom:0}.task-exercise{margin:6px 7px;border:1px solid #777;border-left:1px solid #222;padding:5px 6px;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:5px;padding-top:4px}.exercise-evidence>strong{font-size:11px;text-transform:uppercase}.exercise-criterion{font-size:13px;margin:4px 0 0}.task-flow>footer{padding:5px 7px;border-top:1px solid #222;font-size:12px;background:#f6f6f6}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.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}.sheet-content{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}.viewer-zoom-status{font-size:10px}}
@media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{position:relative;display:block;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;overflow:visible;zoom:1!important}.sheet-content{position:relative;width:auto;min-height:0;padding:0;transform:none!important}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet-content{padding:0}.sheet-content>.page-resource-header,.sheet-content>.page-resource-footer{display:none!important}.pdf-margin{position:absolute;top:0;width:18mm;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.pdf-margin.left{left:-19.5mm}.pdf-margin.right{right:-19.5mm}.task-block,.task-exercise,figure{break-inside:avoid}}
@media(max-width:900px){.sheet{display:block;min-height:0}.sheet-content{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}.viewer-zoom-status{font-size:16px}}
@media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{position:relative;display:block;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;overflow:visible;zoom:1!important}.sheet-content{position:relative;width:auto;min-height:0;padding:0;transform:none!important}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet-content{padding:0}.sheet-content>.page-resource-header,.sheet-content>.page-resource-footer{display:none!important}.pdf-margin{position:absolute;top:0;width:18mm;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.pdf-margin.left{left:-19.5mm}.pdf-margin.right{right:-19.5mm}.task-block,.task-exercise,.block-steps li,figure{break-inside:avoid}}
"""
css += page_resource_header_css(data)
if use_resource_header:
@@ -2957,7 +3076,8 @@ const viewerSheets = Array.from(document.querySelectorAll('.sheet')).map(sheet =
sheet,
content: sheet.querySelector('.sheet-content')
})).filter(item => item.content);
let viewerCanvasScale = 1;
const viewerCanvasDefault = 2.18;
let viewerCanvasScale = viewerCanvasDefault;
let viewerContentScale = 1;
function clampViewerScale(value, minimum, maximum){
return Math.min(maximum, Math.max(minimum, value));
@@ -3029,7 +3149,7 @@ if(viewerPaper){
}
if(viewerZoomReset){
viewerZoomReset.addEventListener('click', () => {
viewerCanvasScale = Math.min(1, viewerCanvasMaximum());
viewerCanvasScale = Math.min(viewerCanvasDefault, viewerCanvasMaximum());
viewerContentScale = 1;
refreshViewerLayout();
});
@@ -6782,7 +6902,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", "")))
add_graphic(str(asset.get("html_path") or asset.get("path", "")))
add_graphic(str(asset.get("source_path", "")))
for figure in data.get("figures", []):
add_graphic(figure.get("path", ""))
pdf_links: list[str] = []
@@ -6820,6 +6941,205 @@ def prepare_html_figure_assets(data: dict) -> None:
shutil.copy2(source, output_dir / source.name)
def react_task_model(
task_ref: str,
task: dict,
equation_numbers: dict[str, int],
figure_numbers: dict[str, int],
) -> dict:
flow: list[dict] = []
for item in task.get("flow", []) or []:
flow.append(
{
"id": str(item.get("id", "")),
"kind": str(item.get("kind", "block")),
"title": str(item.get("title", "")),
"content_html": render_latex_fragment_html(
str(item.get("content_tex", "")), equation_numbers, figure_numbers
),
"steps": [
{
"id": str(step.get("id", "")),
"title": str(step.get("title", "")),
"content_html": render_latex_fragment_html(
str(step.get("content_tex", "")), equation_numbers, figure_numbers
),
}
for step in item.get("steps", []) or []
],
}
)
return {
"ref": task_ref,
"title": str(task.get("title") or task_ref),
"uuid": str(task.get("uuid", "")),
"criterion": str(task.get("criterion", "")),
"prompt_html": render_latex_fragment_html(
str(task.get("prompt_tex", "")), equation_numbers, figure_numbers
),
"flow": flow,
}
def react_card_model(data: dict) -> dict:
equation_numbers, figure_numbers = collect_numbered_labels(data)
sections = ordered_sections(data)
page_specs: list[tuple[dict, str | None]] = []
for section in sections:
task_refs = section.get("task_refs", []) or []
if section.get("content_kind") == "tasks" and task_refs:
page_specs.extend((section, str(task_ref)) for task_ref in task_refs)
else:
page_specs.append((section, None))
total_pages = 2 + len(page_specs)
front_left, front_right = render_dual_margin_html(
learning_tree_tags(data, column="ogolne", effect_color="green!50!black"),
learning_tree_tags(data, column="zawodowe", effect_color="orange!85!black"),
data=data,
)
scope = data.get("front_page_scope", {}) or {}
scope_table = scope.get("scope_table", {}) or {}
front = {
"header_html": render_page_header_html(data, 1, total_pages),
"left_margin_html": front_left,
"right_margin_html": front_right,
"goal_title": str(scope.get("title", "Cel karty")),
"goal_html": render_latex_fragment_html(
str(scope.get("content_tex", "")), equation_numbers, figure_numbers
),
"scope_title": str(scope.get("scope_title", "Zakres karty")),
"scope_html": render_latex_fragment_html(
str(scope.get("scope_content_tex", "")), equation_numbers, figure_numbers
),
"scope_headers": list(scope_table.get("headers", [])),
"scope_rows": [
{
"chapter": str(row.get("chapter", "")),
"task": str(row.get("task", "")),
"idea_html": render_latex_fragment_html(
str(row.get("idea_tex", row.get("idea", ""))), equation_numbers, figure_numbers
),
"priority": str(row.get("priority", "")),
"key": bool(row.get("key", str(row.get("task", "")).lower() == "task04")),
}
for row in scope_table.get("rows", []) or []
],
}
section_models: list[dict] = []
for index, (section, task_ref) in enumerate(page_specs, start=1):
left_margin, right_margin = render_section_margins_html(section, data)
task_refs = section.get("task_refs", []) or []
tasks = (
[react_task_model(task_ref, data["tasks"][task_ref], equation_numbers, figure_numbers)]
if task_ref
else []
)
assets = []
include_section_assets = not task_ref or task_ref == str(task_refs[0])
for asset in (section.get("assets", []) or []) if include_section_assets else []:
source_path = str(asset.get("html_path") or asset.get("path", ""))
assets.append(
{
"label": str(asset.get("label", "")),
"caption": str(asset.get("caption", "")),
"alt": str(asset.get("alt") or asset.get("caption", "")),
"href": html_figure_asset(source_path),
"source_href": html_figure_asset(str(asset.get("source_path", "")))
if asset.get("source_path")
else "",
"width": float(asset.get("width", 1.0)),
"kind": str(asset.get("kind", "diagram")),
"interactive": asset.get("interactive"),
}
)
title = str(section.get("title", ""))
if task_ref and len(task_refs) > 1:
task_title = str(data["tasks"][task_ref].get("title", "")).strip()
title = f'{task_ref.upper()}{"" + task_title if task_title else ""}'
section_models.append(
{
"id": html_id(f'section-{index}-{task_ref or "content"}'),
"index": index,
"title": title,
"header_html": render_page_header_html(data, index + 1, total_pages),
"left_margin_html": left_margin,
"right_margin_html": right_margin,
"content_html": ""
if section.get("content_kind") == "tasks"
else render_latex_fragment_html(
str(section.get("content_tex", "")), equation_numbers, figure_numbers
),
"tasks": tasks,
"assets": assets,
"steps": [
{"id": str(step.get("id", "")), "title": str(step.get("title", ""))}
for step in section.get("steps", []) or []
] if (not task_ref or task_ref == str(task_refs[0])) else [],
}
)
return {
"schema": "esc-card-react-view.v1",
"card": data.get("card", {}),
"total_pages": total_pages,
"front": front,
"sections": section_models,
"dictionary": {
"header_html": render_page_header_html(data, total_pages, total_pages),
"content_html": render_learning_tree_overview_html(data),
},
"toc": [
{"id": section["id"], "label": f'{section["index"]}. {section["title"]}'}
for section in section_models
],
}
def normalize_generated_text(content: str) -> str:
return "\n".join(line.rstrip() for line in content.splitlines()) + "\n"
def write_react_app(data: dict, template: dict, legacy_html: str) -> list[Path]:
if not data.get("generated", {}).get("react_app"):
return []
if template_emitter(template, "html") != "classic":
raise RuntimeError("React renderer obsługuje obecnie emitter HTML 'classic'")
web_root = html_output_dir(data)
web_root.mkdir(parents=True, exist_ok=True)
style_match = re.search(r"<style>(.*?)</style>", legacy_html, re.S)
base_css = style_match.group(1) if style_match else ""
model_path = web_root / "card-data.json"
style_path = web_root / "style.css"
index_path = web_root / "index.html"
model_path.write_text(json.dumps(react_card_model(data), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
style_path.write_text(normalize_generated_text(base_css), encoding="utf-8")
index_path.write_text(
"""<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="card-renderer" content="react">
<title>Karta pracy</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="app.css">
</head>
<body class="resource-header-enabled">
<div id="root"></div>
<script type="module" src="app.js"></script>
</body>
</html>
""",
encoding="utf-8",
)
build_script = REPO_ROOT / "react" / "build.mjs"
subprocess.run(
["node", str(build_script), str(web_root)],
cwd=REPO_ROOT,
check=True,
)
return [index_path, style_path, model_path, web_root / "app.js", web_root / "app.css"]
def write_outputs(data: dict, template: dict, force_web: bool = False) -> list[Path]:
prepare_html_figure_assets(data)
outputs = {
@@ -6840,9 +7160,10 @@ def write_outputs(data: dict, template: dict, force_web: bool = False) -> list[P
continue
path = CARD_ROOT / output_ref
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
path.write_text(normalize_generated_text(content), encoding="utf-8")
paths.append(path)
paths.extend(write_web_outputs(data, template, force_web))
paths.extend(write_react_app(data, template, outputs.get("html", "")))
return paths