| 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. |
| 39 | package fonts |
| 40 | |
| 41 | import ( |
| 42 | "crypto/sha256" |
| 43 | "embed" |
| 44 | "encoding/hex" |
| 45 | "fmt" |
| 46 | "io/fs" |
| 47 | "sort" |
| 48 | "strings" |
| 49 | ) |
| 50 | |
| 51 | //go:embed files |
| 52 | var 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. |
| 62 | type Family uint8 |
| 63 | |
| 64 | const ( |
| 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. |
| 88 | const 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. |
| 96 | func (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. |
| 114 | func (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. |
| 131 | func 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. |
| 145 | type 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. |
| 165 | func (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. |
| 183 | var 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 | |
| 202 | var faces []Face |
| 203 | |
| 204 | // byPath indexes the served faces for Asset below. |
| 205 | var 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. |
| 210 | const PathPrefix = "/static/fonts/" |
| 211 | |
| 212 | func 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. |
| 240 | func 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. |
| 249 | func 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. |
| 275 | func 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. |
| 285 | func 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. |
| 294 | const LicencePath = PathPrefix + "LICENSE-OFL-1.1.txt" |
| 295 | |