diff --git a/README.md b/README.md index 9217403..414d82d 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,11 @@ PNGs, measured vs headless Chrome) is in [`FIDELITY.md`](FIDELITY.md) and `-webkit-line-clamp`; **`@font-face`** — the document's own typefaces are fetched and registered before anything is measured, **WOFF2 included**, so a page is set in the - face it asked for and not in a substitute at other metrics. + face it asked for and not in a substitute at other metrics. A host can also + install typefaces of its own with **`Engine.FontFaces`** (built with + `ParseFontFace`), as a browser's system fonts; parsed fonts are shared + across renders, so a page set in the same faces as the last one does not + parse them again. - **Tables**: automatic table layout (CSS 2.1 §17.5.2.2) — every column keeps at least its longest unbreakable word, percentage and fixed cell widths are honoured, surplus goes to the auto columns; `colspan`, `border-spacing`, diff --git a/css/fontface.go b/css/fontface.go index c70b2dc..508f37b 100644 --- a/css/fontface.go +++ b/css/fontface.go @@ -181,8 +181,14 @@ func parseFontSrc(v string) []FontSrc { } // urlToken extracts the url(...) target of one src entry, unquoted. +// +// It and formatHint match in place (indexFold) rather than lowercasing a +// copy of the entry: an entry is often a whole font inlined as a data: URI, +// hundreds of kilobytes that were copied twice per rule per render. In place, +// the index is also an index into entry itself, which ToLower did not +// promise once a non-ASCII character changed length. func urlToken(entry string) (string, bool) { - i := strings.Index(strings.ToLower(entry), "url(") + i := indexFold(entry, "url(") if i < 0 { return "", false } @@ -203,7 +209,7 @@ func urlToken(entry string) (string, bool) { // unquoted; empty when the entry gives none. A "woff2-variations" style hint // keeps only its base token, which is what decides decodability. func formatHint(entry string) string { - i := strings.Index(strings.ToLower(entry), "format(") + i := indexFold(entry, "format(") if i < 0 { return "" } diff --git a/css/fontface_test.go b/css/fontface_test.go index 81d8921..982da86 100644 --- a/css/fontface_test.go +++ b/css/fontface_test.go @@ -85,6 +85,23 @@ func TestParseFontFacesSrcList(t *testing.T) { } } +// url() and format() match in any case, and the URL keeps its own case — +// including after a non-ASCII character, whose lowercase form is a +// different length (Ⱥ is 2 bytes, ⱥ 3): an index found in a lowercased +// copy would cut the entry at the wrong byte. +func TestParseFontFacesSrcCase(t *testing.T) { + src := `@font-face{font-family:F;src:URL("Ⱥ/Data.TTF") FORMAT("TrueType"), Url(x.woff) Format(woff)}` + faces := ParseFontFaces(src, Media{}) + if len(faces) != 1 { + t.Fatalf("got %d faces", len(faces)) + } + want := []FontSrc{{"Ⱥ/Data.TTF", "truetype"}, {"x.woff", "woff"}} + got := faces[0].Srcs + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("srcs = %+v, want %+v", got, want) + } +} + // A rule with nothing to load, or no family to load it for, is not a face. func TestParseFontFacesDropsUnusable(t *testing.T) { for _, src := range []string{ diff --git a/css/parse.go b/css/parse.go index 54a3b7a..ab3fee9 100644 --- a/css/parse.go +++ b/css/parse.go @@ -430,7 +430,7 @@ func ParseDeclarations(body string) []Declaration { // A trailing !important is not part of the value — strip it here, once, // so no property parser downstream ever has to know about it. important := false - if idx := strings.LastIndex(strings.ToLower(val), "!important"); idx >= 0 { + if idx := lastImportant(val); idx >= 0 { val = strings.TrimSpace(val[:idx]) important = true } @@ -455,6 +455,21 @@ func ParseDeclarations(body string) []Declaration { return out } +// lastImportant is the byte index of the last "!important" in val, in any +// ASCII case, or -1. It looks only where a '!' is, instead of searching a +// lowercased copy of the value: a value is often a data: URI — an inlined +// image or font, hundreds of kilobytes — that was copied for every +// declaration of every render. +func lastImportant(val string) int { + const word = "!important" + for i := strings.LastIndexByte(val, '!'); i >= 0; i = strings.LastIndexByte(val[:i], '!') { + if i+len(word) <= len(val) && equalFoldASCII(val[i:i+len(word)], word) { + return i + } + } + return -1 +} + // splitDeclChunks splits a declaration block on top-level semicolons, ignoring // semicolons nested inside parentheses (e.g. a `url(data:…;base64,…)` value) or // inside single/double quotes. This keeps a data-URI or function argument that diff --git a/css/parse_test.go b/css/parse_test.go index a521562..c0e372e 100644 --- a/css/parse_test.go +++ b/css/parse_test.go @@ -68,7 +68,7 @@ func TestParseDeclarationsEmptyCustomPropertyKept(t *testing.T) { // whitespace around the !important marker, and that a plain declaration is // unaffected. func TestParseDeclarationsImportantCaseAndSpacing(t *testing.T) { - d := ParseDeclarations("a: 1 !IMPORTANT; b: 2 !important ; c: 3") + d := ParseDeclarations("a: 1 !IMPORTANT; b: 2 !important ; c: 3; e: x!imp; f: 'Ⱥ!' !Important; g: y !!important") want := map[string]struct { val string important bool @@ -76,6 +76,9 @@ func TestParseDeclarationsImportantCaseAndSpacing(t *testing.T) { "a": {"1", true}, "b": {"2", true}, "c": {"3", false}, + "e": {"x!imp", false}, // a '!' that does not start the word, too short to hold it + "f": {"'Ⱥ!'", true}, // the index stays right after a non-ASCII character + "g": {"y !", true}, // the last '!' is the one that starts the word } if len(d) != len(want) { t.Fatalf("got %d decls: %v", len(d), d) diff --git a/engine.go b/engine.go index 4435df8..3280c45 100644 --- a/engine.go +++ b/engine.go @@ -136,6 +136,17 @@ type Engine struct { // reflects the scale. 0 or 1 (the default) renders at one device pixel per // CSS px, exactly as before. DeviceScale float64 + // FontFaces are typefaces the host provides to every render, as a + // browser's installed fonts: text whose font-family names one of these + // families is set in it without the page declaring an @font-face. A + // family the page does declare a face for keeps the page's. Only + // Family, Weight, Italic and Data (SFNT, WOFF or WOFF2 bytes) are read; + // a face that does not parse is skipped. The files are parsed once and + // the parsed fonts reused across renders, which makes this far cheaper + // than inlining the same fonts into every page as data: URIs — a host + // rendering many pages in one typeface (a slide deck, page by page) + // otherwise pays for decoding and parsing them on each. + FontFaces []LoadedFontFace } // New returns an Engine with a browser-like HTTP client (Chrome TLS @@ -398,6 +409,7 @@ func (e *Engine) renderCoreStaged(ctx context.Context, doc *Document, vpW, vpH i // exported to render offline) declares its faces there, as data: URIs. fontSheets := append(rp.sheets[:len(rp.sheets):len(rp.sheets)], css.InlineStyleText(doc.Root)) RegisterFontFaces(fonts, e.LoadFontFaces(ctx, doc, fontSheets, e.media(vpW))) + registerHostFaces(fonts, e.FontFaces) rp.sm = e.cascade(doc.Root, vpW, rp.sheets, nil) // TEXT-FIRST progressive frame (progressive path ONLY): lay out and emit the diff --git a/fontcache_test.go b/fontcache_test.go new file mode 100644 index 0000000..425d309 --- /dev/null +++ b/fontcache_test.go @@ -0,0 +1,210 @@ +// Copyright (c) the go-webengine authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package engine + +import ( + "bytes" + "context" + "encoding/base64" + "image" + "sync" + "testing" + + "github.com/go-opentype/fonts/cabin" + "github.com/go-opentype/fonts/lora" + + "github.com/go-webengine/engine/paint" +) + +func TestFontCacheHitsAndBounds(t *testing.T) { + c := newFontCache(2, 1<<30) + a1, err := c.parse(cabin.TTF) + if err != nil { + t.Fatal(err) + } + a2, err := c.parse(bytes.Clone(cabin.TTF)) // the same bytes, another slice + if err != nil { + t.Fatal(err) + } + if a1 != a2 { + t.Error("the same bytes were parsed twice") + } + // A third distinct font evicts the least recently used: cabin was used + // after lora, so lora goes. + l1, _ := c.parse(lora.TTF) + if _, err := c.parse(cabin.TTF); err != nil { + t.Fatal(err) + } + if _, err := c.parse(lora.BoldTTF); err != nil { + t.Fatal(err) + } + if c.lru.Len() != 2 { + t.Fatalf("%d entries, bound 2", c.lru.Len()) + } + if l2, _ := c.parse(lora.TTF); l2 == l1 { + t.Error("the least recently used font was kept past the bound") + } + + // The byte bound: room for one of the two, so the older goes. + b := newFontCache(10, len(cabin.TTF)+len(lora.TTF)-1) + b.parse(cabin.TTF) + b.parse(lora.TTF) + if b.lru.Len() != 1 || b.bytes != len(lora.TTF) { + t.Errorf("%d entries, %d bytes, want lora alone", b.lru.Len(), b.bytes) + } + // Larger than the whole cache: parsed and returned, not kept. + s := newFontCache(10, 10) + if ft, err := s.parse(cabin.TTF); err != nil || ft == nil { + t.Fatalf("oversized: %v", err) + } + if s.lru.Len() != 0 { + t.Error("a font larger than the cache was kept") + } + // A file that does not parse is refused and not kept. + if _, err := s.parse([]byte("not a font at all")); err == nil { + t.Error("garbage parsed") + } + if s.lru.Len() != 0 { + t.Error("a failed parse was kept") + } +} + +// TestFontCacheConcurrentMiss: renders missing at once all get a font and +// leave one entry. +func TestFontCacheConcurrentMiss(t *testing.T) { + c := newFontCache(4, 1<<30) + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + if _, err := c.parse(cabin.TTF); err != nil { + t.Error(err) + } + }) + } + wg.Wait() + if c.lru.Len() != 1 { + t.Errorf("%d entries for one font", c.lru.Len()) + } +} + +// TestSharedFontsRenderConcurrently renders one page using a data: URI +// face from several goroutines on one Engine: the cached parsed font and the +// shared bundled fonts are read concurrently (go test -race), and every +// render matches a serial one. +func TestSharedFontsRenderConcurrently(t *testing.T) { + uri := "data:font/ttf;base64," + base64.StdEncoding.EncodeToString(cabin.TTF) + page := `` + + `

