Spaces:
Sleeping
Sleeping
| """ | |
| 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'<!--\s*ggb(?P<attrs>[^>]*)-->') | |
| _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 | |