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 "strconv"
6 "strings"
7
8 "amadan.net/oficina/slides/internal/slides/deck"
9 "amadan.net/oficina/slides/internal/slides/fonts"
10)
11
12// blocks.go is one function per block type in §2.4's vocabulary. Each
13// measures the block at the width it has been given and hands back a
14// plan; nothing is positioned until the region or the canvas box says
15// where the top of it goes.
16//
17// Every size, colour and gap in here is slides.css's, evaluated at
18// 1920 (sizes.go). Where this file departs from the stylesheet it says
19// so on the spot, and there is one such place: a shape's label is
20// drawn (planShape says why).
21
22// slot is what a block is being asked to fit: the column width, a
23// height it must fill (a canvas box, or image-full's media region;
24// zero means "as tall as your content"), and the inline alignment it
25// inherits.
26type slot struct {
27 w, h float64
28 align Align
29}
30
31// plan is a measured block: how tall it turned out, how wide its
32// content actually is, and how to draw it once its top-left is known.
33// A plan with no paint draws nothing (a spacer, an unknown type).
34type plan struct {
35 h, w float64
36 // flex says the block can be shrunk to fit the space that is left
37 // (a picture and a shape can; wrapped text cannot), and minH is
38 // the floor it shrinks to. See engine.shrink.
39 flex bool
40 minH float64
41 paint func(x, y, w, h float64) []Op
42}
43
44// plan measures one block. byID and done are the columns machinery:
45// a columns block lays its children out itself, and done stops any
46// block being drawn twice.
47func (e *engine) plan(b deck.Block, s slot, byID map[string]deck.Block, done map[string]bool) plan {
48 e.block = b.ID
49 switch b.Type {
50 case "heading":
51 return e.planHeading(b, s)
52 case "text":
53 return e.planText(b, s)
54 case "list":
55 return e.planList(b, s)
56 case "quote":
57 return e.planQuote(b, s)
58 case "code":
59 return e.planCode(b, s)
60 case "table":
61 return e.planTable(b, s)
62 case "shape":
63 return e.planShape(b, s)
64 case "image":
65 return e.planImage(b, s)
66 case "divider":
67 return e.planDivider(s)
68 case "spacer":
69 return plan{h: e.spacerHeight(b)}
70 case "columns":
71 return e.planColumns(b, s, byID, done)
72 }
73 // An unknown type is a block from a newer build of the core.
74 // Drawing nothing keeps the rest of the slide standing.
75 return plan{}
76}
77
78// textPlan wraps a block's text and turns it into a plan, reporting a
79// line that came out wider than its column — a word longer than the
80// box, which is drawn overflowing rather than broken or cut.
81func (e *engine) textPlan(t deck.Text, base style, s slot, lead, paraGap float64) plan {
82 tb := e.wrap(t, base, s.w, lead, paraGap)
83 e.noteWideLines(tb, s.w)
84 return plan{
85 h: tb.h, w: widest(tb),
86 paint: func(x, y, w, _ float64) []Op { return e.paint(tb, x, y, w, s.align) },
87 }
88}
89
90func (e *engine) noteWideLines(tb *textBlock, w float64) {
91 for _, ln := range tb.lines {
92 if ln.overflowed {
93 e.warn(Warning{Kind: WarnOverflow, Block: e.block, Detail: fmt.Sprintf(
94 "a word is wider than its %.0fpx column (%.0fpx); it is drawn past the edge rather than broken",
95 w, ln.w)})
96 return
97 }
98 }
99}
100
101func widest(tb *textBlock) float64 {
102 var w float64
103 for _, ln := range tb.lines {
104 w = max(w, ln.w)
105 }
106 return w
107}
108
109// ── Text-bearing blocks ──────────────────────────────────────────────
110
111// planHeading is `h1` to `h3`: the heading face, the top of the type
112// scale, and `font-weight: 600`, which with a 400/700 pair on offer is
113// the bold face (CSS matches a weight over 500 upwards first). A level
114// 3 heading is the accent colour, as the stylesheet has it.
115func (e *engine) planHeading(b deck.Block, s slot) plan {
116 level := int(numProp(b.Props, "level", 1))
117 if level < 1 || level > 3 {
118 level = 1
119 }
120 size := [...]float64{e.sc.t1(), e.sc.t2(), e.sc.t3()}[level-1]
121 fill := e.fg
122 if level == 3 {
123 fill = e.accent
124 }
125 st := style{face: e.head, size: size, fill: fill, track: trackHeading}
126 st.face.Bold = true
127 // A heading is one line by the schema; a stray second paragraph is
128 // a line break and not a new paragraph, which is what render does
129 // with it too (<br>, not a second <h1>), so there is no gap.
130 return e.textPlan(b.Text, st, s, leadHeading, 0)
131}
132
133func (e *engine) planText(b deck.Block, s slot) plan {
134 size := e.sc.bodySize(sizeProp(b.Props, "m"))
135 s.align = alignProp(b.Props, s.align)
136 st := style{face: e.body, size: size, fill: e.fg}
137 return e.textPlan(b.Text, st, s, leadBody, paraGapEm*size)
138}
139
140// planList is one item per paragraph (§2.6), each with a marker in the
141// accent colour hanging in the padding to the left of the text — which
142// is what `list-style-position: outside` does, and why a wrapped item
143// lines up under its own first line rather than under the bullet.
144func (e *engine) planList(b deck.Block, s slot) plan {
145 size := e.sc.bodySize(sizeProp(b.Props, "m"))
146 numbered, _ := b.Props["style"].(string)
147 indent := int(numProp(b.Props, "indent", 0))
148 if indent < 0 {
149 indent = 0
150 }
151 if indent > 3 {
152 indent = 3
153 }
154 inset := listIndentEm[indent] * size
155 st := style{face: e.body, size: size, fill: e.fg}
156 markerSt := style{face: e.body, size: size, fill: e.accent}
157 itemGap := listGapEm * size
158
159 type item struct {
160 tb *textBlock
161 marker string
162 markW float64
163 }
164 items := make([]item, 0, len(b.Text))
165 var h, w float64
166 for i, para := range b.Text {
167 tb := e.wrap(deck.Text{para}, st, s.w-inset, leadBody, 0)
168 e.noteWideLines(tb, s.w-inset)
169 marker := "•"
170 if numbered == "number" {
171 marker = strconv.Itoa(i+1) + "."
172 }
173 it := item{tb: tb, marker: marker, markW: e.measure(markerSt, marker)}
174 if i > 0 {
175 h += itemGap
176 }
177 h += tb.h
178 w = max(w, inset+widest(tb))
179 items = append(items, it)
180 }
181
182 return plan{h: h, w: w, paint: func(x, y, colW, _ float64) []Op {
183 var ops []Op
184 top := y
185 for i, it := range items {
186 if i > 0 {
187 top += itemGap
188 }
189 ops = append(ops, e.paint(it.tb, x+inset, top, colW-inset, s.align)...)
190 if len(it.tb.lines) > 0 {
191 ln := it.tb.lines[0]
192 baseline := top + (ln.height-(ln.asc+ln.desc))/2 + ln.asc
193 // The marker sits in the padding, its end a third of an
194 // em clear of the text.
195 mx := x + inset - 0.35*size - it.markW
196 ops = append(ops, Text{X: mx, Y: baseline, Runs: []Run{
197 {S: it.marker, Face: markerSt.face, Size: size, Fill: e.accent},
198 }})
199 }
200 top += it.tb.h
201 }
202 return ops
203 }}
204}
205
206// planQuote is a pull quote: the words in italic at the largest body
207// size, and the attribution under them in the accent colour with the
208// em dash the stylesheet's ::before puts there.
209func (e *engine) planQuote(b deck.Block, s slot) plan {
210 size := e.sc.bodyL()
211 st := style{face: e.body, size: size, fill: e.fg}
212 st.face.Italic = true
213 tb := e.wrap(b.Text, st, s.w, leadBody, paraGapEm*size)
214 e.noteWideLines(tb, s.w)
215
216 var cite *textBlock
217 citeSize := size * citeEm
218 if c := stringProp(b.Props, "cite"); c != "" {
219 citeSt := style{face: e.body, size: citeSize, fill: e.accent}
220 cite = e.wrap(deck.Text{{{T: "— " + c}}}, citeSt, s.w, leadBody, 0)
221 }
222
223 h, w := tb.h, widest(tb)
224 if cite != nil {
225 h += quoteGap*size + cite.h
226 w = max(w, widest(cite))
227 }
228 return plan{h: h, w: w, paint: func(x, y, colW, _ float64) []Op {
229 ops := e.paint(tb, x, y, colW, s.align)
230 if cite != nil {
231 ops = append(ops, e.paint(cite, x, y+tb.h+quoteGap*size, colW, s.align)...)
232 }
233 return ops
234 }}
235}
236
237// planCode is a panel with one line of monospace per paragraph (§2.4),
238// never wrapped: the stylesheet gives a code block `overflow-x: auto`,
239// so on screen a long line scrolls, and breaking one here would put a
240// line in the PDF that is not in the deck.
241func (e *engine) planCode(b deck.Block, s slot) plan {
242 size := e.sc.code()
243 inset := codePadEm * size
244 st := style{face: e.code, size: size, fill: e.fg}
245 lineH := leadCode * size
246 asc, desc, _ := fonts.Metrics(st.face, size)
247
248 lines := make([]string, 0, len(b.Text))
249 var widest float64
250 for _, para := range b.Text {
251 // `tab-size: 2`. Measured as two spaces because a tab has no
252 // glyph and would otherwise be drawn at .notdef's width.
253 text := strings.ReplaceAll(plainText(para), "\t", " ")
254 lines = append(lines, text)
255 widest = max(widest, e.measure(st, text))
256 }
257 if len(lines) == 0 {
258 lines = append(lines, "")
259 }
260 h := 2*inset + float64(len(lines))*lineH
261 if widest > s.w-2*inset {
262 e.warn(Warning{Kind: WarnOverflow, Block: b.ID, Detail: fmt.Sprintf(
263 "a code line is %.0fpx wide in a %.0fpx panel; on screen it scrolls, in print it is drawn past the edge",
264 widest, s.w-2*inset)})
265 }
266
267 panel := mix(e.fg, e.bg, mixCodePanel)
268 return plan{h: h, w: widest + 2*inset, paint: func(x, y, colW, _ float64) []Op {
269 ops := []Op{Rect{X: x, Y: y, W: colW, H: h, Fill: panel, Radius: codeRadiusEm * size}}
270 top := y + inset
271 for _, text := range lines {
272 baseline := top + (lineH-(asc+desc))/2 + asc
273 if text != "" {
274 ops = append(ops, Text{X: x + inset, Y: baseline, Runs: []Run{
275 {S: text, Face: st.face, Size: size, Fill: st.fill},
276 }})
277 }
278 top += lineH
279 }
280 return ops
281 }}
282}
283
284// planTable is equal columns, a rule under every row, and a header row
285// in bold over an accent rule (§2.4, and the stylesheet's `th`).
286func (e *engine) planTable(b deck.Block, s slot) plan {
287 cells := cellsProp(b.Props)
288 cols := int(numProp(b.Props, "cols", 0))
289 if cols < 1 {
290 cols = len(cells)
291 }
292 if cols < 1 {
293 return plan{}
294 }
295 header := boolProp(b.Props, "header")
296 size := e.sc.bodyS()
297 padX, padY := cellPadXEm*size, cellPadYEm*size
298 colW := s.w / float64(cols)
299
300 base := style{face: e.body, size: size, fill: e.fg}
301 head := base
302 head.face.Bold = true
303
304 type row struct {
305 cells []*textBlock
306 h float64
307 head bool
308 }
309 var rows []row
310 for i := 0; i < len(cells); i += cols {
311 end := min(i+cols, len(cells))
312 r := row{head: header && len(rows) == 0}
313 st := base
314 if r.head {
315 st = head
316 }
317 for _, cell := range cells[i:end] {
318 tb := e.wrap(cell, st, colW-2*padX, leadBody, paraGapEm*size)
319 r.cells = append(r.cells, tb)
320 r.h = max(r.h, tb.h)
321 }
322 r.h += 2 * padY
323 rows = append(rows, r)
324 }
325
326 var h float64
327 for _, r := range rows {
328 h += r.h + cellRule
329 }
330 ruleFill := mix(e.fg, e.bg, mixTableRule)
331 return plan{h: h, w: s.w, paint: func(x, y, colWidth, _ float64) []Op {
332 var ops []Op
333 cw := colWidth / float64(cols)
334 top := y
335 for _, r := range rows {
336 for i, cell := range r.cells {
337 ops = append(ops, e.paint(cell, x+float64(i)*cw+padX, top+padY, cw-2*padX, Start)...)
338 }
339 bottom := top + r.h
340 stroke := ruleFill
341 if r.head {
342 stroke = e.accent
343 }
344 ops = append(ops, Line{X1: x, Y1: bottom, X2: x + colWidth, Y2: bottom, Stroke: stroke, W: cellRule})
345 top = bottom + cellRule
346 }
347 return ops
348 }}
349}
350
351// ── Blocks that are not text ─────────────────────────────────────────
352
353// planShape draws §2.4's four kinds, mapping render's 0..100 viewBox
354// onto the box: the 1-unit inset it draws with becomes one percent of
355// the box, and a radius is read the same way (`rx="12"` in that
356// viewBox is 12% of the width).
357//
358// A flow shape starts as tall as it is wide: the stylesheet gives it
359// `inline-size: 100%; block-size: auto` over a 1:1 viewBox, and a
360// replaced element with an intrinsic ratio and no height is square.
361// That is nearly always taller than the region, so it is marked
362// flexible and engine.shrink squeezes it down to what is left, no
363// further than `min-block-size: 8cqw`. Which is what the browser does
364// with it.
365//
366// One departure from the browser, stated rather than hidden:
367//
368// - The label is DRAWN, centred. render puts it in the SVG's
369// <title>, where a browser announces it and does not paint it; a
370// paint list has no accessibility tree to put it in, and dropping
371// it would lose the words from the export altogether.
372func (e *engine) planShape(b deck.Block, s slot) plan {
373 kind := stringProp(b.Props, "kind")
374 switch kind {
375 case "rect", "ellipse", "line", "arrow":
376 default:
377 kind = "rect"
378 }
379 fill := e.color(stringProp(b.Props, "fill"), "")
380 stroke := e.color(stringProp(b.Props, "stroke"), e.fg)
381 strokeW := clamp(numProp(b.Props, "strokeWidth", 1), 0, 50)
382 radius := clamp(numProp(b.Props, "radius", 0), 0, 50)
383
384 h := s.h
385 if h == 0 {
386 h = max(s.w, shapeMinBlockCqw*e.sc.cqw)
387 }
388
389 label := b.Text.Plain()
390 labelSt := style{face: e.body, size: e.sc.bodyM(), fill: e.fg}
391 var labelBlock *textBlock
392 if label != "" {
393 labelBlock = e.wrap(deck.Text{{{T: label}}}, labelSt, s.w, leadBody, 0)
394 }
395
396 return plan{h: h, w: s.w, flex: s.h == 0, minH: shapeMinBlockCqw * e.sc.cqw, paint: func(x, y, w, h float64) []Op {
397 insetX, insetY := w*0.01, h*0.01
398 var ops []Op
399 switch kind {
400 case "ellipse":
401 ops = append(ops, Ellipse{X: x + insetX, Y: y + insetY, W: w - 2*insetX, H: h - 2*insetY,
402 Fill: fill, Stroke: stroke, StrokeW: strokeW})
403 case "line", "arrow":
404 ops = append(ops, Line{
405 X1: x + insetX, Y1: y + h/2, X2: x + w - insetX, Y2: y + h/2,
406 Stroke: stroke, W: strokeW, Arrow: kind == "arrow"})
407 default:
408 ops = append(ops, Rect{X: x + insetX, Y: y + insetY, W: w - 2*insetX, H: h - 2*insetY,
409 Fill: fill, Stroke: stroke, StrokeW: strokeW, Radius: radius / 100 * w})
410 }
411 if labelBlock != nil {
412 ops = append(ops, e.paint(labelBlock, x, y+(h-labelBlock.h)/2, w, Center)...)
413 }
414 return ops
415 }}
416}
417
418// planImage places a picture. In a region it is as tall as its own
419// aspect ratio makes it at the column width; in a canvas box or
420// image-full's media region it fills the box, which is what
421// `block-size: 100%` does.
422//
423// An asset the deck never registered draws nothing and is reported.
424// render puts an empty frame there for the editor to show as
425// unfinished; an export has nobody to show it to.
426func (e *engine) planImage(b deck.Block, s slot) plan {
427 id := stringProp(b.Props, "asset")
428 asset, ok := e.deck.Assets[id]
429 if !ok {
430 e.warn(Warning{Kind: WarnAsset, Block: b.ID, Detail: fmt.Sprintf(
431 "this image names asset %q, which this deck never registered", id)})
432 return plan{}
433 }
434 fit := "cover"
435 if f, _ := b.Props["fit"].(string); f == "contain" {
436 fit = "contain"
437 }
438 alt, _ := b.Props["alt"].(string)
439 if alt == "" && !boolProp(b.Props, "decorative") {
440 alt = asset.Alt
441 }
442
443 h := s.h
444 if h == 0 {
445 ratio := imageFallback
446 if asset.Width > 0 && asset.Height > 0 {
447 ratio = float64(asset.Height) / float64(asset.Width)
448 }
449 h = s.w * ratio
450 }
451 return plan{h: h, w: s.w, flex: s.h == 0, paint: func(x, y, w, h float64) []Op {
452 return []Op{Image{X: x, Y: y, W: w, H: h, Asset: id, Fit: fit, Alt: alt}}
453 }}
454}
455
456func (e *engine) planDivider(s slot) plan {
457 fill := mix(e.fg, e.bg, mixDivider)
458 rule := e.sc.rule()
459 return plan{h: rule, w: s.w, paint: func(x, y, w, _ float64) []Op {
460 return []Op{Rect{X: x, Y: y, W: w, H: rule, Fill: fill, Radius: rule / 2}}
461 }}
462}
463
464// spacerHeight is `[data-block="spacer"]`'s three sizes. It draws
465// nothing; it is the gap it makes.
466func (e *engine) spacerHeight(b deck.Block) float64 {
467 switch sizeProp(b.Props, "l") {
468 case "s":
469 return e.sc.gap() / 2
470 case "l":
471 return e.sc.gap() * 2
472 }
473 return e.sc.gap()
474}
475
476// planColumns lays a columns block's children out side by side in the
477// order props.children names them (§2.4), each in an equal column.
478func (e *engine) planColumns(b deck.Block, s slot, byID map[string]deck.Block, done map[string]bool) plan {
479 count := int(numProp(b.Props, "count", 2))
480 if count != 3 {
481 count = 2
482 }
483 colWidth := (s.w - e.sc.gap()*float64(count-1)) / float64(count)
484
485 var kids []plan
486 var h float64
487 for _, id := range childIDs(b) {
488 child, ok := byID[id]
489 if !ok || done[id] {
490 continue
491 }
492 done[id] = true
493 p := e.plan(child, slot{w: colWidth, h: 0, align: s.align}, byID, done)
494 kids = append(kids, p)
495 h = max(h, p.h)
496 }
497 if len(kids) == 0 {
498 return plan{}
499 }
500 return plan{h: h, w: s.w, paint: func(x, y, w, _ float64) []Op {
501 cw := (w - e.sc.gap()*float64(count-1)) / float64(count)
502 var ops []Op
503 for i, p := range kids {
504 if p.paint == nil {
505 continue
506 }
507 ops = append(ops, p.paint(x+float64(i)*(cw+e.sc.gap()), y, cw, p.h)...)
508 }
509 return ops
510 }}
511}
512
513// plainText is one paragraph's runs with the marks dropped — what a
514// code block is (§2.4: plain, one paragraph per line).
515func plainText(para []deck.Run) string {
516 var b strings.Builder
517 for _, r := range para {
518 b.WriteString(r.T)
519 }
520 return b.String()
521}
522
523func clamp(v, lo, hi float64) float64 {
524 if v < lo {
525 return lo
526 }
527 if v > hi {
528 return hi
529 }
530 return v
531}
532