Dash-math_Database / layout /renderer /dt_render_blocks.py
Mochi0622's picture
update files
d5d9742
Raw
History Blame Contribute Delete
31.6 kB
"""
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