Dash-math_Database / layout /renderer /dt_render_utils.py
Mochi0622's picture
initial deploy
e66cfb4
Raw
History Blame Contribute Delete
17.1 kB
"""
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()