| 1 | // Package render turns a slide from the core's JSON view (the deck |
| 2 | // package's types) into the HTML every live surface shows: the viewer, |
| 3 | // the presenter, the remote's thumbnails and the editor stage. Spec |
| 4 | // §4.1. |
| 5 | // |
| 6 | // One slide is one `<section slides-slide>`. The theme arrives as CSS |
| 7 | // custom properties inline on that section, the template's regions and |
| 8 | // the canvas frame arrive as attributes, and the phone layer arrives as |
| 9 | // data-phone-* attributes that internal/slides/static/slides.css |
| 10 | // honours. Nothing here writes a style rule; the stylesheet holds the |
| 11 | // looks, and this package holds the tree. |
| 12 | // |
| 13 | // The one rule (§0.1) is the shape of the whole file: a slide is one |
| 14 | // block tree rendered many ways, so Slide takes a Viewport and never a |
| 15 | // second document. The phone layer only ever adds — a block hidden on a |
| 16 | // phone is still in the output, carrying the attribute the stylesheet |
| 17 | // hides it by, because the notes, the search index and the presenter |
| 18 | // still want its words. The phone layer's `order` is applied to the |
| 19 | // document when rendering FOR a phone (codex 10): the blocks are |
| 20 | // emitted in the sequence a phone reads them, so a screen reader and |
| 21 | // the Tab key follow the same order a sighted reader sees, and the |
| 22 | // stylesheet's `order` property is a convenience for a desktop |
| 23 | // rendering opened on a narrow screen, not the thing that makes the |
| 24 | // order true. |
| 25 | // |
| 26 | // ── Why the markup is built rather than templated ───────────────────── |
| 27 | // |
| 28 | // Everything a person typed goes through html/template: Text renders |
| 29 | // runs and links through a real template, so link marks get contextual |
| 30 | // URL filtering on top of this package's own scheme allowlist. The |
| 31 | // surrounding elements are assembled by hand because html/template |
| 32 | // cannot take a dynamic tag name (`<{{.Tag}}`) or a dynamic attribute |
| 33 | // name, and eleven block types across seven elements would otherwise |
| 34 | // mean a template per element with the attribute block copied into each |
| 35 | // one. The invariants that make it safe are narrow enough to state: |
| 36 | // |
| 37 | // - Tag and attribute names are string literals in this file. Nothing |
| 38 | // from a deck ever becomes one. |
| 39 | // - Every attribute VALUE goes through template.HTMLEscapeString and |
| 40 | // sits in double quotes. |
| 41 | // - Values that land in CSS or in a URL — colours, font stacks, asset |
| 42 | // ids, hrefs — pass an allowlist first (safeColor, safeFontStack, |
| 43 | // safeAssetID, safeHref), which is stricter than escaping: an |
| 44 | // unsafe value is dropped, not neutered. |
| 45 | // - Every run of text goes through esc or through the template. |
| 46 | package render |
| 47 | |
| 48 | import ( |
| 49 | "bytes" |
| 50 | "encoding/json" |
| 51 | "fmt" |
| 52 | "html/template" |
| 53 | "net/url" |
| 54 | "regexp" |
| 55 | "slices" |
| 56 | "strconv" |
| 57 | "strings" |
| 58 | |
| 59 | "amadan.net/oficina/slides/internal/slides/deck" |
| 60 | ) |
| 61 | |
| 62 | // Viewport is which of the two shapes a slide is being rendered for. |
| 63 | // There are exactly two because there are exactly two in the model: the |
| 64 | // desktop layout is the base and the phone layer adds to it (§2.5). |
| 65 | type Viewport int |
| 66 | |
| 67 | const ( |
| 68 | // Desktop is the base: the 16:9 frame, the template's grid as the |
| 69 | // template drew it, canvas boxes where they were put. |
| 70 | Desktop Viewport = iota |
| 71 | // Phone marks the section data-vp="phone" and, on a canvas slide |
| 72 | // whose phone is "reflow", lays the blocks out as a single column. |
| 73 | Phone |
| 74 | ) |
| 75 | |
| 76 | func (v Viewport) String() string { |
| 77 | if v == Phone { |
| 78 | return "phone" |
| 79 | } |
| 80 | return "desktop" |
| 81 | } |
| 82 | |
| 83 | // Slide renders d's slide at index for vp: exactly one <section>, with |
| 84 | // no surrounding whitespace, ready to drop into a page. |
| 85 | // |
| 86 | // It fails only on a caller mistake — no deck, or an index that is not |
| 87 | // a slide. Everything a deck can contain renders as something: an |
| 88 | // unknown theme falls back to the default, an unknown block type |
| 89 | // renders nothing rather than breaking the slide around it, and a prop |
| 90 | // with the wrong type takes its default. A half-written document that |
| 91 | // arrived from a peer mid-edit is the ordinary case, not an error. |
| 92 | func Slide(d *deck.Deck, index int, vp Viewport) (template.HTML, error) { |
| 93 | return SlideWith(d, index, vp, Options{}) |
| 94 | } |
| 95 | |
| 96 | // Options is what a surface tells the renderer about itself. One field |
| 97 | // so far, and it is a struct rather than a fourth argument because the |
| 98 | // next one should not change eight call sites again. |
| 99 | type Options struct { |
| 100 | // AssetBase is the path an image's bytes are served from on THIS |
| 101 | // surface, with no trailing slash: "/d/{sqid}/assets" signed in, |
| 102 | // "/s/{token}/assets" over a share link, "/p/{session}/assets" |
| 103 | // while presenting. |
| 104 | // |
| 105 | // Every one of those is the deck's own capability with /assets on |
| 106 | // the end, because one slide is rendered on surfaces authenticated |
| 107 | // four different ways and the <img> has to be fetchable by whoever |
| 108 | // is looking at it. Empty falls back to DefaultAssetBase. |
| 109 | AssetBase string |
| 110 | } |
| 111 | |
| 112 | // DefaultAssetBase is what a caller that passed no Options gets. It is |
| 113 | // no surface's real base — nothing is mounted there — so a caller that |
| 114 | // forgot to say which surface it is rendering for gets visibly broken |
| 115 | // images rather than a URL that quietly works from the wrong place. |
| 116 | const DefaultAssetBase = "/assets" |
| 117 | |
| 118 | // SlideWith is Slide with the surface's own options. Slide stays for |
| 119 | // the callers that do not care (a test, a fixture); every real surface |
| 120 | // passes its asset base. |
| 121 | func SlideWith(d *deck.Deck, index int, vp Viewport, opt Options) (template.HTML, error) { |
| 122 | if d == nil { |
| 123 | return "", fmt.Errorf("render: no deck") |
| 124 | } |
| 125 | if index < 0 || index >= len(d.Slides) { |
| 126 | return "", fmt.Errorf("render: slide %d is outside a deck of %d", index, len(d.Slides)) |
| 127 | } |
| 128 | s := d.Slides[index] |
| 129 | |
| 130 | layout := s.Layout |
| 131 | if layout != "canvas" { |
| 132 | layout = "flow" |
| 133 | } |
| 134 | tmpl := s.Template |
| 135 | if !slices.Contains(templates, tmpl) { |
| 136 | tmpl = "blank" |
| 137 | } |
| 138 | |
| 139 | // §2.3: a canvas slide set to phone: reflow becomes a blank flow |
| 140 | // slide on a phone, its blocks in z then top-left reading order. |
| 141 | // The slide says it was a canvas so the editor stage and the |
| 142 | // presenter can tell what they are looking at. |
| 143 | reflowed := false |
| 144 | if layout == "canvas" && vp == Phone && s.Phone == "reflow" { |
| 145 | layout, tmpl, reflowed = "flow", "blank", true |
| 146 | } |
| 147 | |
| 148 | base := strings.TrimSuffix(strings.TrimSpace(opt.AssetBase), "/") |
| 149 | if base == "" { |
| 150 | base = DefaultAssetBase |
| 151 | } |
| 152 | c := &renderer{deck: d, canvas: layout == "canvas", phone: vp == Phone, assetBase: base, late: map[string]bool{}} |
| 153 | |
| 154 | attrs := []attr{ |
| 155 | {name: "slides-slide", bare: true}, |
| 156 | {name: "data-index", value: strconv.Itoa(index)}, |
| 157 | {name: "data-id", value: s.ID}, |
| 158 | {name: "data-layout", value: layout}, |
| 159 | {name: "data-template", value: tmpl}, |
| 160 | {name: "data-theme", value: c.themeID(d.Meta.Theme)}, |
| 161 | } |
| 162 | if kind := s.Background.Kind; kind == "color" || kind == "image" { |
| 163 | attrs = append(attrs, attr{name: "data-background", value: kind}) |
| 164 | } |
| 165 | if t := s.Transition; t != "" && t != "none" { |
| 166 | attrs = append(attrs, attr{name: "data-transition", value: t}) |
| 167 | } |
| 168 | if vp == Phone { |
| 169 | attrs = append(attrs, attr{name: "data-vp", value: "phone"}) |
| 170 | } |
| 171 | if reflowed { |
| 172 | attrs = append(attrs, attr{name: "data-reflowed", value: "canvas"}) |
| 173 | } |
| 174 | |
| 175 | children := c.body(s, reflowed) |
| 176 | if !c.canvas { |
| 177 | // A flow slide's regions sit on a frame inside the section |
| 178 | // rather than on the section itself. slides.css says why at |
| 179 | // length: `gap: var(--slides-gap)` is a container-query length |
| 180 | // USED on the element it is written on, and an element is |
| 181 | // never its own query container, so on the section it measured |
| 182 | // the page outside the slide and the space between a heading |
| 183 | // and a body moved with the browser window. One element of |
| 184 | // markup is what makes that gap a fixed fraction of the slide |
| 185 | // — which is what lets internal/slides/layout state a number |
| 186 | // for it, and what makes a thumbnail the same shape as the |
| 187 | // stage. |
| 188 | // |
| 189 | // A canvas slide has none: its blocks are absolutely placed on |
| 190 | // the section and there is no gap in the first place. |
| 191 | children = []template.HTML{elBlock("div", []attr{{name: "slides-frame", bare: true}}, "", children)} |
| 192 | } |
| 193 | return elBlock("section", attrs, c.sectionStyle(s), children), nil |
| 194 | } |
| 195 | |
| 196 | // Text renders the §2.6 text schema: one <p> per paragraph, runs inside |
| 197 | // carrying <strong>, <em>, <code>, <mark> and <a>. It is the seam every |
| 198 | // text-bearing block goes through, and the only place a link is made. |
| 199 | func Text(t deck.Text) template.HTML { |
| 200 | if len(t) == 0 { |
| 201 | return "" |
| 202 | } |
| 203 | out := make([]template.HTML, 0, len(t)) |
| 204 | for _, p := range t { |
| 205 | out = append(out, elInline("p", nil, "", inline(p))) |
| 206 | } |
| 207 | return join(out) |
| 208 | } |
| 209 | |
| 210 | // Notes renders speaker notes — the presenter's pane, the scriptless |
| 211 | // deck page's notes under the slide, the printed handout. |
| 212 | // |
| 213 | // It is Text under another name, and a function rather than an alias |
| 214 | // because the two surfaces are not the same surface: notes are the one |
| 215 | // text a viewer is never supposed to see, and having a name for them |
| 216 | // means a later change (a heading, a lang attribute, stripping links |
| 217 | // from a projected pane) has one place to land instead of touching |
| 218 | // every caller of Text. |
| 219 | func Notes(t deck.Text) template.HTML { return Text(t) } |
| 220 | |
| 221 | // templates is the flow template vocabulary (§2.2). Anything else is |
| 222 | // rendered as blank, which has a single body region and always works. |
| 223 | var templates = []string{"title", "title-body", "two-column", "section", "image-full", "quote", "blank"} |
| 224 | |
| 225 | // regionOrder is the order regions appear in the section, whatever |
| 226 | // order the blocks arrived in. It is the reading order the phone |
| 227 | // collapses to, so it is also the order a screen reader hears: the |
| 228 | // heading, then the body, then the aside, then the media. |
| 229 | // |
| 230 | // image-full puts the heading over the media, and does it with a grid |
| 231 | // area and a z-index in the stylesheet rather than by moving the |
| 232 | // heading down the document — the picture is not the first thing the |
| 233 | // slide is about. |
| 234 | var regionOrder = []string{"heading", "body", "aside", "media"} |
| 235 | |
| 236 | // unreachedRegion is the wrapper the blocks nothing reaches land in |
| 237 | // (codex 7): a block named as a child by a columns block that no root |
| 238 | // arrives at — a cycle merged in from two peers, or a document written |
| 239 | // past the ops. It comes after every real region so the content is on |
| 240 | // the slide and in the reading order, at the end, rather than gone. |
| 241 | const unreachedRegion = "unreached" |
| 242 | |
| 243 | // renderer is one Slide call's state: the deck it is reading, whether |
| 244 | // this slide's blocks carry boxes, and whether it is being rendered for |
| 245 | // a phone. The phone flag is the one viewport-dependent decision that |
| 246 | // reaches a block (codex 10): the blocks are EMITTED in the phone |
| 247 | // layer's reading order, so the sequence a screen reader hears and the |
| 248 | // Tab key walks is the one a sighted reader sees, rather than the |
| 249 | // stylesheet's `order` moving boxes over a document that still reads |
| 250 | // in desktop order. The desktop rendering keeps the desktop order. |
| 251 | type renderer struct { |
| 252 | deck *deck.Deck |
| 253 | canvas bool |
| 254 | phone bool |
| 255 | // assetBase is Options.AssetBase, already trimmed and defaulted. |
| 256 | assetBase string |
| 257 | // late is the set of blocks rendered in the unreached pass, so |
| 258 | // attrs can mark them (data-unreached) and the stylesheet can |
| 259 | // show them for what they are. |
| 260 | late map[string]bool |
| 261 | } |
| 262 | |
| 263 | // assetRef resolves an asset id — a block's props.asset, a slide |
| 264 | // background's value — to the object store address the deck registered |
| 265 | // for it (§2.7). An id nothing registered, or a ref that is not a |
| 266 | // single safe path segment, resolves to nothing, and every caller then |
| 267 | // draws "no image" rather than a URL nobody can serve. |
| 268 | func (c *renderer) assetRef(id string) string { |
| 269 | a, ok := c.deck.Assets[id] |
| 270 | if !ok { |
| 271 | return "" |
| 272 | } |
| 273 | return safeRef(a.Ref) |
| 274 | } |
| 275 | |
| 276 | // assetPath is where an asset's bytes are served from on this surface: |
| 277 | // the caller's own base and the ref. Keyed by the deck's capability |
| 278 | // rather than global, so a link that opens one deck cannot fetch |
| 279 | // another one's pictures. |
| 280 | func (c *renderer) assetPath(ref string) string { return c.assetBase + "/" + ref } |
| 281 | |
| 282 | // ThemeID resolves a theme name to one this build has: a deck naming a |
| 283 | // theme that was renamed out from under it renders as the default |
| 284 | // rather than as nothing. |
| 285 | // |
| 286 | // Exported because the layout engine needs the same answer — it asks |
| 287 | // internal/slides/fonts for the face that theme sets its headings in, |
| 288 | // and asking with an unresolved name would measure a slide in a face |
| 289 | // the browser is not using. |
| 290 | func ThemeID(name string) string { |
| 291 | if _, ok := themeVars[name]; ok { |
| 292 | return name |
| 293 | } |
| 294 | return defaultThemeID |
| 295 | } |
| 296 | |
| 297 | // themeID is ThemeID under the renderer's own hand. |
| 298 | func (c *renderer) themeID(name string) string { return ThemeID(name) } |
| 299 | |
| 300 | // sectionStyle is the theme's custom properties, plus the three things |
| 301 | // a deck may override: meta.accent, meta.fonts, and the slide's own |
| 302 | // background. Each override is allowlisted before it is written, and a |
| 303 | // value that does not pass is dropped — the theme's own value stands. |
| 304 | // |
| 305 | // Colour overrides pass a second test after the allowlist: the result |
| 306 | // has to be readable. §8 takes WCAG 2.2 AA with no exemptions, and a |
| 307 | // deck that sets a plum background on a theme whose text is near-black |
| 308 | // would otherwise render an unreadable slide with nothing to say so. A |
| 309 | // background colour therefore brings a foreground and accents that |
| 310 | // clear the thresholds against it, and an accent a deck names is used |
| 311 | // only if it clears 3:1 against the background it will actually sit on. |
| 312 | func (c *renderer) sectionStyle(s deck.Slide) string { |
| 313 | out := slideVars(c.deck, s) |
| 314 | |
| 315 | // The slide's own backdrop when it is a picture (§2.2): a custom |
| 316 | // property the stylesheet paints, pointing at THIS surface's asset |
| 317 | // base — which is why it is here and not in slideVars. A colour is |
| 318 | // the same on every surface; a URL is not. |
| 319 | // |
| 320 | // Unquoted url() because safeAssetID and safeRef between them have |
| 321 | // already ruled out everything that would need quoting. |
| 322 | var image string |
| 323 | if s.Background.Kind == "image" { |
| 324 | if ref := c.assetRef(safeAssetID(s.Background.Value)); ref != "" { |
| 325 | image = "--slides-bg-image:url(" + c.assetPath(ref) + ")" |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | parts := make([]string, 0, len(varOrder)+1) |
| 330 | for _, k := range varOrder { |
| 331 | parts = append(parts, k+":"+out[k]) |
| 332 | } |
| 333 | if image != "" { |
| 334 | parts = append(parts, image) |
| 335 | } |
| 336 | return strings.Join(parts, ";") |
| 337 | } |
| 338 | |
| 339 | // SlideVars is the palette d's slide at index is drawn in: the theme's |
| 340 | // custom properties with the deck's and the slide's own overrides |
| 341 | // already judged. It is what Slide writes on the section, so the |
| 342 | // layout engine (internal/slides/layout, and through it the SVG and |
| 343 | // the PDF) paints the slide the browser paints rather than forming a |
| 344 | // second opinion about it. False for an index that is not a slide. |
| 345 | // |
| 346 | // Colours and font stacks only — a background IMAGE is not here, |
| 347 | // because its URL depends on which surface is asking (Options). |
| 348 | func SlideVars(d *deck.Deck, index int) (map[string]string, bool) { |
| 349 | if d == nil || index < 0 || index >= len(d.Slides) { |
| 350 | return nil, false |
| 351 | } |
| 352 | return slideVars(d, d.Slides[index]), true |
| 353 | } |
| 354 | |
| 355 | // slideVars is sectionStyle's arithmetic without the serialising. |
| 356 | func slideVars(d *deck.Deck, s deck.Slide) map[string]string { |
| 357 | vars := themeVars[ThemeID(d.Meta.Theme)] |
| 358 | out := make(map[string]string, len(vars)+1) |
| 359 | for k, v := range vars { |
| 360 | out[k] = v |
| 361 | } |
| 362 | if f := d.Meta.Fonts; f != nil { |
| 363 | if h := safeFontStack(f.Heading); h != "" { |
| 364 | out["--slides-font-heading"] = h |
| 365 | } |
| 366 | if b := safeFontStack(f.Body); b != "" { |
| 367 | out["--slides-font-body"] = b |
| 368 | } |
| 369 | } |
| 370 | // The slide's own backdrop (§2.2). A colour replaces the theme's |
| 371 | // background outright — replaces, rather than being appended as a |
| 372 | // second --slides-bg that happens to win, so the style attribute |
| 373 | // says one thing about the background and not two. Hex only: a |
| 374 | // colour keyword is a colour this package cannot measure, and an |
| 375 | // unmeasurable background cannot be given a readable foreground. |
| 376 | if s.Background.Kind == "color" { |
| 377 | if bg := safeHexColor(s.Background.Value); bg != "" && bg != out["--slides-bg"] { |
| 378 | out["--slides-bg"] = bg |
| 379 | // The theme's own text first, then the colour it was |
| 380 | // sitting on (the light half of a dark theme, and the |
| 381 | // other way round), then plain black or white. |
| 382 | out["--slides-fg"] = readableOn(bg, bodyContrast, |
| 383 | vars["--slides-fg"], vars["--slides-bg"], "#000000", "#ffffff") |
| 384 | // An accent that cannot be seen on the new background |
| 385 | // collapses to the foreground, so the slide reads even |
| 386 | // though it has lost its second colour. A deck that wants |
| 387 | // an accent back on a background of its own sets |
| 388 | // meta.accent, which is judged below. |
| 389 | for _, k := range []string{"--slides-accent", "--slides-accent-2"} { |
| 390 | out[k] = readableOn(bg, accentContrast, vars[k], out["--slides-fg"]) |
| 391 | } |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | // The deck's accent, last, so it is judged against the background |
| 396 | // this slide actually has rather than against the theme's. |
| 397 | if accent := safeHexColor(d.Meta.Accent); accent != "" && |
| 398 | contrastRatio(accent, out["--slides-bg"]) >= accentContrast { |
| 399 | out["--slides-accent"] = accent |
| 400 | } |
| 401 | return out |
| 402 | } |
| 403 | |
| 404 | // body is the section's children: region wrappers on a flow slide, |
| 405 | // bare blocks on a canvas one. |
| 406 | func (c *renderer) body(s deck.Slide, reflowed bool) []template.HTML { |
| 407 | blocks := s.Blocks |
| 408 | if reflowed { |
| 409 | blocks = reflowOrder(blocks) |
| 410 | } |
| 411 | if c.phone { |
| 412 | blocks = phoneOrder(blocks) |
| 413 | } |
| 414 | |
| 415 | // A columns block's children render inside it, in the order |
| 416 | // props.children names them, and nowhere else (§2.4). |
| 417 | byID := make(map[string]deck.Block, len(blocks)) |
| 418 | claimed := make(map[string]bool) |
| 419 | for _, b := range blocks { |
| 420 | byID[b.ID] = b |
| 421 | } |
| 422 | for _, b := range blocks { |
| 423 | if b.Type != "columns" { |
| 424 | continue |
| 425 | } |
| 426 | for _, id := range childIDs(b) { |
| 427 | if _, ok := byID[id]; ok { |
| 428 | claimed[id] = true |
| 429 | } |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | // done is shared across the whole slide, so a block renders at most |
| 434 | // once however many columns blocks name it. claimed decides WHERE |
| 435 | // it renders; done makes sure that is the only place. |
| 436 | done := map[string]bool{} |
| 437 | |
| 438 | // What no root reaches renders last, marked, rather than not at all |
| 439 | // (codex 7). The ops refuse a cycle and a shared child now, but two |
| 440 | // peers' concurrent assignments can still merge into one, and a |
| 441 | // document that arrived by another road than the ops can hold |
| 442 | // anything. Rendering from each unreached block in turn: a cycle's |
| 443 | // members each render once (done stops the loop), in array order. |
| 444 | late := unreached(blocks, byID, claimed) |
| 445 | for _, b := range late { |
| 446 | c.late[b.ID] = true |
| 447 | } |
| 448 | |
| 449 | if c.canvas { |
| 450 | out := make([]template.HTML, 0, len(blocks)) |
| 451 | for _, b := range blocks { |
| 452 | if claimed[b.ID] { |
| 453 | continue |
| 454 | } |
| 455 | if h := c.block(b, byID, done); h != "" { |
| 456 | out = append(out, h) |
| 457 | } |
| 458 | } |
| 459 | for _, b := range late { |
| 460 | if h := c.block(b, byID, done); h != "" { |
| 461 | out = append(out, h) |
| 462 | } |
| 463 | } |
| 464 | return out |
| 465 | } |
| 466 | |
| 467 | // Flow: group into regions, canonical regions first, then any |
| 468 | // region name the core let through, in first-appearance order, |
| 469 | // then whatever nothing reached. |
| 470 | grouped := map[string][]template.HTML{} |
| 471 | var extra []string |
| 472 | for _, b := range blocks { |
| 473 | if claimed[b.ID] { |
| 474 | continue |
| 475 | } |
| 476 | name := b.Region |
| 477 | switch { |
| 478 | case name == "": |
| 479 | name = "body" |
| 480 | case !slices.Contains(regionOrder, name) && !slices.Contains(extra, name): |
| 481 | extra = append(extra, name) |
| 482 | } |
| 483 | if h := c.block(b, byID, done); h != "" { |
| 484 | grouped[name] = append(grouped[name], h) |
| 485 | } |
| 486 | } |
| 487 | for _, b := range late { |
| 488 | if h := c.block(b, byID, done); h != "" { |
| 489 | grouped[unreachedRegion] = append(grouped[unreachedRegion], h) |
| 490 | } |
| 491 | } |
| 492 | |
| 493 | names := append(slices.Clone(regionOrder), extra...) |
| 494 | names = append(names, unreachedRegion) |
| 495 | out := make([]template.HTML, 0, len(names)) |
| 496 | for _, name := range names { |
| 497 | if len(grouped[name]) == 0 { |
| 498 | continue |
| 499 | } |
| 500 | out = append(out, elBlock("div", []attr{ |
| 501 | {name: "slides-region", bare: true}, |
| 502 | {name: "data-region", value: name}, |
| 503 | }, "", grouped[name])) |
| 504 | } |
| 505 | return out |
| 506 | } |
| 507 | |
| 508 | // unreached is every block no traversal from the slide's roots — the |
| 509 | // blocks no columns block claims — arrives at: the members of a cycle |
| 510 | // of columns blocks and whatever hangs under them. Array order. |
| 511 | func unreached(blocks []deck.Block, byID map[string]deck.Block, claimed map[string]bool) []deck.Block { |
| 512 | reached := map[string]bool{} |
| 513 | var visit func(b deck.Block) |
| 514 | visit = func(b deck.Block) { |
| 515 | if reached[b.ID] { |
| 516 | return |
| 517 | } |
| 518 | reached[b.ID] = true |
| 519 | for _, id := range childIDs(b) { |
| 520 | if child, ok := byID[id]; ok && b.Type == "columns" { |
| 521 | visit(child) |
| 522 | } |
| 523 | } |
| 524 | } |
| 525 | for _, b := range blocks { |
| 526 | if !claimed[b.ID] { |
| 527 | visit(b) |
| 528 | } |
| 529 | } |
| 530 | var out []deck.Block |
| 531 | for _, b := range blocks { |
| 532 | if !reached[b.ID] { |
| 533 | out = append(out, b) |
| 534 | } |
| 535 | } |
| 536 | return out |
| 537 | } |
| 538 | |
| 539 | // phoneOrder is §2.5's `order` applied to the document itself (codex |
| 540 | // 10): a stable sort by the phone layer's order, an unset order |
| 541 | // counting as 0 — exactly the key the stylesheet's `order: |
| 542 | // var(--slides-phone-order, 0)` sorts by, so the two agree and the CSS |
| 543 | // is no longer load-bearing. Stable, so blocks with the same order keep |
| 544 | // the sequence they had, region grouping included. |
| 545 | func phoneOrder(blocks []deck.Block) []deck.Block { |
| 546 | out := slices.Clone(blocks) |
| 547 | slices.SortStableFunc(out, func(a, b deck.Block) int { |
| 548 | return phoneRank(a) - phoneRank(b) |
| 549 | }) |
| 550 | return out |
| 551 | } |
| 552 | |
| 553 | func phoneRank(b deck.Block) int { |
| 554 | if b.Phone.Order != nil { |
| 555 | return *b.Phone.Order |
| 556 | } |
| 557 | return 0 |
| 558 | } |
| 559 | |
| 560 | // reflowOrder is §2.3's ordering for a canvas slide reflowed onto a |
| 561 | // phone: z, then top, then left. Stable, so two blocks stacked at the |
| 562 | // same point keep the order the array had. |
| 563 | func reflowOrder(blocks []deck.Block) []deck.Block { |
| 564 | out := slices.Clone(blocks) |
| 565 | slices.SortStableFunc(out, func(a, b deck.Block) int { |
| 566 | if a.Z != b.Z { |
| 567 | return a.Z - b.Z |
| 568 | } |
| 569 | ax, ay := corner(a.Box) |
| 570 | bx, by := corner(b.Box) |
| 571 | switch { |
| 572 | case ay < by: |
| 573 | return -1 |
| 574 | case ay > by: |
| 575 | return 1 |
| 576 | case ax < bx: |
| 577 | return -1 |
| 578 | case ax > bx: |
| 579 | return 1 |
| 580 | } |
| 581 | return 0 |
| 582 | }) |
| 583 | return out |
| 584 | } |
| 585 | |
| 586 | // corner is a box's top-left, or the origin for a block that has none. |
| 587 | func corner(b *deck.Box) (x, y float64) { |
| 588 | if b == nil { |
| 589 | return 0, 0 |
| 590 | } |
| 591 | return b.X, b.Y |
| 592 | } |
| 593 | |
| 594 | // block renders one block. done is the set already rendered on this |
| 595 | // slide: it stops a block appearing twice when two columns blocks name |
| 596 | // it, and it stops a columns block that names a columns block that |
| 597 | // names it back. The core refuses both shapes now (codex 7), but a |
| 598 | // merge of two peers' assignments can still produce either, and this |
| 599 | // is what makes that a rendering rather than a stack overflow. |
| 600 | func (c *renderer) block(b deck.Block, byID map[string]deck.Block, done map[string]bool) template.HTML { |
| 601 | if done[b.ID] { |
| 602 | return "" |
| 603 | } |
| 604 | done[b.ID] = true |
| 605 | |
| 606 | switch b.Type { |
| 607 | case "heading": |
| 608 | return c.headingBlock(b) |
| 609 | case "text": |
| 610 | return c.textBlock(b) |
| 611 | case "list": |
| 612 | return c.listBlock(b) |
| 613 | case "quote": |
| 614 | return c.quoteBlock(b) |
| 615 | case "image": |
| 616 | return c.imageBlock(b) |
| 617 | case "columns": |
| 618 | return c.columnsBlock(b, byID, done) |
| 619 | case "code": |
| 620 | return c.codeBlock(b) |
| 621 | case "shape": |
| 622 | return c.shapeBlock(b) |
| 623 | case "table": |
| 624 | return c.tableBlock(b) |
| 625 | case "divider": |
| 626 | return elVoid("hr", c.attrs(b), c.style(b)) |
| 627 | case "spacer": |
| 628 | return elInline("div", c.attrs(b, |
| 629 | attr{name: "data-size", value: sizeProp(b.Props, "l")}, |
| 630 | attr{name: "aria-hidden", value: "true"}), c.style(b), "") |
| 631 | } |
| 632 | // An unknown type is a block from a newer build of the core. |
| 633 | // Rendering nothing keeps the rest of the slide standing. |
| 634 | return "" |
| 635 | } |
| 636 | |
| 637 | // attrs is the attribute set every block carries: the marker the |
| 638 | // stylesheet selects on, the type, the id the editor addresses it by, |
| 639 | // then whatever the caller adds, then the placement (region or z) and |
| 640 | // the phone layer. |
| 641 | func (c *renderer) attrs(b deck.Block, extra ...attr) []attr { |
| 642 | out := []attr{ |
| 643 | {name: "slides-block", bare: true}, |
| 644 | {name: "data-block", value: b.Type}, |
| 645 | {name: "data-id", value: b.ID}, |
| 646 | } |
| 647 | out = append(out, extra...) |
| 648 | if c.canvas { |
| 649 | out = append(out, attr{name: "data-z", value: strconv.Itoa(b.Z)}) |
| 650 | } else if b.Region != "" { |
| 651 | out = append(out, attr{name: "data-region", value: b.Region}) |
| 652 | } |
| 653 | if c.late[b.ID] { |
| 654 | // Rendered in the unreached pass (body): on the slide, at the |
| 655 | // end, and saying so — the stylesheet outlines it and the |
| 656 | // editor can tell a person why it is where it is. |
| 657 | out = append(out, attr{name: "data-unreached", value: "true"}) |
| 658 | } |
| 659 | |
| 660 | // The phone layer, always emitted whichever viewport this is: the |
| 661 | // stylesheet honours it under the phone container query, and the |
| 662 | // editor's desktop/phone switcher (§3.2) reads it back. |
| 663 | p := b.Phone |
| 664 | if p.Hidden { |
| 665 | out = append(out, attr{name: "data-phone-hidden", value: "true"}) |
| 666 | } |
| 667 | if p.Order != nil { |
| 668 | out = append(out, attr{name: "data-phone-order", value: strconv.Itoa(*p.Order)}) |
| 669 | } |
| 670 | if p.Size != "" { |
| 671 | out = append(out, attr{name: "data-phone-size", value: sizeStep(p.Size, "m")}) |
| 672 | } |
| 673 | if p.Box != nil && c.canvas { |
| 674 | out = append(out, attr{name: "data-phone-box", value: fracs(*p.Box)}) |
| 675 | } |
| 676 | return out |
| 677 | } |
| 678 | |
| 679 | // style is a block's inline style: nothing at all on a flow slide, and |
| 680 | // on a canvas slide the box as percentages of the frame plus the z. |
| 681 | // A phone box rides along as custom properties, which is what lets the |
| 682 | // stylesheet move the block under its container query without this |
| 683 | // package needing to know how wide the screen turned out to be. |
| 684 | // |
| 685 | // --slides-phone-order is here for the same reason: CSS cannot read an |
| 686 | // integer out of a data attribute, so the order the phone layer asks |
| 687 | // for has to arrive as a value. On a phone rendering it is redundant — |
| 688 | // the blocks are already emitted in that order (phoneOrder) — and it |
| 689 | // stays for a desktop rendering that a narrow screen opens anyway, |
| 690 | // where the container query is the only ordering there is. |
| 691 | func (c *renderer) style(b deck.Block) string { |
| 692 | var parts []string |
| 693 | if c.canvas && b.Box != nil { |
| 694 | parts = append(parts, |
| 695 | "left:"+pct(b.Box.X), |
| 696 | "top:"+pct(b.Box.Y), |
| 697 | "width:"+pct(b.Box.W), |
| 698 | "height:"+pct(b.Box.H), |
| 699 | "z-index:"+strconv.Itoa(b.Z), |
| 700 | ) |
| 701 | if p := b.Phone.Box; p != nil { |
| 702 | parts = append(parts, |
| 703 | "--slides-phone-left:"+pct(p.X), |
| 704 | "--slides-phone-top:"+pct(p.Y), |
| 705 | "--slides-phone-width:"+pct(p.W), |
| 706 | "--slides-phone-height:"+pct(p.H), |
| 707 | ) |
| 708 | } |
| 709 | } |
| 710 | if o := b.Phone.Order; o != nil { |
| 711 | parts = append(parts, "--slides-phone-order:"+strconv.Itoa(*o)) |
| 712 | } |
| 713 | if r := numProp(b.Props, "radius", 0); b.Type == "image" && r > 0 { |
| 714 | parts = append(parts, "--slides-radius:"+num(r)+"px") |
| 715 | } |
| 716 | return strings.Join(parts, ";") |
| 717 | } |
| 718 | |
| 719 | func (c *renderer) headingBlock(b deck.Block) template.HTML { |
| 720 | level := int(numProp(b.Props, "level", 1)) |
| 721 | if level < 1 || level > 3 { |
| 722 | level = 1 |
| 723 | } |
| 724 | // A heading is one line by the schema; a stray second paragraph |
| 725 | // becomes a line break rather than a second heading. |
| 726 | lines := make([]template.HTML, 0, len(b.Text)) |
| 727 | for _, p := range b.Text { |
| 728 | lines = append(lines, inline(p)) |
| 729 | } |
| 730 | return elInline("h"+strconv.Itoa(level), c.attrs(b, |
| 731 | attr{name: "data-level", value: strconv.Itoa(level)}), |
| 732 | c.style(b), joinWith("<br>", lines)) |
| 733 | } |
| 734 | |
| 735 | func (c *renderer) textBlock(b deck.Block) template.HTML { |
| 736 | extra := []attr{{name: "data-size", value: sizeProp(b.Props, "m")}} |
| 737 | if a := alignProp(b.Props); a != "" { |
| 738 | extra = append(extra, attr{name: "data-align", value: a}) |
| 739 | } |
| 740 | paras := make([]template.HTML, 0, len(b.Text)) |
| 741 | for _, p := range b.Text { |
| 742 | paras = append(paras, elInline("p", nil, "", inline(p))) |
| 743 | } |
| 744 | return elBlock("div", c.attrs(b, extra...), c.style(b), paras) |
| 745 | } |
| 746 | |
| 747 | func (c *renderer) listBlock(b deck.Block) template.HTML { |
| 748 | style, tag := "bullet", "ul" |
| 749 | if s, _ := b.Props["style"].(string); s == "number" { |
| 750 | style, tag = "number", "ol" |
| 751 | } |
| 752 | extra := []attr{ |
| 753 | {name: "data-style", value: style}, |
| 754 | {name: "data-size", value: sizeProp(b.Props, "m")}, |
| 755 | } |
| 756 | // A nested list is a second list block with indent 1 (§2.6), not a |
| 757 | // nested <ul>: the schema has no nesting, so neither does this. |
| 758 | if n := int(numProp(b.Props, "indent", 0)); n > 0 { |
| 759 | if n > 3 { |
| 760 | n = 3 |
| 761 | } |
| 762 | extra = append(extra, attr{name: "data-indent", value: strconv.Itoa(n)}) |
| 763 | } |
| 764 | items := make([]template.HTML, 0, len(b.Text)) |
| 765 | for _, p := range b.Text { |
| 766 | items = append(items, elInline("li", nil, "", inline(p))) |
| 767 | } |
| 768 | return elBlock(tag, c.attrs(b, extra...), c.style(b), items) |
| 769 | } |
| 770 | |
| 771 | func (c *renderer) quoteBlock(b deck.Block) template.HTML { |
| 772 | paras := make([]template.HTML, 0, len(b.Text)) |
| 773 | for _, p := range b.Text { |
| 774 | paras = append(paras, elInline("p", nil, "", inline(p))) |
| 775 | } |
| 776 | // figure + figcaption rather than a <cite> loose inside the |
| 777 | // blockquote: the attribution is about the quote, it is not part of |
| 778 | // what was said. |
| 779 | children := []template.HTML{elBlock("blockquote", nil, "", paras)} |
| 780 | if cite, _ := b.Props["cite"].(string); cite != "" { |
| 781 | children = append(children, elInline("figcaption", nil, "", |
| 782 | elInline("cite", nil, "", esc(cite)))) |
| 783 | } |
| 784 | return elBlock("figure", c.attrs(b), c.style(b), children) |
| 785 | } |
| 786 | |
| 787 | func (c *renderer) imageBlock(b deck.Block) template.HTML { |
| 788 | id := safeAssetID(stringProp(b.Props, "asset")) |
| 789 | fit := "cover" |
| 790 | if f, _ := b.Props["fit"].(string); f == "contain" { |
| 791 | fit = "contain" |
| 792 | } |
| 793 | extra := []attr{{name: "data-fit", value: fit}} |
| 794 | ref := c.assetRef(id) |
| 795 | if ref == "" { |
| 796 | // No asset, an id that is not an id, or an id nothing in this |
| 797 | // deck's `assets` map ever registered: an empty frame the |
| 798 | // editor shows as unfinished, never a broken <img>. |
| 799 | return elInline("figure", append(c.attrs(b, extra...), |
| 800 | attr{name: "data-missing-asset", value: "true"}), c.style(b), "") |
| 801 | } |
| 802 | |
| 803 | alt, missing := c.altFor(b, id) |
| 804 | img := []attr{ |
| 805 | {name: "src", value: c.assetPath(ref)}, |
| 806 | {name: "alt", value: alt}, |
| 807 | } |
| 808 | if a, ok := c.deck.Assets[id]; ok && a.Width > 0 && a.Height > 0 { |
| 809 | img = append(img, |
| 810 | attr{name: "width", value: strconv.Itoa(a.Width)}, |
| 811 | attr{name: "height", value: strconv.Itoa(a.Height)}) |
| 812 | } |
| 813 | if missing { |
| 814 | // §2.4 makes alt required. The attribute is never omitted — an |
| 815 | // <img> with no alt is read out as its filename — so a missing |
| 816 | // one is an empty alt plus a mark the editor can surface. |
| 817 | img = append(img, attr{name: "data-missing-alt", value: "true"}) |
| 818 | } |
| 819 | return elBlock("figure", c.attrs(b, extra...), c.style(b), |
| 820 | []template.HTML{elVoid("img", img, "")}) |
| 821 | } |
| 822 | |
| 823 | // altFor resolves an image's alternative text: the block's own alt, the |
| 824 | // asset's registered alt, or empty. Decorative means empty on purpose |
| 825 | // (§2.4) and is not reported as missing. |
| 826 | func (c *renderer) altFor(b deck.Block, id string) (alt string, missing bool) { |
| 827 | if v, ok := b.Props["alt"].(string); ok && v != "" { |
| 828 | return v, false |
| 829 | } |
| 830 | if dec, _ := b.Props["decorative"].(bool); dec { |
| 831 | return "", false |
| 832 | } |
| 833 | if a, ok := c.deck.Assets[id]; ok && a.Alt != "" { |
| 834 | return a.Alt, false |
| 835 | } |
| 836 | return "", true |
| 837 | } |
| 838 | |
| 839 | func (c *renderer) columnsBlock(b deck.Block, byID map[string]deck.Block, done map[string]bool) template.HTML { |
| 840 | count := int(numProp(b.Props, "count", 2)) |
| 841 | if count != 3 { |
| 842 | count = 2 |
| 843 | } |
| 844 | kids := make([]deck.Block, 0, len(childIDs(b))) |
| 845 | for _, id := range childIDs(b) { |
| 846 | if child, ok := byID[id]; ok { |
| 847 | kids = append(kids, child) |
| 848 | } |
| 849 | } |
| 850 | // The phone layer's order applies among siblings, and a columns |
| 851 | // block's children are siblings of each other (codex 10). |
| 852 | if c.phone { |
| 853 | kids = phoneOrder(kids) |
| 854 | } |
| 855 | var children []template.HTML |
| 856 | for _, child := range kids { |
| 857 | if h := c.block(child, byID, done); h != "" { |
| 858 | children = append(children, h) |
| 859 | } |
| 860 | } |
| 861 | return elBlock("div", c.attrs(b, |
| 862 | attr{name: "data-count", value: strconv.Itoa(count)}), c.style(b), children) |
| 863 | } |
| 864 | |
| 865 | func (c *renderer) codeBlock(b deck.Block) template.HTML { |
| 866 | extra := []attr{{name: "data-size", value: sizeProp(b.Props, "m")}} |
| 867 | code := []attr{} |
| 868 | if lang := stringProp(b.Props, "language"); langPattern.MatchString(lang) { |
| 869 | extra = append(extra, attr{name: "data-language", value: lang}) |
| 870 | code = append(code, attr{name: "class", value: "language-" + lang}) |
| 871 | } |
| 872 | // No highlighter: the text is the text, escaped, one paragraph per |
| 873 | // line (§2.4). Nothing inside a <pre> gets extra whitespace. |
| 874 | return elInline("pre", c.attrs(b, extra...), c.style(b), |
| 875 | elInline("code", code, "", esc(b.Text.Plain()))) |
| 876 | } |
| 877 | |
| 878 | // shapeKinds is the §2.4 vocabulary. Anything else draws a rectangle, |
| 879 | // which is the shape that says least. |
| 880 | var shapeKinds = []string{"rect", "ellipse", "line", "arrow"} |
| 881 | |
| 882 | func (c *renderer) shapeBlock(b deck.Block) template.HTML { |
| 883 | kind := stringProp(b.Props, "kind") |
| 884 | if !slices.Contains(shapeKinds, kind) { |
| 885 | kind = "rect" |
| 886 | } |
| 887 | fill := safeColor(stringProp(b.Props, "fill")) |
| 888 | if fill == "" { |
| 889 | fill = "none" |
| 890 | } |
| 891 | stroke := safeColor(stringProp(b.Props, "stroke")) |
| 892 | if stroke == "" { |
| 893 | stroke = "currentColor" |
| 894 | } |
| 895 | width := num(clamp(numProp(b.Props, "strokeWidth", 1), 0, 50)) |
| 896 | radius := num(clamp(numProp(b.Props, "radius", 0), 0, 50)) |
| 897 | |
| 898 | // A 0..100 viewBox stretched to the block's box, with non-scaling |
| 899 | // strokes so a wide shape does not get a fat left edge and a thin |
| 900 | // top one. Geometry only — the colours come from props, the size |
| 901 | // from the box or the region. |
| 902 | geom := []attr{{name: "fill", value: fill}, {name: "stroke", value: stroke}, |
| 903 | {name: "stroke-width", value: width}, {name: "vector-effect", value: "non-scaling-stroke"}} |
| 904 | var shape template.HTML |
| 905 | switch kind { |
| 906 | case "ellipse": |
| 907 | shape = elClosed("ellipse", append([]attr{ |
| 908 | {name: "cx", value: "50"}, {name: "cy", value: "50"}, |
| 909 | {name: "rx", value: "49"}, {name: "ry", value: "49"}}, geom...)) |
| 910 | case "line": |
| 911 | shape = elClosed("line", append([]attr{ |
| 912 | {name: "x1", value: "1"}, {name: "y1", value: "50"}, |
| 913 | {name: "x2", value: "99"}, {name: "y2", value: "50"}}, geom...)) |
| 914 | case "arrow": |
| 915 | line := elClosed("line", append([]attr{ |
| 916 | {name: "x1", value: "1"}, {name: "y1", value: "50"}, |
| 917 | {name: "x2", value: "88"}, {name: "y2", value: "50"}}, geom...)) |
| 918 | head := elClosed("path", []attr{ |
| 919 | {name: "d", value: "M99 50 L86 42 L86 58 Z"}, |
| 920 | {name: "fill", value: stroke}, {name: "stroke", value: "none"}}) |
| 921 | shape = join([]template.HTML{line, head}) |
| 922 | default: |
| 923 | shape = elClosed("rect", append([]attr{ |
| 924 | {name: "x", value: "1"}, {name: "y", value: "1"}, |
| 925 | {name: "width", value: "98"}, {name: "height", value: "98"}, |
| 926 | {name: "rx", value: radius}}, geom...)) |
| 927 | } |
| 928 | |
| 929 | extra := []attr{ |
| 930 | {name: "data-kind", value: kind}, |
| 931 | {name: "viewBox", value: "0 0 100 100"}, |
| 932 | {name: "preserveAspectRatio", value: "none"}, |
| 933 | {name: "focusable", value: "false"}, |
| 934 | } |
| 935 | children := []template.HTML{shape} |
| 936 | // A shape with a label is a picture with a name; one without is |
| 937 | // decoration, and is kept out of the accessibility tree rather than |
| 938 | // announced as "graphic". |
| 939 | if label := b.Text.Plain(); label != "" { |
| 940 | extra = append(extra, attr{name: "role", value: "img"}) |
| 941 | children = append([]template.HTML{elInline("title", nil, "", esc(label))}, children...) |
| 942 | } else { |
| 943 | extra = append(extra, attr{name: "aria-hidden", value: "true"}) |
| 944 | } |
| 945 | return elBlock("svg", c.attrs(b, extra...), c.style(b), children) |
| 946 | } |
| 947 | |
| 948 | func (c *renderer) tableBlock(b deck.Block) template.HTML { |
| 949 | cells := cellsProp(b.Props) |
| 950 | // A deck may also carry a "rows" prop; it is never read here, because |
| 951 | // row count is derived below from len(cells) and cols. |
| 952 | cols := int(numProp(b.Props, "cols", 0)) |
| 953 | if cols < 1 { |
| 954 | cols = len(cells) |
| 955 | } |
| 956 | if cols < 1 { |
| 957 | return elInline("table", c.attrs(b), c.style(b), "") |
| 958 | } |
| 959 | header, _ := b.Props["header"].(bool) |
| 960 | |
| 961 | var rows [][]deck.Text |
| 962 | for i := 0; i < len(cells); i += cols { |
| 963 | end := min(i+cols, len(cells)) |
| 964 | rows = append(rows, cells[i:end]) |
| 965 | } |
| 966 | |
| 967 | row := func(cs []deck.Text, head bool) template.HTML { |
| 968 | out := make([]template.HTML, 0, len(cs)) |
| 969 | for _, cell := range cs { |
| 970 | // A cell is the same text schema, but short: paragraphs |
| 971 | // become line breaks rather than <p>, so a one-line cell is |
| 972 | // one line high. |
| 973 | lines := make([]template.HTML, 0, len(cell)) |
| 974 | for _, p := range cell { |
| 975 | lines = append(lines, inline(p)) |
| 976 | } |
| 977 | body := joinWith("<br>", lines) |
| 978 | if head { |
| 979 | out = append(out, elInline("th", []attr{{name: "scope", value: "col"}}, "", body)) |
| 980 | } else { |
| 981 | out = append(out, elInline("td", nil, "", body)) |
| 982 | } |
| 983 | } |
| 984 | return elBlock("tr", nil, "", out) |
| 985 | } |
| 986 | |
| 987 | var children []template.HTML |
| 988 | if header && len(rows) > 0 { |
| 989 | children = append(children, elBlock("thead", nil, "", []template.HTML{row(rows[0], true)})) |
| 990 | rows = rows[1:] |
| 991 | } |
| 992 | body := make([]template.HTML, 0, len(rows)) |
| 993 | for _, r := range rows { |
| 994 | body = append(body, row(r, false)) |
| 995 | } |
| 996 | if len(body) > 0 { |
| 997 | children = append(children, elBlock("tbody", nil, "", body)) |
| 998 | } |
| 999 | return elBlock("table", c.attrs(b), c.style(b), children) |
| 1000 | } |
| 1001 | |
| 1002 | // ── Text ────────────────────────────────────────────────────────────── |
| 1003 | |
| 1004 | // runTemplate is the one real html/template in this package, and it |
| 1005 | // covers the one place a deck's content decides markup: a run's marks |
| 1006 | // and its link. The nesting is fixed — link outside, then strong, em, |
| 1007 | // mark, code — so the same run always produces the same bytes. |
| 1008 | // |
| 1009 | // rel="noopener" on every link (§4.1's brief). No target: a slide is |
| 1010 | // not a menu, and a link that opened a tab the reader did not ask for |
| 1011 | // would take them out of a presentation mid-sentence. |
| 1012 | var runTemplate = template.Must(template.New("run").Parse( |
| 1013 | `{{define "run"}}{{if .Link}}<a href="{{.Href}}" rel="noopener">{{template "marks" .}}</a>` + |
| 1014 | `{{else}}{{template "marks" .}}{{end}}{{end}}` + |
| 1015 | `{{define "marks"}}{{if .B}}<strong>{{end}}{{if .I}}<em>{{end}}{{if .H}}<mark>{{end}}{{if .C}}<code>{{end}}` + |
| 1016 | `{{.T}}` + |
| 1017 | `{{if .C}}</code>{{end}}{{if .H}}</mark>{{end}}{{if .I}}</em>{{end}}{{if .B}}</strong>{{end}}{{end}}`)) |
| 1018 | |
| 1019 | // runView is one run as the template sees it: the marks, the text, and |
| 1020 | // a href that has already passed safeHref. |
| 1021 | type runView struct { |
| 1022 | T string |
| 1023 | B, I, C, H bool |
| 1024 | Href string |
| 1025 | Link bool |
| 1026 | } |
| 1027 | |
| 1028 | // inline renders one paragraph's runs, with no wrapping element — the |
| 1029 | // caller decides whether they sit in a <p>, an <li>, a <th> or a |
| 1030 | // heading. |
| 1031 | func inline(runs []deck.Run) template.HTML { |
| 1032 | var buf bytes.Buffer |
| 1033 | for _, r := range runs { |
| 1034 | href := safeHref(r.Href) |
| 1035 | v := runView{T: r.T, B: r.B, I: r.I, C: r.C, H: r.H, Href: href, Link: href != ""} |
| 1036 | if err := runTemplate.ExecuteTemplate(&buf, "run", v); err != nil { |
| 1037 | // Executing a template over a struct of strings and bools |
| 1038 | // has no failure mode; the fallback exists so a future one |
| 1039 | // degrades to plain escaped words rather than to markup. |
| 1040 | return esc(plainRuns(runs)) |
| 1041 | } |
| 1042 | } |
| 1043 | return template.HTML(buf.String()) |
| 1044 | } |
| 1045 | |
| 1046 | func plainRuns(runs []deck.Run) string { |
| 1047 | var b strings.Builder |
| 1048 | for _, r := range runs { |
| 1049 | b.WriteString(r.T) |
| 1050 | } |
| 1051 | return b.String() |
| 1052 | } |
| 1053 | |
| 1054 | // linkSchemes is what a link mark may point at. Everything else — a |
| 1055 | // javascript: URL, a data: document, a bare relative path somebody |
| 1056 | // pasted — renders as the text it was, with no anchor: a link that goes |
| 1057 | // nowhere useful is worse than words, and a neutered href still looks |
| 1058 | // clickable. |
| 1059 | var linkSchemes = []string{"http", "https", "mailto"} |
| 1060 | |
| 1061 | func safeHref(raw string) string { |
| 1062 | if raw == "" || strings.TrimSpace(raw) != raw { |
| 1063 | return "" |
| 1064 | } |
| 1065 | u, err := url.Parse(raw) |
| 1066 | if err != nil { |
| 1067 | return "" |
| 1068 | } |
| 1069 | if !slices.Contains(linkSchemes, strings.ToLower(u.Scheme)) { |
| 1070 | return "" |
| 1071 | } |
| 1072 | return raw |
| 1073 | } |
| 1074 | |
| 1075 | // ── Elements ────────────────────────────────────────────────────────── |
| 1076 | |
| 1077 | // attr is one attribute, in the order the renderer writes it. A bare |
| 1078 | // attribute (slides-slide, slides-block, slides-region) is written with |
| 1079 | // no value: it is a marker for a CSS selector, not data. |
| 1080 | type attr struct { |
| 1081 | name string |
| 1082 | value string |
| 1083 | bare bool |
| 1084 | } |
| 1085 | |
| 1086 | // openTag writes `<tag a="b" style="…">`. Names are literals from this |
| 1087 | // file; values are escaped; style has already been assembled from |
| 1088 | // allowlisted parts. |
| 1089 | func openTag(tag string, attrs []attr, style string) string { |
| 1090 | var b strings.Builder |
| 1091 | b.WriteString("<") |
| 1092 | b.WriteString(tag) |
| 1093 | for _, a := range attrs { |
| 1094 | b.WriteString(" ") |
| 1095 | b.WriteString(a.name) |
| 1096 | if a.bare { |
| 1097 | continue |
| 1098 | } |
| 1099 | b.WriteString(`="`) |
| 1100 | b.WriteString(template.HTMLEscapeString(a.value)) |
| 1101 | b.WriteString(`"`) |
| 1102 | } |
| 1103 | if style != "" { |
| 1104 | b.WriteString(` style="`) |
| 1105 | b.WriteString(template.HTMLEscapeString(style)) |
| 1106 | b.WriteString(`"`) |
| 1107 | } |
| 1108 | b.WriteString(">") |
| 1109 | return b.String() |
| 1110 | } |
| 1111 | |
| 1112 | // elInline writes an element whose content is inline: no whitespace is |
| 1113 | // added, because inside a <p>, a <pre> or a heading it would be read. |
| 1114 | func elInline(tag string, attrs []attr, style string, body template.HTML) template.HTML { |
| 1115 | return template.HTML(openTag(tag, attrs, style) + string(body) + "</" + tag + ">") |
| 1116 | } |
| 1117 | |
| 1118 | // elBlock writes an element whose children are blocks, one per line — |
| 1119 | // the newlines are what makes a golden file readable, and they are |
| 1120 | // collapsed away wherever the stylesheet cares (grid and flex ignore |
| 1121 | // whitespace between items). |
| 1122 | func elBlock(tag string, attrs []attr, style string, children []template.HTML) template.HTML { |
| 1123 | open := openTag(tag, attrs, style) |
| 1124 | if len(children) == 0 { |
| 1125 | return template.HTML(open + "</" + tag + ">") |
| 1126 | } |
| 1127 | return template.HTML(open + "\n" + string(join(children)) + "\n</" + tag + ">") |
| 1128 | } |
| 1129 | |
| 1130 | // elVoid writes an HTML void element: <img>, <hr>. There is no closing |
| 1131 | // tag to write, and a trailing slash would be noise. |
| 1132 | func elVoid(tag string, attrs []attr, style string) template.HTML { |
| 1133 | return template.HTML(openTag(tag, attrs, style)) |
| 1134 | } |
| 1135 | |
| 1136 | // elClosed writes a self-closing element: <rect …/>. Only for the SVG |
| 1137 | // primitives, and the slash is load-bearing — inside <svg> the HTML |
| 1138 | // parser is in foreign content, where nothing is void and `<line>` with |
| 1139 | // no slash swallows whatever follows it as a child. An arrow drawn that |
| 1140 | // way loses its head, because a shape element's children never paint. |
| 1141 | func elClosed(tag string, attrs []attr) template.HTML { |
| 1142 | return template.HTML(strings.TrimSuffix(openTag(tag, attrs, ""), ">") + " />") |
| 1143 | } |
| 1144 | |
| 1145 | func esc(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) } |
| 1146 | |
| 1147 | func join(parts []template.HTML) template.HTML { return joinWith("\n", parts) } |
| 1148 | |
| 1149 | func joinWith(sep string, parts []template.HTML) template.HTML { |
| 1150 | ss := make([]string, len(parts)) |
| 1151 | for i, p := range parts { |
| 1152 | ss[i] = string(p) |
| 1153 | } |
| 1154 | return template.HTML(strings.Join(ss, sep)) |
| 1155 | } |
| 1156 | |
| 1157 | // ── Props ───────────────────────────────────────────────────────────── |
| 1158 | // |
| 1159 | // props is free-form (deck.Block's own comment says why), so every read |
| 1160 | // takes a default and every enumerated value is checked against its |
| 1161 | // vocabulary. A prop of the wrong type is a peer mid-edit, not a bug to |
| 1162 | // propagate. |
| 1163 | |
| 1164 | func stringProp(props map[string]any, key string) string { |
| 1165 | s, _ := props[key].(string) |
| 1166 | return s |
| 1167 | } |
| 1168 | |
| 1169 | func numProp(props map[string]any, key string, def float64) float64 { |
| 1170 | switch v := props[key].(type) { |
| 1171 | case float64: |
| 1172 | return v |
| 1173 | case int: |
| 1174 | return float64(v) |
| 1175 | case json.Number: |
| 1176 | if f, err := v.Float64(); err == nil { |
| 1177 | return f |
| 1178 | } |
| 1179 | } |
| 1180 | return def |
| 1181 | } |
| 1182 | |
| 1183 | // sizeProp reads props.size as a size step; sizeStep is the same check |
| 1184 | // for a step that arrived somewhere else, like the phone layer's. |
| 1185 | func sizeProp(props map[string]any, def string) string { |
| 1186 | s, _ := props["size"].(string) |
| 1187 | return sizeStep(s, def) |
| 1188 | } |
| 1189 | |
| 1190 | // sizeStep is §2.4's s, m, l — defaulted rather than passed through, so |
| 1191 | // an unknown step never reaches a CSS selector that has no rule for it |
| 1192 | // and leaves a block at whatever size it inherited. |
| 1193 | func sizeStep(s, def string) string { |
| 1194 | switch s { |
| 1195 | case "s", "m", "l": |
| 1196 | return s |
| 1197 | } |
| 1198 | return def |
| 1199 | } |
| 1200 | |
| 1201 | func alignProp(props map[string]any) string { |
| 1202 | switch a, _ := props["align"].(string); a { |
| 1203 | case "start", "center", "end", "justify": |
| 1204 | return a |
| 1205 | } |
| 1206 | return "" |
| 1207 | } |
| 1208 | |
| 1209 | // childIDs is a columns block's props.children (§2.4), as they arrive |
| 1210 | // from JSON. |
| 1211 | func childIDs(b deck.Block) []string { |
| 1212 | switch v := b.Props["children"].(type) { |
| 1213 | case []string: |
| 1214 | return v |
| 1215 | case []any: |
| 1216 | out := make([]string, 0, len(v)) |
| 1217 | for _, c := range v { |
| 1218 | if s, ok := c.(string); ok { |
| 1219 | out = append(out, s) |
| 1220 | } |
| 1221 | } |
| 1222 | return out |
| 1223 | } |
| 1224 | return nil |
| 1225 | } |
| 1226 | |
| 1227 | // cellsProp reads a table's props.cells: a row-major array of texts |
| 1228 | // (core/src/view.rs). It goes back through JSON rather than being |
| 1229 | // walked by hand — the view built it with encoding/json's shapes, and a |
| 1230 | // round trip is both shorter and harder to get wrong than five type |
| 1231 | // assertions deep. |
| 1232 | func cellsProp(props map[string]any) []deck.Text { |
| 1233 | raw, ok := props["cells"] |
| 1234 | if !ok { |
| 1235 | return nil |
| 1236 | } |
| 1237 | b, err := json.Marshal(raw) |
| 1238 | if err != nil { |
| 1239 | return nil |
| 1240 | } |
| 1241 | var out []deck.Text |
| 1242 | if err := json.Unmarshal(b, &out); err != nil { |
| 1243 | return nil |
| 1244 | } |
| 1245 | return out |
| 1246 | } |
| 1247 | |
| 1248 | // ── Values that land in CSS or a URL ────────────────────────────────── |
| 1249 | |
| 1250 | var ( |
| 1251 | // colorPattern is a hex colour or a plain CSS colour keyword. |
| 1252 | // Deliberately no functions: oklch() and color-mix() are free in |
| 1253 | // this family's own stylesheets (rastrillo SKILL.md), but a value |
| 1254 | // somebody typed into a deck is not a stylesheet, and a syntax that |
| 1255 | // takes arbitrary nested tokens is a syntax to keep out of a style |
| 1256 | // attribute. |
| 1257 | colorPattern = regexp.MustCompile(`^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,24})$`) |
| 1258 | |
| 1259 | // fontPattern is a font-family list: names, quotes, commas, and the |
| 1260 | // generic families. No parentheses, so no url() and no expression(). |
| 1261 | fontPattern = regexp.MustCompile(`^[A-Za-z0-9 ,'._-]{1,200}$`) |
| 1262 | |
| 1263 | // idPattern is an asset id. The core mints them as 12 lowercase |
| 1264 | // base32 characters; this is wider so an id from a later scheme |
| 1265 | // still resolves, and narrow enough that no id can be a path. |
| 1266 | idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) |
| 1267 | |
| 1268 | // refPattern is an object-store address (deck.Asset.Ref). One path |
| 1269 | // segment of unreserved characters: the store mints a hex SHA-256, |
| 1270 | // and this stays wide enough for a store that mints something else |
| 1271 | // without ever admitting a slash, a colon, or a character that |
| 1272 | // would have to be percent-encoded to survive a URL. |
| 1273 | refPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) |
| 1274 | |
| 1275 | // langPattern is a code block's language, which becomes a class. |
| 1276 | langPattern = regexp.MustCompile(`^[A-Za-z0-9+#._-]{1,32}$`) |
| 1277 | ) |
| 1278 | |
| 1279 | // safeColor takes a hex colour or a CSS colour keyword. It is for the |
| 1280 | // values whose contrast nothing has to compute: a shape's fill and |
| 1281 | // stroke, which sit on whatever the author put behind them. |
| 1282 | func safeColor(v string) string { |
| 1283 | v = strings.TrimSpace(v) |
| 1284 | if !colorPattern.MatchString(v) { |
| 1285 | return "" |
| 1286 | } |
| 1287 | if strings.HasPrefix(v, "#") { |
| 1288 | switch len(v) { |
| 1289 | case 4, 5, 7, 9: // #rgb, #rgba, #rrggbb, #rrggbbaa |
| 1290 | default: |
| 1291 | return "" |
| 1292 | } |
| 1293 | } |
| 1294 | return v |
| 1295 | } |
| 1296 | |
| 1297 | // safeHexColor is safeColor narrowed to opaque hex — #rgb or #rrggbb. |
| 1298 | // It is for the two colours whose readability this package has to |
| 1299 | // promise: a slide's background and a deck's accent. A keyword or an |
| 1300 | // alpha channel is dropped rather than guessed at, because a colour |
| 1301 | // with no measurable luminance cannot be checked against §8's floor. |
| 1302 | func safeHexColor(v string) string { |
| 1303 | v = safeColor(v) |
| 1304 | if _, ok := parseHexColor(v); !ok { |
| 1305 | return "" |
| 1306 | } |
| 1307 | return v |
| 1308 | } |
| 1309 | |
| 1310 | func safeFontStack(v string) string { |
| 1311 | v = strings.TrimSpace(v) |
| 1312 | if !fontPattern.MatchString(v) { |
| 1313 | return "" |
| 1314 | } |
| 1315 | return v |
| 1316 | } |
| 1317 | |
| 1318 | func safeAssetID(v string) string { |
| 1319 | if !idPattern.MatchString(v) { |
| 1320 | return "" |
| 1321 | } |
| 1322 | return v |
| 1323 | } |
| 1324 | |
| 1325 | // safeRef is an object-store address as it may appear in a URL path: |
| 1326 | // one segment, no slash, no colon, nothing a URL would have to escape. |
| 1327 | // rastrillo blobs mints a hex SHA-256, which passes; anything else is |
| 1328 | // dropped rather than escaped — the rule every other value that lands |
| 1329 | // in a URL in this file follows. |
| 1330 | func safeRef(v string) string { |
| 1331 | if !refPattern.MatchString(v) { |
| 1332 | return "" |
| 1333 | } |
| 1334 | return v |
| 1335 | } |
| 1336 | |
| 1337 | // ── Numbers ─────────────────────────────────────────────────────────── |
| 1338 | |
| 1339 | // pct turns a box fraction into a percentage with no floating-point |
| 1340 | // tail: 0.06 is "6%", not "6.000000000000001%". |
| 1341 | func pct(v float64) string { return trimNum(strconv.FormatFloat(v*100, 'f', 4, 64)) + "%" } |
| 1342 | |
| 1343 | // fracs is a box as the four decimals §2.3 stores, for data-phone-box — |
| 1344 | // a value the editor reads back, not one CSS uses. |
| 1345 | func fracs(b deck.Box) string { |
| 1346 | return strings.Join([]string{ |
| 1347 | strconv.FormatFloat(b.X, 'f', 4, 64), |
| 1348 | strconv.FormatFloat(b.Y, 'f', 4, 64), |
| 1349 | strconv.FormatFloat(b.W, 'f', 4, 64), |
| 1350 | strconv.FormatFloat(b.H, 'f', 4, 64), |
| 1351 | }, ",") |
| 1352 | } |
| 1353 | |
| 1354 | // num is a prop number as CSS or SVG wants it. |
| 1355 | func num(v float64) string { return trimNum(strconv.FormatFloat(v, 'f', 4, 64)) } |
| 1356 | |
| 1357 | func trimNum(s string) string { |
| 1358 | if strings.Contains(s, ".") { |
| 1359 | s = strings.TrimRight(s, "0") |
| 1360 | s = strings.TrimSuffix(s, ".") |
| 1361 | } |
| 1362 | if s == "" || s == "-" || s == "-0" { |
| 1363 | return "0" |
| 1364 | } |
| 1365 | return s |
| 1366 | } |
| 1367 | |
| 1368 | func clamp(v, lo, hi float64) float64 { |
| 1369 | if v < lo { |
| 1370 | return lo |
| 1371 | } |
| 1372 | if v > hi { |
| 1373 | return hi |
| 1374 | } |
| 1375 | return v |
| 1376 | } |
| 1377 | |