"""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'
{content}
' # -------------------------------------------------------------------------- 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'' for c in gradient["colors"] if isinstance(c, dict) ) if (gradient.get("type") or "linear") == "radial": defs = f'{stops}' else: rot = _num(gradient.get("rotate")) defs = ( f'' f"{stops}" ) 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'{defs}' f'' ) 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'
{text["content"]}
' style = _base_box(el, _shadow(el.get("shadow"))) return f'
{svg}{inner}
' # --------------------------------------------------------------------------- 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'' if is_start: body = f'' defs.append( f'{body}' ) else: defs.append( f'' ) marker_attrs += f' marker-{"start" if is_start else "end"}="url(#{mid})"' defs_str = f"{''.join(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'
' f'' f'{defs_str}
' ) # -------------------------------------------------------------------------- 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'' 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'{text}') rows_html.append(f'{"".join(cells)}') style = _base_box(el, "overflow:visible;") return ( f'
' f'' f"{cols}{''.join(rows_html)}
" ) # -------------------------------------------------------------------------- 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'
' f'
' ) label = _esc(src) if src else "image" return ( f'
' f'🖼 {label}
' ) 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'
' f'
' ) return f'
â–¶ {_esc(src) or "video"}
' 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'
{el["html"]}
' return ( f'
' f'
' ) 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'
' f'
{_esc(text)}
' ) 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'
' 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 = """
__ELEMENTS__
""" 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'
' f"unsupported element: {_esc(el_type)}
" ) except Exception as exc: # a malformed element must not kill the page parts.append( f'
' f"{_esc(el_type)} render error: {_esc(exc)}
" ) return ( _PAGE.replace("__FONT__", FONT_STACK) .replace("__BG__", _background_css(slide.get("background"))) .replace("__ELEMENTS__", "".join(parts)) .replace("__CHARTS__", json.dumps(charts)) )