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)