Spaces:
Running on Zero
Running on Zero
| """Render a CogEvol / OpenMAIC slide scene-graph JSON into a self-contained HTML page. | |
| The contract implemented here follows the canonical OpenMAIC slide object model | |
| (`packages/@openmaic/dsl/src/slides.ts`, MIT, THU-MAIC): a 1000x562 canvas with | |
| absolutely-positioned `text | shape | line | image | table | chart | latex | video | code` | |
| elements plus a `background` object. | |
| """ | |
| from __future__ import annotations | |
| import html | |
| import json | |
| from typing import Any | |
| CANVAS_W = 1000 | |
| CANVAS_H = 562 | |
| DEFAULT_THEME_COLORS = [ | |
| "#5b9bd5", | |
| "#ed7d31", | |
| "#a5a5a5", | |
| "#ffc000", | |
| "#4472c4", | |
| "#70ad47", | |
| "#264478", | |
| "#9e480e", | |
| ] | |
| FONT_STACK = ( | |
| "'Microsoft YaHei','PingFang SC','Hiragino Sans GB','Noto Sans CJK SC'," | |
| "'Source Han Sans SC','WenQuanYi Micro Hei',Inter,Arial,sans-serif" | |
| ) | |
| def _num(value: Any, default: float = 0.0) -> float: | |
| try: | |
| if isinstance(value, bool): | |
| return default | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return default | |
| def _esc(value: Any) -> str: | |
| return html.escape("" if value is None else str(value), quote=True) | |
| def _css_outline(outline: dict | None, fallback: str = "") -> str: | |
| if not isinstance(outline, dict): | |
| return fallback | |
| width = _num(outline.get("width"), 1) | |
| style = outline.get("style") or "solid" | |
| color = outline.get("color") or "#000000" | |
| if width <= 0: | |
| return fallback | |
| return f"border:{width}px {_esc(style)} {_esc(color)};" | |
| def _shadow(shadow: dict | None) -> str: | |
| if not isinstance(shadow, dict): | |
| return "" | |
| return ( | |
| f"filter:drop-shadow({_num(shadow.get('h'))}px {_num(shadow.get('v'))}px " | |
| f"{_num(shadow.get('blur'))}px {_esc(shadow.get('color') or 'rgba(0,0,0,.3)')});" | |
| ) | |
| def _base_box(el: dict, extra: str = "") -> str: | |
| left, top = _num(el.get("left")), _num(el.get("top")) | |
| width, height = _num(el.get("width"), 100), _num(el.get("height"), 40) | |
| rotate = _num(el.get("rotate")) | |
| style = ( | |
| f"position:absolute;left:{left}px;top:{top}px;" | |
| f"width:{width}px;height:{height}px;" | |
| ) | |
| if rotate: | |
| style += f"transform:rotate({rotate}deg);" | |
| opacity = el.get("opacity") | |
| if isinstance(opacity, (int, float)) and opacity != 1: | |
| style += f"opacity:{opacity};" | |
| return style + extra | |
| def _gradient_css(gradient: dict) -> str: | |
| colors = gradient.get("colors") or [] | |
| stops = ", ".join( | |
| f"{_esc(c.get('color') or '#ffffff')} {_num(c.get('pos'))}%" | |
| for c in colors | |
| if isinstance(c, dict) | |
| ) | |
| if not stops: | |
| return "" | |
| if (gradient.get("type") or "linear") == "radial": | |
| return f"radial-gradient(circle, {stops})" | |
| return f"linear-gradient({_num(gradient.get('rotate'))}deg, {stops})" | |
| # --------------------------------------------------------------------------- text | |
| def _render_text(el: dict) -> str: | |
| style = _base_box(el) | |
| style += f"color:{_esc(el.get('defaultColor') or '#333333')};" | |
| font = el.get("defaultFontName") | |
| if font: | |
| style += f"font-family:{_esc(font)},{FONT_STACK};" | |
| if el.get("fill"): | |
| style += f"background:{_esc(el['fill'])};" | |
| style += _css_outline(el.get("outline")) | |
| style += f"line-height:{_num(el.get('lineHeight'), 1.5)};" | |
| if el.get("wordSpace"): | |
| style += f"letter-spacing:{_num(el.get('wordSpace'))}px;" | |
| style += _shadow(el.get("shadow")) | |
| if el.get("vertical"): | |
| style += "writing-mode:vertical-rl;" | |
| valign = el.get("vAlign") or "top" | |
| flex = {"top": "flex-start", "middle": "center", "bottom": "flex-end"}.get(valign, "flex-start") | |
| style += f"display:flex;flex-direction:column;justify-content:{flex};overflow:visible;" | |
| content = el.get("content") or "" | |
| return f'<div class="el text" style="{style}"><div class="rich">{content}</div></div>' | |
| # -------------------------------------------------------------------------- shape | |
| def _render_shape(el: dict, idx: int) -> str: | |
| view_box = el.get("viewBox") or [200, 200] | |
| if isinstance(view_box, (int, float)): | |
| view_box = [view_box, view_box] | |
| vb_w = _num(view_box[0] if len(view_box) > 0 else 200, 200) | |
| vb_h = _num(view_box[1] if len(view_box) > 1 else vb_w, vb_w) | |
| path = el.get("path") or "M 0 0 L 200 0 L 200 200 L 0 200 Z" | |
| fill = el.get("fill") or "#5b9bd5" | |
| defs = "" | |
| fill_attr = _esc(fill) | |
| gradient = el.get("gradient") | |
| if isinstance(gradient, dict) and gradient.get("colors"): | |
| gid = f"grad{idx}" | |
| stops = "".join( | |
| f'<stop offset="{_num(c.get("pos"))}%" stop-color="{_esc(c.get("color") or "#ffffff")}"/>' | |
| for c in gradient["colors"] | |
| if isinstance(c, dict) | |
| ) | |
| if (gradient.get("type") or "linear") == "radial": | |
| defs = f'<defs><radialGradient id="{gid}">{stops}</radialGradient></defs>' | |
| else: | |
| rot = _num(gradient.get("rotate")) | |
| defs = ( | |
| f'<defs><linearGradient id="{gid}" gradientTransform="rotate({rot})">' | |
| f"{stops}</linearGradient></defs>" | |
| ) | |
| fill_attr = f"url(#{gid})" | |
| outline = el.get("outline") or {} | |
| stroke = "" | |
| if isinstance(outline, dict) and outline.get("color"): | |
| ow = _num(outline.get("width"), 1) | |
| dash = {"dashed": "10 6", "dotted": "2 4"}.get(outline.get("style") or "solid", "") | |
| stroke = f' stroke="{_esc(outline["color"])}" stroke-width="{ow}"' | |
| if dash: | |
| stroke += f' stroke-dasharray="{dash}"' | |
| flip = "" | |
| if el.get("flipH") or el.get("flipV"): | |
| sx, sy = (-1 if el.get("flipH") else 1), (-1 if el.get("flipV") else 1) | |
| flip = f"transform:scale({sx},{sy});" | |
| svg = ( | |
| f'<svg class="shape-svg" style="{flip}" width="100%" height="100%" ' | |
| f'viewBox="0 0 {vb_w} {vb_h}" preserveAspectRatio="none" ' | |
| f'xmlns="http://www.w3.org/2000/svg">{defs}' | |
| f'<path d="{_esc(path)}" fill="{fill_attr}"{stroke}/></svg>' | |
| ) | |
| inner = "" | |
| text = el.get("text") | |
| if isinstance(text, dict) and text.get("content"): | |
| align = {"top": "flex-start", "middle": "center", "bottom": "flex-end"}.get( | |
| text.get("align") or "middle", "center" | |
| ) | |
| t_style = ( | |
| "position:absolute;inset:0;display:flex;flex-direction:column;" | |
| f"justify-content:{align};padding:6px;box-sizing:border-box;" | |
| f"color:{_esc(text.get('defaultColor') or '#333333')};" | |
| f"line-height:{_num(text.get('lineHeight'), 1.5)};" | |
| ) | |
| if text.get("defaultFontName"): | |
| t_style += f"font-family:{_esc(text['defaultFontName'])},{FONT_STACK};" | |
| inner = f'<div class="shape-text rich" style="{t_style}">{text["content"]}</div>' | |
| style = _base_box(el, _shadow(el.get("shadow"))) | |
| return f'<div class="el shape" style="{style}">{svg}{inner}</div>' | |
| # --------------------------------------------------------------------------- line | |
| def _render_line(el: dict, idx: int) -> str: | |
| start = el.get("start") or [0, 0] | |
| end = el.get("end") or [100, 0] | |
| sx, sy = _num(start[0] if len(start) > 0 else 0), _num(start[1] if len(start) > 1 else 0) | |
| ex, ey = _num(end[0] if len(end) > 0 else 0), _num(end[1] if len(end) > 1 else 0) | |
| ctrl_points = [] | |
| for key in ("broken", "broken2", "curve"): | |
| pt = el.get(key) | |
| if isinstance(pt, (list, tuple)) and len(pt) >= 2: | |
| ctrl_points.append((_num(pt[0]), _num(pt[1]))) | |
| cubic = el.get("cubic") | |
| if isinstance(cubic, (list, tuple)) and len(cubic) == 2: | |
| for pt in cubic: | |
| if isinstance(pt, (list, tuple)) and len(pt) >= 2: | |
| ctrl_points.append((_num(pt[0]), _num(pt[1]))) | |
| stroke_w = max(_num(el.get("width"), 2), 1) | |
| xs = [sx, ex] + [p[0] for p in ctrl_points] | |
| ys = [sy, ey] + [p[1] for p in ctrl_points] | |
| pad = stroke_w * 3 + 6 | |
| svg_w = max(xs) - min(min(xs), 0) + pad | |
| svg_h = max(ys) - min(min(ys), 0) + pad | |
| if cubic and len(ctrl_points) >= 2: | |
| c1, c2 = ctrl_points[-2], ctrl_points[-1] | |
| d = f"M {sx} {sy} C {c1[0]} {c1[1]} {c2[0]} {c2[1]} {ex} {ey}" | |
| elif el.get("curve") and ctrl_points: | |
| c = ctrl_points[0] | |
| d = f"M {sx} {sy} Q {c[0]} {c[1]} {ex} {ey}" | |
| elif el.get("broken") and ctrl_points: | |
| pts = " ".join(f"L {p[0]} {p[1]}" for p in ctrl_points) | |
| d = f"M {sx} {sy} {pts} L {ex} {ey}" | |
| else: | |
| d = f"M {sx} {sy} L {ex} {ey}" | |
| color = _esc(el.get("color") or "#333333") | |
| dash = {"dashed": stroke_w * 5, "dotted": stroke_w * 2}.get(el.get("style") or "solid") | |
| dash_attr = f' stroke-dasharray="{dash} {dash}"' if dash else "" | |
| points = el.get("points") or ["", ""] | |
| p_start = points[0] if len(points) > 0 else "" | |
| p_end = points[1] if len(points) > 1 else "" | |
| defs, marker_attrs = [], "" | |
| for name, kind, is_start in (("s", p_start, True), ("e", p_end, False)): | |
| if kind not in ("arrow", "dot"): | |
| continue | |
| mid = f"m{idx}{name}" | |
| if kind == "arrow": | |
| body = f'<path d="M0,0 L0,6 L6,3 z" fill="{color}"/>' | |
| if is_start: | |
| body = f'<path d="M6,0 L6,6 L0,3 z" fill="{color}"/>' | |
| defs.append( | |
| f'<marker id="{mid}" markerWidth="6" markerHeight="6" refX="3" refY="3" ' | |
| f'orient="auto" markerUnits="strokeWidth">{body}</marker>' | |
| ) | |
| else: | |
| defs.append( | |
| f'<marker id="{mid}" markerWidth="6" markerHeight="6" refX="3" refY="3" ' | |
| f'markerUnits="strokeWidth"><circle cx="3" cy="3" r="2.6" fill="{color}"/></marker>' | |
| ) | |
| marker_attrs += f' marker-{"start" if is_start else "end"}="url(#{mid})"' | |
| defs_str = f"<defs>{''.join(defs)}</defs>" if defs else "" | |
| left, top = _num(el.get("left")), _num(el.get("top")) | |
| style = f"position:absolute;left:{left}px;top:{top}px;width:{svg_w}px;height:{svg_h}px;overflow:visible;" | |
| return ( | |
| f'<div class="el line" style="{style}">' | |
| f'<svg width="{svg_w}" height="{svg_h}" overflow="visible" xmlns="http://www.w3.org/2000/svg">' | |
| f'{defs_str}<path d="{d}" fill="none" stroke="{color}" stroke-width="{stroke_w}"' | |
| f'{dash_attr}{marker_attrs} stroke-linecap="round"/></svg></div>' | |
| ) | |
| # -------------------------------------------------------------------------- table | |
| def _render_table(el: dict) -> str: | |
| data = el.get("data") or [] | |
| col_widths = el.get("colWidths") or [] | |
| n_cols = max((len(r) for r in data if isinstance(r, list)), default=1) | |
| if len(col_widths) != n_cols or not col_widths: | |
| col_widths = [1.0 / max(n_cols, 1)] * n_cols | |
| total = sum(_num(w, 0) for w in col_widths) or 1.0 | |
| cols = "".join(f'<col style="width:{_num(w) / total * 100:.4f}%">' for w in col_widths) | |
| outline = el.get("outline") or {} | |
| border = "1px solid #cbd5e1" | |
| if isinstance(outline, dict): | |
| border = ( | |
| f"{_num(outline.get('width'), 1)}px {_esc(outline.get('style') or 'solid')} " | |
| f"{_esc(outline.get('color') or '#cbd5e1')}" | |
| ) | |
| min_h = _num(el.get("cellMinHeight"), 36) | |
| row_heights = el.get("rowHeights") or [] | |
| rows_html = [] | |
| for r_i, row in enumerate(data): | |
| if not isinstance(row, list): | |
| continue | |
| h = _num(row_heights[r_i], min_h) if r_i < len(row_heights) else min_h | |
| cells = [] | |
| for cell in row: | |
| if not isinstance(cell, dict): | |
| cell = {"text": str(cell)} | |
| st = cell.get("style") or {} | |
| css = f"border:{border};padding:{_esc(cell.get('padding') or '4px 8px')};" | |
| css += f"vertical-align:{_esc(cell.get('vAlign') or 'middle')};" | |
| if st.get("bold"): | |
| css += "font-weight:700;" | |
| if st.get("em"): | |
| css += "font-style:italic;" | |
| deco = [] | |
| if st.get("underline"): | |
| deco.append("underline") | |
| if st.get("strikethrough"): | |
| deco.append("line-through") | |
| if deco: | |
| css += f"text-decoration:{' '.join(deco)};" | |
| if st.get("color"): | |
| css += f"color:{_esc(st['color'])};" | |
| if st.get("backcolor"): | |
| css += f"background:{_esc(st['backcolor'])};" | |
| if st.get("fontsize"): | |
| fs = str(st["fontsize"]) | |
| css += f"font-size:{_esc(fs if not fs.isdigit() else fs + 'px')};" | |
| if st.get("fontname"): | |
| css += f"font-family:{_esc(st['fontname'])},{FONT_STACK};" | |
| if st.get("align"): | |
| css += f"text-align:{_esc(st['align'])};" | |
| span = "" | |
| colspan = int(_num(cell.get("colspan"), 1)) or 1 | |
| rowspan = int(_num(cell.get("rowspan"), 1)) or 1 | |
| if colspan > 1: | |
| span += f' colspan="{colspan}"' | |
| if rowspan > 1: | |
| span += f' rowspan="{rowspan}"' | |
| text = str(cell.get("text", "")) | |
| cells.append(f'<td style="{css}"{span}>{text}</td>') | |
| rows_html.append(f'<tr style="height:{h}px">{"".join(cells)}</tr>') | |
| style = _base_box(el, "overflow:visible;") | |
| return ( | |
| f'<div class="el table" style="{style}">' | |
| f'<table style="width:100%;table-layout:fixed;border-collapse:collapse;">' | |
| f"<colgroup>{cols}</colgroup><tbody>{''.join(rows_html)}</tbody></table></div>" | |
| ) | |
| # -------------------------------------------------------------------------- other | |
| def _render_image(el: dict) -> str: | |
| src = el.get("src") or "" | |
| style = _base_box(el, _css_outline(el.get("outline")) + _shadow(el.get("shadow"))) | |
| if el.get("radius"): | |
| style += f"border-radius:{_num(el.get('radius'))}px;overflow:hidden;" | |
| fit = "contain" if el.get("fixedRatio", True) else "fill" | |
| if isinstance(src, str) and (src.startswith("http") or src.startswith("data:")): | |
| return ( | |
| f'<div class="el image" style="{style}">' | |
| f'<img src="{_esc(src)}" style="width:100%;height:100%;object-fit:{fit}"></div>' | |
| ) | |
| label = _esc(src) if src else "image" | |
| return ( | |
| f'<div class="el image placeholder" style="{style}">' | |
| f'<span>🖼 {label}</span></div>' | |
| ) | |
| def _render_video(el: dict) -> str: | |
| src = el.get("src") or el.get("mediaRef") or "" | |
| style = _base_box(el) | |
| if isinstance(src, str) and src.startswith("http"): | |
| return ( | |
| f'<div class="el video" style="{style}">' | |
| f'<video src="{_esc(src)}" controls style="width:100%;height:100%"></video></div>' | |
| ) | |
| return f'<div class="el image placeholder" style="{style}"><span>▶ {_esc(src) or "video"}</span></div>' | |
| def _render_latex(el: dict, idx: int) -> str: | |
| style = _base_box(el, "display:flex;align-items:center;overflow:visible;") | |
| align = el.get("align") or "center" | |
| justify = {"left": "flex-start", "center": "center", "right": "flex-end"}.get(align, "center") | |
| style += f"justify-content:{justify};" | |
| if el.get("color"): | |
| style += f"color:{_esc(el['color'])};" | |
| if el.get("html"): | |
| return f'<div class="el latex" style="{style}">{el["html"]}</div>' | |
| return ( | |
| f'<div class="el latex" style="{style}">' | |
| f'<span class="katex-target" id="ktx{idx}" data-latex="{_esc(el.get("latex") or "")}"></span></div>' | |
| ) | |
| def _render_code(el: dict) -> str: | |
| lines = el.get("lines") or [] | |
| text = "\n".join( | |
| str(line.get("content", "")) if isinstance(line, dict) else str(line) for line in lines | |
| ) | |
| style = _base_box(el, "overflow:auto;background:#1e293b;border-radius:6px;") | |
| fs = _num(el.get("fontSize"), 14) | |
| return ( | |
| f'<div class="el code" style="{style}">' | |
| f'<pre style="margin:0;padding:10px;color:#e2e8f0;font-size:{fs}px;' | |
| f'font-family:ui-monospace,SFMono-Regular,Menlo,monospace">{_esc(text)}</pre></div>' | |
| ) | |
| def _render_chart(el: dict, idx: int) -> tuple[str, dict | None]: | |
| style = _base_box(el) | |
| if el.get("fill"): | |
| style += f"background:{_esc(el['fill'])};" | |
| style += _css_outline(el.get("outline")) | |
| div = f'<div class="el chart" id="chart{idx}" style="{style}"></div>' | |
| data = el.get("data") or {} | |
| labels = [str(x) for x in (data.get("labels") or [])] | |
| legends = [str(x) for x in (data.get("legends") or [])] | |
| series = data.get("series") or [] | |
| if not isinstance(series, list) or not labels: | |
| return div, None | |
| series = [s if isinstance(s, list) else [s] for s in series] | |
| colors = el.get("themeColors") or DEFAULT_THEME_COLORS | |
| text_color = el.get("textColor") or "#333333" | |
| line_color = el.get("lineColor") or "#d9d9d9" | |
| chart_type = el.get("chartType") or "bar" | |
| smooth = bool((el.get("options") or {}).get("lineSmooth")) | |
| stack = "total" if (el.get("options") or {}).get("stack") else None | |
| spec = { | |
| "chartType": chart_type, | |
| "labels": labels, | |
| "legends": legends, | |
| "series": series, | |
| "colors": colors, | |
| "textColor": text_color, | |
| "lineColor": line_color, | |
| "smooth": smooth, | |
| "stack": stack, | |
| "id": f"chart{idx}", | |
| } | |
| return div, spec | |
| # ---------------------------------------------------------------------- assemble | |
| _PAGE = """<!DOCTYPE html> | |
| <html lang="en"><head><meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"> | |
| <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script> | |
| <style> | |
| html,body{margin:0;padding:0;background:#eef1f5;font-family:__FONT__;} | |
| #wrap{width:100%;overflow:hidden;} | |
| #stage{width:1000px;height:562px;position:relative;transform-origin:top left; | |
| box-shadow:0 2px 18px rgba(0,0,0,.18);overflow:hidden;} | |
| .el{box-sizing:border-box;} | |
| .el.text .rich{width:100%;} | |
| .rich p{margin:0 0 4px 0;} | |
| .rich ul,.rich ol{margin:0 0 4px 0;padding-left:1.3em;} | |
| .placeholder{display:flex;align-items:center;justify-content:center;border:1px dashed #94a3b8; | |
| color:#64748b;background:#f8fafc;font-size:13px;text-align:center;} | |
| table{font-size:15px;} | |
| </style></head> | |
| <body><div id="wrap"><div id="stage" style="__BG__">__ELEMENTS__</div></div> | |
| <script> | |
| const CHARTS = __CHARTS__; | |
| function fit(){ | |
| const s = document.getElementById('wrap').clientWidth / 1000; | |
| const st = document.getElementById('stage'); | |
| st.style.transform = 'scale(' + s + ')'; | |
| document.getElementById('wrap').style.height = (562 * s) + 'px'; | |
| } | |
| window.addEventListener('resize', fit); fit(); | |
| window.addEventListener('load', function(){ | |
| document.querySelectorAll('.katex-target').forEach(function(node){ | |
| try { katex.render(node.dataset.latex || '', node, {throwOnError:false, displayMode:true}); } | |
| catch(e){ node.textContent = node.dataset.latex || ''; } | |
| }); | |
| if (typeof echarts === 'undefined') return; | |
| CHARTS.forEach(function(c){ | |
| const dom = document.getElementById(c.id); if(!dom) return; | |
| const axisLabel = {color: c.textColor}; | |
| const splitLine = {lineStyle:{color: c.lineColor}}; | |
| let option = {color: c.colors, animation:false, | |
| textStyle:{fontFamily:'inherit', color:c.textColor}, | |
| legend: c.legends.length > 1 ? {data:c.legends, textStyle:{color:c.textColor}} : undefined, | |
| grid:{left:'8%', right:'6%', top: c.legends.length>1 ? '18%' : '10%', bottom:'12%', containLabel:true}}; | |
| const mk = function(type, extra){ | |
| return c.series.map(function(s, i){ | |
| return Object.assign({name: c.legends[i] || ('Series ' + (i+1)), type: type, | |
| data: s, stack: c.stack || undefined}, extra || {}); }); | |
| }; | |
| if (c.chartType === 'pie' || c.chartType === 'ring') { | |
| const vals = (c.series[0] || []).map(function(v, i){ return {value:v, name:c.labels[i]}; }); | |
| option.grid = undefined; | |
| option.series = [{type:'pie', data:vals, radius: c.chartType === 'ring' ? ['40%','68%'] : '68%', | |
| label:{color:c.textColor}}]; | |
| } else if (c.chartType === 'radar') { | |
| const flat = c.series.flat(); | |
| const max = Math.max.apply(null, flat.length ? flat : [1]); | |
| option.grid = undefined; | |
| option.radar = {indicator: c.labels.map(function(l){ return {name:l, max:max}; }), | |
| axisName:{color:c.textColor}}; | |
| option.series = [{type:'radar', data: c.series.map(function(s,i){ | |
| return {value:s, name: c.legends[i] || ('Series ' + (i+1))}; })}]; | |
| } else if (c.chartType === 'scatter') { | |
| option.xAxis = {type:'value', axisLabel:axisLabel, splitLine:splitLine}; | |
| option.yAxis = {type:'value', axisLabel:axisLabel, splitLine:splitLine}; | |
| option.series = c.series.map(function(s,i){ | |
| return {type:'scatter', name:c.legends[i] || ('Series ' + (i+1)), | |
| data: s.map(function(v,j){ return [Number(c.labels[j]) || j, v]; })}; }); | |
| } else if (c.chartType === 'bar') { | |
| option.xAxis = {type:'value', axisLabel:axisLabel, splitLine:splitLine}; | |
| option.yAxis = {type:'category', data:c.labels, axisLabel:axisLabel}; | |
| option.series = mk('bar'); | |
| } else if (c.chartType === 'line' || c.chartType === 'area') { | |
| option.xAxis = {type:'category', data:c.labels, axisLabel:axisLabel}; | |
| option.yAxis = {type:'value', axisLabel:axisLabel, splitLine:splitLine}; | |
| option.series = mk('line', c.chartType === 'area' | |
| ? {smooth:c.smooth, areaStyle:{}} : {smooth:c.smooth}); | |
| } else { | |
| option.xAxis = {type:'category', data:c.labels, axisLabel:axisLabel}; | |
| option.yAxis = {type:'value', axisLabel:axisLabel, splitLine:splitLine}; | |
| option.series = mk('bar'); | |
| } | |
| try { echarts.init(dom).setOption(option); } catch(e) {} | |
| }); | |
| }); | |
| </script></body></html> | |
| """ | |
| def _background_css(background: Any) -> str: | |
| if not isinstance(background, dict): | |
| return "background:#ffffff;" | |
| b_type = background.get("type") or "solid" | |
| if b_type == "gradient" and isinstance(background.get("gradient"), dict): | |
| css = _gradient_css(background["gradient"]) | |
| if css: | |
| return f"background:{css};" | |
| if b_type == "image": | |
| image = background.get("image") | |
| src = image.get("src") if isinstance(image, dict) else background.get("imageSrc") | |
| if isinstance(src, str) and (src.startswith("http") or src.startswith("data:")): | |
| return f"background:#fff url('{_esc(src)}') center/cover no-repeat;" | |
| return f"background:{_esc(background.get('color') or '#ffffff')};" | |
| def render_slide(slide: dict) -> str: | |
| """Render a slide scene-graph dict into a standalone HTML document string.""" | |
| elements = slide.get("elements") or [] | |
| parts: list[str] = [] | |
| charts: list[dict] = [] | |
| for idx, el in enumerate(elements): | |
| if not isinstance(el, dict): | |
| continue | |
| el_type = el.get("type") | |
| try: | |
| if el_type == "text": | |
| parts.append(_render_text(el)) | |
| elif el_type == "shape": | |
| parts.append(_render_shape(el, idx)) | |
| elif el_type == "line": | |
| parts.append(_render_line(el, idx)) | |
| elif el_type == "table": | |
| parts.append(_render_table(el)) | |
| elif el_type == "image": | |
| parts.append(_render_image(el)) | |
| elif el_type == "video": | |
| parts.append(_render_video(el)) | |
| elif el_type == "latex": | |
| parts.append(_render_latex(el, idx)) | |
| elif el_type == "code": | |
| parts.append(_render_code(el)) | |
| elif el_type == "chart": | |
| div, spec = _render_chart(el, idx) | |
| parts.append(div) | |
| if spec: | |
| charts.append(spec) | |
| else: | |
| parts.append( | |
| f'<div class="el placeholder" style="{_base_box(el)}">' | |
| f"<span>unsupported element: {_esc(el_type)}</span></div>" | |
| ) | |
| except Exception as exc: # a malformed element must not kill the page | |
| parts.append( | |
| f'<div class="el placeholder" style="{_base_box(el)}">' | |
| f"<span>{_esc(el_type)} render error: {_esc(exc)}</span></div>" | |
| ) | |
| return ( | |
| _PAGE.replace("__FONT__", FONT_STACK) | |
| .replace("__BG__", _background_css(slide.get("background"))) | |
| .replace("__ELEMENTS__", "".join(parts)) | |
| .replace("__CHARTS__", json.dumps(charts)) | |
| ) | |