Spaces:
Runtime error
Runtime error
Download backend/app/slides/layout.py from firebird-technologies/NoteStack-Test: direct link, hf CLI and curl.
- Browser
- Download file 38.9 kB
-
https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/main/backend/app/slides/layout.py
- Command line
-
hf download hf://spaces/firebird-technologies/NoteStack-Test/backend/app/slides/layout.py
-
curl -L -o layout.py https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/main/backend/app/slides/layout.py
38.9 kB
| """Where everything on a slide goes. Each theme has its own composition for every layout variant (GEOMETRY), the | |
| variant of each slide is picked by code (assign_variants), and compose() turns one slide into placed elements: boxes | |
| in pixels on the 1920 x 1080 canvas, each with its text and font size. The HTML and the PowerPoint both draw these | |
| same elements, so the two always match. | |
| Text is measured here before it is placed: every text box gets the largest font size at which its text fits, and | |
| text that cannot fit even at the smallest size is shortened. Nothing is ever drawn outside its box.""" | |
| import copy | |
| import math | |
| import random | |
| import re | |
| from dataclasses import dataclass, field | |
| from app.slides.content import LIMITS, clip | |
| from app.slides.metrics import WIDTHS | |
| from app.slides.themes import theme_id | |
| # --- geometry ------------------------------------------------------------------------------------------------------- | |
| # A block is (x, y, w, h[, align[, valign[, kind]]]). `kind` says how a body, a column or the closing line is drawn. | |
| # Night stellar (dark-space): cinematic, centred openers and closers, asymmetric splits. Moon light (light-space): | |
| # editorial, left aligned, top anchored, mirrored splits. | |
| GEOMETRY: dict[str, dict[str, dict[str, dict[str, tuple]]]] = { | |
| "dark-space": { | |
| "title": { | |
| "a": {"kicker": (360, 258, 1200, 40, "center"), "heading": (160, 312, 1600, 300, "center", "middle"), | |
| "lead": (360, 636, 1200, 130, "center")}, | |
| "b": {"kicker": (140, 512, 1100, 40), "heading": (140, 562, 1150, 290, "left", "bottom"), | |
| "lead": (140, 872, 1000, 120)}, | |
| "c": {"kicker": (140, 286, 860, 40), "heading": (140, 340, 880, 400, "left", "middle"), | |
| "lead": (140, 762, 860, 150)}, | |
| }, | |
| "agenda": { | |
| "a": {"kicker": (140, 160, 700, 40), "heading": (140, 212, 700, 420), | |
| "body": (960, 140, 820, 820, "left", "middle", "agenda_list")}, | |
| "b": {"kicker": (360, 110, 1200, 40, "center"), "heading": (260, 160, 1400, 130, "center", "middle"), | |
| "body": (220, 340, 1480, 620, "left", "top", "agenda_grid")}, | |
| }, | |
| "section": { | |
| "a": {"kicker": (360, 368, 1200, 40, "center"), "heading": (210, 420, 1500, 240, "center", "middle"), | |
| "lead": (410, 684, 1100, 150, "center")}, | |
| "b": {"kicker": (140, 552, 1100, 40), "heading": (140, 604, 1300, 240, "left", "bottom"), | |
| "lead": (140, 864, 1100, 120)}, | |
| }, | |
| "points": { | |
| "a": {"kicker": (140, 100, 1300, 40), "heading": (140, 150, 1360, 150), "lead": (140, 314, 1300, 110), | |
| "body": (140, 446, 1300, 534, "left", "top", "list")}, | |
| "b": {"kicker": (140, 160, 640, 40), "heading": (140, 212, 640, 500), "lead": (140, 730, 640, 240), | |
| "body": (880, 130, 900, 840, "left", "middle", "list")}, | |
| "c": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100), | |
| "body": (140, 432, 1640, 548, "left", "top", "cards")}, | |
| "d": {"kicker": (140, 160, 700, 40), "heading": (140, 212, 700, 430), "lead": (140, 662, 700, 300), | |
| "body": (930, 110, 850, 860, "left", "middle", "timeline_v")}, | |
| }, | |
| "two_column": { | |
| "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100), | |
| "left": (140, 432, 790, 548, "left", "top", "col"), "right": (990, 432, 790, 548, "left", "top", "col")}, | |
| "b": {"kicker": (360, 90, 1200, 40, "center"), "heading": (210, 140, 1500, 130, "center", "middle"), | |
| "lead": (360, 282, 1200, 90, "center"), | |
| "left": (140, 410, 780, 570, "left", "top", "panel"), "right": (1000, 410, 780, 570, "left", "top", "panel")}, | |
| }, | |
| "stat": { | |
| "a": {"kicker": (360, 120, 1200, 40, "center"), "heading": (260, 170, 1400, 130, "center", "middle"), | |
| "value": (160, 318, 1600, 300, "center", "middle"), "label": (360, 630, 1200, 120, "center"), | |
| "lead": (410, 776, 1100, 180, "center")}, | |
| "b": {"kicker": (140, 150, 820, 40), "value": (140, 206, 820, 420, "left", "middle"), | |
| "label": (140, 650, 820, 300), "heading": (1060, 160, 720, 290, "left", "bottom"), | |
| "lead": (1060, 480, 720, 480)}, | |
| }, | |
| "quote": { | |
| "a": {"kicker": (360, 150, 1200, 40, "center"), "quote": (210, 214, 1500, 500, "center", "middle"), | |
| "by": (360, 740, 1200, 60, "center"), "heading": (360, 846, 1200, 110, "center")}, | |
| "b": {"kicker": (140, 150, 640, 40), "heading": (140, 202, 640, 400), "lead": (140, 630, 640, 320), | |
| "quote": (900, 160, 880, 640, "left", "middle"), "by": (900, 820, 880, 60)}, | |
| }, | |
| "closing": { | |
| "a": {"kicker": (360, 90, 1200, 40, "center"), "heading": (210, 140, 1500, 120, "center", "middle"), | |
| "body": (140, 300, 1640, 430, "center", "top", "take_arc"), | |
| "closing": (260, 760, 1400, 190, "center", "middle", "line")}, | |
| "b": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 120), | |
| "body": (140, 308, 1640, 450, "left", "top", "take_cols"), | |
| "closing": (140, 800, 1640, 170, "left", "middle", "band")}, | |
| "c": {"kicker": (140, 190, 820, 40), "closing": (140, 244, 820, 600, "left", "middle", "big"), | |
| "heading": (1060, 150, 720, 110), "body": (1060, 290, 720, 680, "left", "top", "take_stack")}, | |
| }, | |
| }, | |
| "light-space": { | |
| "title": { | |
| "a": {"kicker": (140, 318, 1150, 40), "heading": (140, 368, 1150, 350), "lead": (140, 738, 1000, 150)}, | |
| "b": {"kicker": (140, 120, 1640, 40), "heading": (140, 174, 1640, 430, "left", "bottom"), "lead": (140, 640, 1150, 170)}, | |
| "c": {"kicker": (360, 326, 1200, 40, "center"), "heading": (300, 378, 1320, 270, "center", "middle"), | |
| "lead": (410, 670, 1100, 130, "center")}, | |
| }, | |
| "agenda": { | |
| "a": {"kicker": (140, 110, 1640, 40), "heading": (140, 160, 1640, 130), | |
| "body": (140, 336, 1640, 630, "left", "top", "agenda_grid")}, | |
| "b": {"kicker": (140, 376, 640, 40), "heading": (140, 428, 640, 320), | |
| "body": (900, 130, 880, 840, "left", "middle", "agenda_list")}, | |
| }, | |
| "section": { | |
| "a": {"kicker": (140, 408, 1300, 40), "heading": (140, 460, 1300, 240), "lead": (140, 716, 1100, 150)}, | |
| "b": {"kicker": (620, 408, 1160, 40, "right"), "heading": (480, 460, 1300, 240, "right"), | |
| "lead": (680, 716, 1100, 150, "right")}, | |
| }, | |
| "points": { | |
| "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1500, 100), | |
| "body": (140, 432, 1640, 548, "left", "top", "list2")}, | |
| "b": {"kicker": (1180, 160, 600, 40), "heading": (1180, 212, 600, 500), "lead": (1180, 730, 600, 240), | |
| "body": (140, 130, 940, 840, "left", "middle", "list")}, | |
| "c": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100), | |
| "body": (140, 432, 1640, 548, "left", "top", "cards")}, | |
| "d": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100), | |
| "body": (140, 440, 1640, 540, "left", "top", "timeline_h")}, | |
| }, | |
| "two_column": { | |
| "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), "lead": (140, 302, 1640, 100), | |
| "left": (140, 432, 790, 548, "left", "top", "panel"), "right": (990, 432, 790, 548, "left", "top", "panel")}, | |
| "b": {"kicker": (140, 160, 560, 40), "heading": (140, 212, 560, 420), "lead": (140, 650, 560, 320), | |
| "left": (780, 130, 1000, 400, "left", "top", "col"), "right": (780, 566, 1000, 400, "left", "top", "col")}, | |
| }, | |
| "stat": { | |
| "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 140), | |
| "value": (140, 330, 900, 360, "left", "middle"), "label": (140, 700, 900, 260), | |
| "lead": (1120, 360, 660, 600)}, | |
| "b": {"kicker": (360, 130, 1200, 40, "center"), "value": (260, 186, 1400, 320, "center", "middle"), | |
| "label": (360, 526, 1200, 120, "center"), "heading": (260, 690, 1400, 110, "center", "middle"), | |
| "lead": (410, 820, 1100, 150, "center")}, | |
| }, | |
| "quote": { | |
| "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 110), | |
| "quote": (250, 300, 1430, 470, "left", "middle"), "by": (250, 796, 1430, 60)}, | |
| "b": {"kicker": (620, 130, 1160, 40, "right"), "heading": (620, 180, 1160, 110, "right"), | |
| "quote": (420, 330, 1360, 450, "right", "middle"), "by": (620, 806, 1160, 60, "right")}, | |
| }, | |
| "closing": { | |
| "a": {"kicker": (140, 100, 1640, 40), "heading": (140, 150, 1640, 120), | |
| "body": (140, 318, 1640, 440, "left", "top", "take_cols"), | |
| "closing": (140, 800, 1640, 170, "left", "middle", "band")}, | |
| "b": {"kicker": (140, 110, 1640, 40), "closing": (140, 160, 1500, 390, "left", "top", "big"), | |
| "heading": (140, 590, 1640, 80), "body": (140, 690, 1640, 290, "left", "top", "take_row")}, | |
| "c": {"kicker": (360, 130, 1200, 40, "center"), "heading": (260, 180, 1400, 110, "center", "middle"), | |
| "body": (410, 330, 1100, 410, "left", "top", "take_stack"), | |
| "closing": (260, 772, 1400, 180, "center", "middle", "line")}, | |
| }, | |
| }, | |
| } | |
| VARIANTS = {layout: tuple(GEOMETRY["dark-space"][layout]) for layout in GEOMETRY["dark-space"]} | |
| def block(theme: str, layout: str, variant: str, name: str) -> tuple | None: | |
| g = GEOMETRY[theme_id(theme)][layout].get(variant) or next(iter(GEOMETRY[theme_id(theme)][layout].values())) | |
| b = g.get(name) | |
| if not b: | |
| return None | |
| x, y, w, h, *rest = b | |
| return (x, y, w, h, rest[0] if rest else "left", rest[1] if len(rest) > 1 else "top", rest[2] if len(rest) > 2 else "") | |
| # --- picking variants ----------------------------------------------------------------------------------------------- | |
| def fits_variant(slide: dict, theme: str, variant: str) -> bool: | |
| """Whether a variant suits this slide's content (a card row needs 3 or 4 points, a timeline 3 to 5).""" | |
| layout = slide["layout"] | |
| n = len(slide["points"]) | |
| kind = (block(theme, layout, variant, "body") or (0, 0, 0, 0, "", "", ""))[6] | |
| if kind == "cards": | |
| return 3 <= n <= 4 | |
| if kind in ("timeline_v", "timeline_h"): | |
| return 3 <= n <= 5 | |
| if kind == "list2": | |
| return n >= 3 | |
| if kind == "agenda_list": | |
| return n <= 7 | |
| return True | |
| def assign_variants(slides: list[dict], theme: str, seed: int, previous: list[tuple[str, str]] | None = None) -> None: | |
| """Give every slide a variant, in place. Only variants that suit the slide are considered; the previous slide's | |
| layout and variant is never repeated; variants not used yet in this deck come first; and the variant the last deck | |
| in the notebook had at the same position is avoided. Ties are broken by `seed` (the deck's own id), so every new | |
| deck differs while one deck always draws the same.""" | |
| theme = theme_id(theme) | |
| rnd = random.Random(seed) | |
| used: dict[tuple[str, str], int] = {} | |
| before: tuple[str, str] | None = None | |
| previous = previous or [] | |
| for i, s in enumerate(slides): | |
| options = [v for v in VARIANTS[s["layout"]] if fits_variant(s, theme, v)] or list(VARIANTS[s["layout"]]) | |
| fresh = [v for v in options if (s["layout"], v) != before] | |
| if not fresh: # the only variant that suits it was just used: any other one draws it too, so never repeat | |
| fresh = [v for v in VARIANTS[s["layout"]] if (s["layout"], v) != before] or options | |
| last_time = previous[i] if i < len(previous) else None | |
| scores = {v: (used.get((s["layout"], v), 0), 1 if last_time == (s["layout"], v) else 0, rnd.random()) | |
| for v in fresh} | |
| choice = min(fresh, key=scores.__getitem__) | |
| s["variant"] = choice | |
| used[(s["layout"], choice)] = used.get((s["layout"], choice), 0) + 1 | |
| before = (s["layout"], choice) | |
| # --- measuring text ------------------------------------------------------------------------------------------------- | |
| def _char_w(c: str, font: str, bold: bool) -> float: | |
| """Advance of one character in em, from the fonts' measured widths, padded a little so real text is never wider.""" | |
| if font == "mono": | |
| return 0.62 | |
| table = WIDTHS["display_bold" if font == "display" else "body_bold" if bold else "body"] | |
| w = table.get(c) | |
| if w is None: | |
| w = 1.0 if ord(c) >= 0x2E80 else 0.64 # full width scripts, and anything else not measured | |
| return w * 1.03 | |
| class Run: | |
| text: str | |
| bold: bool = False | |
| color: str = "ink" | |
| field: str = "" # where the text lives in the slide ("heading", "points.2.text"), so the page can edit it; "" is ours | |
| def _lines(runs: list[Run], width: float, size: float, font: str, upper: bool, track: float) -> int: | |
| """Lines a paragraph wraps to in `width` pixels.""" | |
| words: list[tuple[str, bool]] = [] | |
| for r in runs: | |
| t = r.text.upper() if upper else r.text | |
| words += [(w, r.bold) for w in t.split(" ") if w] | |
| if not words: | |
| return 0 | |
| space = (_char_w(" ", font, False) + track) * size | |
| lines, x = 1, 0.0 | |
| for word, bold in words: | |
| ww = sum((_char_w(c, font, bold) + track) * size for c in word) | |
| if ww > width: # a word longer than the line breaks anywhere | |
| extra = x + (space if x else 0) + ww | |
| lines += int(extra // width) | |
| x = extra % width | |
| continue | |
| need = ww if x == 0 else x + space + ww | |
| if need <= width: | |
| x = need | |
| else: | |
| lines += 1 | |
| x = ww | |
| return lines | |
| # --- placed elements ------------------------------------------------------------------------------------------------ | |
| class El: | |
| kind: str # text | rect | circle | line | |
| role: str | |
| x: float | |
| y: float | |
| w: float | |
| h: float | |
| paras: list[list[Run]] = field(default_factory=list) | |
| size: float = 0 | |
| lh: float = 1.3 | |
| font: str = "body" # display | body | mono | |
| align: str = "left" | |
| valign: str = "top" | |
| upper: bool = False | |
| track: float = 0.0 # letter spacing in em | |
| gap: float = 0.0 # space between paragraphs, in px | |
| fill: str | None = None # palette key | |
| stroke: str | None = None | |
| stroke_w: float = 0 | |
| radius: float = 0 | |
| opacity: float = 1.0 | |
| def text_height(self) -> float: | |
| return text_height(self.paras, self.w, self.size, self.lh, self.font, self.upper, self.track, self.gap) | |
| def text_height(paras: list[list[Run]], width: float, size: float, lh: float, font: str, upper: bool = False, | |
| track: float = 0.0, gap: float = 0.0) -> float: | |
| n = [_lines(p, width, size, font, upper, track) for p in paras] | |
| used = [k for k in n if k] | |
| return sum(k * size * lh for k in used) + max(len(used) - 1, 0) * gap | |
| SCALES = (1.0, 0.92, 0.85, 0.78, 0.72, 0.66, 0.6, 0.55) | |
| GROW = (1.4, 1.28, 1.18, 1.08) # sparse content is drawn larger, so a slide with little on it is not half empty | |
| def _scales(start: int, grow: bool = False) -> tuple[float, ...]: | |
| """Scales to try, largest first: from `start` down, with the growing ones first when allowed and not shrunk.""" | |
| return (GROW + SCALES) if grow and start == 0 else SCALES[start:] | |
| # Base font sizes in px, by format (both themes). Presenter slides carry fewer words, so they are drawn larger. | |
| TYPE = { | |
| "detailed": {"kicker": 24, "heading": 66, "title": 110, "section": 88, "lead": 33, "title_lead": 37, "point": 31, | |
| "card_term": 33, "card_text": 28, "value": 242, "label": 37, "quote": 57, "by": 26, "take": 31, | |
| "closing": 51, "closing_big": 73, "agenda": 35, "col_label": 31, "col_item": 30, "num": 24, | |
| "footer": 20}, | |
| "presenter": {"kicker": 26, "heading": 79, "title": 123, "section": 101, "lead": 40, "title_lead": 42, | |
| "point": 44, "card_term": 37, "card_text": 37, "value": 286, "label": 44, "quote": 66, "by": 29, | |
| "take": 40, "closing": 62, "closing_big": 86, "agenda": 40, "col_label": 33, "col_item": 40, | |
| "num": 26, "footer": 20}, | |
| } | |
| def _fit_size(paras: list[list[Run]], width: float, height: float, base: float, lh: float, font: str, | |
| start: int, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, grow: bool = False) -> float | None: | |
| """The largest size (base times a scale, from `start` down) at which the paragraphs fit the box, or None.""" | |
| for scale in _scales(start, grow): | |
| size = round(base * scale, 1) | |
| if text_height(paras, width, size, lh, font, upper, track, size * gap_em) <= height: | |
| return size | |
| return None | |
| class NoFit(Exception): | |
| """Text that does not fit even at the smallest size: the caller shortens it and tries again.""" | |
| def __init__(self, field_name: str, index: int | None = None): | |
| super().__init__(field_name) | |
| self.field_name = field_name | |
| self.index = index | |
| class Composed: | |
| slide: dict # the slide as drawn (text shortened when it had to be) | |
| elements: list[El] | |
| seed: int | |
| def _text(role: str, rect: tuple, paras: list[list[Run]], base: float, start: int, *, font: str = "body", | |
| lh: float = 1.35, upper: bool = False, track: float = 0.0, gap_em: float = 0.0, field_name: str = "", | |
| index: int | None = None, grow: bool = False) -> El: | |
| x, y, w, h, align, valign = rect[:6] | |
| size = _fit_size(paras, w, h, base, lh, font, start, upper, track, gap_em, grow) | |
| if size is None: | |
| raise NoFit(field_name or role, index) | |
| return El("text", role, x, y, w, h, paras=paras, size=size, lh=lh, font=font, align=align, valign=valign, | |
| upper=upper, track=track, gap=size * gap_em) | |
| def _point_runs(p: dict, i: int, fmt: str) -> list[Run]: | |
| if p["term"] and fmt == "detailed": | |
| return [Run(p["term"], True, "ink", f"points.{i}.term"), Run(". ", True, "ink"), | |
| Run(p["text"], False, "mute", f"points.{i}.text")] | |
| return [Run(p["text"], False, "ink", f"points.{i}.text")] | |
| def _body(els: list[El], s: dict, rect: tuple, kind: str, fmt: str, t: dict, start: int, dark: bool) -> None: | |
| """The slide's main content: points, agenda items or takeaways, drawn as `kind`.""" | |
| x, y, w, h, _align, valign, _ = rect | |
| pts = s["points"] | |
| if kind in ("list", "list2", "agenda_list", "agenda_grid", "take_stack"): | |
| if kind in ("agenda_list", "agenda_grid"): | |
| items = [[Run(p["text"], False, "ink", f"points.{i}.text")] for i, p in enumerate(pts)] | |
| base, numbered = t["agenda"], True | |
| elif kind == "take_stack": | |
| items = [[Run(tx, False, "ink", f"takeaways.{i}")] for i, tx in enumerate(s["takeaways"])] | |
| base, numbered = t["take"], True | |
| else: | |
| items = [_point_runs(p, i, fmt) for i, p in enumerate(pts)] | |
| base, numbered = t["point"], False | |
| cols = 2 if kind in ("list2", "agenda_grid") and len(items) >= 3 else 1 | |
| per = math.ceil(len(items) / cols) | |
| col_gap = 70 | |
| cw = (w - col_gap * (cols - 1)) / cols | |
| indent = 78 if numbered else 44 | |
| tw = cw - indent | |
| # One size for every item: the largest at which the tallest column fits with breathing room between items. | |
| size = None | |
| for scale in _scales(start, True): | |
| sz = round(base * scale, 1) | |
| gap = sz * 0.9 | |
| heights = [text_height([it], tw, sz, 1.38, "body") for it in items] | |
| tallest = max(sum(heights[c * per:(c + 1) * per]) + gap * (len(heights[c * per:(c + 1) * per]) - 1) | |
| for c in range(cols)) | |
| if tallest <= h: | |
| size = sz | |
| break | |
| if size is None: | |
| raise NoFit("takeaways" if kind == "take_stack" else "points") | |
| heights = [text_height([it], tw, size, 1.38, "body") for it in items] | |
| for c in range(cols): | |
| chunk = list(range(c * per, min((c + 1) * per, len(items)))) | |
| total = sum(heights[i] for i in chunk) | |
| free = h - total | |
| gap = min(free / max(len(chunk) - 1, 1), size * 2.2) if len(chunk) > 1 else 0 | |
| block_h = total + gap * max(len(chunk) - 1, 0) | |
| cy = y + (h - block_h) / 2 if valign == "middle" else y | |
| cx = x + c * (cw + col_gap) | |
| for i in chunk: | |
| ih = heights[i] | |
| if numbered: | |
| els.append(El("text", "num", cx, cy + size * 0.12, 64, size * 1.3, paras=[[Run(f"{i + 1:02d}", True, "accent")]], | |
| size=round(size * 0.82, 1), lh=1.2, font="mono")) | |
| else: | |
| d = size * 0.34 | |
| els.append(El("circle", "dot", cx + 4, cy + size * 0.69 - d / 2, d, d, fill="accent")) | |
| els.append(El("text", "item", cx + indent, cy, tw, ih + 2, paras=[items[i]], size=size, lh=1.38)) | |
| if numbered and kind != "take_stack" and i != chunk[-1]: | |
| els.append(El("line", "rule", cx + indent, cy + ih + gap / 2, tw, 0, stroke="line", stroke_w=1.5)) | |
| cy += ih + gap | |
| return | |
| if kind in ("cards", "take_cols", "take_row", "take_arc"): | |
| if kind == "cards": | |
| cells = [(p["term"] if fmt == "detailed" else "", p["text"]) for p in pts] | |
| else: | |
| cells = [("", tx) for tx in s["takeaways"]] | |
| n = max(len(cells), 1) | |
| gap = 36 if kind != "take_row" else 60 | |
| cw = (w - gap * (n - 1)) / n | |
| boxed = kind in ("cards", "take_cols") | |
| pad = 40 if boxed else 0 | |
| top = 0.0 | |
| if kind == "take_arc": | |
| top = 120 # the glowing marker above each takeaway | |
| elif kind == "take_row": | |
| top = 70 | |
| else: | |
| top = 64 # number row inside the card | |
| inner_w = cw - 2 * pad | |
| inner_h = h - 2 * pad - top | |
| term_base = t["card_term"] | |
| text_base = t["card_text"] if kind == "cards" else t["take"] | |
| size = None | |
| for scale in _scales(start, True): | |
| ts, xs = round(term_base * scale, 1), round(text_base * scale, 1) | |
| ok = True | |
| for term, text in cells: | |
| hh = (text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0) | |
| hh += text_height([[Run(text)]], inner_w, xs, 1.4, "body") | |
| if hh > inner_h: | |
| ok = False | |
| break | |
| if ok: | |
| size = (ts, xs) | |
| break | |
| if size is None: | |
| raise NoFit("takeaways" if kind != "cards" else "points") | |
| ts, xs = size | |
| # Cards as tall as the tallest content (not the whole box), so short text does not float in a tall card. | |
| tallest = max((text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") + ts * 0.5 if term else 0) | |
| + text_height([[Run(text)]], inner_w, xs, 1.4, "body") for term, text in cells) | |
| h = min(h, tallest + 2 * pad + top + 12) | |
| for i, (term, text) in enumerate(cells): | |
| cx = x + i * (cw + gap) | |
| if boxed: | |
| els.append(El("rect", "card", cx, y, cw, h, fill="card", stroke="cardline", stroke_w=2, radius=22)) | |
| if kind == "take_arc": | |
| r = 46 | |
| els.append(El("circle", "halo", cx + cw / 2 - r * 1.5, y + 2 - r * 0.5, r * 3, r * 3, fill="accent2", opacity=0.18)) | |
| els.append(El("circle", "marker", cx + cw / 2 - r, y + 2, r * 2, r * 2, fill="accent2")) | |
| els.append(El("text", "num", cx + cw / 2 - r, y + 2, r * 2, r * 2, paras=[[Run(f"{i + 1}", True, "white")]], | |
| size=34, lh=1.0, font="display", align="center", valign="middle")) | |
| elif kind == "take_row": | |
| els.append(El("rect", "bar", cx, y, cw, 5, fill="accent")) | |
| els.append(El("text", "num", cx, y + 20, cw, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]], | |
| size=24, lh=1.2, font="mono")) | |
| else: | |
| els.append(El("text", "num", cx + pad, y + pad, inner_w, 34, paras=[[Run(f"{i + 1:02d}", True, "accent")]], | |
| size=t["num"], lh=1.2, font="mono")) | |
| cy = y + pad + top | |
| align = "center" if kind == "take_arc" else "left" | |
| if term: | |
| th = text_height([[Run(term, True)]], inner_w, ts, 1.15, "display") | |
| els.append(El("text", "term", cx + pad, cy, inner_w, th + 2, paras=[[Run(term, True, "ink", f"points.{i}.term")]], size=ts, | |
| lh=1.15, font="display", align=align)) | |
| cy += th + ts * 0.5 | |
| where = f"points.{i}.text" if kind == "cards" else f"takeaways.{i}" | |
| els.append(El("text", "item", cx + pad, cy, inner_w, y + h - pad - cy, | |
| paras=[[Run(text, False, "mute" if term else "ink", where)]], size=xs, lh=1.4, align=align)) | |
| return | |
| if kind in ("timeline_v", "timeline_h"): | |
| items = [_point_runs(p, i, fmt) for i, p in enumerate(pts)] | |
| n = len(items) | |
| r = 28 | |
| if kind == "timeline_v": | |
| tw = w - 96 | |
| size = None | |
| for scale in _scales(start, True): | |
| sz = round(t["point"] * scale, 1) | |
| heights = [max(text_height([it], tw, sz, 1.38, "body"), r * 2) for it in items] | |
| if sum(heights) + sz * 1.2 * (n - 1) <= h: | |
| size = sz | |
| break | |
| if size is None: | |
| raise NoFit("points") | |
| heights = [max(text_height([it], tw, size, 1.38, "body"), r * 2) for it in items] | |
| gap = min((h - sum(heights)) / max(n - 1, 1), size * 2.4) | |
| total = sum(heights) + gap * (n - 1) | |
| cy = y + (h - total) / 2 if valign == "middle" else y | |
| els.append(El("line", "spine", x + r, cy + r, 0, total - 2 * r if total > 2 * r else 0, stroke="line", stroke_w=3)) | |
| for i, it in enumerate(items): | |
| els.append(El("circle", "marker", x, cy, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3)) | |
| els.append(El("text", "num", x, cy, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0, | |
| font="display", align="center", valign="middle")) | |
| els.append(El("text", "item", x + 96, cy + max((r * 2 - size * 1.38) / 2, 0) if heights[i] <= r * 2 else cy, | |
| tw, heights[i] + 2, paras=[it], size=size, lh=1.38)) | |
| cy += heights[i] + gap | |
| else: | |
| gap = 40 | |
| cw = (w - gap * (n - 1)) / n | |
| th = h - r * 2 - 40 | |
| size = _fit_all([[it] for it in items], cw, th, t["point"] * 0.9, 1.38, start) | |
| if size is None: | |
| raise NoFit("points") | |
| els.append(El("line", "spine", x + r, y + r, w - 2 * r, 0, stroke="line", stroke_w=3)) | |
| for i, it in enumerate(items): | |
| cx = x + i * (cw + gap) | |
| els.append(El("circle", "marker", cx, y, r * 2, r * 2, fill="card", stroke="accent", stroke_w=3)) | |
| els.append(El("text", "num", cx, y, r * 2, r * 2, paras=[[Run(str(i + 1), True, "accent")]], size=24, lh=1.0, | |
| font="display", align="center", valign="middle")) | |
| els.append(El("text", "item", cx, y + r * 2 + 40, cw, th, paras=[it], size=size, lh=1.38)) | |
| return | |
| raise ValueError(f"unknown body kind {kind}") | |
| def _fit_all(blocks: list[list[list[Run]]], width: float, height: float, base: float, lh: float, start: int) -> float | None: | |
| for scale in _scales(start, True): | |
| size = round(base * scale, 1) | |
| if all(text_height(b, width, size, lh, "body") <= height for b in blocks): | |
| return size | |
| return None | |
| def _columns(els: list[El], s: dict, rects: tuple[tuple, tuple], t: dict, start: int) -> None: | |
| """The two sides of a comparison, at one font size, each panel as tall as the taller side's content.""" | |
| kind = rects[0][6] | |
| pad = 44 if kind == "panel" else 0 | |
| top = 0 if kind == "panel" else 28 | |
| sides = [s["left"], s["right"]] | |
| def measure(col: dict, iw: float, ls: float, xs: float) -> tuple[float, list[float]]: | |
| label = [[Run(col["label"], True, "accent")]] if col["label"] else [] | |
| lab = text_height(label, iw, ls, 1.2, "display") + (xs * 0.9 if label else 0) | |
| heights = [text_height([[Run(i)]], iw - 40, xs, 1.38, "body") for i in col["items"]] | |
| return lab + sum(heights) + xs * 0.75 * (len(heights) - 1), heights | |
| for scale in _scales(start, True): | |
| ls, xs = round(t["col_label"] * scale, 1), round(t["col_item"] * scale, 1) | |
| need = [measure(col, r[2] - 2 * pad, ls, xs)[0] for col, r in zip(sides, rects, strict=True)] | |
| bad = [i for i, (n, r) in enumerate(zip(need, rects, strict=True)) if n > r[3] - 2 * pad - top] | |
| if not bad: | |
| break | |
| else: | |
| raise NoFit(("left", "right")[bad[0]] + "_items") | |
| panel_h = max(need) + 2 * pad + top + 8 | |
| for side, col, (x, y, w, h, *_) in zip(("left", "right"), sides, rects, strict=True): | |
| if kind == "panel": | |
| els.append(El("rect", "panel", x, y, w, min(h, panel_h), fill="card", stroke="cardline", stroke_w=2, radius=22)) | |
| else: | |
| els.append(El("rect", "bar", x, y, 72, 5, fill="accent")) | |
| ix, iw = x + pad, w - 2 * pad | |
| cy = y + pad + top | |
| _, heights = measure(col, iw, ls, xs) | |
| if col["label"]: | |
| lab_h = text_height([[Run(col["label"], True)]], iw, ls, 1.2, "display") | |
| els.append(El("text", "col_label", ix, cy, iw, lab_h + 2, paras=[[Run(col["label"], True, "accent", f"{side}.label")]], | |
| size=ls, lh=1.2, font="display")) | |
| cy += lab_h + xs * 0.9 | |
| for n, (item, hh) in enumerate(zip(col["items"], heights, strict=True)): | |
| d = xs * 0.32 | |
| els.append(El("circle", "dot", ix + 2, cy + xs * 0.69 - d / 2, d, d, fill="accent")) | |
| els.append(El("text", "item", ix + 40, cy, iw - 40, hh + 2, paras=[[Run(item, False, "ink", f"{side}.items.{n}")]], | |
| size=xs, lh=1.38)) | |
| cy += hh + xs * 0.75 | |
| def _compose_once(s: dict, deck: dict, index: int, theme: str, fmt: str, start: int) -> list[El]: | |
| from app.slides.themes import THEMES | |
| t = TYPE[fmt] | |
| dark = THEMES[theme].dark | |
| layout, v = s["layout"], s["variant"] | |
| els: list[El] = [] | |
| lead_slot = block(theme, layout, v, "lead") | |
| def rect(name: str) -> tuple | None: | |
| r = block(theme, layout, v, name) | |
| # No lead: what sits right under the lead's slot (in the same column) moves up into it. | |
| if r and name in ("body", "left", "right") and lead_slot and not s["lead"]: | |
| lx, ly, lw, lh_ = lead_slot[:4] | |
| if r[1] >= ly + lh_ - 2 and r[1] - (ly + lh_) < 80 and r[0] < lx + lw and lx < r[0] + r[2]: | |
| return (r[0], ly, r[2], r[3] + r[1] - ly, *r[4:]) | |
| return r | |
| k = rect("kicker") | |
| if k and s["kicker"]: | |
| x, y, w, h, align, *_ = k | |
| bar_x = x + (w - 64) / 2 if align == "center" else (x + w - 64 if align == "right" else x) | |
| els.append(El("rect", "bar", bar_x, y - 18, 64, 5, fill="accent")) | |
| els.append(_text("kicker", k, [[Run(s["kicker"], True, "accent", "kicker")]], t["kicker"], 0, font="mono", lh=1.2, | |
| upper=True, track=0.16, field_name="kicker")) | |
| heading_base = {"title": t["title"], "section": t["section"]}.get(layout, t["heading"]) | |
| hr = rect("heading") | |
| if hr and s["heading"]: | |
| els.append(_text("heading", hr, [[Run(s["heading"], True, "ink", "heading")]], heading_base, start, font="display", | |
| lh=1.08, field_name="heading")) | |
| lr = rect("lead") | |
| if lr and s["lead"]: | |
| base = t["title_lead"] if layout == "title" else t["lead"] | |
| head = next((e for e in els if e.role == "heading"), None) | |
| if head and head.valign == "top" and abs(head.x - lr[0]) < 1 and head.y < lr[1]: | |
| # Under a top aligned heading in the same column: follow the heading instead of waiting at the box's top. | |
| top = head.y + head.text_height() + head.size * 0.55 | |
| lr = (lr[0], top, lr[2], lr[1] + lr[3] - top, *lr[4:]) | |
| els.append(_text("lead", lr, [[Run(s["lead"], False, "mute", "lead")]], base, start, lh=1.4, field_name="lead")) | |
| if layout == "stat": | |
| els.append(_text("value", rect("value"), [[Run(s["stat"]["value"], True, "accent", "stat.value")]], t["value"], start, | |
| font="display", lh=1.0, field_name="stat_value")) | |
| els.append(_text("label", rect("label"), [[Run(s["stat"]["label"], False, "ink", "stat.label")]], t["label"], start, lh=1.35, | |
| field_name="stat_label", grow=True)) | |
| if layout == "quote": | |
| q = rect("quote") | |
| els.append(_text("quote", q, [[Run("“", True, "accent"), Run(s["quote"]["text"], False, "ink", "quote.text"), | |
| Run("”", True, "accent")]], t["quote"], start, font="display", lh=1.22, | |
| field_name="quote", grow=True)) | |
| if s["quote"]["by"] and (b := rect("by")): | |
| els.append(_text("by", b, [[Run(s["quote"]["by"], False, "mute", "quote.by")]], t["by"], 0, font="mono", lh=1.3, | |
| upper=True, track=0.08, field_name="by")) | |
| if layout == "two_column": | |
| _columns(els, s, (rect("left"), rect("right")), t, start) | |
| br = rect("body") | |
| if br: | |
| _body(els, s, br, br[6], fmt, t, start, dark) | |
| cr = rect("closing") | |
| if cr and s["closing"]: | |
| kind = cr[6] | |
| if kind == "band": | |
| x, y, w, h, *_ = cr | |
| els.append(El("rect", "band", x, y, w, h, fill="band", radius=24)) | |
| inner = (x + 60, y, w - 120, h, "left", "middle") | |
| els.append(_text("closing", inner, [[Run(s["closing"], True, "bandink", "closing")]], t["closing"], start, | |
| font="display", lh=1.15, field_name="closing")) | |
| else: | |
| base = t["closing_big"] if kind == "big" else t["closing"] | |
| els.append(_text("closing", cr, [[Run(s["closing"], True, "ink", "closing")]], base, start, font="display", lh=1.12, | |
| field_name="closing", grow=kind == "big")) | |
| if layout != "title": | |
| total = len(deck["slides"]) | |
| foot = [[Run(clip(deck.get("title"), 70), False, "mute")]] | |
| els.append(El("text", "footer", 140, 1018, 1200, 30, paras=foot, size=t["footer"], lh=1.2, font="mono")) | |
| els.append(El("text", "footer", 1480, 1018, 300, 30, paras=[[Run(f"{index + 1:02d} / {total:02d}", False, "mute")]], | |
| size=t["footer"], lh=1.2, font="mono", align="right")) | |
| return els | |
| def _shorten(s: dict, err: NoFit, fmt: str) -> bool: | |
| """Make the text that did not fit shorter (or drop a point). False when there is nothing left to shorten.""" | |
| lo = LIMITS[fmt]["points"][0] | |
| name = err.field_name | |
| def cut(text: str) -> str: | |
| return clip(text, max(int(len(text) * 0.82), 12)) if len(text) > 14 else text | |
| if name == "points": | |
| pts = s["points"] | |
| if s["layout"] == "agenda" or len(pts) > max(lo, 3): | |
| if len(pts) > 2: | |
| pts.pop() | |
| return True | |
| longest = max(range(len(pts)), key=lambda i: len(pts[i]["text"]) + len(pts[i]["term"]), default=None) | |
| if longest is None or len(pts[longest]["text"]) <= 14: | |
| return False | |
| pts[longest]["text"] = cut(pts[longest]["text"]) | |
| return True | |
| if name == "takeaways": | |
| tk = s["takeaways"] | |
| i = max(range(len(tk)), key=lambda j: len(tk[j]), default=None) | |
| if i is None or len(tk[i]) <= 14: | |
| return False | |
| tk[i] = cut(tk[i]) | |
| return True | |
| if name in ("left_items", "right_items"): | |
| col = s[name.split("_")[0]] | |
| if len(col["items"]) > 2: | |
| col["items"].pop() | |
| return True | |
| i = max(range(len(col["items"])), key=lambda j: len(col["items"][j]), default=None) | |
| if i is None or len(col["items"][i]) <= 14: | |
| return False | |
| col["items"][i] = cut(col["items"][i]) | |
| return True | |
| path = {"stat_value": ("stat", "value"), "stat_label": ("stat", "label"), "quote": ("quote", "text"), | |
| "by": ("quote", "by")}.get(name) | |
| if path: | |
| holder, key = s[path[0]], path[1] | |
| elif name in s and isinstance(s[name], str): | |
| holder, key = s, name | |
| else: | |
| return False | |
| if len(holder[key]) <= 14: | |
| if name in ("lead", "by", "kicker"): | |
| holder[key] = "" | |
| return True | |
| return False | |
| holder[key] = cut(holder[key]) | |
| return True | |
| def compose(deck: dict, index: int, theme: str, fmt: str, seed: int = 0) -> Composed: | |
| """Slide `index` of a clamped deck, placed. Text that does not fit is shortened until it does; a slide that | |
| still cannot be drawn becomes a plain section slide with its heading.""" | |
| theme, fmt = theme_id(theme), fmt if fmt in TYPE else "detailed" | |
| s = copy.deepcopy(deck["slides"][index]) | |
| if s["variant"] not in VARIANTS[s["layout"]]: | |
| s["variant"] = VARIANTS[s["layout"]][0] | |
| start = min(int(s.get("shrink") or 0), len(SCALES) - 1) | |
| for _ in range(60): | |
| try: | |
| return Composed(s, _compose_once(s, deck, index, theme, fmt, start), seed) | |
| except NoFit as err: | |
| if not _shorten(s, err, fmt): | |
| break | |
| plain = {**copy.deepcopy(s), "layout": "section", "variant": VARIANTS["section"][0], "lead": "", "points": [], | |
| "heading": clip(s["heading"] or deck.get("title"), 60)} | |
| return Composed(plain, _compose_once(plain, deck, index, theme, fmt, 0), seed) | |
| def seed_of(value: object) -> int: | |
| """A stable integer seed from a deck id.""" | |
| digits = re.sub(r"[^0-9a-f]", "", str(value or "").lower()) | |
| return int(digits[:12] or "0", 16) | |