Hamburgefonstiv serif →

` + e := New() + e.DisableJS = true + want, _, err := e.RenderHTML(context.Background(), page, "", image.Rect(0, 0, 400, 60)) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for range 6 { + wg.Go(func() { + got, _, err := e.RenderHTML(context.Background(), page, "", image.Rect(0, 0, 400, 60)) + if err != nil { + t.Error(err) + return + } + if !bytes.Equal(got.Pix, want.Pix) { + t.Error("a concurrent render differs from the serial one") + } + }) + } + wg.Wait() +} + +// TestEngineFontFaces: a face the host provides sets the text exactly as the +// same file declared by the page as a data: URI does; a page declaring its +// own face for the family keeps it; a host face that does not parse is +// skipped. +func TestEngineFontFaces(t *testing.T) { + uri := func(ttf []byte) string { return "data:font/ttf;base64," + base64.StdEncoding.EncodeToString(ttf) } + page := func(rule string) string { + return `` + + `

Hamburgefonstiv

` + } + render := func(e *Engine, html string) []byte { + t.Helper() + img, _, err := e.RenderHTML(context.Background(), html, "", image.Rect(0, 0, 400, 60)) + if err != nil { + t.Fatal(err) + } + return img.Pix + } + plain := New() + plain.DisableJS = true + declared := render(plain, page(`@font-face{font-family:'Host Face';src:url(`+uri(cabin.TTF)+`)}`)) + fallback := render(plain, page("")) + + host := New() + host.DisableJS = true + host.FontFaces = []LoadedFontFace{ + {Family: "Broken", Weight: 400, Data: []byte("not a font")}, + {Family: "Host Face", Weight: 400, Data: cabin.TTF}, + } + if got := render(host, page("")); !bytes.Equal(got, declared) { + t.Error("a host face does not render like the same face declared by the page") + } + parsed, err := ParseFontFace("Host Face", 400, false, cabin.TTF) + if err != nil { + t.Fatal(err) + } + host.FontFaces = []LoadedFontFace{parsed} + if got := render(host, page("")); !bytes.Equal(got, declared) { + t.Error("a face from ParseFontFace does not render like the same face declared by the page") + } + if _, err := ParseFontFace("x", 400, false, []byte("not a font")); err == nil { + t.Error("ParseFontFace accepted garbage") + } + if bytes.Equal(declared, fallback) { + t.Fatal("control: the face changes nothing") + } + own := render(plain, page(`@font-face{font-family:'Host Face';src:url(`+uri(lora.TTF)+`)}`)) + if got := render(host, page(`@font-face{font-family:'Host Face';src:url(`+uri(lora.TTF)+`)}`)); !bytes.Equal(got, own) { + t.Error("the page's own face for the family did not win over the host's") + } +} + +// BenchmarkRenderInlineFontFace renders a small page that carries its +// typeface as a data: URI, as a slide deck exported for offline rendering +// does on every slide. +func BenchmarkRenderInlineFontFace(b *testing.B) { + uri := "data:font/ttf;base64," + base64.StdEncoding.EncodeToString(cabin.TTF) + page := `` + + `

