oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package layout
2
3import (
4 "fmt"
5 "slices"
6 "strings"
7
8 "amadan.net/oficina/slides/internal/slides/deck"
9 "amadan.net/oficina/slides/internal/slides/fonts"
10 "amadan.net/oficina/slides/internal/slides/render"
11)
12
13// Layout draws d's slide at index into a paint list.
14//
15// It fails only on a caller mistake — no deck, or an index that is not
16// a slide. Everything a deck can contain lays out as something: an
17// unknown theme falls back to the default, an unknown block type draws
18// nothing rather than breaking the slide around it, and a prop with
19// the wrong type takes its default. A half-written document that
20// arrived from a peer mid-edit is the ordinary case, not an error.
21//
22// The warnings are the things worth saying out loud about the result:
23// a block that outgrew its box, a rune no embedded face can draw, an
24// image the deck never registered. None of them stops the drawing.
25func Layout(d *deck.Deck, index int) (Paint, []Warning, error) {
26 if d == nil {
27 return Paint{}, nil, fmt.Errorf("layout: no deck")
28 }
29 if index < 0 || index >= len(d.Slides) {
30 return Paint{}, nil, fmt.Errorf("layout: slide %d is outside a deck of %d", index, len(d.Slides))
31 }
32 e := newEngine(d, index)
33 e.slide(d.Slides[index])
34 return Paint{W: FrameW, H: FrameH, Background: e.bg, Ops: e.ops}, e.warnings, nil
35}
36
37// engine is one Layout call: the palette it paints in, the faces it
38// measures with, and what it has drawn and noticed so far.
39type engine struct {
40 deck *deck.Deck
41
42 bg, fg Fill
43 accent, accent2 Fill
44 highlight Fill
45 head, body, code fonts.Face
46
47 // sc is the type and spacing scale this slide is drawn at, which
48 // depends on whether its section has padding (sizes.go).
49 sc scale
50
51 ops []Op
52 warnings []Warning
53
54 // slideID is the slide being drawn. Every warning this engine
55 // makes carries it (see warn), so a caller that laid out a whole
56 // deck can say which slide each line is about.
57 slideID string
58
59 // block is the id warnings are attributed to while a block is
60 // being measured, and seen keeps a missing glyph from being
61 // reported once per line it appears on.
62 block string
63 seen map[rune]bool
64}
65
66func newEngine(d *deck.Deck, index int) *engine {
67 // The same palette the section carries, overrides and contrast
68 // judgements included — render.SlideVars is the seam, so the paint
69 // list cannot form a second opinion about what colour a slide is.
70 vars, _ := render.SlideVars(d, index)
71 theme := render.ThemeID(d.Meta.Theme)
72
73 e := &engine{
74 deck: d,
75 slideID: d.Slides[index].ID,
76 bg: Fill(vars["--slides-bg"]),
77 fg: Fill(vars["--slides-fg"]),
78 code: fonts.Get(theme, fonts.Code, false, false),
79 seen: map[rune]bool{},
80 }
81 e.accent = Fill(vars["--slides-accent"])
82 e.accent2 = Fill(vars["--slides-accent-2"])
83 e.highlight = mix(e.accent, e.bg, mixHighlight)
84 e.head = e.faceFor(theme, fonts.Heading, vars["--slides-font-heading"])
85 e.body = e.faceFor(theme, fonts.Body, vars["--slides-font-body"])
86 return e
87}
88
89// faceFor is the face a stack names, or the theme's own when the deck
90// named a family this binary does not carry (meta.fonts lets it name
91// any at all). The substitution is reported rather than made quietly:
92// it is the one case where the browser and this engine are knowingly
93// setting a slide in two different faces, and an export nobody warned
94// about is an export nobody checks.
95func (e *engine) faceFor(theme string, role fonts.Role, stack string) fonts.Face {
96 if fam, ok := fonts.FamilyForStack(stack); ok {
97 return fonts.Face{Family: fam}
98 }
99 sub := fonts.Get(theme, role, false, false)
100 e.warn(Warning{Kind: WarnFont, Detail: fmt.Sprintf(
101 "this deck asks for %q, which is not a face this build carries: measured in %s instead",
102 strings.TrimSpace(strings.Split(stack, ",")[0]), sub.Family.CSSName())})
103 return sub
104}
105
106// warn records a warning, stamping the slide being drawn onto it so no
107// call site has to remember to. A warning that already names a slide
108// keeps the one it names.
109func (e *engine) warn(w Warning) {
110 if w.Slide == "" {
111 w.Slide = e.slideID
112 }
113 e.warnings = append(e.warnings, w)
114}
115
116// noteGlyphs reports runes no face could draw, once each per slide.
117func (e *engine) noteGlyphs(rs []rune) {
118 var fresh []string
119 for _, r := range rs {
120 if e.seen[r] {
121 continue
122 }
123 e.seen[r] = true
124 fresh = append(fresh, fmt.Sprintf("%q", r))
125 }
126 if len(fresh) == 0 {
127 return
128 }
129 e.warn(Warning{Kind: WarnGlyph, Block: e.block, Detail: fmt.Sprintf(
130 "no embedded face has a glyph for %s; drawn at the width of a box",
131 strings.Join(fresh, ", "))})
132}
133
134func (e *engine) draw(ops ...Op) { e.ops = append(e.ops, ops...) }
135
136// ── One slide ────────────────────────────────────────────────────────
137
138// slide paints the background and then the blocks, in the order they
139// are read: regions in reading order on a flow slide, z order on a
140// canvas one.
141func (e *engine) slide(s deck.Slide) {
142 // A picture behind the slide (§2.2) is an image op under
143 // everything else, covering the frame — `background-size: cover`.
144 if s.Background.Kind == "image" {
145 if id := s.Background.Value; id != "" {
146 if _, ok := e.deck.Assets[id]; ok {
147 e.draw(Image{X: 0, Y: 0, W: FrameW, H: FrameH, Asset: id, Fit: "cover"})
148 } else {
149 e.warn(Warning{Kind: WarnAsset, Detail: fmt.Sprintf(
150 "this slide's background names asset %q, which this deck never registered", id)})
151 }
152 }
153 }
154
155 tmpl := s.Template
156 if !slices.Contains(Templates(), tmpl) {
157 tmpl = "blank"
158 }
159 e.sc = scaleFor(s.Layout, tmpl)
160
161 // A columns block's children are laid out inside it and nowhere
162 // else (§2.4) — render.body's rule, and it has to be the same rule
163 // or a child would be drawn twice.
164 byID := make(map[string]deck.Block, len(s.Blocks))
165 claimed := map[string]bool{}
166 for _, b := range s.Blocks {
167 byID[b.ID] = b
168 }
169 for _, b := range s.Blocks {
170 if b.Type != "columns" {
171 continue
172 }
173 for _, id := range childIDs(b) {
174 if _, ok := byID[id]; ok {
175 claimed[id] = true
176 }
177 }
178 }
179 // done is shared across the whole slide, so a block is drawn at
180 // most once however many columns blocks name it.
181 done := map[string]bool{}
182
183 // What no root reaches is drawn last and reported (codex 7): a
184 // block named as a child by a columns block that nothing arrives
185 // at — two peers' assignments merged into a cycle, or a document
186 // written past the ops, which refuse the shape. render.body does
187 // the same, so the page and the export agree on what is there.
188 late := unreached(s.Blocks, byID, claimed)
189 for _, b := range late {
190 e.warn(Warning{Kind: WarnUnreached, Block: b.ID, Detail: fmt.Sprintf(
191 "a %s block is named as a child by a columns block nothing reaches (a cycle); it is drawn after everything else",
192 b.Type)})
193 }
194
195 if s.Layout == "canvas" {
196 e.canvas(s, late, byID, claimed, done)
197 return
198 }
199
200 // Flow: group into regions, canonical regions first, then any
201 // region name the core let through, in first-appearance order; the
202 // unreached blocks go to the end of the body, which is where the
203 // browser's unreached region auto-places on every template.
204 grouped := map[string][]deck.Block{}
205 var extra []string
206 for _, b := range s.Blocks {
207 if claimed[b.ID] {
208 continue
209 }
210 name := b.Region
211 switch {
212 case name == "":
213 name = "body"
214 case !slices.Contains(regionOrder, name) && !slices.Contains(extra, name):
215 extra = append(extra, name)
216 }
217 grouped[name] = append(grouped[name], b)
218 }
219 grouped["body"] = append(grouped["body"], late...)
220 // Measure every region, in reading order, then place them, then
221 // draw them in paint order. The measuring has to come first
222 // because two of the grids depend on what is in them: a
223 // `grid-template-rows: auto 1fr` heading row is as tall as the
224 // heading, and `title` centres its two rows as a pair.
225 var laid []*region
226 for _, name := range append(slices.Clone(regionOrder), extra...) {
227 if len(grouped[name]) == 0 {
228 continue
229 }
230 laid = append(laid, e.measureRegion(tmpl, name, grouped[name], byID, done))
231 }
232 e.place(tmpl, laid)
233 for _, name := range append(paintOrder(tmpl), extra...) {
234 for _, rg := range laid {
235 if rg.name == name {
236 e.drawRegion(tmpl, rg)
237 }
238 }
239 }
240}
241
242// paintOrder is the order the regions are DRAWN in, which is reading
243// order everywhere but one place: image-full puts the heading over the
244// picture with a z-index, so the picture has to be painted first. A
245// paint list has no z-index, only order, and getting this wrong is not
246// subtle — the photograph covers the title.
247func paintOrder(tmpl string) []string {
248 if tmpl == "image-full" {
249 return []string{"media", "body", "aside", "heading"}
250 }
251 return slices.Clone(regionOrder)
252}
253
254// region is one measured region: where the table put it, what is in
255// it, how tall that came out, and where the top of it ended up.
256type region struct {
257 name string
258 box Region
259 plans []plan
260 total float64
261 y float64
262 first string // the id of the first block in it, for a warning
263}
264
265// measure lays one region's blocks out at its width without deciding
266// where the top of it goes.
267func (e *engine) measureRegion(tmpl, name string, blocks []deck.Block, byID map[string]deck.Block, done map[string]bool) *region {
268 rg := &region{name: name, box: regionFor(tmpl, name), first: firstID(blocks)}
269
270 // image-full's media region is the whole frame and the picture
271 // fills it (`block-size: 100%`); every other region is as tall as
272 // what is in it.
273 fill := 0.0
274 if tmpl == "image-full" && name == "media" {
275 fill = rg.box.H
276 }
277
278 for _, b := range blocks {
279 if done[b.ID] {
280 continue
281 }
282 done[b.ID] = true
283 p := e.plan(b, slot{w: rg.box.W, h: fill, align: rg.box.AlignX}, byID, done)
284 if p.h == 0 && p.paint == nil {
285 continue
286 }
287 rg.plans = append(rg.plans, p)
288 }
289 // The accent bar under a section's heading: slides.css draws it
290 // with a ::after on the region, so it is the region's and not any
291 // block's, and it is part of what gets centred with it.
292 if tmpl == "section" && name == "heading" && len(rg.plans) > 0 {
293 rg.plans = append(rg.plans, e.sectionRule())
294 }
295 rg.total = e.shrink(rg.plans, rg.box.H)
296 return rg
297}
298
299// place decides where each region's content starts, and is where the
300// two grids that depend on their own contents are handled.
301//
302// - `title` centres its two auto rows as a PAIR (`align-content:
303// center` over `auto auto`), so the heading and the body straddle
304// the middle with one gap between them, whatever height each is.
305// - `title-body` and `two-column` have `grid-template-rows: auto 1fr`,
306// so a heading that runs to two lines pushes the row below it down
307// instead of overlapping it. The table's fixed heading row is the
308// one-line case; this is every other case.
309func (e *engine) place(tmpl string, laid []*region) {
310 for _, rg := range laid {
311 rg.y = rg.box.Y
312 switch rg.box.AlignY {
313 case Center:
314 rg.y += (rg.box.H - rg.total) / 2
315 case End:
316 rg.y = rg.box.Bottom() - rg.total
317 }
318 }
319
320 byName := map[string]*region{}
321 for _, rg := range laid {
322 byName[rg.name] = rg
323 }
324 head := byName["heading"]
325
326 switch tmpl {
327 case "title":
328 body := byName["body"]
329 if head == nil || body == nil {
330 return
331 }
332 top := contentY + (contentH-(head.total+sectionGap+body.total))/2
333 head.y = top
334 body.y = top + head.total + sectionGap
335 case "title-body", "two-column":
336 if head == nil {
337 return
338 }
339 below := head.y + head.total + sectionGap
340 for _, name := range []string{"body", "aside"} {
341 rg := byName[name]
342 if rg == nil || below <= rg.box.Y {
343 continue
344 }
345 // The row below grew downwards, so it starts lower and has
346 // less room; a Start-aligned region simply moves. A
347 // heading long enough to leave it no room at all leaves it
348 // none — not a negative amount, which would read as
349 // nonsense in the overflow warning.
350 rg.box.H = max(0, rg.box.H-(below-rg.box.Y))
351 rg.box.Y = below
352 rg.y = below
353 }
354 }
355}
356
357// drawRegion puts one measured region's ops in the paint list.
358func (e *engine) drawRegion(tmpl string, rg *region) {
359 if len(rg.plans) == 0 {
360 return
361 }
362 // image-full's heading sits on a scrim, so the words survive a
363 // light picture. Drawn from the measured content rather than from
364 // the region box, because the stylesheet sizes that box to fit
365 // (`justify-self: start` inside `max-inline-size: 80%`).
366 if tmpl == "image-full" && rg.name == "heading" {
367 e.draw(e.scrim(rg.plans, rg.box, rg.y, rg.total))
368 }
369
370 top := rg.y
371 for i, p := range rg.plans {
372 if i > 0 {
373 top += e.sc.gap()
374 }
375 // A picture or a shape squeezed to nothing draws nothing —
376 // which is what the browser shows too, and a zero-sized op in
377 // an export is noise nobody can act on. The overflow warning
378 // below is what says the region was over-full.
379 if p.paint != nil && !(p.flex && p.h <= 0) {
380 e.draw(p.paint(rg.box.X, top, rg.box.W, p.h)...)
381 }
382 top += p.h
383 }
384 if rg.total > rg.box.H+0.001 {
385 e.warn(Warning{Kind: WarnOverflow, Block: rg.first, Detail: fmt.Sprintf(
386 "the %s region of a %s slide holds %.0fpx of content in %.0fpx; it is drawn past the box rather than cut",
387 rg.name, tmpl, rg.total, rg.box.H)})
388 }
389}
390
391// shrink is the one piece of flexbox this engine implements, and it is
392// here because leaving it out is visible from across a room.
393//
394// A region is a column flex container with a definite height. Text
395// blocks cannot shrink — their minimum content size is the wrapped
396// text — but a picture and a shape can, and they do: an image block in
397// a body region is `display: flex` over an `<img>` at 100%, so its
398// hypothetical size is the picture's own aspect ratio at the column
399// width (1680 tall for a square picture in a 1680 column), and the
400// browser shrinks it to whatever the region has left. Without this a
401// slide with one heading and one photograph puts the photograph three
402// slides below the bottom of the frame.
403//
404// The CSS algorithm with equal shrink factors: distribute the excess
405// in proportion to each flexible item's base size, freeze anything
406// that hits its floor, and go round again. It returns the height the
407// region's contents ended up as, gaps included.
408func (e *engine) shrink(plans []plan, avail float64) float64 {
409 total := e.sc.gap() * float64(len(plans)-1)
410 var flexible []int
411 for i, p := range plans {
412 total += p.h
413 if p.flex {
414 flexible = append(flexible, i)
415 }
416 }
417 for total > avail+0.001 && len(flexible) > 0 {
418 excess := total - avail
419 var base float64
420 for _, i := range flexible {
421 base += plans[i].h
422 }
423 if base <= 0 {
424 break
425 }
426 var frozen []int
427 var shrunk float64
428 for _, i := range flexible {
429 want := plans[i].h - excess*plans[i].h/base
430 if want <= plans[i].minH {
431 want = plans[i].minH
432 frozen = append(frozen, i)
433 }
434 shrunk += plans[i].h - want
435 plans[i].h = want
436 }
437 total -= shrunk
438 if len(frozen) == 0 {
439 break
440 }
441 // Drop the frozen ones and, if the excess is not used up,
442 // take it out of the rest.
443 var rest []int
444 for _, i := range flexible {
445 if !slices.Contains(frozen, i) {
446 rest = append(rest, i)
447 }
448 }
449 flexible = rest
450 }
451 return total
452}
453
454// scrim is the panel behind image-full's heading: the region's own
455// background, its padding, and its corner radius.
456//
457// The stylesheet mixes it 82% towards transparent over the picture.
458// This draws it opaque, because a paint list has no compositing — an
459// SVG and a PDF would blend it differently, and the scrim exists to
460// make words readable over an unknown photograph, which an opaque
461// panel does and a wrongly-composited one might not.
462func (e *engine) scrim(plans []plan, r Region, y, total float64) Rect {
463 w := 0.0
464 for _, p := range plans {
465 w = max(w, p.w)
466 }
467 w = min(w+2*scrimPadX, scrimW)
468 return Rect{
469 X: r.X - scrimPadX, Y: y - scrimPadY,
470 W: w, H: total + 2*scrimPadY,
471 Fill: e.bg, Radius: 1 * e.sc.cqw,
472 }
473}
474
475// sectionRule is the accent bar under a section slide's heading —
476// `[data-template="section"] [data-region="heading"]::after`, 18cqw of
477// the accent colour, and the only decoration in the whole stylesheet.
478func (e *engine) sectionRule() plan {
479 w := 18 * e.sc.cqw
480 rule := e.sc.rule()
481 return plan{h: rule, w: w, paint: func(x, y, colW, _ float64) []Op {
482 return []Op{Rect{X: x + (colW-w)/2, Y: y, W: w, H: rule, Fill: e.accent, Radius: rule / 2}}
483 }}
484}
485
486// canvas draws blocks where they were put (§2.3): each box is a
487// fraction of the frame, and z is paint order. late is what nothing
488// reaches (see slide), drawn after the rest at its own box.
489func (e *engine) canvas(s deck.Slide, late []deck.Block, byID map[string]deck.Block, claimed, done map[string]bool) {
490 blocks := slices.Clone(s.Blocks)
491 // Stable, so two blocks at the same z keep the order the array
492 // had — the same tie-break render's reflow order takes.
493 slices.SortStableFunc(blocks, func(a, b deck.Block) int { return a.Z - b.Z })
494 roots := make([]deck.Block, 0, len(blocks)+len(late))
495 for _, b := range blocks {
496 if !claimed[b.ID] {
497 roots = append(roots, b)
498 }
499 }
500 roots = append(roots, late...)
501
502 for _, b := range roots {
503 if done[b.ID] {
504 continue
505 }
506 done[b.ID] = true
507 box := canvasBox(b.Box)
508 p := e.plan(b, slot{w: box.W, h: box.H, align: Start}, byID, done)
509 if p.paint != nil {
510 e.draw(p.paint(box.X, box.Y, box.W, p.h)...)
511 }
512 if p.h > box.H {
513 e.warn(Warning{Kind: WarnOverflow, Block: b.ID, Detail: fmt.Sprintf(
514 "a %s block holds %.0fpx of content in a %.0fpx box; it is drawn past the box rather than cut",
515 b.Type, p.h, box.H)})
516 }
517 }
518}
519
520// canvasBox turns §2.3's fractions into frame coordinates. A block
521// with no box at all — which the core does not produce and a peer
522// mid-edit might — gets the content box, which is where a browser's
523// static position would put it.
524func canvasBox(b *deck.Box) Box {
525 if b == nil {
526 return Box{contentX, contentY, contentW, contentH}
527 }
528 return Box{X: b.X * FrameW, Y: b.Y * FrameH, W: b.W * FrameW, H: b.H * FrameH}
529}
530
531// unreached is every block no traversal from the slide's roots — the
532// blocks no columns block claims — arrives at: the members of a cycle
533// of columns blocks and whatever hangs under them. Array order. The
534// same walk as render's, because the two have to agree on what is on
535// the slide.
536func unreached(blocks []deck.Block, byID map[string]deck.Block, claimed map[string]bool) []deck.Block {
537 reached := map[string]bool{}
538 var visit func(b deck.Block)
539 visit = func(b deck.Block) {
540 if reached[b.ID] {
541 return
542 }
543 reached[b.ID] = true
544 if b.Type != "columns" {
545 return
546 }
547 for _, id := range childIDs(b) {
548 if child, ok := byID[id]; ok {
549 visit(child)
550 }
551 }
552 }
553 for _, b := range blocks {
554 if !claimed[b.ID] {
555 visit(b)
556 }
557 }
558 var out []deck.Block
559 for _, b := range blocks {
560 if !reached[b.ID] {
561 out = append(out, b)
562 }
563 }
564 return out
565}
566
567func firstID(blocks []deck.Block) string {
568 if len(blocks) == 0 {
569 return ""
570 }
571 return blocks[0].ID
572}
573