oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
46package render
47
48import (
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).
65type Viewport int
66
67const (
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
76func (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.
92func 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.
99type 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.
116const 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.
121func 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.
199func 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.
219func 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.
223var 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.
234var 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.
241const 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.
251type 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.
268func (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.
280func (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.
290func 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.
298func (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.
312func (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).
348func 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.
356func 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.
406func (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.
511func 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.
545func 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
553func 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.
563func 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.
587func 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.
600func (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.
641func (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.
691func (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
719func (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
735func (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
747func (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
771func (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
787func (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.
826func (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
839func (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
865func (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.
880var shapeKinds = []string{"rect", "ellipse", "line", "arrow"}
881
882func (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
948func (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.
1012var 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.
1021type 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.
1031func 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
1046func 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.
1059var linkSchemes = []string{"http", "https", "mailto"}
1060
1061func 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.
1080type 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.
1089func 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.
1114func 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).
1122func 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.
1132func 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.
1141func elClosed(tag string, attrs []attr) template.HTML {
1142 return template.HTML(strings.TrimSuffix(openTag(tag, attrs, ""), ">") + " />")
1143}
1144
1145func esc(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) }
1146
1147func join(parts []template.HTML) template.HTML { return joinWith("\n", parts) }
1148
1149func 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
1164func stringProp(props map[string]any, key string) string {
1165 s, _ := props[key].(string)
1166 return s
1167}
1168
1169func 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.
1185func 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.
1193func sizeStep(s, def string) string {
1194 switch s {
1195 case "s", "m", "l":
1196 return s
1197 }
1198 return def
1199}
1200
1201func 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.
1211func 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.
1232func 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
1250var (
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.
1282func 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.
1302func safeHexColor(v string) string {
1303 v = safeColor(v)
1304 if _, ok := parseHexColor(v); !ok {
1305 return ""
1306 }
1307 return v
1308}
1309
1310func safeFontStack(v string) string {
1311 v = strings.TrimSpace(v)
1312 if !fontPattern.MatchString(v) {
1313 return ""
1314 }
1315 return v
1316}
1317
1318func 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.
1330func 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%".
1341func 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.
1345func 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.
1355func num(v float64) string { return trimNum(strconv.FormatFloat(v, 'f', 4, 64)) }
1356
1357func 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
1368func 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