A slide title

Some text in the bundled sans, and code.

` + e := New() + e.DisableJS = true + b.ReportAllocs() + for b.Loop() { + if _, _, err := e.RenderHTML(context.Background(), page, "", image.Rect(0, 0, 1280, 720)); err != nil { + b.Fatal(err) + } + } +} + +// TestRegisterFontFacesUsesParsedFont: a face LoadFontFaces parsed is +// registered without parsing again, and a face built by a caller (no +// parsed font) still registers from its bytes. +func TestRegisterFontFacesUsesParsedFont(t *testing.T) { + ft, err := parsedFonts.parse(cabin.TTF) + if err != nil { + t.Fatal(err) + } + fonts := paint.NewFonts() + n := RegisterFontFaces(fonts, []LoadedFontFace{ + {Family: "parsed", Weight: 400, Data: []byte("unused when the font is there"), font: ft}, + {Family: "bytes", Weight: 400, Data: cabin.TTF}, + {Family: "broken", Weight: 400, Data: []byte("not a font")}, + }) + if n != 2 || !fonts.Registered("parsed") || !fonts.Registered("bytes") || fonts.Registered("broken") { + t.Errorf("registered %d", n) + } +} diff --git a/fontfaces.go b/fontfaces.go index f147bfb..691967b 100644 --- a/fontfaces.go +++ b/fontfaces.go @@ -5,7 +5,9 @@ package engine import ( + "container/list" "context" + "crypto/sha256" "io" "net/http" "sort" @@ -35,6 +37,10 @@ type LoadedFontFace struct { Italic bool Data []byte // SFNT bytes, ready for opentype.Parse URL string // where they came from, resolved + + // font is Data parsed, when LoadFontFaces parsed it, so that + // RegisterFontFaces does not parse it again. + font *opentype.Font } // LoadFontFaces fetches the files the document's own @font-face rules name and @@ -186,11 +192,12 @@ func (e *Engine) loadOneFontFace(ctx context.Context, doc *Document, face css.Fo // PDF exporter embedding these very bytes — would otherwise be handed // a file that is not a font at all. A data: URI carrying the wrong // payload is the case that showed it. - if _, err := opentype.Parse(sfnt); err != nil { + ft, err := parsedFonts.parse(sfnt) + if err != nil { continue } abs, _ := resolveURL(doc.URL, s.URL) - return LoadedFontFace{Family: face.Family, Weight: face.Weight, Italic: face.Italic, Data: sfnt, URL: abs}, true + return LoadedFontFace{Family: face.Family, Weight: face.Weight, Italic: face.Italic, Data: sfnt, URL: abs, font: ft}, true } return LoadedFontFace{}, false } @@ -273,13 +280,120 @@ func decodeFontFile(data []byte) ([]byte, bool) { func RegisterFontFaces(fonts *paint.Fonts, faces []LoadedFontFace) int { n := 0 for _, f := range faces { - if err := fonts.Register(f.Family, f.Weight, f.Italic, f.Data); err == nil { + ft := f.font + if ft == nil { + var err error + if ft, err = parsedFonts.parse(f.Data); err != nil { + continue + } + } + if fonts.RegisterFont(f.Family, f.Weight, f.Italic, ft) == nil { n++ } } return n } +// ParseFontFace parses a font file (SFNT, WOFF or WOFF2) into a face for +// Engine.FontFaces. A face built this way is parsed once, here; one built +// as a bare struct literal is parsed on first use and then found in the +// cache of parsed fonts by the hash of its bytes, on every render. +func ParseFontFace(family string, weight int, italic bool, data []byte) (LoadedFontFace, error) { + ft, err := parsedFonts.parse(data) + if err != nil { + return LoadedFontFace{}, err + } + return LoadedFontFace{Family: family, Weight: weight, Italic: italic, Data: data, font: ft}, nil +} + +// registerHostFaces registers the Engine's own FontFaces for the families +// the document did not bring a face for: a page's @font-face wins over an +// installed font of the same name, as in a browser. +func registerHostFaces(fonts *paint.Fonts, faces []LoadedFontFace) { + var fill []LoadedFontFace + for _, f := range faces { + if !fonts.Registered(f.Family) { + fill = append(fill, f) + } + } + RegisterFontFaces(fonts, fill) +} + +// parsedFonts keeps the fonts @font-face rules brought, parsed, across +// renders. Parsing a font is the dearest step of loading it — a slide deck +// rendered page by page, or a site's pages sharing its web fonts, asked for +// the same files on every render and parsed each of them twice (once to +// validate, once to register). A parsed font is read-only, so one copy +// serves every render, concurrently. +// +// It is keyed by the SHA-256 of the file's bytes, not by URL: a key can +// only ever name the bytes it was parsed from, whoever serves them and +// whatever caching headers say. It is bounded, least recently used out +// first, by count and by the bytes of the files, so that a process +// rendering arbitrary pages holds at most fontCacheBytes of files (and +// their parsed tables) however many distinct fonts it meets. +var parsedFonts = newFontCache(fontCacheEntries, fontCacheBytes) + +const ( + fontCacheEntries = 64 + fontCacheBytes = 32 << 20 +) + +type fontCache struct { + mu sync.Mutex + maxEntries int + maxBytes int + bytes int + byKey map[[sha256.Size]byte]*list.Element + lru *list.List // of *fontCacheEntry, most recent first +} + +type fontCacheEntry struct { + key [sha256.Size]byte + size int + font *opentype.Font +} + +func newFontCache(maxEntries, maxBytes int) *fontCache { + return &fontCache{maxEntries: maxEntries, maxBytes: maxBytes, byKey: map[[sha256.Size]byte]*list.Element{}, lru: list.New()} +} + +// parse returns sfnt parsed, from the cache when these bytes were parsed +// before. Two renders missing at once both parse; the second to finish +// finds the first's entry and keeps it. +func (c *fontCache) parse(sfnt []byte) (*opentype.Font, error) { + key := sha256.Sum256(sfnt) + c.mu.Lock() + if el, ok := c.byKey[key]; ok { + c.lru.MoveToFront(el) + ft := el.Value.(*fontCacheEntry).font + c.mu.Unlock() + return ft, nil + } + c.mu.Unlock() + ft, err := opentype.Parse(sfnt) + if err != nil { + return nil, err // a file that does not parse is not kept: it costs nothing to refuse again + } + if len(sfnt) > c.maxBytes { + return ft, nil // larger than the whole cache: used, not kept + } + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.byKey[key]; ok { + c.lru.MoveToFront(el) + return el.Value.(*fontCacheEntry).font, nil + } + c.byKey[key] = c.lru.PushFront(&fontCacheEntry{key: key, size: len(sfnt), font: ft}) + c.bytes += len(sfnt) + for c.lru.Len() > c.maxEntries || c.bytes > c.maxBytes { + old := c.lru.Remove(c.lru.Back()).(*fontCacheEntry) + delete(c.byKey, old.key) + c.bytes -= old.size + } + return ft, nil +} + // srcsKey identifies a FontFace by its own src list — two rules with the same // URLs and format hints, in the same order, are the same fetch regardless of // how many times the same rule was parsed (the same stylesheet named twice, diff --git a/paint/fonts.go b/paint/fonts.go index 49c263c..facee40 100644 --- a/paint/fonts.go +++ b/paint/fonts.go @@ -10,7 +10,9 @@ package paint import ( "errors" "fmt" + "maps" "strings" + "sync" "github.com/go-opentype/fonts/dejavusans" "github.com/go-opentype/fonts/gomono" @@ -83,27 +85,46 @@ type faceKey struct { // bold/italic fall back to it, as the family ships no other styles). It panics // only if a bundled font fails to parse, which would be a build-time defect in // the fonts module, not a runtime condition. +// +// The bundled files are parsed once per process and the parsed fonts shared +// by every Fonts: an *opentype.Font is read-only once parsed (a Face, which +// caches glyphs, is what is per-registry). Parsing them per registry — once +// per render — was most of the time and allocation of rendering a small page. func NewFonts() *Fonts { - f := &Fonts{fonts: map[styleKey]*opentype.Font{}, faces: map[faceKey]*opentype.Face{}, - fallback: map[faceStyle]*opentype.Font{}, fallbackFaces: map[fallbackKey]*opentype.Face{}, + b := bundled() + f := &Fonts{fonts: make(map[styleKey]*opentype.Font, len(b.fonts)), faces: map[faceKey]*opentype.Face{}, + fallback: make(map[faceStyle]*opentype.Font, len(b.fallback)), fallbackFaces: map[fallbackKey]*opentype.Face{}, named: map[namedKey]*opentype.Font{}, namedWeight: map[namedKey]int{}} - f.fallback[faceStyle{false, false}] = mustParseFont(dejavusans.TTF) - f.fallback[faceStyle{true, false}] = mustParseFont(dejavusans.BoldTTF) - f.fallback[faceStyle{false, true}] = mustParseFont(dejavusans.ItalicTTF) - f.fallback[faceStyle{true, true}] = mustParseFont(dejavusans.BoldItalicTTF) + maps.Copy(f.fonts, b.fonts) + maps.Copy(f.fallback, b.fallback) + return f +} + +// bundledFonts are the parsed bundled families, shared read-only. +type bundledFonts struct { + fonts map[styleKey]*opentype.Font + fallback map[faceStyle]*opentype.Font +} + +var bundled = sync.OnceValue(func() bundledFonts { + b := bundledFonts{fonts: map[styleKey]*opentype.Font{}, fallback: map[faceStyle]*opentype.Font{}} + b.fallback[faceStyle{false, false}] = mustParseFont(dejavusans.TTF) + b.fallback[faceStyle{true, false}] = mustParseFont(dejavusans.BoldTTF) + b.fallback[faceStyle{false, true}] = mustParseFont(dejavusans.ItalicTTF) + b.fallback[faceStyle{true, true}] = mustParseFont(dejavusans.BoldItalicTTF) set := func(fam css.FontFamily, reg, bold, italic, boldItalic []byte) { - f.fonts[styleKey{fam, false, false}] = mustParseFont(reg) - f.fonts[styleKey{fam, true, false}] = mustParseFont(bold) - f.fonts[styleKey{fam, false, true}] = mustParseFont(italic) - f.fonts[styleKey{fam, true, true}] = mustParseFont(boldItalic) + b.fonts[styleKey{fam, false, false}] = mustParseFont(reg) + b.fonts[styleKey{fam, true, false}] = mustParseFont(bold) + b.fonts[styleKey{fam, false, true}] = mustParseFont(italic) + b.fonts[styleKey{fam, true, true}] = mustParseFont(boldItalic) } set(css.Sans, inter.TTF, inter.BoldTTF, inter.ItalicTTF, inter.BoldItalicTTF) set(css.Serif, lora.TTF, lora.BoldTTF, lora.ItalicTTF, lora.BoldItalicTTF) // Go Mono ships a single upright regular; its bold/italic requests fall back // to it through font() (the family ships no other styles). - f.fonts[styleKey{css.Mono, false, false}] = mustParseFont(gomono.TTF) - return f -} + b.fonts[styleKey{css.Mono, false, false}] = mustParseFont(gomono.TTF) + return b +}) // mustParseFont parses a bundled font, panicking on failure — which would be a // build-time defect in the fonts module, not a runtime condition. @@ -170,6 +191,22 @@ func (f *Fonts) Register(family string, weight int, italic bool, sfnt []byte) er if err != nil { return fmt.Errorf("paint: Register %q: %w", family, err) } + return f.RegisterFont(family, weight, italic, ft) +} + +// RegisterFont is Register for a font already parsed, which a caller that +// parsed it to validate it, or keeps parsed fonts across renders, passes +// instead of the bytes. The font is only read, never modified, so one +// *opentype.Font may be registered with any number of registries, +// concurrently. +func (f *Fonts) RegisterFont(family string, weight int, italic bool, ft *opentype.Font) error { + family = strings.ToLower(strings.TrimSpace(family)) + if family == "" { + return errors.New("paint: RegisterFont: no family name") + } + if ft == nil { + return fmt.Errorf("paint: RegisterFont %q: no font", family) + } if weight <= 0 { weight = 400 } diff --git a/paint/sharedfonts_test.go b/paint/sharedfonts_test.go new file mode 100644 index 0000000..0a29f8e --- /dev/null +++ b/paint/sharedfonts_test.go @@ -0,0 +1,54 @@ +// Copyright (c) the go-webengine/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package paint + +import ( + "testing" + + "github.com/go-opentype/fonts/cabin" + "github.com/go-opentype/opentype" + "github.com/go-webengine/engine/css" +) + +// The bundled families are parsed once and shared, but every registry is +// its own: a face registered with one is not seen by another. +func TestNewFontsSharesBundledFonts(t *testing.T) { + a, b := NewFonts(), NewFonts() + k := styleKey{css.Sans, false, false} + if a.fonts[k] == nil || a.fonts[k] != b.fonts[k] { + t.Error("the bundled sans is not one shared parsed font") + } + if a.fallback[faceStyle{}] == nil || a.fallback[faceStyle{}] != b.fallback[faceStyle{}] { + t.Error("the fallback is not one shared parsed font") + } + if err := a.Register("Cabin", 400, false, cabin.TTF); err != nil { + t.Fatal(err) + } + if !a.Registered("cabin") || b.Registered("cabin") { + t.Error("a face registered with one registry leaked into another") + } +} + +func TestRegisterFont(t *testing.T) { + ft, err := opentype.Parse(cabin.TTF) + if err != nil { + t.Fatal(err) + } + a, b := NewFonts(), NewFonts() + for _, f := range []*Fonts{a, b} { + if err := f.RegisterFont(" Cabin ", 400, false, ft); err != nil { + t.Fatal(err) + } + } + fam := named("cabin", css.GenericSans) + if a.Measure("Partenaires", fam, 40, 400, false) != b.Measure("Partenaires", fam, 40, 400, false) { + t.Error("one parsed font measures differently in two registries") + } + if err := a.RegisterFont(" ", 400, false, ft); err == nil { + t.Error("a face with no family name was registered") + } + if err := a.RegisterFont("x", 400, false, nil); err == nil { + t.Error("a nil font was registered") + } +}