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 "strings"
5 "unicode"
6
7 "amadan.net/oficina/slides/internal/slides/deck"
8 "amadan.net/oficina/slides/internal/slides/fonts"
9)
10
11// text.go measures and breaks lines. It is the one place spec §4.2
12// admits divergence from the browser, so it is worth saying exactly
13// what it does and does not do.
14//
15// Greedy: a line takes words until the next one would not fit, then
16// breaks. No hyphenation dictionary, no Knuth-Plass, no shaping — the
17// same unkerned sum of advance widths a browser without GPOS would
18// get (internal/slides/fonts' parser reads no GPOS, and says so).
19// Break opportunities are spaces and the inside of a hyphenated word,
20// which are the two a browser takes without being asked.
21//
22// A word wider than the box is placed on its own line and left to
23// overflow, rather than being broken mid-word. That is what the
24// stylesheet does (`overflow-wrap` is never set, so a long URL runs
25// past its column), and print has the same rule as screen: never clip.
26
27// style is how one stretch of text is set: a face, a size, a colour,
28// and the two things that are drawn around it rather than in it.
29type style struct {
30 face fonts.Face
31 size float64
32 fill Fill
33 href string
34 // track is letter-spacing in ems (headings only).
35 track float64
36 // mark is §2.6's highlight, drawn as a rectangle behind the run in
37 // markFill (the stylesheet mixes it from the accent).
38 mark bool
39 markFill Fill
40}
41
42// token is one word or one run of spaces, with the style it is set in
43// and its measured width. Splitting into tokens before wrapping is
44// what lets a line break fall between two runs with different marks —
45// "very **bold** words" wraps between "very" and "bold" like any other
46// text.
47type token struct {
48 s string
49 st style
50 space bool
51 w float64
52}
53
54// line is one assembled line: its tokens, its width, and the box it
55// occupies. asc and desc are the strut's — the block's own face at the
56// block's own size — because that is what CSS centres the content box
57// of a line against.
58type line struct {
59 toks []token
60 w float64
61 height float64
62 // gapBefore is the space above this line that is not leading: the
63 // margin between two paragraphs of one block.
64 gapBefore float64
65 asc, desc float64
66 overflowed bool
67}
68
69// block is a measured, wrapped run of text: the lines and how tall
70// they are together. Nothing in it is positioned yet.
71type textBlock struct {
72 lines []line
73 h float64
74}
75
76// measure is the width of s in st, letter-spacing included, and it is
77// the only place a rune the faces cannot draw is noticed.
78func (e *engine) measure(st style, s string) float64 {
79 w, missing := fonts.Advance(st.face, st.size, s)
80 if len(missing) > 0 {
81 e.noteGlyphs(missing)
82 }
83 if st.track != 0 {
84 w += st.track * st.size * float64(len([]rune(s)))
85 }
86 return w
87}
88
89// styleFor turns a run's marks into a style over a base one (§2.6).
90//
91// Bold and italic pick a different face of the same family. Code
92// changes family AND size, because the stylesheet does
93// (`:not(pre) > code` is 0.88em in the mono face). A link takes the
94// accent colour and carries its href so the caller can underline it —
95// §8 does not let colour alone say "this is a link".
96func (e *engine) styleFor(base style, r deck.Run) style {
97 st := base
98 if r.B {
99 st.face.Bold = true
100 }
101 if r.I {
102 st.face.Italic = true
103 }
104 if r.C {
105 st.face.Family = fonts.Mono
106 st.size = base.size * inlineEm
107 }
108 if r.H {
109 st.mark, st.markFill = true, e.highlight
110 }
111 if r.Href != "" && safeHref(r.Href) {
112 st.fill = e.accent
113 st.href = r.Href
114 }
115 return st
116}
117
118// tokenize splits one paragraph's runs into words and spaces.
119func (e *engine) tokenize(runs []deck.Run, base style) []token {
120 var out []token
121 for _, r := range runs {
122 st := e.styleFor(base, r)
123 for _, piece := range splitRuns(r.T) {
124 out = append(out, token{
125 s: piece,
126 st: st,
127 space: isSpaceRun(piece),
128 w: e.measure(st, piece),
129 })
130 }
131 }
132 return out
133}
134
135// splitRuns cuts a string into alternating words and runs of
136// whitespace, and cuts a word after each hyphen — "context-free" is
137// two tokens, so a line may end on the hyphen the way a browser lets
138// it.
139func splitRuns(s string) []string {
140 var out []string
141 var cur strings.Builder
142 var inSpace bool
143 flush := func() {
144 if cur.Len() > 0 {
145 out = append(out, cur.String())
146 cur.Reset()
147 }
148 }
149 for _, r := range s {
150 sp := unicode.IsSpace(r)
151 if cur.Len() > 0 && sp != inSpace {
152 flush()
153 }
154 inSpace = sp
155 cur.WriteRune(r)
156 if !sp && r == '-' {
157 flush()
158 }
159 }
160 flush()
161 return out
162}
163
164func isSpaceRun(s string) bool {
165 for _, r := range s {
166 if !unicode.IsSpace(r) {
167 return false
168 }
169 }
170 return s != ""
171}
172
173// wrap breaks t into lines no wider than w, one paragraph after
174// another with paraGap between them, and returns the measured block.
175//
176// lead is the CSS line-height ratio, applied to the block's own size,
177// so every line of a block is the same height whatever is on it —
178// which is what a `line-height: 1.3` inherited as a NUMBER does.
179func (e *engine) wrap(t deck.Text, base style, w, lead, paraGap float64) *textBlock {
180 asc, desc, _ := fonts.Metrics(base.face, base.size)
181 lineH := lead * base.size
182 out := &textBlock{}
183
184 // pending is the paragraph margin owed to the next line emitted.
185 var pending float64
186 emit := func(toks []token, width float64, over bool) {
187 out.lines = append(out.lines, line{
188 toks: toks, w: width, height: lineH, gapBefore: pending,
189 asc: asc, desc: desc, overflowed: over,
190 })
191 pending = 0
192 }
193
194 for i, para := range t {
195 if i > 0 {
196 pending = paraGap
197 out.h += paraGap
198 }
199 start := len(out.lines)
200 toks := e.tokenize(para, base)
201
202 var cur []token
203 var curW float64
204 flush := func() {
205 // A line never ends on a space: the trailing space is
206 // dropped, as a browser drops it at a soft break.
207 for len(cur) > 0 && cur[len(cur)-1].space {
208 curW -= cur[len(cur)-1].w
209 cur = cur[:len(cur)-1]
210 }
211 emit(cur, curW, curW > w)
212 cur, curW = nil, 0
213 }
214 for _, tk := range toks {
215 if tk.space {
216 // A line never opens with a space either.
217 if len(cur) == 0 {
218 continue
219 }
220 cur = append(cur, tk)
221 curW += tk.w
222 continue
223 }
224 if len(cur) > 0 && curW+tk.w > w {
225 flush()
226 }
227 cur = append(cur, tk)
228 curW += tk.w
229 }
230 if len(cur) > 0 || len(out.lines) == start {
231 // The second half puts an empty line box under an empty
232 // paragraph, which is what `<p></p>` is on screen.
233 flush()
234 }
235 out.h += float64(len(out.lines)-start) * lineH
236 }
237 return out
238}
239
240// paint places a wrapped block with its top-left at (x, y) in a column
241// w wide, and returns the ops that draw it.
242//
243// The baseline is where CSS puts it: the text's content box is centred
244// in the line box (half the leading above, half below) and the
245// baseline is one ascent down from the top of that box.
246func (e *engine) paint(tb *textBlock, x, y, w float64, align Align) []Op {
247 var ops []Op
248 top := y
249 for _, ln := range tb.lines {
250 lineX := x
251 switch align {
252 case Center:
253 lineX = x + (w-ln.w)/2
254 case End:
255 lineX = x + w - ln.w
256 }
257 top += ln.gapBefore
258 baseline := top + (ln.height-(ln.asc+ln.desc))/2 + ln.asc
259 ops = append(ops, lineOps(lineX, baseline, ln)...)
260 top += ln.height
261 }
262 return ops
263}
264
265// lineOps draws one line: the marks behind the words, the words, and
266// the underline beneath a link.
267//
268// Adjacent tokens in the same style become one Run, because a paint
269// list is read by people as well as by the serialisers and a line
270// spelled one word per run is unreadable. The marks and underlines are
271// separate ops for the reason §8 gives: a link that is only a colour
272// is not a link anybody colour-blind can see, and a PDF annotation
273// draws nothing on the page.
274func lineOps(x, baseline float64, ln line) []Op {
275 var marks, unders []Op
276 var runs []Run
277 cx := x
278
279 // A run of tokens sharing a style, accumulated then flushed.
280 var pend strings.Builder
281 var pendSt style
282 var pendW, pendX float64
283 flush := func() {
284 if pend.Len() == 0 {
285 return
286 }
287 if pendSt.mark {
288 // `mark` is a background behind the words, one ascent up
289 // and one descent down, with the stylesheet's own 0.15em
290 // of breathing room either side.
291 pad := 0.15 * pendSt.size
292 marks = append(marks, Rect{
293 X: pendX - pad, Y: baseline - ln.asc, W: pendW + 2*pad, H: ln.asc + ln.desc,
294 Fill: pendSt.markFill, Radius: 0.2 * pendSt.size,
295 })
296 }
297 if pendSt.href != "" {
298 // `text-underline-offset: 0.15em`,
299 // `text-decoration-thickness: 0.07em`.
300 off := 0.15 * pendSt.size
301 unders = append(unders, Line{
302 X1: pendX, Y1: baseline + off, X2: pendX + pendW, Y2: baseline + off,
303 Stroke: pendSt.fill, W: 0.07 * pendSt.size,
304 })
305 }
306 runs = append(runs, Run{
307 S: pend.String(), Face: pendSt.face, Size: pendSt.size,
308 Fill: pendSt.fill, Href: pendSt.href,
309 })
310 pend.Reset()
311 pendW = 0
312 }
313
314 for _, tk := range ln.toks {
315 if pend.Len() > 0 && tk.st != pendSt {
316 flush()
317 }
318 if pend.Len() == 0 {
319 pendSt, pendX = tk.st, cx
320 }
321 pend.WriteString(tk.s)
322 pendW += tk.w
323 cx += tk.w
324 }
325 flush()
326
327 if len(runs) == 0 {
328 return nil
329 }
330 // Marks first (they are behind), then the words, then the
331 // underlines, which sit under the letters and over the mark.
332 out := append([]Op{}, marks...)
333 out = append(out, Text{X: x, Y: baseline, Runs: runs})
334 return append(out, unders...)
335}
336
337// safeHref is render's own rule, restated: a link that does not point
338// at http, https or mailto is not drawn as a link. It is restated
339// rather than exported because the two answers are about different
340// media — this one decides whether to underline something on a page,
341// not whether to write an <a> — and one of them changing should not
342// silently change the other.
343func safeHref(raw string) bool {
344 if raw == "" || strings.TrimSpace(raw) != raw {
345 return false
346 }
347 scheme, _, ok := strings.Cut(raw, ":")
348 if !ok {
349 return false
350 }
351 switch strings.ToLower(scheme) {
352 case "http", "https", "mailto":
353 return true
354 }
355 return false
356}
357