v0.1.0 47aaa0f

font_lazy_test.go RawHistoryFolder

1package text
2
3import (
4	"bytes"
5	"fmt"
6	"io"
7	"os"
8	"reflect"
9	"testing"
10
11	"github.com/go-text/typesetting/font"
12)
13
14type countingSource struct {
15	entries []FontFile
16	data    map[string][]byte
17	reads   map[string]int
18}
19
20func (s *countingSource) Fonts() (map[string][]byte, error) { panic("Load must use metadata index") }
21func (s *countingSource) Index() ([]FontFile, error)        { return s.entries, nil }
22func (s *countingSource) Open(p string) (io.ReadCloser, error) {
23	s.reads[p]++
24	return io.NopCloser(bytes.NewReader(s.data[p])), nil
25}
26func TestLazyFontSelection(t *testing.T) {
27	b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
28	if err != nil {
29		t.Fatal(err)
30	}
31	s := &countingSource{data: map[string][]byte{"good": b, "bad": []byte("broken")}, reads: map[string]int{}}
32	s.entries = append(s.entries, FontFile{Path: "bad", Family: "Noto Sans", Aspect: font.Aspect{Weight: 400, Stretch: 1}})
33	s.entries = append(s.entries, FontFile{Path: "good", Family: "Noto Sans", Aspect: font.Aspect{Weight: 400, Stretch: 1}})
34	for i := 0; i < 2000; i++ {
35		s.entries = append(s.entries, FontFile{Path: fmt.Sprint(i), Family: fmt.Sprintf("unused%d", i)})
36	}
37	c, err := Load(s)
38	if err != nil || len(s.reads) != 0 {
39		t.Fatalf("index read font bytes: %v %v", err, s.reads)
40	}
41	e := NewEngine(c)
42	for i := 0; i < 2; i++ {
43		l, err := e.Measure("Hello", Request{Families: []string{"Noto Sans"}, Size: 16}, 0)
44		if err != nil || l.Width == 0 {
45			t.Fatalf("measure: %v diagnostics %v", err, c.Diagnostics)
46		}
47		e.EndFrame()
48	}
49	if len(s.reads) != 2 || s.reads["bad"] != 1 || s.reads["good"] != 1 || len(c.Diagnostics) != 1 {
50		t.Fatalf("reads: %v diagnostics: %v", s.reads, c.Diagnostics)
51	}
52}
53
54func TestUnknownFamilyFallback(t *testing.T) {
55	b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
56	if err != nil {
57		t.Fatal(err)
58	}
59	s := &countingSource{entries: []FontFile{{Path: "first", Family: "Unlisted Family", Aspect: font.Aspect{Weight: 400, Stretch: 1}}, {Path: "second", Family: "Other Family", Aspect: font.Aspect{Weight: 400, Stretch: 1}}}, data: map[string][]byte{"first": b, "second": b}, reads: map[string]int{}}
60	c, err := Load(s)
61	if err != nil {
62		t.Fatal(err)
63	}
64	l, err := NewEngine(c).Measure("Hello", Request{Families: []string{"sans-serif"}, Size: 16}, 0)
65	if err != nil || l.Width == 0 || len(s.reads) != 1 || s.reads["first"] != 1 {
66		t.Fatalf("fallback %v reads %v", err, s.reads)
67	}
68}
69
70func TestCollectionDiscovery(t *testing.T) {
71	if !isFontPath("font.TTC") || !isFontPath("font.otf") || isFontPath("font.txt") {
72		t.Fatal("font extension filter")
73	}
74	// A collection entry retains its individual face index in the catalog.
75	s := &countingSource{entries: []FontFile{{Path: "faces.ttc", Family: "Other Family", Index: 1}}, reads: map[string]int{}}
76	c, err := Load(s)
77	if err != nil || len(c.Faces) != 1 || c.Faces[0].index != 1 {
78		t.Fatalf("collection entry: %v %+v", err, c)
79	}
80}
81
82func TestOversizedSelectedFont(t *testing.T) {
83	b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
84	if err != nil {
85		t.Fatal(err)
86	}
87	s := &countingSource{entries: []FontFile{{Path: "large", Family: "Noto Sans"}, {Path: "good", Family: "Noto Sans"}}, data: map[string][]byte{"large": make([]byte, maxFontBytes+1), "good": b}, reads: map[string]int{}}
88	c, err := Load(s)
89	if err != nil {
90		t.Fatal(err)
91	}
92	e := NewEngine(c)
93	for i := 0; i < 2; i++ {
94		l, err := e.Measure("Hello", Request{Families: []string{"Noto Sans"}, Size: 16}, 0)
95		if err != nil || l.Width == 0 {
96			t.Fatalf("measure: %v", err)
97		}
98		e.EndFrame()
99	}
100	if s.reads["large"] != 1 || s.reads["good"] != 1 || len(c.Diagnostics) != 1 {
101		t.Fatalf("reads %v diagnostics %v", s.reads, c.Diagnostics)
102	}
103}
104
105func TestIndexedGlyphFallback(t *testing.T) {
106	latin, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
107	if err != nil {
108		t.Fatal(err)
109	}
110	japanese, err := os.ReadFile("../../testdata/fonts/IBMPlexSansJP-Regular.ttf")
111	if err != nil {
112		t.Fatal(err)
113	}
114	s := &countingSource{entries: []FontFile{{Path: "latin", Family: "Noto Sans"}, {Path: "japanese", Family: "Unlisted Japanese", UnicodeRanges: [4]uint32{0, 1 << (59 - 32)}}}, data: map[string][]byte{"latin": latin, "japanese": japanese}, reads: map[string]int{}}
115	c, err := Load(s)
116	if err != nil {
117		t.Fatal(err)
118	}
119	e := NewEngine(c)
120	var first *Face
121	for i := 0; i < 2; i++ {
122		l, err := e.Measure("日本語", Request{Families: []string{"Noto Sans"}, Size: 20}, 0)
123		if err != nil {
124			t.Fatal(err)
125		}
126		f := l.Lines[0].Runs[0].Face
127		if f.Family != "Unlisted Japanese" || len(l.Lines[0].Runs[0].Glyphs) == 0 || l.Lines[0].Runs[0].Glyphs[0].Missing {
128			t.Fatalf("wrong fallback: %+v", l.Lines[0].Runs)
129		}
130		if first != nil && f != first {
131			t.Fatal("selection changed")
132		}
133		first = f
134		e.EndFrame()
135	}
136	if s.reads["latin"] != 1 || s.reads["japanese"] != 1 {
137		t.Fatalf("reads %v", s.reads)
138	}
139}
140
141func TestIndexedFallbackCap(t *testing.T) {
142	b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
143	if err != nil {
144		t.Fatal(err)
145	}
146	s := &countingSource{data: map[string][]byte{}, reads: map[string]int{}}
147	for i := 0; i < 2000; i++ {
148		p := fmt.Sprint(i)
149		s.entries = append(s.entries, FontFile{Path: p, Family: fmt.Sprintf("Other %d", i)})
150		s.data[p] = b
151	}
152	c, err := Load(s)
153	if err != nil {
154		t.Fatal(err)
155	}
156	e := NewEngine(c)
157	// The unknown codepoint has no OS/2 mapping; every face is eligible.
158	for i := 0; i < 2; i++ {
159		l, err := e.Measure("\U0010ffff", Request{Families: []string{"sans-serif"}, Size: 20}, 0)
160		if err != nil {
161			t.Fatal(err)
162		}
163		if !l.Lines[0].Runs[0].Glyphs[0].Missing {
164			t.Fatal("expected tofu")
165		}
166		if len(s.reads) != maxFallbackFaces {
167			t.Fatalf("measure %d: %d reads, want %d", i, len(s.reads), maxFallbackFaces)
168		}
169		e.EndFrame()
170	}
171}
172
173func TestUnicodeRangeFilter(t *testing.T) {
174	f := &Face{unicodeRanges: [4]uint32{1, 0, 0, 0}}
175	if !f.mayCover('A') || f.mayCover('日') || !f.mayCover('\U0010ffff') {
176		t.Fatal("range filter")
177	}
178}
179
180func TestPriorityIndependentOfClusterOrder(t *testing.T) {
181	latin, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
182	if err != nil {
183		t.Fatal(err)
184	}
185	jp, err := os.ReadFile("../../testdata/fonts/IBMPlexSansJP-Regular.ttf")
186	if err != nil {
187		t.Fatal(err)
188	}
189	s := &countingSource{entries: []FontFile{{Path: "named", Family: "Noto Sans"}, {Path: "other", Family: "Other"}}, data: map[string][]byte{"named": latin, "other": jp}, reads: map[string]int{}}
190	c, err := Load(s)
191	if err != nil {
192		t.Fatal(err)
193	}
194	e := NewEngine(c)
195	r := Request{Families: []string{"Noto Sans"}, Size: 20}
196	for i := 0; i < 2; i++ {
197		joined, err := e.Measure("日A", r, 0)
198		if err != nil {
199			t.Fatal(err)
200		}
201		if len(joined.Lines[0].Runs) < 2 {
202			t.Fatalf("expected separate runs: %+v", joined.Lines[0].Runs)
203		}
204		left, err := e.Measure("日", r, 0)
205		if err != nil {
206			t.Fatal(err)
207		}
208		right, err := e.Measure("A", r, 0)
209		if err != nil {
210			t.Fatal(err)
211		}
212		runs := joined.Lines[0].Runs
213		if runs[0].Face != left.Lines[0].Runs[0].Face || runs[len(runs)-1].Face != right.Lines[0].Runs[0].Face || runs[len(runs)-1].Face.Family != "Noto Sans" {
214			t.Fatalf("priority changed: %+v", runs)
215		}
216		e.EndFrame()
217	}
218}
219
220func TestFallbackWindowAcrossManyBlocks(t *testing.T) {
221	b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
222	if err != nil {
223		t.Fatal(err)
224	}
225	s := &countingSource{data: map[string][]byte{}, reads: map[string]int{}}
226	for i := 0; i < 2000; i++ {
227		p := fmt.Sprint(i)
228		s.entries = append(s.entries, FontFile{Path: p, Family: fmt.Sprintf("Other %d", i)})
229		s.data[p] = b
230	}
231	c, err := Load(s)
232	if err != nil {
233		t.Fatal(err)
234	}
235	var sample []rune
236	for i := 0; i < 50; i++ {
237		sample = append(sample, rune(0x10000+i*0x500))
238	}
239	e := NewEngine(c)
240	r := Request{Families: []string{"sans-serif"}, Size: 16}
241	first, err := e.Measure(string(sample), r, 0)
242	if err != nil {
243		t.Fatal(err)
244	}
245	reads := len(s.reads)
246	if reads > maxFallbackFaces || reads == 0 {
247		t.Fatalf("first measure loaded %d", reads)
248	}
249	e.EndFrame()
250	second, err := e.Measure(string(sample), r, 0)
251	if err != nil {
252		t.Fatal(err)
253	}
254	if len(s.reads) != reads || !reflect.DeepEqual(first, second) {
255		t.Fatalf("unstable fallback: %d -> %d", reads, len(s.reads))
256	}
257}
258
259// A failed first fallback face must not stall primary (metrics) selection.
260func TestPrimarySkipsFailedFallbackFace(t *testing.T) {
261	b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
262	if err != nil {
263		t.Fatal(err)
264	}
265	s := &countingSource{entries: []FontFile{{Path: "broken", Family: "Unlisted A"}, {Path: "good", Family: "Unlisted B"}}, data: map[string][]byte{"broken": []byte("broken"), "good": b}, reads: map[string]int{}}
266	c, err := Load(s)
267	if err != nil {
268		t.Fatal(err)
269	}
270	r := Request{Families: []string{"sans-serif"}, Size: 16}
271	for i := 0; i < 2; i++ {
272		if f := c.primary(r); f == nil || f.path != "good" {
273			t.Fatalf("primary %+v", f)
274		}
275	}
276	e := NewEngine(c)
277	if l, err := e.Measure("Hello", r, 0); err != nil || l.Width == 0 {
278		t.Fatalf("measure: %v", err)
279	}
280	// Empty text has no missing runes but must still find a face for metrics.
281	if l, err := e.Measure("", r, 0); err != nil || l.LineHeight == 0 {
282		t.Fatalf("empty measure: %+v %v", l, err)
283	}
284	if s.reads["broken"] != 1 || s.reads["good"] != 1 {
285		t.Fatalf("reads %v", s.reads)
286	}
287}
288
289// A named face that fails to load must not let a new face into the window.
290func TestFallbackWindowIgnoresFailedNamedFaces(t *testing.T) {
291	s := &countingSource{data: map[string][]byte{"named": []byte("broken")}, reads: map[string]int{}}
292	s.entries = append(s.entries, FontFile{Path: "named", Family: "Noto Sans"})
293	for i := 0; i < maxFallbackFaces+4; i++ {
294		s.entries = append(s.entries, FontFile{Path: fmt.Sprint(i), Family: fmt.Sprintf("Other %d", i)})
295	}
296	c, err := Load(s)
297	if err != nil {
298		t.Fatal(err)
299	}
300	r := Request{Families: []string{"Noto Sans"}, Size: 16}
301	before := c.fallbackWindow([]rune{'A'}, r)
302	if c.primary(r) != nil && c.Faces[0].bad {
303		t.Fatal("broken named face selected")
304	}
305	if !c.Faces[0].bad {
306		t.Fatal("named face should have failed")
307	}
308	after := c.fallbackWindow([]rune{'A'}, r)
309	if !reflect.DeepEqual(before, after) || len(after) != maxFallbackFaces {
310		t.Fatalf("window moved after failure: %d -> %d", len(before), len(after))
311	}
312	for _, f := range after {
313		if f.path == "named" {
314			t.Fatal("named face entered the fallback window")
315		}
316	}
317}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close