""" dt_render_blocks.py -------------------- Block 層渲染:paragraph、list、heading、card、solution、flow、 GeoGebra、code、pycall,以及頂層的 render_doc / build_content_and_toc。 依賴:dt_render_utils(渲染工具) 公開 API: render_doc(doc) → html.Div 主要入口 build_content_and_toc(nodes) → (children, toc) render_paragraph(item) → Dash element render_list(node) → Dash element render_heading(node, hid) → Dash element render_card(node) → Dash element render_solution(node) → Dash element render_flow(node) → Dash element render_geogebra(node) → Dash element render_code(node) → Dash element render_pycall(node) → Dash element render_hr(_) → Dash element render_comment(node) → Dash element render_doc_section(doc, sid) → Dash element render_section_nav(sections, active_id) → Dash element maybe_render_paragraph_with_geogebra(item) → Dash element | None """ from __future__ import annotations import re, json, random, hashlib, importlib.util, types from pathlib import Path from dash import html, dcc import dash_mantine_components as dmc from dash_iconify import DashIconify from plotly.graph_objs import Figure as _PlotlyFigure from ..toc import make_toc from .dt_render_utils import ( HAS_PRISM, DashLatex, _norm_hex, _to_dark_color, _themed_color_style, _as_css_color, _ensure_theme_colors, _parse_img_hints, _apply_img_size_style, _split_text_links_images, render_url, render_picture, render_inline_media, _strip_math_delims, _render_inline_math, render_math_inline, render_math_block, render_option_label, render_inline_runs, render_runs, _as_bool, _is_true, ) # --------------------------------------------------------------------------- # GeoGebra 工具 # --------------------------------------------------------------------------- def _ggb_id_from_url(u: str | None) -> str | None: if not u: return None m = re.search(r'/m/([A-Za-z0-9]+)', u) return m.group(1) if m else None _GGB_ANY_RE = re.compile(r'') _Q_ATTR = re.compile(r'([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*"([^"]*)"' r'|([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*([^\s">]+)') def _attrs_to_dict(s: str) -> dict: out: dict = {} if not s: return out for m in _Q_ATTR.finditer(s): k = (m.group(1) or m.group(3)).strip() v = (m.group(2) or m.group(4)).strip() out[k] = v return out def _px_or_raw(v: str | None, fallback_px: int) -> tuple: v = (v or "").strip() if not v: return (f"{fallback_px}px", fallback_px) if v.endswith("%"): return (v, 800) try: n = int(float(v)) except: n = fallback_px return (f"{n}px", n) def render_geogebra(node: dict): """ GeoGebra 嵌入。 用法(JSON / ggb comment): width / w : 外層容器最大寬度,預設 "100%" height / h : GeoGebra 原始設計高度(px),預設 800 design_width / dw : GeoGebra 原始設計寬度(px);未填時同 width(百分比則預設 800) design_height / dh : GeoGebra 原始設計高度(px);未填時同 height extra_height / eh : 額外補高(px),直接加在設計高度上 app : GeoGebra app 類型("3d" / "geometry" / "graphing" / "cas") border : "1" 顯示邊框 caption : 圖說文字 card : "1" 包在 Card 裡 原理: iframe 以「設計尺寸」固定寬高(px)嵌入,不再縮放。 外層容器設 overflow: auto,當頁面容器比設計寬度窄時可橫向/縱向捲動, 確保 GeoGebra 內部所有區塊(2D + 3D 並排、slider、文字)完整顯示,不被裁切。 """ gid = (node.get("id") or "").strip() w_raw = node.get("width") or node.get("w") or "100%" h_raw = node.get("height") or node.get("h") or "800" # 把舊的預設值視為未設定 if h_raw in ("480", "480px"): h_raw = "800" border = str(node.get("border") or "0") == "1" caption = (node.get("caption") or "").strip() use_card = str(node.get("card", "0")) not in ("0", "false", "False", "") app = (node.get("app") or "").strip().lower() if not gid: return dmc.Alert("⚠ GeoGebra:缺少 id 或 url。", color="yellow", variant="light") w_css, w_px = _px_or_raw(w_raw, 800) _, h_px = _px_or_raw(h_raw, 800) # ── 設計尺寸:直接傳給 GeoGebra,決定 iframe 的真實像素大小 ────────── _, design_w = _px_or_raw(node.get("design_width") or node.get("dw") or "", 0) _, design_h = _px_or_raw(node.get("design_height") or node.get("dh") or "", 0) if design_w <= 0: # 未指定設計寬:用夠大的固定值讓 GeoGebra 不需要縮放。 # 1400 可容納 2D+3D 並排的典型 Classic 作品; # 如需更小或更大可在 JSON/Markdown 用 dw= 覆蓋。 design_w = w_px if not w_raw.endswith("%") else 1400 if design_h <= 0: design_h = h_px # ── 額外補高:直接加,不乘縮放比 ──────────────────────────────────── _, extra_px = _px_or_raw(node.get("extra_height") or node.get("eh") or "0", 0) design_h_final = design_h + extra_px # ── iframe URL ──────────────────────────────────────────────────────── app_segment = f"/app/{app}" if app else "" iframe_src = ( f"https://www.geogebra.org/material/iframe/id/{gid}" f"{app_segment}" f"/width/{design_w}/height/{design_h_final}" f"/border/{'888888' if border else 'ffffff'}" f"/sfsb/true/smb/false/stb/false/stbh/false/ai/false/asb/false" f"/sri/false/rc/false/ld/false/sdz/false/ctl/false" ) # ── iframe:className="ggb-iframe" 供 ggb_resize.js 偵測 ────────────── iframe = html.Iframe( src=iframe_src, width=str(design_w), height=str(design_h_final), className="ggb-iframe", style={"border": "none", "display": "block"}, allow="fullscreen", ) # ── 捲動容器:className="ggb-wrap" 供 JS 調整 maxHeight ───────────── scroll_box = html.Div( iframe, className="ggb-wrap", style={ "width": w_css, "maxWidth": "100%", "overflowX": "auto", "overflowY": "auto", "margin": "0 auto", "maxHeight": f"{design_h_final}px", }, ) inner_children = [scroll_box] if caption: inner_children.append( html.P(caption, style={"textAlign": "center", "fontSize": "0.9rem", "color": "#888", "marginTop": "4px"}) ) inner = html.Div(inner_children) return (dmc.Card([inner], withBorder=border, shadow="sm", radius="md", p=4) if use_card else inner) def maybe_render_paragraph_with_geogebra(item: dict): """若段落只包含一個 GeoGebra marker,直接渲染成 GeoGebra;否則回 None。""" content = (item.get("content") or "").strip() m = _GGB_ANY_RE.fullmatch(content) if not m: return None attrs = _attrs_to_dict(m.group("attrs") or "") gid = attrs.get("id") or _ggb_id_from_url(attrs.get("url", "")) if not gid: return None node = { "type": "geogebra", "id": gid, "width": attrs.get("w") or attrs.get("width") or "100%", "height": attrs.get("h") or attrs.get("height") or "800", "design_width": attrs.get("dw") or attrs.get("design_width") or "", "design_height": attrs.get("dh") or attrs.get("design_height") or "", "extra_height": attrs.get("eh") or attrs.get("extra_height") or "0", "app": attrs.get("app") or "", "border": attrs.get("border") or "0", "caption": attrs.get("caption") or "", "card": attrs.get("card") or "0", } return render_geogebra(node) # --------------------------------------------------------------------------- # 基本 block 渲染 # --------------------------------------------------------------------------- def render_paragraph(item: dict): content = item.get("content") or "" runs = item.get("runs") or [] if not content and not runs and str(item.get("newline", "false")).lower() in ("true", "1", "yes", "on"): return html.Br() if runs: return html.Div(render_inline_runs(runs), style={"display": "block"}) pieces = [] for kind, *vals in _split_text_links_images(content): if kind == "text": pieces.append(html.Span(vals[0], style={"display": "inline", "whiteSpace": "pre-line"})) elif kind == "a": pieces.append(render_url(vals[0], vals[1])) elif kind == "img": pieces.append(render_picture(vals[0], vals[1], hints=vals[2])) return html.Div(pieces, style={"display": "block"}) def render_list(node: dict): items = node.get("content", []) or [] rows = [] INDENT_W, MARKER_W = 18, 22 for it in items: level = int(it.get("indent", 0)) marker_raw = it.get("marker") or "•" runs = it.get("runs", []) col = _as_css_color(it.get("color")) if marker_raw in ("-", "*", "+", "•"): marker = ("•" if level == 0 else "◦" if level == 1 else "▪") else: marker = marker_raw pad_px = INDENT_W * level + MARKER_W row_style = { "display": "flex", "alignItems": "flex-start", "columnGap": "6px", "marginLeft": f"{INDENT_W * level}px", "paddingTop": "0.25rem", "paddingBottom": "0.25rem", } if col: row_style.update(_themed_color_style(col, _to_dark_color(col))) has_dfrac = any("\\dfrac" in (r.get("latex") or "") for r in runs) has_any_math= any(r.get("latex") for r in runs) marker_margin_top = "0.4em" if has_dfrac else "0.1em" if has_any_math else "0em" marker_box = html.Div(marker, style={ "minWidth": f"{MARKER_W}px", "textAlign": "right", "marginTop": marker_margin_top, "opacity": 0.7, "pointerEvents": "none", "userSelect": "none", "whiteSpace": "nowrap", "flex": "0 0 auto", }) content_box = html.Div(render_runs(runs, outdent_px=pad_px), style={"flex": "1 1 0%", "minWidth": 0}) rows.append(html.Div([marker_box, content_box], style=row_style)) return html.Div(rows, style={"paddingLeft": "0px", "marginLeft": "0px"}) def render_hr(_): return dmc.Divider(my=12) def render_comment(node: dict): label = node.get("content", "").strip("").strip() return dmc.Badge(label, variant="light", color="gray", leftSection=DashIconify(icon="tabler:bookmark", height=14), mb=6) # --------------------------------------------------------------------------- # Heading # --------------------------------------------------------------------------- def _plain_text_for_heading(nodes: list) -> str: out = [] for n in nodes: if n.get("runs"): for r in n["runs"]: if "text" in r: out.append(r["text"]) elif "latex" in r and r.get("kind") == "math": out.append(f"${r['latex']}$") else: out.append(n.get("content", "")) return "".join(out).strip() def render_heading_segment(node: dict) -> list: runs = node.get("runs") if runs: return render_runs(runs) text = node.get("content") or "" return [text] if text else [] def render_heading(node: dict, hid: str): order = max(1, min(int(node.get("level", 3)), 6)) runs = node.get("runs") children = render_runs(runs) if runs else (node.get("content") or "") clean_id = (hid or "").strip() if clean_id.startswith("h-"): clean_id = clean_id[2:] title = dmc.Title(children, order=order, id=clean_id, py=8) prompt_md = (node.get("ai_prompt_md") or "").strip() ex_seq = node.get("example_seq") if prompt_md and ex_seq: return dmc.Group( [title, dcc.Store(id={"type": "moreq-example-meta", "ex": ex_seq}, data={"prompt_md": prompt_md}), html.Div(id={"type": "moreq-example-out", "ex": ex_seq})], justify="space-between", align="center", ) return title def render_section_nav(sections: list, active_id: str): return dmc.SegmentedControl( id="unit-section-picker", value=active_id, data=[{"label": s["title"], "value": s["id"]} for s in sections], fullWidth=True, radius="md", size="sm", ) def render_doc_section(doc: dict, section_id: str | None = None): sections = doc.get("sections") or [] if not sections: return render_doc(doc.get("blocks") or []) if not section_id: section_id = sections[0]["id"] target = next((s for s in sections if s["id"] == section_id), sections[0]) return render_doc(target.get("blocks") or []) # --------------------------------------------------------------------------- # Cards # --------------------------------------------------------------------------- _CARD_KIND_PALETTE = { "定義": { "accent": "red", "header_light": "#AF1F4A", "header_dark": "#841A43", "fg_light": "#FFFFFF", "fg_dark": "#FFE6EC", "body_light": "#FFF1F4", "body_dark": "#1F0B12", }, "定理": { "accent": "cyan", "header_light": "#0F4C81", "header_dark": "#0A2E4D", "fg_light": "#FFFFFF", "fg_dark": "#D7F9FF", "body_light": "#E6F6FF", "body_dark": "#061A2B", }, "引理": { "accent": "teal", "header_light": "#0F766E", "header_dark": "#0B3B39", "fg_light": "#FFFFFF", "fg_dark": "#CCFBF1", "body_light": "#F0FDFA", "body_dark": "#062A27", }, "特性": { "accent": "#F6A160", "header_light": "#CD7F55", "header_dark": "#7C2D12", "fg_light": "#FFFFFF", "fg_dark": "#FFE8CC", "body_light": "#FFF4E6", "body_dark": "#2B1B0B", }, "註解": { "accent": "gray", "header_light": "#495057", "header_dark": "#90959BB4", "fg_light": "#FFFFFF", "fg_dark": "#E9ECEF", "body_light": "#F8F9FA", "body_dark": "#212529", }, "其他": { "accent": "#6039A0", "header_light": "#9064D8", "header_dark": "#3B1A7A", "fg_light": "#FFFFFF", "fg_dark": "#F3E8FF", "body_light": "#FAF5FF", "body_dark": "#1F1233", }, } def _merge_card_palette(node: dict) -> dict: kind = (node.get("kind") or "").strip() base = _CARD_KIND_PALETTE.get(kind, _CARD_KIND_PALETTE["其他"]).copy() if node.get("bg_light"): base["body_light"] = node["bg_light"] if node.get("bg_dark"): base["body_dark"] = node["bg_dark"] if node.get("fg_light"): base["fg_light"] = node["fg_light"] if node.get("fg_dark"): base["fg_dark"] = node["fg_dark"] if node.get("accent"): base["accent"] = node["accent"] return base def render_card(node: dict): p = _merge_card_palette(node) kind = (node.get("kind") or "").strip() headline = (node.get("headline") or "").strip() body_blocks = node.get("body") or [] # 把亮/暗色都注入成 CSS 變數,掛在最外層 Card 上 # CSS class dt-card / dt-card-header 再用 var() 讀取, # 配合 [data-mantine-color-scheme="dark"] selector 自動切換。 card_vars = { "--c-body-light": p.get("body_light", "#ffffff"), "--c-body-dark": p.get("body_dark", "#1a1a1a"), "--c-header-light": p.get("header_light", "#555555"), "--c-header-dark": p.get("header_dark", "#333333"), "--c-fg-light": p.get("fg_light", "#ffffff"), "--c-fg-dark": p.get("fg_dark", "#eeeeee"), } header = dmc.CardSection( dmc.Group([ dmc.Badge(kind, color=p.get("accent", "gray"), variant="filled"), dmc.Text(headline, fw=700), ], gap="sm"), withBorder=True, inheritPadding=True, py=6, className="dt-card-header", ) body_children = [] for b in body_blocks: t = (b.get("type") or "").lower() if t == "paragraph": body_children.append(render_paragraph(b)) elif t == "list": body_children.append(render_list(b)) elif t == "math": body_children.append(render_math_block(b.get("latex", ""))) elif t == "url": body_children.append(render_url(b.get("text"), b.get("href"))) elif t == "picture": body_children.append(render_picture(b.get("alt"), b.get("src"))) elif t == "horizontal_rule": body_children.append(render_hr(b)) elif t == "code": body_children.append(render_code(b)) else: raw = b.get("content", "") if raw: body_children.append(dcc.Markdown(raw, mathjax=not DashLatex)) return dmc.Card( [header, dmc.Stack(body_children, gap="xs", px="md", py="sm")], withBorder=True, shadow="md", radius="md", mb=14, className="dt-card", style=card_vars, ) # --------------------------------------------------------------------------- # Code / pycall # --------------------------------------------------------------------------- def render_code(node: dict): filename = (node.get("filename") or "").strip() lang = (node.get("lang") or "text").lower() code = node.get("code") or node.get("content") or "" header = ( dmc.Group([dmc.Badge(filename or "file", variant="light"), dmc.Badge(lang, variant="outline")], gap="xs", mb=6) if (filename or lang) else None ) comp = ( dmc.Prism(code, language=lang, withLineNumbers=True, copyLabel="複製", copiedLabel="已複製", style={"fontSize": "13px", "borderRadius": "10px"}) if HAS_PRISM else dmc.Code(code, block=True, style={"fontSize": "13px", "whiteSpace": "pre", "display": "block"}) ) return html.Div(([header, comp] if header else [comp]), style={"margin": "12px 0"}) PROJECT_ROOT = Path(__file__).resolve().parents[2] # layout/renderer/ → 上兩層 = 專案根目錄 def _load_func_from_path(mod_path: str, func_name: str): raw = (mod_path or "").strip() if not raw: raise FileNotFoundError("pyfunc path is empty") p = Path(raw) if not p.is_absolute(): p = (PROJECT_ROOT / p).resolve() if (not p.exists()) or (p.suffix != ".py"): raise FileNotFoundError(f"pyfunc path not found: {p}") h = hashlib.md5(str(p).encode("utf-8")).hexdigest()[:10] spec = importlib.util.spec_from_file_location(f"dynmod_{h}", str(p)) if not spec or not spec.loader: raise RuntimeError(f"cannot import module from {p}") mod = importlib.util.module_from_spec(spec) # type: ignore spec.loader.exec_module(mod) # type: ignore fn = getattr(mod, func_name, None) if not callable(fn): raise AttributeError(f"function not found: {func_name} in {p}") return fn def render_pycall(node: dict): p = node.get("path") or ""; fn = node.get("func") or "" try: mod = types.ModuleType("dynmod") spec = importlib.util.spec_from_file_location( "dynmod_" + hashlib.md5(p.encode("utf-8")).hexdigest()[:8], p ) if not spec or not spec.loader: raise RuntimeError(f"cannot import module from {p}") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) # type: ignore if not hasattr(mod, fn): raise AttributeError(f"function '{fn}' not found in {p}") rv = getattr(mod, fn)() if hasattr(rv, "type") and hasattr(rv, "props"): return rv if isinstance(rv, _PlotlyFigure): return dcc.Graph(figure=rv, responsive=True, style={"height": "100%", "minHeight": "320px"}) if isinstance(rv, tuple) and len(rv) == 2 and isinstance(rv[1], dict): return html.Div(rv[0], **rv[1]) return dmc.Alert([dmc.Text(f"{node.get('filename','')}::{fn} 回傳型別不支援:{type(rv).__name__}"), dmc.Code(str(rv), block=True, style={"whiteSpace": "pre"})], color="yellow", variant="light") except Exception as e: return dmc.Alert([dmc.Text(f"執行 {node.get('filename','')}::{fn} 失敗:{e}"), dmc.Space(h=6), render_code(node)], color="red", variant="light") # --------------------------------------------------------------------------- # Solution / Flow # --------------------------------------------------------------------------- def _storage_type(persist: str) -> str: return "session" if (persist or "none").strip().lower() == "session" else "memory" def render_solution(node: dict): uid = node.get("uid") or f"sol-{random.randint(1000,9999)}" label = (node.get("label") or "看解法").strip() default = (node.get("default") or "closed").strip().lower() persist = (node.get("persist") or "none").strip().lower() card = str(node.get("card", "0")) accent = (node.get("accent") or "").strip() after = (node.get("after") or "").strip() inner_children = render_doc(node.get("body") or []) store_open = dcc.Store(id={"type": "solution-open", "key": uid}, data=(default == "open"), storage_type=_storage_type(persist)) store_after = dcc.Store(id={"type": "solution-after", "key": uid}, data=after, storage_type="memory") btn = dmc.Button(label, id={"type": "solution-toggle", "key": uid}, variant="light", **({} if not accent else {"color": accent})) prompt_md = (node.get("ai_prompt_md") or "").strip() controls_children = [btn] extra_children = [] if prompt_md: extra_children.append(dcc.Store(id={"type": "moreq-prompt", "key": uid}, data=prompt_md)) controls_children.append(dmc.Button( "更多類似題目", id={"type": "moreq-open", "key": uid}, variant="outline", size="sm", n_clicks=0, leftSection=DashIconify(icon="tabler:plus", height=16), )) extra_children.append(dmc.Modal( id={"type": "moreq-modal", "key": uid}, title="更多類似題目", opened=False, size="lg", children=html.Div(id={"type": "moreq-content", "key": uid}), )) controls = dmc.Group(controls_children, gap="sm", align="center") hint = dmc.Text("先完成前面的題目再查看解法喔~", id={"type": "solution-hint", "key": uid}, size="sm", style={"display": "none", "opacity": 0.7, "marginTop": "6px"}) coll = dmc.Collapse( id={"type": "solution-collapse", "key": uid}, opened=(default == "open"), children=(dmc.Paper(inner_children, shadow="sm", radius="lg", withBorder=True, p="md") if card == "1" else html.Div(inner_children)), transitionDuration=150, ) return html.Div( id={"type": "solution-wrapper", "key": uid}, children=[store_open, store_after, *extra_children, controls, coll, hint], style={"margin": "8px 0"}, ) def _make_flow_step_box(fid: str, st: dict): meta = dcc.Store(id={"type": "step-meta", "flow": fid, "step": st["id"]}, data={"prev_qid": st.get("gate_from")}) return html.Div( [render_doc(st["content"]), meta], id={"type": "step-box", "flow": fid, "step": st["id"]}, ) def render_flow(node: dict): fid = node["id"] steps = node.get("steps", []) first_children = [_make_flow_step_box(fid, steps[0])] if steps else [] return html.Div( [dcc.Store(id={"type": "flow-steps", "flow": fid}, data=steps), html.Div(id={"type": "flow-body", "flow": fid}, children=first_children)], className="flow", ) # --------------------------------------------------------------------------- # 頂層 render_doc / build_content_and_toc # --------------------------------------------------------------------------- def render_doc(doc: list[dict]): """把 block 清單渲染成 html.Div。""" # 延遲 import 避免循環(render_quiz 在 dt_quiz) from .dt_quiz import render_quiz children = [] for item in doc: t = (item.get("type") or "").strip().lower() if t == "pycall": children.append(render_pycall(item)) elif t == "code": children.append(render_code(item)) elif t == "math": children.append(render_math_block(item.get("latex", ""))) elif t == "paragraph": g = maybe_render_paragraph_with_geogebra(item) children.append(g if g is not None else render_paragraph(item)) elif t == "list": children.append(render_list(item)) elif t == "horizontal_rule": children.append(render_hr(item)) elif t == "card": children.append(render_card(item)) elif t == "icon": icon_name = (item.get("icon") or item.get("name") or "").strip() try: height = int(item.get("height", 18)) except (TypeError, ValueError): height = 18 children.append(DashIconify(icon=icon_name, height=height)) elif t == "comment": continue elif t == "url": children.append(render_url(item.get("text"), item.get("href"))) elif t == "picture": alt = item.get("alt"); src = item.get("src") children.append(render_picture(alt, src, hints=_parse_img_hints(alt))) children.append(dmc.Space(h=6)) elif t == "quiz": children.append(render_quiz(item)) elif t == "heading": children.append(render_heading(item, item.get("id", ""))) elif t == "geogebra": children.append(render_geogebra(item)); children.append(dmc.Space(h=6)) elif t == "flow": children.append(render_flow(item)) elif t == "solution": children.append(render_solution(item)) elif t == "pyfunc": mod_path = item.get("path", ""); func = item.get("call", "make_view") kwargs = item.get("kwargs") or {} try: fn = _load_func_from_path(mod_path, func) res = fn(**kwargs) if kwargs else fn() if isinstance(res, _PlotlyFigure): children.append(dcc.Graph(figure=res, config={"responsive": True, "displaylogo": False}, style=({"height": f"{int(kwargs.get('height', 420))}px"} if "height" in kwargs else {}))) elif hasattr(res, "props") or isinstance(res, (html.Div, dmc.Alert, dcc.Graph)): children.append(res) else: children.append(dmc.Alert(f"pyfunc 回傳型別不支援:{type(res)}", title="pyfunc", color="red", variant="light")) except Exception as e: children.append(dmc.Alert(str(e), title="pyfunc error", color="red", variant="light")) else: payload = json.dumps(item, ensure_ascii=False, indent=2) children.append(dmc.Alert( [dmc.Text(f"unknown: {item.get('type')}"), dmc.Space(h=6), dmc.Code(payload, block=True, style={"whiteSpace": "pre"})], color="gray", variant="light", )) return html.Div(children, style={"lineHeight": "1.9"}) def build_content_and_toc(nodes: list, *, top_offset: int = 90) -> tuple: """ 從 block 清單生成: content_children:右側主要內容(heading 已加 id) toc:左側 TOC 元件 """ content_children: list = [] toc_titles: list[dict] = [] heading_buf: list[dict] = [] heading_level: int | None = None hcount = 0 def flush_heading(): nonlocal heading_buf, heading_level, hcount if not heading_buf: return hid = f"h-{hcount}" children = [] for seg in heading_buf: children.extend(render_heading_segment(seg)) order = max(1, min(int(heading_level or 3), 6)) content_children.append(dmc.Title(children, order=order, id=hid, py=8)) title_text = _plain_text_for_heading(heading_buf) if 1 <= order <= 4: toc_titles.append({"id": hid, "label": title_text or "(空白標題)", "level": order}) hcount += 1 heading_buf.clear() heading_level = None content_children.append(dmc.Space(h=6)) for node in nodes: t = (node.get("type") or "").strip().lower() if t == "heading": level = int(node.get("level", 3)) nl = _is_true(node.get("newline")) if not heading_buf: heading_buf = [node]; heading_level = level if nl: flush_heading() else: prev_nl = _is_true(heading_buf[-1].get("newline")) if (level != heading_level) or prev_nl: flush_heading(); heading_buf = [node]; heading_level = level if nl: flush_heading() else: heading_buf.append(node) if nl: flush_heading() continue flush_heading() if t == "paragraph": content_children.append(render_paragraph(node)); content_children.append(dmc.Space(h=6)) elif t == "list": content_children.append(render_list(node)); content_children.append(dmc.Space(h=6)) elif t == "horizontal_rule": content_children.append(render_hr(node)) elif t == "card": content_children.append(render_card(node)); content_children.append(dmc.Space(h=6)) elif t == "comment": content_children.append(render_comment(node)); content_children.append(dmc.Space(h=6)) elif t == "math": content_children.append(render_math_block(node.get("latex", ""))); content_children.append(dmc.Space(h=6)) elif t in ("url", "URL"): content_children.append(render_url(node.get("text"), node.get("href"))); content_children.append(dmc.Space(h=6)) elif t == "picture": content_children.append(render_picture(node.get("alt"), node.get("src"))); content_children.append(dmc.Space(h=6)) elif t == "code": content_children.append(render_code(node)); content_children.append(dmc.Space(h=6)) else: raw = node.get("content", "") if raw: content_children.append(dcc.Markdown(raw, mathjax=not DashLatex)) content_children.append(dmc.Space(h=6)) flush_heading() toc = make_toc( titles=toc_titles, top_offset=top_offset, col_span={"xs": 0, "sm": 0, "md": 3, "lg": 3, "xl": 3}, visible_from="md", ) return content_children, toc