Spaces:
Sleeping
Sleeping
| """ | |
| 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'(?<!!)]\[([^\]]+)\]\(([^)\s]+)\)') | |
| _LINK_RE = re.compile(r'(?<!!\[)(?<!\!)\[([^\]]+)\]\(([^)\s]+)\)') | |
| _MATH_FRAG = re.compile(r'(\\\(.+?\\\)|\\\[.+?\\\]|\$\$.+?\$\$|\$(?!\$).+?(?<!\$)\$)', re.S) | |
| _MATH_IN_LABEL = re.compile(r'(\${1,2}.*?\${1,2})', re.DOTALL) | |
| # --------------------------------------------------------------------------- | |
| # 顏色工具(此處獨立實作,略有擴充,不依賴 md_color.py) | |
| # --------------------------------------------------------------------------- | |
| def _norm_hex(c: str) -> 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 → <br>)。""" | |
| 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() | |