// Package fonts is CARLOS Docs' core font set: the same font programs // the browser renders a document with and the PDF exporter embeds in // one, so the two measure identically. // // That "identically" is the whole point, and it is spec §11.1's actual // requirement — deterministic layout is impossible while the screen // lays out with whatever face the reader's machine happens to call // "Georgia" and the exporter lays out with something else. A document // that paginates one way on screen and another way in its own PDF is // not a rendering difference, it is two documents. // // # Which faces, and why not the ones §11.1 names // // §11.1 names Carlito and Caladea. Those are the *metric-compatible // substitutions* for the two proprietary faces a DOCX import names // (Calibri and Cambria) — they have a job the moment DOCX import // exists to name Calibri, and no job before that, because nothing in // this app can currently produce a document that asks for Calibri. // What §11.1 asks for that this package does deliver is the curated, // self-hosted core set. // // Liberation is that set here: Serif, Sans and Mono, four faces each, // SIL OFL 1.1 (files/LICENSE-OFL-1.1.txt, redistributed with them as // that licence requires). It is the same idea one family over — // Liberation is the metric-compatible set for Times New Roman, Arial // and Courier New, exactly as Carlito and Caladea are for Calibri and // Cambria. When DOCX import lands, Carlito and Caladea join this // payload; nothing here forecloses that. // // # TrueType, not woff2 // // §11.1 says woff2 and is right to: these files are roughly twice the // bytes a woff2 would be. Converting them needs a compressor this // build does not have, and a browser fetches only the faces a page // actually uses, so a declared-but-unused face costs a reader nothing. // The conversion is a follow-up with a real trigger — the first time // page weight is measured and these show up in it — not a blocker for // having a core set at all. package fonts import ( "crypto/sha256" "embed" "encoding/hex" "fmt" "io/fs" "sort" "strings" ) //go:embed files var files embed.FS // Family is one of the three faces a document can ask for. // // Serif is the zero value for no reason worth relying on: it is first // in the list, and it was once the default. It is not the default now // (see Default below), and nothing here should read the zero value as // "the default" — Go gives a Family field in an uninitialised struct // the value Serif whether or not anybody meant it, so a default spelled // that way is a default nobody can see going wrong. type Family uint8 const ( Serif Family = iota Sans Mono ) // Default is the face a run carrying no font mark is set in. // // It is a decision, not a property of any face, and it is stated in // four places that cannot import each other: here, views/show.html's // `[docs-text]` rule, internal/export/pdf.go's styleFor (which reads // this constant, so those two are one place and a half), and // fixtures/fonts.mjs's DEFAULT_FONT, which is what the toolbar tells a // person their unmarked text is in. internal/docs's // TestPageGeometryMatchesTheStylesheet and // TestTheToolbarDefaultFontIsTheDefaultFace are what hold the other // three to this one. // // Moving it changes the metrics every unmarked paragraph is laid out // with, on screen and in its own PDF, so every existing document // repaginates. That is not a side effect to be minimised — it is the // change — and it is safe only because the screen and the exporter // measure with the same font programs and are made to name the same // face here. const Default = Sans // Key is the family's name in the vocabulary shared by the CRDT marks // (font_serif, font_sans, font_mono), the CSS classes // (docs-font-serif), and the toolbar's own control // (fixtures/fonts.mjs's FONTS). All three faces have a mark now; the // default's is simply one the toolbar never writes, because choosing // the default clears the group rather than setting anything. func (f Family) Key() string { switch f { case Sans: return "sans" case Mono: return "mono" default: return "serif" } } // CSSName is the family name the browser matches @font-face against. // Deliberately not "Liberation Serif": a reader with Liberation // installed locally would otherwise match their own copy, which may be // a different version with different metrics, and the PDF would stop // agreeing with the screen for exactly the readers who look most // likely to be fine. A private name can only ever match the file this // binary serves. func (f Family) CSSName() string { switch f { case Sans: return "Docs Sans" case Mono: return "Docs Mono" default: return "Docs Serif" } } // FamilyForKey resolves a key from the shared vocabulary above. The // false return is load-bearing: keys reach this from mark names in a // document, and a document can carry any bytes at all // (POST /d/{sqid}/updates), so an unrecognised key must be a miss the // caller decides about rather than a silent fallback to the default // that hides a mark this build does not understand. func FamilyForKey(key string) (Family, bool) { switch key { case "serif": return Serif, true case "sans": return Sans, true case "mono": return Mono, true } return 0, false } // Face is one font program: a family and a weight/slope, the bytes, // and the URL it is served at. type Face struct { Family Family Bold bool Italic bool // File is the name inside files/, and Path is the URL the app // serves it at — the same content-hashed, immutable-cacheable // shape internal/docs/assets.go uses for the editor bundle, and // safe for the same reason: the URL changes whenever the bytes do, // so a stale cache entry has nothing to collide with. File string Path string Data []byte } // PostScriptName is the name this face is registered under inside a // PDF. It is not a display name and nothing matches on it; it exists // because a PDF font dictionary requires one and a reader shows it in // document properties. func (f Face) PostScriptName() string { name := map[Family]string{Serif: "LiberationSerif", Sans: "LiberationSans", Mono: "LiberationMono"}[f.Family] switch { case f.Bold && f.Italic: return name + "-BoldItalic" case f.Bold: return name + "-Bold" case f.Italic: return name + "-Italic" } return name + "-Regular" } // faceFiles maps each of the twelve faces to its file. Written out // rather than assembled from the family name and a suffix so that a // missing or renamed file is a compile-time-visible line here and a // panic at init (see the init below), not a nil Data discovered by // whoever exports a bold-italic paragraph six months from now. var faceFiles = []struct { family Family bold, italic bool file string }{ {Serif, false, false, "LiberationSerif-Regular.ttf"}, {Serif, true, false, "LiberationSerif-Bold.ttf"}, {Serif, false, true, "LiberationSerif-Italic.ttf"}, {Serif, true, true, "LiberationSerif-BoldItalic.ttf"}, {Sans, false, false, "LiberationSans-Regular.ttf"}, {Sans, true, false, "LiberationSans-Bold.ttf"}, {Sans, false, true, "LiberationSans-Italic.ttf"}, {Sans, true, true, "LiberationSans-BoldItalic.ttf"}, {Mono, false, false, "LiberationMono-Regular.ttf"}, {Mono, true, false, "LiberationMono-Bold.ttf"}, {Mono, false, true, "LiberationMono-Italic.ttf"}, {Mono, true, true, "LiberationMono-BoldItalic.ttf"}, } var faces []Face // byPath indexes the served faces for Asset below. var byPath = map[string]Face{} // PathPrefix is where the app mounts this package's assets. Exported // so internal/docs routes on the same constant this package builds // Face.Path from, rather than on a second literal that could drift. const PathPrefix = "/static/fonts/" func init() { for _, ff := range faceFiles { data, err := fs.ReadFile(files, "files/"+ff.file) if err != nil { // A missing face is a build mistake, and a build mistake // that reaches a running instance shows up as a document // exporting without its bold text rather than as an error. // Refuse to start instead. panic(fmt.Sprintf("fonts: embedded face %q missing: %v", ff.file, err)) } sum := sha256.Sum256(data) hash := hex.EncodeToString(sum[:])[:16] name := strings.TrimSuffix(ff.file, ".ttf") f := Face{ Family: ff.family, Bold: ff.bold, Italic: ff.italic, File: ff.file, Path: PathPrefix + name + "." + hash + ".ttf", Data: data, } faces = append(faces, f) byPath[f.Path] = f } sort.Slice(faces, func(i, j int) bool { return faces[i].Path < faces[j].Path }) } // All returns every face, in a stable order. func All() []Face { out := make([]Face, len(faces)) copy(out, faces) return out } // Get returns the one face for a family and weight/slope. Every // combination exists, so there is no miss to report — that is what // faceFiles above being exhaustive buys. func Get(family Family, bold, italic bool) Face { for _, f := range faces { if f.Family == family && f.Bold == bold && f.Italic == italic { return f } } // Unreachable while faceFiles covers all twelve; init would have // panicked otherwise. Falling back rather than panicking because // this is called per text run during an export, and losing a // document's italics beats losing the export. // // faces is sorted by served path, so faces[0] is NOT "regular // serif" as this comment used to claim — it is Liberation Mono // Bold, because "Bold." sorts before "BoldItalic" and Mono // before Sans and Serif. Ask for the intended fallback by name // instead of trusting an ordering that exists for a different // reason. for _, f := range faces { if f.Family == Default && !f.Bold && !f.Italic { return f } } return faces[0] } // Asset resolves a served URL path to its face. func Asset(path string) (Face, bool) { f, ok := byPath[path] return f, ok } // Licence is the SIL OFL 1.1 text these faces are redistributed // under. Served alongside them: the licence requires the text to // travel with the font software, and a font shipped inside a Go // binary and served over HTTP is still font software being // distributed. func Licence() string { b, err := fs.ReadFile(files, "files/LICENSE-OFL-1.1.txt") if err != nil { panic(fmt.Sprintf("fonts: embedded licence missing: %v", err)) } return string(b) } // LicencePath is where Licence is served. const LicencePath = PathPrefix + "LICENSE-OFL-1.1.txt"