| 1 | package layout |
| 2 | |
| 3 | import ( |
| 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. |
| 29 | type 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. |
| 47 | type 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. |
| 58 | type 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. |
| 71 | type 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. |
| 78 | func (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". |
| 96 | func (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. |
| 119 | func (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. |
| 139 | func 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 | |
| 164 | func 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. |
| 179 | func (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. |
| 246 | func (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. |
| 274 | func 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. |
| 343 | func 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 | |