""" dt_render_utils.py ------------------ 純渲染工具:顏色計算、圖片/連結、數學式、inline run 渲染。 無任何 Dash @callback,可獨立於 app 使用。 公開 API: # 顏色 _norm_hex(c) → str _to_dark_color(hex_light) → str _themed_color_style(light, dark) → dict _ensure_theme_colors(run) → (cl, cd) _as_css_color(v) → str | None # 圖片 / 連結 _parse_img_hints(alt) → dict _apply_img_size_style(base, hints)→ dict _split_text_links_images(s) → list[tuple] render_url(text, href) → Dash element render_picture(alt, src, hints) → Dash element render_inline_media(text) → list[Dash element] # 數學 _strip_math_delims(s) → (latex, is_block) _render_inline_math(text) → Dash element render_math_inline(latex, cl, cd) → Dash element render_math_block(latex, cl, cd) → Dash element render_option_label(text) → Dash element # Run 渲染 render_inline_runs(runs) → list[Dash element] render_runs(runs, outdent_px) → list[Dash element] # 雜項 _as_bool(v, default) → bool _is_true(x) → bool _prompt_md_to_runs(md) → list[run] _runs_to_text_for_prompt(runs) → str # 全域常數 DEFAULT_NORMALIZE, DEFAULT_TOL, HAS_PRISM """ from __future__ import annotations import re from dash import html, dcc import dash_mantine_components as dmc from dash_iconify import DashIconify try: from dash_latex import DashLatex except Exception: DashLatex = None HAS_PRISM = hasattr(dmc, "Prism") DEFAULT_NORMALIZE = ["sym", "numeric", "nospace"] DEFAULT_TOL = 1e-9 # --------------------------------------------------------------------------- # Regex # --------------------------------------------------------------------------- import re as _re_md _IMG_RE = _re_md.compile(r'!\[([^\]]*)\]\(([^)\s]+)\)') _LINK_RE = _re_md.compile(r'(? str: if not isinstance(c, str): return "" s = c.strip().lower() if s.startswith("#"): if len(s) == 4: return "#" + "".join(ch * 2 for ch in s[1:]) if len(s) >= 7: return s[:7] return s named = { "black": "#000000", "white": "#ffffff", "gray": "#808080", "grey": "#808080", "red": "#ff0000", "green": "#008000", "blue": "#0000ff", "teal": "#008080", "orange": "#ffa500", "purple": "#800080", "pink": "#ffc0cb", "yellow": "#ffff00", "darkblue": "#00008b", "lightgray": "#d3d3d3", "dimgray": "#696969", "ffa6ff": "#ffa6ff", } if s in named: return named[s] m = re.match(r"rgba?\(([^)]+)\)", s) if m: parts = [p.strip() for p in m.group(1).split(",")] def to255(v): return round(float(v[:-1]) * 2.55) if v.endswith("%") else max(0, min(255, int(float(v)))) r, g, b = to255(parts[0]), to255(parts[1]), to255(parts[2]) return "#{:02x}{:02x}{:02x}".format(r, g, b) return s def _hex_to_hsl(hx: str) -> tuple: hx = _norm_hex(hx) if not (hx.startswith("#") and len(hx) == 7): return (0.0, 0.0, 0.0) r = int(hx[1:3], 16) / 255; g = int(hx[3:5], 16) / 255; b = int(hx[5:7], 16) / 255 mx, mn = max(r, g, b), min(r, g, b) l = (mx + mn) / 2 if mx == mn: return (0.0, 0.0, l) d = mx - mn s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn) if mx == r: h = (g - b) / d + (6 if g < b else 0) elif mx == g: h = (b - r) / d + 2 else: h = (r - g) / d + 4 return (h / 6, s, l) def _hsl_to_hex(h, s, l) -> str: def f(p, q, t): if t < 0: t += 1 if t > 1: t -= 1 if t < 1/6: return p + (q - p) * 6 * t if t < 1/2: return q if t < 2/3: return p + (q - p) * (2/3 - t) * 6 return p if s == 0: r = g = b = l else: q = l + s - l * s if l >= 0.5 else l * (1 + s) p = 2 * l - q r = f(p, q, h + 1/3); g = f(p, q, h); b = f(p, q, h - 1/3) return "#{:02x}{:02x}{:02x}".format(int(r*255+0.5), int(g*255+0.5), int(b*255+0.5)) def _to_dark_color(hex_light: str) -> str: table = { "#228be6": "#91c9ff", "#ffa6ff": "#cc66cc", "#000000": "#f2f2f2", "#808080": "#cccccc", } hx = _norm_hex(hex_light) if hx in table: return table[hx] h, s, l = _hex_to_hsl(hx) return _hsl_to_hex(h, max(0.10, s * 0.85), max(0.40, min(0.60, l * 0.65 + 0.20))) def _themed_color_style(light, dark) -> dict: sty = {} if light: sty["--tc-light"] = light if dark: sty["--tc-dark"] = dark if light or dark: sty["color"] = "var(--tc, var(--tc-light))" return sty def _as_css_color(v) -> str | None: return v if isinstance(v, str) else None def _ensure_theme_colors(run: dict) -> tuple: cl = run.get("color_light"); cd = run.get("color_dark") if cl or cd: return cl, cd base = run.get("color") if not base: return None, None light = _norm_hex(base); dark = _to_dark_color(light) return light, dark # --------------------------------------------------------------------------- # 圖片 / 連結工具 # --------------------------------------------------------------------------- def _parse_img_hints(alt: str) -> dict: out: dict = {} if not isinstance(alt, str) or '|' not in alt: return out head, *tokens = alt.split('|') for t in tokens: if '=' in t: k, v = t.split('=', 1) k, v = k.strip().lower(), v.strip() if k in ('w', 'h', 'max', 'fit'): out[k] = v out['_alt'] = head.strip() return out def _apply_img_size_style(base_style: dict, hints: dict) -> dict: style = dict(base_style or {}) w = hints.get('w'); h = hints.get('h'); mx = hints.get('max'); fit = hints.get('fit') if w: style['width'] = w if any(x in w for x in '%pxremvhvw') else f"{w}px" if h: style['height'] = h if any(x in h for x in '%pxremvhvw') else f"{h}px" if mx: style['maxWidth'] = mx if any(x in mx for x in '%pxremvhvw') else str(mx) if fit: style['objectFit'] = fit return style def _split_text_links_images(s: str | None) -> list: s = s if isinstance(s, str) else "" nodes, i = [], 0 img_matches = list(_IMG_RE.finditer(s)) img_spans = [(m.start(), m.end()) for m in img_matches] matches = [("img", m.start(), m.end(), m) for m in img_matches] for m in _LINK_RE.finditer(s): st, ed = m.start(), m.end() if any(st >= a and ed <= b for a, b in img_spans): continue matches.append(("a", st, ed, m)) if not matches: return [("text", s)] matches.sort(key=lambda x: x[1]) for kind, st, ed, m in matches: if st > i: chunk = s[i:st] if chunk: nodes.append(("text", chunk)) if kind == "img": raw_alt = m.group(1).strip() hints = _parse_img_hints(raw_alt) nodes.append(("img", hints.get('_alt', raw_alt), m.group(2).strip(), hints)) else: nodes.append(("a", m.group(1).strip(), m.group(2).strip())) i = ed if i < len(s): nodes.append(("text", s[i:])) return nodes def render_url(text: str | None, href: str | None): href = href or "#" label = (text or href).replace("\n", " ").strip() return dcc.Markdown( f"[{label}]({href})", link_target="_blank", style={"display": "inline-block", "margin": "0 6px", "verticalAlign": "middle"}, ) def render_picture(alt: str | None, src: str | None, hints: dict | None = None): src = src or "data:image/gif;base64,R0lGODlhAQABAAAAACw=" base_style = { "display": "inline-block", "margin": "6px", "verticalAlign": "middle", "maxWidth": "100%", "height": "auto", } hints = hints if hints is not None else _parse_img_hints(alt or "") if hints: base_style = _apply_img_size_style(base_style, hints) return html.Img(src=src, alt="", title=(alt or ""), role="presentation", **{"aria-hidden": "true"}, style=base_style) def render_inline_media(text: str) -> list: out = [] for kind, *vals in _split_text_links_images(text or ""): if kind == "text": out.append(html.Span(vals[0])) elif kind == "a": out.append(render_url(vals[0], vals[1])) elif kind == "img": out.append(render_picture(vals[0], vals[1], hints=vals[2])) return out # --------------------------------------------------------------------------- # 數學渲染 # --------------------------------------------------------------------------- def _strip_math_delims(s: str) -> tuple[str, bool]: t = s.strip() if t.startswith(r'\(') and t.endswith(r'\)'): return t[2:-2], False if t.startswith(r'\[') and t.endswith(r'\]'): return t[2:-2], True if t.startswith('$$') and t.endswith('$$'): return t[2:-2], True if t.startswith('$') and t.endswith('$'): return t[1:-1], False return t, False def _render_inline_math(text: str): """把含 $...$ / \(...\) 的字串渲染成 Dash 元件(單行用,供排序題等舊路徑)。""" if not text: return html.Span() if not DashLatex: return dcc.Markdown(text, mathjax=True, style={"display": "inline"}) parts = [] for frag in _MATH_FRAG.split(text): if not frag: continue if _MATH_FRAG.fullmatch(frag): latex, is_block = _strip_math_delims(frag) parts.append(DashLatex(latex, displayMode=is_block)) else: parts.append(dmc.Text(frag, span=True)) return html.Span(parts) def render_math_inline( latex: str, color_light: str | None = None, color_dark: str | None = None, ): base_style = { "display": "inline-block", "margin": "0 2px", "verticalAlign": "0.1em", } base_style.update(_themed_color_style(color_light, color_dark)) if DashLatex: return html.Span(DashLatex(latex, displayMode=False), style=base_style, className="themed-color") s = latex if latex.strip().startswith("$") else f"${latex}$" return html.Span(dcc.Markdown(s, mathjax=True), style=base_style, className="themed-color") def render_math_block(latex, color_light=None, color_dark=None): style = {"margin": "8px 0", "textAlign": "center", "width": "100%"} style.update(_themed_color_style(color_light, color_dark)) inner = ( DashLatex(latex, displayMode=True) if DashLatex else dcc.Markdown( latex if latex.strip().startswith("$$") else f"$$\n{latex}\n$$", mathjax=True, ) ) return html.Div(inner, style=style, className="themed-color math-block") def render_option_label(text: str): parts = _MATH_IN_LABEL.split(text or "") children = [] for p in parts: if not p: continue if p.startswith("$") and p.endswith("$"): children.append(render_math_inline(p, None, None)) else: children.append(html.Span(p)) return html.Span( children, style={"display": "inline-flex", "gap": "4px", "alignItems": "center", "flexWrap": "wrap"}, className="themed-color", ) # --------------------------------------------------------------------------- # Run 渲染 # --------------------------------------------------------------------------- def render_inline_runs(runs: list) -> list: """把 inline run 清單渲染成 Dash 元件清單(含 newline →
)。""" out = [] if not runs: return out for r in runs: k = r.get("kind") comp = None if "latex" in r and k in ("math", "inline_math"): comp = render_math_inline(r["latex"], r.get("color_light"), r.get("color_dark")) elif "latex" in r and k in ("math_block", "block_math", "display_math"): comp = render_math_block(r["latex"], r.get("color_light"), r.get("color_dark")) elif k == "icon": icon_name = (r.get("icon") or r.get("name") or "").strip() try: h = int(r.get("height", 18)) except (TypeError, ValueError): h = 18 comp = DashIconify(icon=icon_name, height=h, style={"margin": "0 4px", "verticalAlign": "text-bottom"}) else: txt = r.get("text", "") if txt != "": style: dict = {} cl, cd = _ensure_theme_colors(r) if cl or cd: style.update(_themed_color_style(cl, cd)) sty = r.get("style") if sty == "bold": style["fontWeight"] = 700 elif sty == "italic": style["fontStyle"] = "italic" elif sty == "bold-italic": style.update({"fontWeight": 700, "fontStyle": "italic"}) comp = html.Span(txt, style=style, className="themed-color") if comp is not None: out.append(comp) nl = str(r.get("newline", "false")).lower() if nl in ("true", "1", "yes", "on"): out.append(html.Br()) return out def render_runs(runs: list, outdent_px: int = 0) -> list: """把 run 清單渲染成 Dash 元件(block math 可設 outdent)。""" out = [] if not runs: return out for r in runs: k = r.get("kind") if "latex" in r and k in ("math_block", "block_math", "display_math"): comp = render_math_block(r["latex"], r.get("color_light"), r.get("color_dark")) if outdent_px: comp = html.Div(comp, style={ "width": f"calc(100% + {outdent_px}px)", "marginLeft": f"-{outdent_px}px" }) out.append(comp); continue if "latex" in r and k in ("math", "inline_math"): out.append(render_math_inline(r["latex"], r.get("color_light"), r.get("color_dark"))); continue if k == "icon": icon_name = (r.get("icon") or r.get("name") or "").strip() try: h = int(r.get("height", 18)) except (TypeError, ValueError): h = 18 out.append(DashIconify(icon=icon_name, height=h, style={"margin": "0 4px", "verticalAlign": "text-bottom"})) continue text = r.get("text", "") if text == "": continue style: dict = {} cl, cd = _ensure_theme_colors(r) if cl or cd: style.update(_themed_color_style(cl, cd)) sty = r.get("style") if sty == "bold": style["fontWeight"] = 700 elif sty == "italic": style["fontStyle"] = "italic" elif sty == "bold-italic": style.update({"fontWeight": 700, "fontStyle": "italic"}) out.append(html.Span(text, style=style, className="themed-color")) return out # --------------------------------------------------------------------------- # 雜項 # --------------------------------------------------------------------------- def _as_bool(v, default: bool = True) -> bool: if v is None: return default s = str(v).strip().lower() if s in ("1", "true", "yes", "y", "on"): return True if s in ("0", "false", "no", "n", "off"): return False return default def _is_true(x) -> bool: return x is True or (isinstance(x, str) and x.lower() == "true") def _prompt_md_to_runs(md: str) -> list[dict]: """把含數學式的 Markdown 字串轉成 prompt_runs。""" md = md or "" runs: list[dict] = [] def push_text(text: str): for i, part in enumerate(text.split("\n")): r: dict = {"kind": "text", "text": part} if i != len(text.split("\n")) - 1: r["newline"] = "true" runs.append(r) pos = 0 for m in _MATH_FRAG.finditer(md): if m.start() > pos: push_text(md[pos:m.start()]) latex, is_block = _strip_math_delims(m.group(0)) runs.append({"kind": "math_block" if is_block else "math", "latex": latex}) pos = m.end() if pos < len(md): push_text(md[pos:]) return runs def _runs_to_text_for_prompt(runs: list[dict]) -> str: """把 run 清單還原成純文字(用於 AI prompt)。""" parts = [] for r in runs or []: if "latex" in r: parts.append( "$$" + r["latex"] + "$$" if r.get("kind") in ("math_block", "block_math", "display_math") else "$" + r["latex"] + "$" ) elif "text" in r: parts.append(r["text"]) if str(r.get("newline", "false")).lower() == "true": parts.append("\n") return "".join(parts).strip()