oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package fonts is CARLOS Docs' core font set: the same font programs
2// the browser renders a document with and the PDF exporter embeds in
3// one, so the two measure identically.
4//
5// That "identically" is the whole point, and it is spec §11.1's actual
6// requirement — deterministic layout is impossible while the screen
7// lays out with whatever face the reader's machine happens to call
8// "Georgia" and the exporter lays out with something else. A document
9// that paginates one way on screen and another way in its own PDF is
10// not a rendering difference, it is two documents.
11//
12// # Which faces, and why not the ones §11.1 names
13//
14// §11.1 names Carlito and Caladea. Those are the *metric-compatible
15// substitutions* for the two proprietary faces a DOCX import names
16// (Calibri and Cambria) — they have a job the moment DOCX import
17// exists to name Calibri, and no job before that, because nothing in
18// this app can currently produce a document that asks for Calibri.
19// What §11.1 asks for that this package does deliver is the curated,
20// self-hosted core set.
21//
22// Liberation is that set here: Serif, Sans and Mono, four faces each,
23// SIL OFL 1.1 (files/LICENSE-OFL-1.1.txt, redistributed with them as
24// that licence requires). It is the same idea one family over —
25// Liberation is the metric-compatible set for Times New Roman, Arial
26// and Courier New, exactly as Carlito and Caladea are for Calibri and
27// Cambria. When DOCX import lands, Carlito and Caladea join this
28// payload; nothing here forecloses that.
29//
30// # TrueType, not woff2
31//
32// §11.1 says woff2 and is right to: these files are roughly twice the
33// bytes a woff2 would be. Converting them needs a compressor this
34// build does not have, and a browser fetches only the faces a page
35// actually uses, so a declared-but-unused face costs a reader nothing.
36// The conversion is a follow-up with a real trigger — the first time
37// page weight is measured and these show up in it — not a blocker for
38// having a core set at all.
39package fonts
40
41import (
42 "crypto/sha256"
43 "embed"
44 "encoding/hex"
45 "fmt"
46 "io/fs"
47 "sort"
48 "strings"
49)
50
51//go:embed files
52var files embed.FS
53
54// Family is one of the three faces a document can ask for.
55//
56// Serif is the zero value for no reason worth relying on: it is first
57// in the list, and it was once the default. It is not the default now
58// (see Default below), and nothing here should read the zero value as
59// "the default" — Go gives a Family field in an uninitialised struct
60// the value Serif whether or not anybody meant it, so a default spelled
61// that way is a default nobody can see going wrong.
62type Family uint8
63
64const (
65 Serif Family = iota
66 Sans
67 Mono
68)
69
70// Default is the face a run carrying no font mark is set in.
71//
72// It is a decision, not a property of any face, and it is stated in
73// four places that cannot import each other: here, views/show.html's
74// `[docs-text]` rule, internal/export/pdf.go's styleFor (which reads
75// this constant, so those two are one place and a half), and
76// fixtures/fonts.mjs's DEFAULT_FONT, which is what the toolbar tells a
77// person their unmarked text is in. internal/docs's
78// TestPageGeometryMatchesTheStylesheet and
79// TestTheToolbarDefaultFontIsTheDefaultFace are what hold the other
80// three to this one.
81//
82// Moving it changes the metrics every unmarked paragraph is laid out
83// with, on screen and in its own PDF, so every existing document
84// repaginates. That is not a side effect to be minimised — it is the
85// change — and it is safe only because the screen and the exporter
86// measure with the same font programs and are made to name the same
87// face here.
88const Default = Sans
89
90// Key is the family's name in the vocabulary shared by the CRDT marks
91// (font_serif, font_sans, font_mono), the CSS classes
92// (docs-font-serif), and the toolbar's own control
93// (fixtures/fonts.mjs's FONTS). All three faces have a mark now; the
94// default's is simply one the toolbar never writes, because choosing
95// the default clears the group rather than setting anything.
96func (f Family) Key() string {
97 switch f {
98 case Sans:
99 return "sans"
100 case Mono:
101 return "mono"
102 default:
103 return "serif"
104 }
105}
106
107// CSSName is the family name the browser matches @font-face against.
108// Deliberately not "Liberation Serif": a reader with Liberation
109// installed locally would otherwise match their own copy, which may be
110// a different version with different metrics, and the PDF would stop
111// agreeing with the screen for exactly the readers who look most
112// likely to be fine. A private name can only ever match the file this
113// binary serves.
114func (f Family) CSSName() string {
115 switch f {
116 case Sans:
117 return "Docs Sans"
118 case Mono:
119 return "Docs Mono"
120 default:
121 return "Docs Serif"
122 }
123}
124
125// FamilyForKey resolves a key from the shared vocabulary above. The
126// false return is load-bearing: keys reach this from mark names in a
127// document, and a document can carry any bytes at all
128// (POST /d/{sqid}/updates), so an unrecognised key must be a miss the
129// caller decides about rather than a silent fallback to the default
130// that hides a mark this build does not understand.
131func FamilyForKey(key string) (Family, bool) {
132 switch key {
133 case "serif":
134 return Serif, true
135 case "sans":
136 return Sans, true
137 case "mono":
138 return Mono, true
139 }
140 return 0, false
141}
142
143// Face is one font program: a family and a weight/slope, the bytes,
144// and the URL it is served at.
145type Face struct {
146 Family Family
147 Bold bool
148 Italic bool
149
150 // File is the name inside files/, and Path is the URL the app
151 // serves it at — the same content-hashed, immutable-cacheable
152 // shape internal/docs/assets.go uses for the editor bundle, and
153 // safe for the same reason: the URL changes whenever the bytes do,
154 // so a stale cache entry has nothing to collide with.
155 File string
156 Path string
157
158 Data []byte
159}
160
161// PostScriptName is the name this face is registered under inside a
162// PDF. It is not a display name and nothing matches on it; it exists
163// because a PDF font dictionary requires one and a reader shows it in
164// document properties.
165func (f Face) PostScriptName() string {
166 name := map[Family]string{Serif: "LiberationSerif", Sans: "LiberationSans", Mono: "LiberationMono"}[f.Family]
167 switch {
168 case f.Bold && f.Italic:
169 return name + "-BoldItalic"
170 case f.Bold:
171 return name + "-Bold"
172 case f.Italic:
173 return name + "-Italic"
174 }
175 return name + "-Regular"
176}
177
178// faceFiles maps each of the twelve faces to its file. Written out
179// rather than assembled from the family name and a suffix so that a
180// missing or renamed file is a compile-time-visible line here and a
181// panic at init (see the init below), not a nil Data discovered by
182// whoever exports a bold-italic paragraph six months from now.
183var faceFiles = []struct {
184 family Family
185 bold, italic bool
186 file string
187}{
188 {Serif, false, false, "LiberationSerif-Regular.ttf"},
189 {Serif, true, false, "LiberationSerif-Bold.ttf"},
190 {Serif, false, true, "LiberationSerif-Italic.ttf"},
191 {Serif, true, true, "LiberationSerif-BoldItalic.ttf"},
192 {Sans, false, false, "LiberationSans-Regular.ttf"},
193 {Sans, true, false, "LiberationSans-Bold.ttf"},
194 {Sans, false, true, "LiberationSans-Italic.ttf"},
195 {Sans, true, true, "LiberationSans-BoldItalic.ttf"},
196 {Mono, false, false, "LiberationMono-Regular.ttf"},
197 {Mono, true, false, "LiberationMono-Bold.ttf"},
198 {Mono, false, true, "LiberationMono-Italic.ttf"},
199 {Mono, true, true, "LiberationMono-BoldItalic.ttf"},
200}
201
202var faces []Face
203
204// byPath indexes the served faces for Asset below.
205var byPath = map[string]Face{}
206
207// PathPrefix is where the app mounts this package's assets. Exported
208// so internal/docs routes on the same constant this package builds
209// Face.Path from, rather than on a second literal that could drift.
210const PathPrefix = "/static/fonts/"
211
212func init() {
213 for _, ff := range faceFiles {
214 data, err := fs.ReadFile(files, "files/"+ff.file)
215 if err != nil {
216 // A missing face is a build mistake, and a build mistake
217 // that reaches a running instance shows up as a document
218 // exporting without its bold text rather than as an error.
219 // Refuse to start instead.
220 panic(fmt.Sprintf("fonts: embedded face %q missing: %v", ff.file, err))
221 }
222 sum := sha256.Sum256(data)
223 hash := hex.EncodeToString(sum[:])[:16]
224 name := strings.TrimSuffix(ff.file, ".ttf")
225 f := Face{
226 Family: ff.family,
227 Bold: ff.bold,
228 Italic: ff.italic,
229 File: ff.file,
230 Path: PathPrefix + name + "." + hash + ".ttf",
231 Data: data,
232 }
233 faces = append(faces, f)
234 byPath[f.Path] = f
235 }
236 sort.Slice(faces, func(i, j int) bool { return faces[i].Path < faces[j].Path })
237}
238
239// All returns every face, in a stable order.
240func All() []Face {
241 out := make([]Face, len(faces))
242 copy(out, faces)
243 return out
244}
245
246// Get returns the one face for a family and weight/slope. Every
247// combination exists, so there is no miss to report — that is what
248// faceFiles above being exhaustive buys.
249func Get(family Family, bold, italic bool) Face {
250 for _, f := range faces {
251 if f.Family == family && f.Bold == bold && f.Italic == italic {
252 return f
253 }
254 }
255 // Unreachable while faceFiles covers all twelve; init would have
256 // panicked otherwise. Falling back rather than panicking because
257 // this is called per text run during an export, and losing a
258 // document's italics beats losing the export.
259 //
260 // faces is sorted by served path, so faces[0] is NOT "regular
261 // serif" as this comment used to claim — it is Liberation Mono
262 // Bold, because "Bold.<hash>" sorts before "BoldItalic" and Mono
263 // before Sans and Serif. Ask for the intended fallback by name
264 // instead of trusting an ordering that exists for a different
265 // reason.
266 for _, f := range faces {
267 if f.Family == Default && !f.Bold && !f.Italic {
268 return f
269 }
270 }
271 return faces[0]
272}
273
274// Asset resolves a served URL path to its face.
275func Asset(path string) (Face, bool) {
276 f, ok := byPath[path]
277 return f, ok
278}
279
280// Licence is the SIL OFL 1.1 text these faces are redistributed
281// under. Served alongside them: the licence requires the text to
282// travel with the font software, and a font shipped inside a Go
283// binary and served over HTTP is still font software being
284// distributed.
285func Licence() string {
286 b, err := fs.ReadFile(files, "files/LICENSE-OFL-1.1.txt")
287 if err != nil {
288 panic(fmt.Sprintf("fonts: embedded licence missing: %v", err))
289 }
290 return string(b)
291}
292
293// LicencePath is where Licence is served.
294const LicencePath = PathPrefix + "LICENSE-OFL-1.1.txt"
295