Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
10 changes: 8 additions & 2 deletions css/fontface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 ""
}
Expand Down
17 changes: 17 additions & 0 deletions css/fontface_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
17 changes: 16 additions & 1 deletion css/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion css/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,17 @@ 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
}{
"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)
Expand Down
12 changes: 12 additions & 0 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
210 changes: 210 additions & 0 deletions fontcache_test.go
Original file line number Diff line number Diff line change
@@ -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 := `<!DOCTYPE html><html><head><style>@font-face{font-family:'Shared';src:url(` + uri + `) format('truetype')}` +
`html,body{margin:0}p{font-size:30px;font-family:'Shared';margin:0}em{font-family:serif}</style></head>` +
`<body><p>Hamburgefonstiv <em>serif</em> →</p></body></html>`
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 `<!DOCTYPE html><html><head><style>` + rule + `html,body{margin:0}p{font-size:40px;font-family:'Host Face', serif;margin:0}</style></head>` +
`<body><p>Hamburgefonstiv</p></body></html>`
}
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 := `<!DOCTYPE html><html><head><style>@font-face{font-family:'Inline';src:url(` + uri + `) format('truetype')}` +
`html,body{margin:0}h1{font:bold 48px 'Inline'}p{font-size:24px}</style></head>` +
`<body><h1>A slide title</h1><p>Some text in the bundled sans, and <code>code</code>.</p></body></html>`
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)
}
}
Loading
Loading