| 1 | // Package fonts is the one set of typefaces this app draws with, |
| 2 | // everywhere: the browser downloads these files (internal/slides/static/fonts, |
| 3 | // declared in slides.css), the layout engine measures with them, and the |
| 4 | // PDF embeds them. |
| 5 | // |
| 6 | // That "one set" is the whole point (spec §4.2). A layout engine that |
| 7 | // measures Liberation Sans while the projector renders whatever the |
| 8 | // machine calls "system-ui" is not slightly off, it is measuring a |
| 9 | // different font: the exported PDF and the slide on screen break their |
| 10 | // lines in different places for no reason anybody can see. Divergence |
| 11 | // between the two is confined to line breaking — this package is what |
| 12 | // confines it. |
| 13 | // |
| 14 | // Liberation Serif, Sans and Mono, four faces each, SIL OFL 1.1 |
| 15 | // (files/LICENSE-OFL-1.1.txt, redistributed with them as that licence |
| 16 | // requires). Docs made the same choice for the same reason and this is |
| 17 | // its font set, copied: Liberation is metric-compatible with Times New |
| 18 | // Roman, Arial and Courier New, it is complete enough for Latin, |
| 19 | // Greek, Cyrillic and Hebrew, and it costs nothing to redistribute. |
| 20 | // |
| 21 | // # TrueType, not woff2 |
| 22 | // |
| 23 | // These files are roughly twice the bytes a woff2 would be. Converting |
| 24 | // them needs a compressor this build does not have and would add a |
| 25 | // build step to a repo whose static assets are committed artifacts. A |
| 26 | // browser fetches only the faces a page actually uses, so a |
| 27 | // declared-but-unused face costs a reader nothing. The conversion is a |
| 28 | // follow-up with a real trigger — the first time page weight is |
| 29 | // measured and these show up in it. |
| 30 | // |
| 31 | // # Two copies of the same bytes |
| 32 | // |
| 33 | // files/ is embedded for the layout engine and the PDF; |
| 34 | // ../static/fonts/ is embedded for the browser to fetch. They are the |
| 35 | // same twelve files, and TestServedFontsMatchTheEmbeddedOnes holds |
| 36 | // them byte-identical. //go:embed cannot reach up out of a package |
| 37 | // directory, which is the whole reason for the second copy. |
| 38 | package fonts |
| 39 | |
| 40 | import ( |
| 41 | "embed" |
| 42 | "fmt" |
| 43 | "io/fs" |
| 44 | "strings" |
| 45 | "unicode" |
| 46 | ) |
| 47 | |
| 48 | //go:embed files |
| 49 | var files embed.FS |
| 50 | |
| 51 | // Family is one of the three typefaces a slide is set in. |
| 52 | type Family uint8 |
| 53 | |
| 54 | const ( |
| 55 | // Sans is the body face of every theme and the heading face of |
| 56 | // five of the six. |
| 57 | Sans Family = iota |
| 58 | // Serif is `paper`'s heading face — the one theme that has one. |
| 59 | Serif |
| 60 | // Mono is code, on every theme. |
| 61 | Mono |
| 62 | ) |
| 63 | |
| 64 | // Role is what a face is being asked for, which is the question a |
| 65 | // caller actually has: a heading, running text, or code. The family it |
| 66 | // resolves to is the theme's business (see Get). |
| 67 | type Role uint8 |
| 68 | |
| 69 | const ( |
| 70 | Heading Role = iota |
| 71 | Body |
| 72 | Code |
| 73 | ) |
| 74 | |
| 75 | // Face is one font program: a family and a weight/slope. Three fields |
| 76 | // and no payload — the bytes come from Data and the metrics from |
| 77 | // Metrics, so a Face is a value a caller can compare, put in a map key |
| 78 | // and write into a golden file. |
| 79 | type Face struct { |
| 80 | Family Family |
| 81 | Bold bool |
| 82 | Italic bool |
| 83 | } |
| 84 | |
| 85 | // themeHeading is the heading family per theme, and it is the ONE fact |
| 86 | // this package knows about themes. |
| 87 | // |
| 88 | // It has to agree with internal/slides/render/themes.go's |
| 89 | // --slides-font-heading, because that is what the browser renders with |
| 90 | // and this is what the layout engine measures with; the moment they |
| 91 | // disagree the PDF stops matching the screen. Nothing enforces that at |
| 92 | // compile time — render imports this package, not the other way round |
| 93 | // — so render_test.go's TestThemeFacesMatchTheStylesheetStacks reads |
| 94 | // each theme's stack and checks it names the family this table gives. |
| 95 | // |
| 96 | // Every theme's body is Sans and every theme's code is Mono, so |
| 97 | // neither needs a table. |
| 98 | var themeHeading = map[string]Family{ |
| 99 | "paper": Serif, |
| 100 | "ink": Sans, |
| 101 | "sunset": Sans, |
| 102 | "forest": Sans, |
| 103 | "slate": Sans, |
| 104 | "candy": Sans, |
| 105 | } |
| 106 | |
| 107 | // Get is the face a theme sets a role in. |
| 108 | // |
| 109 | // theme is a resolved theme id (render.ThemeID); a name this build has |
| 110 | // never heard of gets Sans headings rather than an error, which is the |
| 111 | // same shape render takes with an unknown theme — a deck whose theme |
| 112 | // was renamed out from under it still lays out. |
| 113 | func Get(theme string, role Role, bold, italic bool) Face { |
| 114 | switch role { |
| 115 | case Code: |
| 116 | return Face{Family: Mono, Bold: bold, Italic: italic} |
| 117 | case Heading: |
| 118 | if f, ok := themeHeading[theme]; ok { |
| 119 | return Face{Family: f, Bold: bold, Italic: italic} |
| 120 | } |
| 121 | } |
| 122 | return Face{Family: Sans, Bold: bold, Italic: italic} |
| 123 | } |
| 124 | |
| 125 | // FamilyForStack maps a CSS font stack — a theme's |
| 126 | // --slides-font-heading, or a font a deck named itself — to the |
| 127 | // embedded family it asks for first. |
| 128 | // |
| 129 | // The false return is load-bearing. meta.fonts lets a deck name any |
| 130 | // family at all (render.safeFontStack only checks the characters), and |
| 131 | // a face this binary does not carry is a face the layout engine cannot |
| 132 | // measure. A caller that gets false has to decide what to substitute |
| 133 | // and to know it is substituting, rather than being handed a silent |
| 134 | // wrong answer. |
| 135 | func FamilyForStack(stack string) (Family, bool) { |
| 136 | first, _, _ := strings.Cut(stack, ",") |
| 137 | first = strings.TrimSpace(first) |
| 138 | first = strings.Trim(first, `"'`) |
| 139 | switch strings.ToLower(first) { |
| 140 | case "liberation sans": |
| 141 | return Sans, true |
| 142 | case "liberation serif": |
| 143 | return Serif, true |
| 144 | case "liberation mono": |
| 145 | return Mono, true |
| 146 | } |
| 147 | return 0, false |
| 148 | } |
| 149 | |
| 150 | // CSSName is the family name slides.css declares this family under and |
| 151 | // a stack names first. It is the face's real name, so a reader who |
| 152 | // happens to have Liberation installed matches their own copy — the |
| 153 | // metrics are the same set, which is what the name promises. |
| 154 | func (f Family) CSSName() string { |
| 155 | switch f { |
| 156 | case Serif: |
| 157 | return "Liberation Serif" |
| 158 | case Mono: |
| 159 | return "Liberation Mono" |
| 160 | } |
| 161 | return "Liberation Sans" |
| 162 | } |
| 163 | |
| 164 | // Stack is the whole CSS font-family value for this family: the served |
| 165 | // face first, then what a browser that has not finished fetching it |
| 166 | // should use in the meantime. |
| 167 | // |
| 168 | // Unquoted, because these are written into a style attribute inline on |
| 169 | // a section (render/themes.go says why) and CSS reads a family name as |
| 170 | // a sequence of identifiers. Quoting would put a run of " in every |
| 171 | // slide's markup and buy nothing. |
| 172 | func (f Family) Stack() string { |
| 173 | switch f { |
| 174 | case Serif: |
| 175 | return "Liberation Serif, Georgia, serif" |
| 176 | case Mono: |
| 177 | return "Liberation Mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" |
| 178 | } |
| 179 | return "Liberation Sans, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" |
| 180 | } |
| 181 | |
| 182 | // PostScriptName is the name this face is registered under inside a |
| 183 | // PDF. It is not a display name and nothing matches on it; it exists |
| 184 | // because a PDF font dictionary requires one and a reader shows it in |
| 185 | // document properties. |
| 186 | func (f Face) PostScriptName() string { |
| 187 | name := map[Family]string{Serif: "LiberationSerif", Sans: "LiberationSans", Mono: "LiberationMono"}[f.Family] |
| 188 | switch { |
| 189 | case f.Bold && f.Italic: |
| 190 | return name + "-BoldItalic" |
| 191 | case f.Bold: |
| 192 | return name + "-Bold" |
| 193 | case f.Italic: |
| 194 | return name + "-Italic" |
| 195 | } |
| 196 | return name + "-Regular" |
| 197 | } |
| 198 | |
| 199 | // File is the face's filename, in files/ and in ../static/fonts/ both. |
| 200 | // It is PostScriptName with the space that never was and a suffix, |
| 201 | // which is what the upstream release calls these files. |
| 202 | func (f Face) File() string { return f.PostScriptName() + ".ttf" } |
| 203 | |
| 204 | // All is the twelve faces, in a stable order: family, then weight, |
| 205 | // then slope. |
| 206 | func All() []Face { |
| 207 | var out []Face |
| 208 | for _, fam := range []Family{Sans, Serif, Mono} { |
| 209 | for _, bold := range []bool{false, true} { |
| 210 | for _, italic := range []bool{false, true} { |
| 211 | out = append(out, Face{Family: fam, Bold: bold, Italic: italic}) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | return out |
| 216 | } |
| 217 | |
| 218 | // data holds every face's bytes, read once at init. |
| 219 | var data = map[Face][]byte{} |
| 220 | |
| 221 | func init() { |
| 222 | for _, f := range All() { |
| 223 | b, err := fs.ReadFile(files, "files/"+f.File()) |
| 224 | if err != nil { |
| 225 | // A missing face is a build mistake, and a build mistake |
| 226 | // that reaches a running instance shows up as a deck |
| 227 | // exporting without its bold text rather than as an |
| 228 | // error. Refuse to start instead. |
| 229 | panic(fmt.Sprintf("fonts: embedded face %q missing: %v", f.File(), err)) |
| 230 | } |
| 231 | data[f] = b |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | // Data is the face's font program. The slice is the embedded one and |
| 236 | // callers must not write to it; every caller here reads. |
| 237 | func Data(f Face) []byte { return data[f] } |
| 238 | |
| 239 | // Metrics is the face's vertical extent at size, in the same units |
| 240 | // size is in (the layout engine works in px at a 1920-wide frame). |
| 241 | // |
| 242 | // desc comes back positive — the table stores it negative and every |
| 243 | // caller wants a height — and lineHeight is the face's OWN default |
| 244 | // leading (ascent + descent + line gap). A caller following a CSS |
| 245 | // line-height ignores that last one and multiplies size instead, which |
| 246 | // is what internal/slides/layout does, because the browser it has to |
| 247 | // agree with is following the stylesheet and not the font. |
| 248 | func Metrics(f Face, size float64) (asc, desc, lineHeight float64) { |
| 249 | font, err := f.Parsed() |
| 250 | if err != nil { |
| 251 | // Unreachable while the twelve embedded faces parse, which |
| 252 | // init has effectively proved. A plausible fallback rather |
| 253 | // than a panic: losing a deck's export to a font table is |
| 254 | // worse than laying one block out a little wrong. |
| 255 | return size * 0.8, size * 0.2, size * 1.2 |
| 256 | } |
| 257 | upm := float64(font.UnitsPerEm) |
| 258 | asc = float64(font.Ascender) / upm * size |
| 259 | desc = -float64(font.Descender) / upm * size |
| 260 | lineHeight = asc + desc + float64(font.LineGap)/upm*size |
| 261 | return asc, desc, lineHeight |
| 262 | } |
| 263 | |
| 264 | // Advance is how wide s is in this face at this size, and which of its |
| 265 | // runes the face has no glyph for. |
| 266 | // |
| 267 | // The width is the sum of the per-rune advances from cmap and hmtx — |
| 268 | // no kerning, because this parser does not read GPOS, which is also |
| 269 | // what an unkerned browser layout of the same string is. |
| 270 | // |
| 271 | // A rune with no glyph is measured with .notdef's advance (what a |
| 272 | // browser draws as a box, at a box's width) AND reported. Reported, |
| 273 | // because §11's degrade-loudly rule applies to an export as much as to |
| 274 | // a screen: a deck in a script Liberation does not cover should come |
| 275 | // back with a warning saying so rather than a page of tofu nobody |
| 276 | // explained. |
| 277 | func Advance(f Face, size float64, s string) (width float64, missing []rune) { |
| 278 | if s == "" { |
| 279 | return 0, nil |
| 280 | } |
| 281 | font, err := f.Parsed() |
| 282 | if err != nil { |
| 283 | return 0, nil |
| 284 | } |
| 285 | upm := float64(font.UnitsPerEm) |
| 286 | var seen map[rune]bool |
| 287 | for _, r := range s { |
| 288 | gid := font.GlyphIndex(r) |
| 289 | if gid == 0 && !unicode.IsSpace(r) { |
| 290 | if seen == nil { |
| 291 | seen = map[rune]bool{} |
| 292 | } |
| 293 | if !seen[r] { |
| 294 | seen[r] = true |
| 295 | missing = append(missing, r) |
| 296 | } |
| 297 | } |
| 298 | width += float64(font.Advance(gid)) / upm * size |
| 299 | } |
| 300 | return width, missing |
| 301 | } |
| 302 | |
| 303 | // Licence is the SIL OFL 1.1 text these faces are redistributed |
| 304 | // under. It ships beside them in both copies: the licence requires the |
| 305 | // text to travel with the font software, and a font inside a Go binary |
| 306 | // and served over HTTP is font software being distributed. |
| 307 | func Licence() string { |
| 308 | b, err := fs.ReadFile(files, "files/LICENSE-OFL-1.1.txt") |
| 309 | if err != nil { |
| 310 | panic(fmt.Sprintf("fonts: embedded licence missing: %v", err)) |
| 311 | } |
| 312 | return string(b) |
| 313 | } |
| 314 | |