oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
38package fonts
39
40import (
41 "embed"
42 "fmt"
43 "io/fs"
44 "strings"
45 "unicode"
46)
47
48//go:embed files
49var files embed.FS
50
51// Family is one of the three typefaces a slide is set in.
52type Family uint8
53
54const (
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).
67type Role uint8
68
69const (
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.
79type 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.
98var 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.
113func 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.
135func 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.
154func (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.
172func (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.
186func (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.
202func (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.
206func 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.
219var data = map[Face][]byte{}
220
221func 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.
237func 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.
248func 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.
277func 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.
307func 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