"""A deck as HTML: one standalone page per slide (shown in the app in a sandboxed frame, scaled to fit) and one page with every slide (printed to PDF). Every word from the model is escaped; the markup is all ours.""" import math from markupsafe import escape from app.slides.content import field_limit from app.slides.decor import Decor, Shape, build, ring_path from app.slides.layout import Composed, El, Run, compose from app.slides.themes import FONTS, THEMES, H, Palette, W, theme_id FONT_LINK = ("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;700" "&family=JetBrains+Mono:wght@400;700&display=swap") _FAMILY = {"display": f"'{FONTS['display']}','{FONTS['body']}',sans-serif", "body": f"'{FONTS['body']}',sans-serif", "mono": f"'{FONTS['mono']}',monospace"} def color(pal: Palette, key: str | None) -> str: if not key: return "transparent" return "#ffffff" if key == "white" else getattr(pal, key) CSS = f""" html,body{{margin:0;padding:0}} .slide{{position:relative;width:{W}px;height:{H}px;overflow:hidden;font-family:{_FAMILY['body']}; -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}} .slide svg.bg{{position:absolute;inset:0}} .e{{position:absolute;box-sizing:border-box;margin:0}} .t{{display:flex;flex-direction:column;overflow:visible;overflow-wrap:anywhere}} .t>.in{{display:block}} .t.v-middle{{justify-content:center}}.t.v-bottom{{justify-content:flex-end}} .t p{{margin:0}} .t b{{font-weight:700}} .r-heading,.r-quote,.r-closing,.r-term{{text-wrap:balance}} .dark .r-heading.l-title,.dark .r-value{{text-shadow:0 0 48px rgba(33,124,255,.45)}} .dark .r-marker{{box-shadow:0 0 36px rgba(33,124,255,.75)}} .dark .r-bar{{box-shadow:0 0 18px rgba(77,148,255,.8)}} .light .r-card,.light .r-panel{{box-shadow:0 18px 44px rgba(11,26,58,.14)}} .light .r-band{{box-shadow:0 18px 44px rgba(11,26,58,.26)}} """ def _style(**kw) -> str: return ";".join(f"{k.replace('_', '-')}:{v}" for k, v in kw.items() if v is not None) def _el(e: El, pal: Palette, layout: str, fmt: str) -> str: box = dict(left=f"{e.x:.1f}px", top=f"{e.y:.1f}px", width=f"{e.w:.1f}px", height=f"{e.h:.1f}px") if e.kind == "text": paras = [] for i, p in enumerate(e.paras): runs = "".join( f'' f'{escape(r.text)}' for r in p) gap = f' style="margin-top:{e.gap:.1f}px"' if i and e.gap else "" paras.append(f"{runs}

") css = _style(**box, font_size=f"{e.size}px", line_height=str(e.lh), font_family=_FAMILY[e.font], text_align=e.align, letter_spacing=f"{e.track}em" if e.track else None, text_transform="uppercase" if e.upper else None, font_weight="700" if e.font == "display" else None) return f'
{"".join(paras)}
' if e.kind == "line": horizontal = e.h == 0 css = _style(left=f"{e.x:.1f}px", top=f"{e.y - (e.stroke_w / 2 if horizontal else 0):.1f}px", width=f"{e.w if horizontal else e.stroke_w:.1f}px", height=f"{e.stroke_w if horizontal else e.h:.1f}px", background=color(pal, e.stroke), border_radius="2px") return f'
' radius = "50%" if e.kind == "circle" else f"{e.radius}px" border = f"{e.stroke_w}px solid {color(pal, e.stroke)}" if e.stroke and e.stroke_w else None css = _style(**box, background=color(pal, e.fill), border=border, border_radius=radius, opacity=str(e.opacity) if e.opacity < 1 else None) return f'
' def _editable(r: Run, layout: str, fmt: str) -> str: """Where a run's text lives and how long it may be, for the page's editor (none on our own text: numbers, footers).""" return f' data-f="{r.field}" data-max="{field_limit(layout, r.field, fmt)}"' if r.field else "" def _svg(d: Decor, pal: Palette, dark: bool, uid: str) -> str: defs, out = [], [] for n, s in enumerate(d.shapes): out.append(_shape(s, pal, dark, f"{uid}{n}", defs)) hx, hy = d.hot sky = (f'' f'') return (f'{sky}{"".join(defs)}' f'{"".join(out)}') def _shape(s: Shape, pal: Palette, dark: bool, gid: str, defs: list[str]) -> str: c = color(pal, s.color) if s.kind == "stars": return "".join(f'' for x, y, r, o in s.points) if s.kind == "glow": defs.append(f'' f'') return f'' if s.kind == "planet": hi, lo, rim = planet_colors(pal, dark) defs.append(f'' f'') return (f'' f'') if s.kind == "band": lo, hi = ring_shade(s) defs.append(f'' + "".join(f'' for o, a in SHADE) + "") pts = ring_path(s) + ring_path(s, s.inner)[::-1] return f'' if s.kind == "ring" and s.arc: return (f'') if s.kind == "ring": return (f'') if s.kind == "const": lines = "".join(f'' for a, b in s.edges) dots = "".join(f'' for x, y, r, _ in s.points) return lines + dots return "" # How brightly a ringed planet's band shows across its width, left to right: lit from the upper left, as the planet is. SHADE = ((0, 1.0), (0.45, 0.8), (1, 0.3)) def ring_shade(s: Shape) -> tuple[float, float]: """The band's left and right extent on the slide, which SHADE runs across.""" half = math.hypot(s.rx * math.cos(math.radians(s.rot)), s.ry * math.sin(math.radians(s.rot))) return s.cx - half, s.cx + half def _pts(pts: list[tuple[float, float]]) -> str: return "L".join(f"{x:.1f} {y:.1f}" for x, y in pts) def planet_colors(pal: Palette, dark: bool) -> tuple[str, str, str]: """Lit side, dark side and rim of a planet.""" return ("#1d4fb3", "#020713", pal.accent) if dark else ("#5d9bff", pal.accent2, pal.accent) def slide_body(c: Composed, deck: dict, index: int, theme: str, fmt: str) -> str: t = THEMES[theme] s = c.slide d = build(theme, s["layout"], s["variant"], c.seed, index) els = "".join(_el(e, t.pal, s["layout"], fmt) for e in c.elements) cls = f"slide {'dark' if t.dark else 'light'} l-{s['layout']} v-{s['variant']}" return f'
{_svg(d, t.pal, t.dark, f"s{index}g")}{els}
' def _page(body: str, extra_css: str = "") -> str: return (f'' f"{body}") def slide_html(deck: dict, index: int, theme: str, fmt: str, seed: int) -> str: """One slide as a standalone page.""" theme = theme_id(theme) return _page(slide_body(compose(deck, index, theme, fmt, seed), deck, index, theme, fmt), f"body{{background:{THEMES[theme].pal.bg1}}}") def slides_html(deck: dict, theme: str, fmt: str, seed: int, start: int, end: int) -> str: """Slides start..end-1 of the deck stacked on one page, each as it is in the deck (its number, its sky).""" theme = theme_id(theme) body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme, fmt) for i in range(start, end)) return _page(body, f"body{{background:{THEMES[theme].pal.bg1}}}") def deck_print_html(deck: dict, theme: str, fmt: str, seed: int, script: str = "") -> str: """Every slide on one page, one per printed page.""" theme = theme_id(theme) body = "".join(slide_body(compose(deck, i, theme, fmt, seed), deck, i, theme, fmt) for i in range(len(deck["slides"]))) css = (f"@page{{size:{W}px {H}px;margin:0}}body{{background:{THEMES[theme].pal.bg1}}}" ".slide{break-after:page;page-break-after:always}.slide:last-child{break-after:auto;page-break-after:auto}" "*{-webkit-print-color-adjust:exact;print-color-adjust:exact}") return _page(body + script, css)