Spaces:
Runtime error
Runtime error
File size: 10,124 Bytes
fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c 4a3d0a5 fa8df4c fa6e4a3 fa8df4c 4a3d0a5 fa8df4c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | """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'<span{_editable(r, layout, fmt)} style="color:{color(pal, r.color)}{";font-weight:700" if r.bold else ""}">'
f'{escape(r.text)}</span>' for r in p)
gap = f' style="margin-top:{e.gap:.1f}px"' if i and e.gap else ""
paras.append(f"<p{gap}>{runs}</p>")
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'<div class="e t r-{e.role} l-{layout} v-{e.valign}" style="{css}"><div class="in">{"".join(paras)}</div></div>'
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'<div class="e r-{e.role}" style="{css}"></div>'
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'<div class="e r-{e.role}" style="{css}"></div>'
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'<radialGradient id="{uid}sky" gradientUnits="userSpaceOnUse" cx="{hx:.0f}" cy="{hy:.0f}" r="{W * 0.85:.0f}">'
f'<stop offset="0" stop-color="{pal.bg2}"/><stop offset="1" stop-color="{pal.bg1}"/></radialGradient>')
return (f'<svg class="bg" width="{W}" height="{H}" viewBox="0 0 {W} {H}"><defs>{sky}{"".join(defs)}</defs>'
f'<rect width="{W}" height="{H}" fill="url(#{uid}sky)"/>{"".join(out)}</svg>')
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'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{o}"/>' for x, y, r, o in s.points)
if s.kind == "glow":
defs.append(f'<radialGradient id="{gid}"><stop offset="0" stop-color="{c}" stop-opacity="{s.opacity}"/>'
f'<stop offset="1" stop-color="{c}" stop-opacity="0"/></radialGradient>')
return f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
if s.kind == "planet":
hi, lo, rim = planet_colors(pal, dark)
defs.append(f'<radialGradient id="{gid}" cx="35%" cy="28%" r="80%"><stop offset="0" stop-color="{hi}"/>'
f'<stop offset="1" stop-color="{lo}"/></radialGradient>')
return (f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="url(#{gid})"/>'
f'<circle cx="{s.cx:.0f}" cy="{s.cy:.0f}" r="{s.r:.0f}" fill="none" stroke="{rim}" stroke-width="3" opacity=".7"/>')
if s.kind == "band":
lo, hi = ring_shade(s)
defs.append(f'<linearGradient id="{gid}" gradientUnits="userSpaceOnUse" x1="{lo:.0f}" y1="0" x2="{hi:.0f}" y2="0">'
+ "".join(f'<stop offset="{o}" stop-color="{c}" stop-opacity="{a * s.opacity:.3f}"/>' for o, a in SHADE)
+ "</linearGradient>")
pts = ring_path(s) + ring_path(s, s.inner)[::-1]
return f'<path d="M{_pts(pts)}Z" fill="url(#{gid})"/>'
if s.kind == "ring" and s.arc:
return (f'<path d="M{_pts(ring_path(s))}" fill="none" stroke="{c}" stroke-width="{s.width}" '
f'stroke-linecap="round" opacity="{s.opacity}"/>')
if s.kind == "ring":
return (f'<ellipse cx="{s.cx:.0f}" cy="{s.cy:.0f}" rx="{s.rx:.0f}" ry="{s.ry:.0f}" fill="none" stroke="{c}" '
f'stroke-width="{s.width}" opacity="{s.opacity}" transform="rotate({s.rot:.1f} {s.cx:.0f} {s.cy:.0f})"/>')
if s.kind == "const":
lines = "".join(f'<line x1="{s.points[a][0]:.0f}" y1="{s.points[a][1]:.0f}" x2="{s.points[b][0]:.0f}" '
f'y2="{s.points[b][1]:.0f}" stroke="{c}" stroke-width="1.6" opacity="{s.opacity * 0.5:.2f}"/>'
for a, b in s.edges)
dots = "".join(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="{r}" fill="{c}" opacity="{s.opacity}"/>' 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'<div class="{cls}" style="background:{t.pal.bg1}">{_svg(d, t.pal, t.dark, f"s{index}g")}{els}</div>'
def _page(body: str, extra_css: str = "") -> str:
return (f'<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="{FONT_LINK}">'
f"<style>{CSS}{extra_css}</style></head><body>{body}</body></html>")
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)
|