Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[fontscan] Better font resolution #178

Merged
merged 16 commits into from
Jan 14, 2025
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
88 changes: 39 additions & 49 deletions fontscan/fontmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"sync"

"github.com/go-text/typesetting/font"
Expand Down Expand Up @@ -75,11 +74,13 @@ type FontMap struct {
// the candidates for the current query, which influences ResolveFace output
candidates candidates

// internal buffers used in SetQuery
// internal buffers used in [buildCandidates]

footprintsBuffer scoredFootprints
cribleBuffer familyCrible

query Query // current query
query Query // current query
script language.Script // current script
}

// NewFontMap return a new font map, which should be filled with the `UseSystemFonts`
Expand Down Expand Up @@ -374,7 +375,7 @@ func (fm *FontMap) FindSystemFonts(family string) []Location {
}

// SetQuery set the families and aspect required, influencing subsequent
// `ResolveFace` calls.
// [ResolveFace] calls. See also [SetScript].
func (fm *FontMap) SetQuery(query Query) {
if len(query.Families) == 0 {
query.Families = []string{""}
Expand All @@ -383,6 +384,13 @@ func (fm *FontMap) SetQuery(query Query) {
fm.built = false
}

// SetScript set the script to which the (next) runes passed to [ResolveFace]
// belongs, influencing the choice of fallback fonts.
func (fm *FontMap) SetScript(s language.Script) {
fm.script = s
fm.built = false
}

// candidates is a cache storing the indices into FontMap.database of footprints matching a Query
// families
type candidates struct {
Expand Down Expand Up @@ -416,23 +424,23 @@ func (fm *FontMap) buildCandidates() {
// first pass for an exact match
{
for _, family := range fm.query.Families {
candidates := fm.database.selectByFamilyExact(family, &fm.footprintsBuffer, fm.cribleBuffer)
candidates := fm.database.selectByFamilyExact(family, fm.cribleBuffer, &fm.footprintsBuffer)
if len(candidates) == 0 {
continue
}

// select the correct aspects
candidates = fm.database.retainsBestMatches(candidates, fm.query.Aspect)

// when no systemFallback is required, the CSS spec says
// with no system fallback, the CSS spec says
// that only one font among the candidates must be tried
fm.candidates.withoutFallback = append(fm.candidates.withoutFallback, candidates[0])
}
}

// second pass with substitutions
{
candidates := fm.database.selectByFamilyWithSubs(fm.query.Families, &fm.footprintsBuffer, fm.cribleBuffer)
candidates := fm.database.selectByFamilyWithSubs(fm.query.Families, fm.script, fm.cribleBuffer, &fm.footprintsBuffer)

// select the correct aspects
candidates = fm.database.retainsBestMatches(candidates, fm.query.Aspect)
Expand All @@ -452,7 +460,6 @@ func (fm *FontMap) buildCandidates() {
{
fm.candidates.manual = fm.database.filterUserProvided(fm.candidates.manual)
fm.candidates.manual = fm.database.retainsBestMatches(fm.candidates.manual, fm.query.Aspect)

}

fm.built = true
Expand Down Expand Up @@ -496,19 +503,25 @@ func (fm *FontMap) resolveForLang(candidates []int, lang LangID) *font.Face {
return nil
}

// ResolveFace select a font based on the current query (see `SetQuery`),
// ResolveFace select a font based on the current query (set by [FontMap.SetQuery] and [FontMap.SetScript]),
// and supporting the given rune, applying CSS font selection rules.
// The function will return nil if the underlying font database is empty,
// or if the file system is broken; otherwise the returned [font.Face] is always valid.
//
// If no fonts match the current query for the current rune according to the
// builtin matching process, the fonts added manually by [AddFont] and [AddFace]
// will be searched in the order in which they were added for a font with coverage
// for the provided rune. The first font covering the requested rune will be returned.
// Fonts are tried with the following steps :
//
// 1 - Only fonts matching exacly one of the [Query.Families] are considered; the list
// is prunned to keep the best match with [Query.Aspect]
// 2 - Fallback fonts are considered, that is fonts with similar families and fonts
// supporting the current script; the list is also prunned according to [Query.Aspect]
// 3 - Fonts added manually by [AddFont] and [AddFace] (prunned according to [Query.Aspect]),
// will be searched, in the order in which they were added.
// 4 - All fonts matching the current script (set by [FontMap.SetScript]) are tried,
// ignoring [Query.Aspect]
//
// If no fonts match after the manual font search, an arbitrary face will be returned.
// If no fonts match after these steps, an arbitrary face will be returned.
// This face will be nil only if the underlying font database is empty,
// or if the file system is broken; otherwise the returned [font.Face] is always valid.
func (fm *FontMap) ResolveFace(r rune) (face *font.Face) {
key := fm.lru.KeyFor(fm.query, r)
key := fm.lru.KeyFor(fm.query, fm.script, r)
face, ok := fm.lru.Get(key, fm.query)
if ok {
return face
Expand All @@ -526,50 +539,27 @@ func (fm *FontMap) ResolveFace(r rune) (face *font.Face) {
return face
}

// if no family has matched so far, try again with system fallback
// if no family has matched so far, try again with system fallback,
// including fonts with matching script and user provided ones
if face := fm.resolveForRune(fm.candidates.withFallback, r); face != nil {
return face
}

// try manually loaded faces even if the typeface doesn't match, looking for matching aspects
// and rune coverage.
// Note that, when [SetScript] has been called, this step is actually not needed,
// since the fonts supporting the given script are already added in [withFallback] fonts
if face := fm.resolveForRune(fm.candidates.manual, r); face != nil {
return face
}

fm.logger.Printf("No font matched for %q and rune %U (%c) -> searching by script coverage and aspect", fm.query.Families, r, r)

script := language.LookupScript(r)
scriptCandidates, ok := fm.scriptMap[language.LookupScript(r)]
if ok {
aspectCandidates := make([]int, len(scriptCandidates))
copy(aspectCandidates, scriptCandidates)
// Filter candidates to those matching the requested aspect first.
aspectCandidates = fm.database.retainsBestMatches(aspectCandidates, fm.query.Aspect)
if face := fm.resolveForRune(aspectCandidates, r); face != nil {
return face
}
fm.logger.Printf("No font matched for aspect %v, script %s, and rune %U (%c) -> searching by script coverage only", fm.query.Aspect, script, r, r)
// aspectCandidates has been filtered down and has exactly enough excess capacity to hold
// the other original candidates.
allCandidates := aspectCandidates[len(aspectCandidates):len(aspectCandidates):cap(aspectCandidates)]
// Populate allCandidates with every script candidate that isn't in aspectCandidates.
for _, idx := range scriptCandidates {
possibleIdx := sort.Search(len(aspectCandidates), func(i int) bool {
return aspectCandidates[i] >= idx
})
if possibleIdx < len(aspectCandidates) && aspectCandidates[possibleIdx] == idx {
continue
}
allCandidates = append(allCandidates, idx)
}
// Try allCandidates.
if face := fm.resolveForRune(allCandidates, r); face != nil {
return face
}
fm.logger.Printf("No font matched for aspect %v, script %s, and rune %U (%c) -> searching by script coverage only", fm.query.Aspect, fm.script, r, r)
scriptCandidates := fm.scriptMap[fm.script]
if face := fm.resolveForRune(scriptCandidates, r); face != nil {
return face
}

fm.logger.Printf("No font matched for script %s and rune %U (%c) -> returning arbitrary face", script, r, r)
fm.logger.Printf("No font matched for script %s and rune %U (%c) -> returning arbitrary face", fm.script, r, r)
// return an arbitrary face
if fm.firstFace == nil && len(fm.database) > 0 {
for _, fp := range fm.database {
Expand Down
Loading
Loading