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 := `` + + `Some text in the bundled sans, and code.