| 1 | package layout |
| 2 | |
| 3 | import ( |
| 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. |
| 26 | type 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). |
| 34 | type 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. |
| 47 | func (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. |
| 81 | func (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 | |
| 90 | func (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 | |
| 101 | func 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. |
| 115 | func (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 | |
| 133 | func (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. |
| 144 | func (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. |
| 209 | func (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. |
| 241 | func (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`). |
| 286 | func (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. |
| 372 | func (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. |
| 426 | func (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 | |
| 456 | func (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. |
| 466 | func (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. |
| 478 | func (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). |
| 515 | func 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 | |
| 523 | func 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 | |