diff --git a/fontscan/fontmap.go b/fontscan/fontmap.go index 9d69363..8c88f32 100644 --- a/fontscan/fontmap.go +++ b/fontscan/fontmap.go @@ -325,30 +325,27 @@ func (fm *FontMap) SetQuery(query Query) { } // candidates is a cache storing the indices into FontMap.database of footprints matching a Query +// families type candidates struct { - // the two fallback slices have the same length: the number of family in the query - withFallback [][]int // for each queried family - withoutFallback []int // for each queried family, only one footprint is selected + // footprints with exact match : + // for each queried family, at most one footprint is selected + withoutFallback []int + + // footprints matching the expanded query (where subsitutions have been applied) + withFallback []int manual []int // manually inserted faces to be tried if the other candidates fail. } -func (cd *candidates) resetWithSize(candidateSize int) { - if cap(cd.withFallback) < candidateSize { // reallocate - cd.withFallback = make([][]int, candidateSize) - cd.withoutFallback = make([]int, candidateSize) +// reset slices, setting the capacity of withoutFallback to nbFamilies +func (cd *candidates) resetWithSize(nbFamilies int) { + if cap(cd.withoutFallback) < nbFamilies { // reallocate + cd.withoutFallback = make([]int, nbFamilies) } - // only reslice - cd.withFallback = cd.withFallback[0:candidateSize] - cd.withoutFallback = cd.withoutFallback[0:candidateSize] - - // reset to "zero" values - for i := range cd.withoutFallback { - cd.withFallback[i] = nil - cd.withoutFallback[i] = -1 - } - cd.manual = cd.manual[0:] + cd.withoutFallback = cd.withoutFallback[:0] + cd.withFallback = cd.withFallback[:0] + cd.manual = cd.manual[:0] } func (fm *FontMap) buildCandidates() { @@ -357,9 +354,10 @@ func (fm *FontMap) buildCandidates() { } fm.candidates.resetWithSize(len(fm.query.Families)) - selectFootprints := func(systemFallback bool) { - for familyIndex, family := range fm.query.Families { - candidates := fm.database.selectByFamily(family, systemFallback, &fm.footprintsBuffer, fm.cribleBuffer) + // first pass for an exact match + { + for _, family := range fm.query.Families { + candidates := fm.database.selectByFamilyExact(family, &fm.footprintsBuffer, fm.cribleBuffer) if len(candidates) == 0 { continue } @@ -367,29 +365,37 @@ func (fm *FontMap) buildCandidates() { // select the correct aspects candidates = fm.database.retainsBestMatches(candidates, fm.query.Aspect) - if systemFallback { - // candidates is owned by fm.footprintsBuffer: copy its content - S := fm.candidates.withFallback[familyIndex] - if L := len(candidates); cap(S) < L { - S = make([]int, L) - } else { - S = S[:L] - } - copy(S, candidates) - fm.candidates.withFallback[familyIndex] = S - } else { - // when no systemFallback is required, the CSS spec says - // that only one font among the candidates must be tried - fm.candidates.withoutFallback[familyIndex] = candidates[0] - } + // when no systemFallback is required, 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) + + // select the correct aspects + candidates = fm.database.retainsBestMatches(candidates, fm.query.Aspect) + + // candidates is owned by fm.footprintsBuffer: copy its content + S := fm.candidates.withFallback + if L := len(candidates); cap(S) < L { + S = make([]int, L) + } else { + S = S[:L] } + copy(S, candidates) + fm.candidates.withFallback = S } - selectFootprints(false) - selectFootprints(true) + // third pass with user provided fonts + { + fm.candidates.manual = fm.database.filterUserProvided(fm.candidates.manual) + fm.candidates.manual = fm.database.retainsBestMatches(fm.candidates.manual, fm.query.Aspect) + + } - fm.candidates.manual = fm.database.filterUserProvided(fm.candidates.manual) - fm.candidates.manual = fm.database.retainsBestMatches(fm.candidates.manual, fm.query.Aspect) fm.built = true } @@ -451,35 +457,25 @@ func (fm *FontMap) ResolveFace(r rune) (face font.Face) { defer func() { fm.lru.Put(key, fm.query, face) }() + // Build the candidates if we missed the cache. If they're already built this is a // no-op. fm.buildCandidates() + // we first look up for an exact family match, without substitutions - for _, footprintIndex := range fm.candidates.withoutFallback { - if footprintIndex == -1 { - continue - } - if face := fm.resolveForRune([]int{footprintIndex}, r); face != nil { - return face - } + if face := fm.resolveForRune(fm.candidates.withoutFallback, r); face != nil { + return face } // if no family has matched so far, try again with system fallback - for _, footprintIndexList := range fm.candidates.withFallback { - if face := fm.resolveForRune(footprintIndexList, r); face != nil { - return face - } + 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. - for _, footprintIndex := range fm.candidates.manual { - if footprintIndex == -1 { - continue - } - if face := fm.resolveForRune([]int{footprintIndex}, r); face != nil { - return face - } + 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) @@ -544,31 +540,19 @@ func (fm *FontMap) ResolveFaceForLang(lang LangID) font.Face { fm.buildCandidates() // we first look up for an exact family match, without substitutions - for _, footprintIndex := range fm.candidates.withoutFallback { - if footprintIndex == -1 { - continue - } - if face := fm.resolveForLang([]int{footprintIndex}, lang); face != nil { - return face - } + if face := fm.resolveForLang(fm.candidates.withoutFallback, lang); face != nil { + return face } // if no family has matched so far, try again with system fallback - for _, footprintIndexList := range fm.candidates.withFallback { - if face := fm.resolveForLang(footprintIndexList, lang); face != nil { - return face - } + if face := fm.resolveForLang(fm.candidates.withFallback, lang); face != nil { + return face } // try manually loaded faces even if the typeface doesn't match, looking for matching aspects // and rune coverage. - for _, footprintIndex := range fm.candidates.manual { - if footprintIndex == -1 { - continue - } - if face := fm.resolveForLang([]int{footprintIndex}, lang); face != nil { - return face - } + if face := fm.resolveForLang(fm.candidates.manual, lang); face != nil { + return face } return nil diff --git a/fontscan/fontmap_test.go b/fontscan/fontmap_test.go index 37caaba..4146239 100644 --- a/fontscan/fontmap_test.go +++ b/fontscan/fontmap_test.go @@ -260,3 +260,26 @@ func TestFontMap_AddFont_FaceLocation(t *testing.T) { face := fm.ResolveFace(0x20) tu.Assert(t, fm.FontLocation(face.Font).File == "Roboto2") } + +func TestQueryHelveticaLinux(t *testing.T) { + // This is a regression test which asserts that + // our behavior is similar than fontconfig + + file1, err := os.Open("../font/testdata/Amiri-Regular.ttf") + tu.AssertNoErr(t, err) + defer file1.Close() + + fm := NewFontMap(nil) + err = fm.AddFont(file1, "file1", "Nimbus Sans") + tu.AssertNoErr(t, err) + + err = fm.AddFont(file1, "file2", "Bitstream Vera Sans") + tu.AssertNoErr(t, err) + + fm.SetQuery(Query{Families: []string{ + "BlinkMacSystemFont", // 'unknown' family + "Helvetica", + }}) + family, _ := fm.FontMetadata(fm.ResolveFace('x').Font) + tu.Assert(t, family == meta.NormalizeFamily("Nimbus Sans")) // prefered Helvetica replacement +} diff --git a/fontscan/footprint.go b/fontscan/footprint.go index 7c0f888..e183565 100644 --- a/fontscan/footprint.go +++ b/fontscan/footprint.go @@ -23,6 +23,8 @@ type footprint struct { // Family is the general nature of the font, like // "Arial" + // Note that, for performance reason, we store the + // normalized version of the family name. Family string // Runes is the set of runes supported by the font. diff --git a/fontscan/match.go b/fontscan/match.go index b6de50a..f04fdb4 100644 --- a/fontscan/match.go +++ b/fontscan/match.go @@ -40,7 +40,11 @@ func (fc familyCrible) reset() { // and applies all the substitutions coded in the package // to add substitutes values func (fc familyCrible) fillWithSubstitutions(family string) { - fl := newFamilyList([]string{family}) + fc.fillWithSubstitutionsList([]string{family}) +} + +func (fc familyCrible) fillWithSubstitutionsList(families []string) { + fl := newFamilyList(families) for _, subs := range familySubstitution { fl.execute(subs) } @@ -104,41 +108,59 @@ func isGenericFamily(family string) bool { } } -// selectByFamily returns all the fonts in the fontmap matching +// selectByFamilyExact returns all the fonts in the fontmap matching // the given `family`, with the best matches coming first. -// `substitute` controls whether or not system substitutions are applied. -// The generic families are always expanded to concrete families. +// +// The match is performed without substituting family names, +// expect for the generic families, which are always expanded to concrete families. // // If two fonts have the same family, user provided are returned first. // // The returned slice may be empty if no font matches the given `family`. -// buffer is used to reduce allocations -func (fm fontSet) selectByFamily(family string, substitute bool, +// +// The two buffers are used to reduce allocations. +func (fm fontSet) selectByFamilyExact(family string, footprintBuffer *scoredFootprints, cribleBuffer familyCrible, ) []int { // build the crible, handling substitutions - family = meta.NormalizeFamily(family) - - footprintBuffer.reset(fm) cribleBuffer.reset() // always substitute generic families - if substitute || isGenericFamily(family) { + if isGenericFamily(family) { cribleBuffer.fillWithSubstitutions(family) } else { - cribleBuffer = familyCrible{family: 0} + cribleBuffer = familyCrible{meta.NormalizeFamily(family): 0} } - // select the matching fonts: + return fm.selectByFamilies(cribleBuffer, footprintBuffer) +} + +// selectByFamilyExact returns all the fonts in the fontmap matching +// the given query, with the best matches coming first. +// +// `query` is expanded with family substitutions +func (fm fontSet) selectByFamilyWithSubs(query []string, + footprintBuffer *scoredFootprints, cribleBuffer familyCrible, +) []int { + cribleBuffer.reset() + cribleBuffer.fillWithSubstitutionsList(query) + return fm.selectByFamilies(cribleBuffer, footprintBuffer) +} + +// select the fonts in the fontSet matching [crible], returning their indices. +// footprintBuffer is used to reduce allocations. +func (fm fontSet) selectByFamilies(crible familyCrible, footprintBuffer *scoredFootprints) []int { + footprintBuffer.reset(fm) + // loop through `footprints` and stores the matching fonts into `dst` for index, footprint := range fm { - if score, has := cribleBuffer[footprint.Family]; has { + if score, has := crible[footprint.Family]; has { footprintBuffer.footprints = append(footprintBuffer.footprints, index) footprintBuffer.scores = append(footprintBuffer.scores, score) } } - // sort the matched font by score (lower is better) + // sort the matched fonts by score (lower is better) sort.Stable(*footprintBuffer) return footprintBuffer.footprints diff --git a/fontscan/match_test.go b/fontscan/match_test.go index d6ade3f..594773e 100644 --- a/fontscan/match_test.go +++ b/fontscan/match_test.go @@ -52,7 +52,7 @@ func fontsFromFamilies(families ...string) (out fontSet) { return out } -func TestFontMap_selectByFamily(t *testing.T) { +func TestFontMap_selectByFamilyExact(t *testing.T) { tests := []struct { fontset fontSet family string @@ -68,22 +68,8 @@ func TestFontMap_selectByFamily(t *testing.T) { {fontsFromFamilies("ar Ial", "emoji"), "Arial", false, []int{0}}, // substitution {fontsFromFamilies("arial"), "Helvetica", false, nil}, - {fontsFromFamilies("arial"), "Helvetica", true, []int{0}}, - {fontsFromFamilies("caladea", "XXX"), "cambria", true, []int{0}}, - // substitution, with order - {fontsFromFamilies("arial", "Helvetica"), "Helvetica", true, []int{1, 0}}, - // substitution, with order, and no matching fonts - {fontsFromFamilies("arial", "Helvetica", "XXX"), "Helvetica", true, []int{1, 0}}, // generic families {fontsFromFamilies("norasi", "XXX"), "serif", false, []int{0}}, - // default to generic families - {fontsFromFamilies("DEjaVuSerif", "XXX"), "cambria", true, []int{0}}, - // substitutions - { - fontsFromFamilies("Nimbus Roman", "Tinos", "Liberation Serif", "DejaVu Serif", "arial"), - "Times", true, - []int{0, 1, 2, 3}, - }, // user provided precedence { fontSet{ @@ -97,16 +83,39 @@ func TestFontMap_selectByFamily(t *testing.T) { }, } for _, tt := range tests { - if got := tt.fontset.selectByFamily(tt.family, tt.substitute, &scoredFootprints{}, make(familyCrible)); !reflect.DeepEqual(got, tt.want) { + if got := tt.fontset.selectByFamilyExact(tt.family, &scoredFootprints{}, make(familyCrible)); !reflect.DeepEqual(got, tt.want) { t.Errorf("FontMap.selectByFamily() = \n%v, want \n%v", got, tt.want) } } } -func BenchmarkNewFamilyCrible(b *testing.B) { - c := make(familyCrible) - for i := 0; i < b.N; i++ { - c.fillWithSubstitutions("Arial") +func TestFontMap_selectByFamilyList(t *testing.T) { + tests := []struct { + fontset fontSet + family string + substitute bool + want []int + }{ + {nil, "", true, nil}, // no match on empty fontset + {fontsFromFamilies("arial"), "Helvetica", true, []int{0}}, + {fontsFromFamilies("caladea", "XXX"), "cambria", true, []int{0}}, + // substitution, with order + {fontsFromFamilies("arial", "Helvetica"), "Helvetica", true, []int{1, 0}}, + // substitution, with order, and no matching fonts + {fontsFromFamilies("arial", "Helvetica", "XXX"), "Helvetica", true, []int{1, 0}}, + // default to generic families + {fontsFromFamilies("DEjaVuSerif", "XXX"), "cambria", true, []int{0}}, + // substitutions + { + fontsFromFamilies("Nimbus Roman", "Tinos", "Liberation Serif", "DejaVu Serif", "arial"), + "Times", true, + []int{0, 1, 2, 3}, + }, + } + for _, tt := range tests { + if got := tt.fontset.selectByFamilyWithSubs([]string{tt.family}, &scoredFootprints{}, make(familyCrible)); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FontMap.selectByFamily() = \n%v, want \n%v", got, tt.want) + } } } diff --git a/fontscan/substitutions.go b/fontscan/substitutions.go index de70719..6de954e 100644 --- a/fontscan/substitutions.go +++ b/fontscan/substitutions.go @@ -27,23 +27,26 @@ func init() { } } -// we want to easily insert at the start, +// familyList is a list of normalized families to match, order +// by user preference (first is best). +// It also implements helpers to insert at the start, // the end and "around" an element -type familyList struct { - items []string -} +type familyList []string -func newFamilyList(families []string) *familyList { - fl := &familyList{} +// normalize the families +func newFamilyList(families []string) familyList { // we'll guess that we end up with about ~140 items - fl.items = make([]string, 0, 140) - fl.items = append(fl.items, families...) + fl := make([]string, 0, 140) + fl = append(fl, families...) + for i, f := range fl { + fl[i] = meta.NormalizeFamily(f) + } return fl } // returns the node equal to `family` or -1, if not found -func (fl *familyList) elementEquals(family string) int { - for i, v := range fl.items { +func (fl familyList) elementEquals(family string) int { + for i, v := range fl { if v == family { return i } @@ -52,8 +55,8 @@ func (fl *familyList) elementEquals(family string) int { } // returns the first node containing `family` or -1, if not found -func (fl *familyList) elementContains(family string) int { - for i, v := range fl.items { +func (fl familyList) elementContains(family string) int { + for i, v := range fl { if strings.Contains(v, family) { return i } @@ -62,8 +65,8 @@ func (fl *familyList) elementContains(family string) int { } // return the crible corresponding to the order -func (fl *familyList) compileTo(dst familyCrible) { - for i, family := range fl.items { +func (fl familyList) compileTo(dst familyCrible) { + for i, family := range fl { if _, has := dst[family]; !has { // for duplicated entries, keep the first (best) score dst[family] = i } @@ -71,25 +74,25 @@ func (fl *familyList) compileTo(dst familyCrible) { } func (fl *familyList) insertStart(families []string) { - fl.items = insertAt(fl.items, 0, families) + *fl = insertAt(*fl, 0, families) } func (fl *familyList) insertEnd(families []string) { - fl.items = insertAt(fl.items, len(fl.items), families) + *fl = insertAt(*fl, len(*fl), families) } // insertAfter inserts families right after element func (fl *familyList) insertAfter(element int, families []string) { - fl.items = insertAt(fl.items, element+1, families) + *fl = insertAt(*fl, element+1, families) } // insertBefore inserts families right before element func (fl *familyList) insertBefore(element int, families []string) { - fl.items = insertAt(fl.items, element, families) + *fl = insertAt(*fl, element, families) } func (fl *familyList) replace(element int, families []string) { - fl.items = replaceAt(fl.items, element, element+1, families) + *fl = replaceAt(*fl, element, element+1, families) } // ----- substitutions ------ @@ -109,9 +112,9 @@ const ( type substitutionTest interface { // returns >= 0 if the substitution should be applied // for opAppendLast and opPrependFirst an arbitrary value could be returned - test(list *familyList) int + test(list familyList) int - // return a copy where families have been normalize + // return a copy where families have been normalized // to their no blank no case version normalize() substitutionTest } @@ -119,7 +122,7 @@ type substitutionTest interface { // a family in the list must equal 'mf' type familyEquals string -func (mf familyEquals) test(list *familyList) int { +func (mf familyEquals) test(list familyList) int { return list.elementEquals(string(mf)) } @@ -130,7 +133,7 @@ func (mf familyEquals) normalize() substitutionTest { // a family in the list must contain 'mf' type familyContains string -func (mf familyContains) test(list *familyList) int { +func (mf familyContains) test(list familyList) int { return list.elementContains(string(mf)) } @@ -141,8 +144,8 @@ func (mf familyContains) normalize() substitutionTest { // the family list has no "serif", "sans-serif" or "monospace" generic fallback type noGenericFamily struct{} -func (noGenericFamily) test(list *familyList) int { - for _, v := range list.items { +func (noGenericFamily) test(list familyList) int { + for _, v := range list { switch v { case "serif", "sans-serif", "monospace": return -1 @@ -163,7 +166,7 @@ type langAndFamilyEqual struct { } // TODO: for now, these tests language base tests are ignored -func (langAndFamilyEqual) test(list *familyList) int { +func (langAndFamilyEqual) test(list familyList) int { return -1 } @@ -180,7 +183,7 @@ type langContainsAndFamilyEquals struct { } // TODO: for now, these tests language base tests are ignored -func (langContainsAndFamilyEquals) test(list *familyList) int { +func (langContainsAndFamilyEquals) test(list familyList) int { return -1 } @@ -197,7 +200,7 @@ type langEqualsAndNoFamily struct { } // TODO: for now, these tests language base tests are ignored -func (langEqualsAndNoFamily) test(list *familyList) int { +func (langEqualsAndNoFamily) test(list familyList) int { return -1 } @@ -213,7 +216,7 @@ type substitution struct { } func (fl *familyList) execute(subs substitution) { - element := subs.test.test(fl) + element := subs.test.test(*fl) if element < 0 { return } diff --git a/fontscan/substitutions_test.go b/fontscan/substitutions_test.go index 0eb2d3c..53a0eee 100644 --- a/fontscan/substitutions_test.go +++ b/fontscan/substitutions_test.go @@ -3,6 +3,9 @@ package fontscan import ( "reflect" "testing" + + meta "github.com/go-text/typesetting/opentype/api/metadata" + tu "github.com/go-text/typesetting/opentype/testutils" ) func Test_familyList_insertStart(t *testing.T) { @@ -232,3 +235,21 @@ func TestReplaceAt(t *testing.T) { } } } + +func BenchmarkNewFamilyCrible(b *testing.B) { + c := make(familyCrible) + for i := 0; i < b.N; i++ { + c.fillWithSubstitutions("Arial") + } +} + +func TestSubstituteHelveticaOrder(t *testing.T) { + c := make(familyCrible) + c.fillWithSubstitutionsList([]string{meta.NormalizeFamily("BlinkMacSystemFont"), meta.NormalizeFamily("Helvetica")}) + // BlinkMacSystemFont is not known by the library, so it is expanded with generic sans-serif, + // but with lower priority then Helvetica + l := c.families() + tu.Assert(t, l[0] == meta.NormalizeFamily("BlinkMacSystemFont")) + tu.Assert(t, l[1] == meta.NormalizeFamily("Helvetica")) + tu.Assert(t, l[2] == meta.NormalizeFamily("Nimbus Sans")) // from Helvetica +}