v0.7.2 71ebe61

text_test.go RawHistoryFolder

1package text
2
3import (
4	"crypto/sha256"
5	"fmt"
6	"image"
7	"math"
8	"os"
9	"path/filepath"
10	"reflect"
11	"strings"
12	"testing"
13
14	"github.com/go-text/typesetting/di"
15	"github.com/go-text/typesetting/font"
16	ot "github.com/go-text/typesetting/font/opentype"
17)
18
19func fixture(t testing.TB) *Catalog {
20	t.Helper()
21	c, e := Load(DirectorySource("../../testdata/fonts"))
22	if e != nil {
23		t.Fatal(e)
24	}
25	return c
26}
27func TestScriptsAndMetrics(t *testing.T) {
28	c := fixture(t)
29	e := NewEngine(c)
30	r := Request{Families: []string{"Noto Sans"}, Size: 20}
31	cases := []struct {
32		label, text, family string
33		rtl                 bool
34	}{
35		{"Latin", "office fi", "Noto Sans", false}, {"Arabic", "سلام", "Noto Sans Arabic", true}, {"Devanagari", "किरण", "Noto Sans Devanagari", false}, {"CJK", "日本語", "IBM Plex Sans JP", false}, {"combining", "a\u0301", "Noto Sans", false}, {"bidi", "abc سلام 123", "", true}, {"tofu", "\U0010ffff", "Noto Sans", false},
36	}
37	for _, tc := range cases {
38		t.Run(tc.label, func(t *testing.T) {
39			l, err := e.Measure(tc.text, r, 0)
40			if err != nil {
41				t.Fatal(err)
42			}
43			if len(l.Lines) == 0 || l.Width <= 0 || l.Height <= 0 {
44				t.Fatalf("bad metrics: %+v", l)
45			}
46			found, rtl, missing := false, false, false
47			for _, line := range l.Lines {
48				for _, run := range line.Runs {
49					if run.Face == nil {
50						t.Fatal("nil face")
51					}
52					if tc.family == run.Face.Family {
53						found = true
54					}
55					if run.Direction == di.DirectionRTL {
56						rtl = true
57					}
58					sum := 0.0
59					for _, g := range run.Glyphs {
60						sum += g.Advance
61						missing = missing || g.Missing
62					}
63					if math.Abs(sum-run.Advance) > 0.02 {
64						t.Fatalf("advance mismatch %f %f", sum, run.Advance)
65					}
66				}
67			}
68			if tc.family != "" && !found {
69				t.Errorf("missing fallback %s; got %+v", tc.family, l.Lines[0].Runs)
70			}
71			if tc.rtl && !rtl {
72				t.Error("missing RTL run")
73			}
74			if tc.label == "tofu" && !missing {
75				t.Error("missing tofu flag")
76			}
77			again, err := e.Measure(tc.text, r, 0)
78			if err != nil || !reflect.DeepEqual(l, again) {
79				t.Error("nondeterministic output")
80			}
81		})
82	}
83	a, _ := e.Measure("office fi", r, 0)
84	glyphs := 0
85	for _, run := range a.Lines[0].Runs {
86		glyphs += len(run.Glyphs)
87	}
88	if glyphs >= len([]rune("office fi")) {
89		t.Error("expected fi ligature")
90	}
91	wide, _ := e.Measure("one two three four five", r, 220)
92	narrow, _ := e.Measure("one two three four five", r, 60)
93	if narrow.Height < wide.Height || len(narrow.Lines) <= len(wide.Lines) {
94		t.Errorf("wrap monotonicity: %+v %+v", narrow, wide)
95	}
96}
97
98func TestCorruptAndMatching(t *testing.T) {
99	if _, err := Load(sourceMap{"broken.ttf": []byte("not a font")}); err == nil {
100		t.Fatal("accepted corrupt font")
101	}
102	c := fixture(t)
103	if f := c.primary(Request{Families: []string{"missing", "sans-serif"}}); f.Family != "Noto Sans" {
104		t.Fatalf("generic mismatch %s", f.Family)
105	}
106	if f := c.primary(Request{Families: []string{"IBM Plex Sans JP"}}); f.Family != "IBM Plex Sans JP" {
107		t.Fatalf("family mismatch %s", f.Family)
108	}
109	if _, err := Load(sourceMap{}); err == nil {
110		t.Fatal("accepted empty catalog")
111	}
112}
113
114type sourceMap map[string][]byte
115
116func (s sourceMap) Fonts() (map[string][]byte, error) { return s, nil }
117func TestRasterCoverage(t *testing.T) {
118	c := fixture(t)
119	f := c.primary(Request{Families: []string{"Noto Sans"}})
120	gid, ok := f.Shape.NominalGlyph('H')
121	if !ok {
122		t.Fatal("no H")
123	}
124	key, err := GlyphKey(f, gid, 32, 0, nil)
125	if err != nil {
126		t.Fatal(err)
127	}
128	mask, err := Rasterize(f, key)
129	if err != nil {
130		t.Fatal(err)
131	}
132	if mask.Alpha.Rect.Dx() == 0 {
133		t.Fatal("empty mask")
134	}
135	coverage := 0.0
136	for _, v := range mask.Alpha.Pix {
137		coverage += float64(v) / 255
138	}
139	// Independent outline area oracle: flatten Beziers, sum signed contour areas.
140	// Uses go-text font-unit outlines; rasterizer uses sfnt 26.6 outlines.
141	outline, ok := f.Shape.GlyphData(gid).(font.GlyphOutline)
142	if !ok {
143		t.Fatal("outline")
144	}
145	area := 0.0
146	var start, prev [2]float64
147	contour := [][2]float64{}
148	flush := func() {
149		if len(contour) < 2 {
150			return
151		}
152		for i, p := range contour {
153			q := contour[(i+1)%len(contour)]
154			area += p[0]*q[1] - q[0]*p[1]
155		}
156		contour = nil
157	}
158	for _, s := range outline.Segments {
159		p := s.ArgsSlice()
160		conv := func(pt ot.SegmentPoint) [2]float64 {
161			return [2]float64{float64(pt.X) * 32 / float64(f.Shape.Upem()), float64(pt.Y) * 32 / float64(f.Shape.Upem())}
162		}
163		switch s.Op {
164		case ot.SegmentOpMoveTo:
165			flush()
166			start = conv(p[0])
167			prev = start
168			contour = append(contour, start)
169		case ot.SegmentOpLineTo:
170			prev = conv(p[0])
171			contour = append(contour, prev)
172		case ot.SegmentOpQuadTo:
173			ctrl, end := conv(p[0]), conv(p[1])
174			from := prev
175			for i := 1; i <= 24; i++ {
176				v := float64(i) / 24
177				point := [2]float64{(1-v)*(1-v)*from[0] + 2*(1-v)*v*ctrl[0] + v*v*end[0], (1-v)*(1-v)*from[1] + 2*(1-v)*v*ctrl[1] + v*v*end[1]}
178				contour = append(contour, point)
179			}
180			prev = end
181		case ot.SegmentOpCubeTo:
182			a, b, end := conv(p[0]), conv(p[1]), conv(p[2])
183			from := prev
184			for i := 1; i <= 24; i++ {
185				v := float64(i) / 24
186				u := 1 - v
187				contour = append(contour, [2]float64{u*u*u*from[0] + 3*u*u*v*a[0] + 3*u*v*v*b[0] + v*v*v*end[0], u*u*u*from[1] + 3*u*u*v*a[1] + 3*u*v*v*b[1] + v*v*v*end[1]})
188			}
189			prev = end
190		}
191		_ = start
192	}
193	flush()
194	area = math.Abs(area) / 2
195	if math.Abs(coverage-area) > area*0.14+2 {
196		t.Fatalf("coverage %.2f outline area %.2f", coverage, area)
197	}
198	for _, phase := range []float64{0, 0.26, 0.51, 0.76} {
199		k, _ := GlyphKey(f, gid, 32, phase, nil)
200		m, err := Rasterize(f, k)
201		if err != nil || m.Alpha.Rect.Dx() == 0 {
202			t.Fatalf("phase %v: %v", phase, err)
203		}
204	}
205	if _, err := Rasterize(f, Key{}); err == nil {
206		t.Fatal("invalid key accepted")
207	}
208}
209func TestAtlas(t *testing.T) {
210	a, _ := NewAtlas(32, 2)
211	m := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 11, 11))}
212	seen := map[int][]image.Rectangle{}
213	for i := 0; i < 8; i++ {
214		p, err := a.Insert(Key{FaceID: string(rune('a' + i))}, m)
215		if err != nil {
216			t.Fatal(err)
217		}
218		for _, rect := range seen[p.Page] {
219			if p.Rect.Overlaps(rect) {
220				t.Fatalf("overlap %v %v", p.Rect, rect)
221			}
222		}
223		seen[p.Page] = append(seen[p.Page], p.Rect)
224	}
225	if _, err := a.Insert(Key{FaceID: "overflow"}, m); err == nil {
226		t.Error("evicted in active frame")
227	}
228	if up := a.Uploads(); len(up) != 2 {
229		t.Fatalf("coalesced uploads %d", len(up))
230	}
231	a.BeginFrame()
232	p, err := a.Insert(Key{FaceID: "new"}, m)
233	if err != nil || p.Page < 0 {
234		t.Fatalf("eviction: %v", err)
235	}
236	up := a.Uploads()
237	if len(up) != 1 || up[0].Rect != image.Rect(0, 0, 32, 32) {
238		t.Fatalf("missing coalesced clear: %+v", up)
239	}
240	if len(up[0].Bytes) > 32*32 {
241		t.Fatal("upload exceeded page size")
242	}
243}
244func TestVariableFixture(t *testing.T) {
245	c := fixture(t)
246	f := c.primary(Request{Families: []string{"Adwaita Sans"}})
247	r := Request{Families: []string{"Adwaita Sans"}, Size: 20, Variations: []font.Variation{{Tag: ot.MustNewTag("wght"), Value: 700}}}
248	l, err := NewEngine(c).Measure("Hello", r, 0)
249	if err != nil || len(l.Lines) == 0 {
250		t.Fatalf("variable shaping: %v", err)
251	}
252	if len(l.Lines[0].Runs) == 0 || len(l.Lines[0].Runs[0].Face.Shape.Coords()) == 0 {
253		t.Fatal("variation axis not active")
254	}
255	glyph := l.Lines[0].Runs[0].Glyphs[0]
256	variable := l.Lines[0].Runs[0].Face
257	k, _ := GlyphKey(variable, glyph.ID, 20, 0, r.Variations)
258	if k.Variations == VariationHash(nil) {
259		t.Fatal("lost variation identity")
260	}
261	m, err := Rasterize(variable, k)
262	if err != nil || m.Alpha.Rect.Dx() == 0 {
263		t.Fatalf("variable raster: %v", err)
264	}
265	if _, err := Rasterize(f, k); err == nil {
266		t.Fatal("default face accepted non-default key")
267	}
268}
269
270// ShapeParagraph measures uncached shaping; ShapeParagraphCached measures a
271// Measure call that hits the per-frame cache.
272func BenchmarkShapeParagraph(b *testing.B) {
273	c := fixture(b)
274	e := NewEngine(c)
275	r := Request{Families: []string{"Noto Sans"}, Size: 16}
276	s := strings.Repeat("Hello سلام कक्षा 日本語! ", 8)
277	b.ReportAllocs()
278	b.ResetTimer()
279	for i := 0; i < b.N; i++ {
280		if _, err := e.measure(s, r, 240); err != nil {
281			b.Fatal(err)
282		}
283	}
284}
285func BenchmarkShapeParagraphCached(b *testing.B) {
286	e := NewEngine(fixture(b))
287	r := Request{Families: []string{"Noto Sans"}, Size: 16}
288	s := strings.Repeat("Hello سلام कक्षा 日本語! ", 8)
289	if _, err := e.Measure(s, r, 240); err != nil {
290		b.Fatal(err)
291	}
292	b.ReportAllocs()
293	b.ResetTimer()
294	for i := 0; i < b.N; i++ {
295		if _, err := e.Measure(s, r, 240); err != nil {
296			b.Fatal(err)
297		}
298	}
299}
300func BenchmarkAtlasInsert(b *testing.B) {
301	a, _ := NewAtlas(1024, 4)
302	m := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 12, 16))}
303	b.ResetTimer()
304	for i := 0; i < b.N; i++ {
305		a.BeginFrame()
306		k := Key{Glyph: font.GID(i)}
307		if _, err := a.Insert(k, m); err != nil {
308			b.Fatal(err)
309		}
310		a.Uploads()
311	}
312}
313func TestFixtureHashes(t *testing.T) {
314	expected := map[string]string{"NotoSans-Regular.ttf": "478c558ea716033cd60c03438f628dfa75694dcf6b5f6d505a2f05fd2b4f3823", "NotoSansArabic-Regular.ttf": "bdff3e5659d67e67def05b33f749683b9376ae819d65d3dd62ac4640b3aaef48", "NotoSansDevanagari-Regular.ttf": "306b53ecfb182a504dd8a7446093c316387d2fd8dc350d0792ed1753fe0996cd", "NotoSansSymbols2-Regular.ttf": "c4a0a80f0041ce4be81e2478faad22776d23edb98ae3f0d19bd37044820ecf9d", "IBMPlexSansJP-Regular.ttf": "25d96fe620f12fba6cf09158807117ec13d1e7c9debff488117cf21dc8844688", "AdwaitaSans-Regular.ttf": "8381c33b9a44f066f2b99dba3d416a2342891e28c956a35dfd8d16ee2987e6d4"}
315	for name, hash := range expected {
316		b, err := os.ReadFile(filepath.Join("../../testdata/fonts", name))
317		if err != nil {
318			t.Fatal(err)
319		}
320		sum := sha256.Sum256(b)
321		if fmt.Sprintf("%x", sum) != hash {
322			t.Errorf("fixture %s hash mismatch", name)
323		}
324	}
325}
326
327func TestLetterSpacingWrap(t *testing.T) {
328	e := NewEngine(fixture(t))
329	r := Request{Families: []string{"Noto Sans"}, Size: 16}
330	plain, err := e.Measure("hello world", r, 1000)
331	if err != nil {
332		t.Fatal(err)
333	}
334	r.LetterSpacing = 4
335	spaced, err := e.Measure("hello world", r, 1000)
336	if err != nil {
337		t.Fatal(err)
338	}
339	if spaced.Width <= plain.Width+10 {
340		t.Fatalf("spacing width: %v -> %v", plain.Width, spaced.Width)
341	}
342	width := (plain.Width + spaced.Width) / 2
343	r.LetterSpacing = 0
344	unwrapped, err := e.Measure("hello world", r, width)
345	if err != nil {
346		t.Fatal(err)
347	}
348	r.LetterSpacing = 4
349	wrapped, err := e.Measure("hello world", r, width)
350	if err != nil {
351		t.Fatal(err)
352	}
353	if len(unwrapped.Lines) != 1 || len(wrapped.Lines) < 2 {
354		t.Fatalf("spacing wrap: %d -> %d at %v", len(unwrapped.Lines), len(wrapped.Lines), width)
355	}
356	r.LetterSpacing = math.NaN()
357	if _, err = e.Measure("x", r, width); err == nil {
358		t.Fatal("accepted NaN spacing")
359	}
360}
361
362func TestLetterSpacingClusterGeometry(t *testing.T) {
363	e := NewEngine(fixture(t))
364	r := Request{Families: []string{"Noto Sans"}, Size: 20}
365	for _, tc := range []struct {
366		name, input   string
367		first, second int
368	}{{"rtl", "سلام", 0, 1}, {"combining", "a\u0301b", 0, 2}, {"ligature", "fib", 0, 2}} {
369		t.Run(tc.name, func(t *testing.T) {
370			r.LetterSpacing = 0
371			plain, err := e.Measure(tc.input, r, 0)
372			if err != nil {
373				t.Fatal(err)
374			}
375			r.LetterSpacing = 6
376			spaced, err := e.Measure(tc.input, r, 0)
377			if err != nil {
378				t.Fatal(err)
379			}
380			if len(plain.Lines) != 1 || len(spaced.Lines) != 1 {
381				t.Fatalf("lines: %d %d", len(plain.Lines), len(spaced.Lines))
382			}
383			if tc.name == "rtl" && math.Abs(spaced.Width-plain.Width-6*float64(len([]rune(tc.input))-1)) > 0.1 {
384				t.Fatalf("RTL spacing width: %v -> %v", plain.Width, spaced.Width)
385			}
386			// Compare visually adjacent cluster origins rather than rune order: RTL is
387			// painted in visual order. Glyphs in the same cluster must not gain space.
388			clusters := func(l Layout) map[int][]Glyph {
389				m := map[int][]Glyph{}
390				for _, run := range l.Lines[0].Runs {
391					for _, g := range run.Glyphs {
392						m[g.Cluster] = append(m[g.Cluster], g)
393					}
394				}
395				return m
396			}
397			a, b := clusters(plain), clusters(spaced)
398			if len(b) <= 1 || len(a) != len(b) || spaced.Width <= plain.Width+2 {
399				t.Fatalf("clusters/width: %v -> %v", a, b)
400			}
401			if tc.name == "combining" || tc.name == "ligature" {
402				if _, ok := b[tc.second]; !ok {
403					t.Fatalf("missing next cluster %d: %v", tc.second, b)
404				}
405				if _, ok := b[tc.first]; !ok {
406					t.Fatalf("missing first cluster %d: %v", tc.first, b)
407				}
408				if math.Abs((b[tc.second][0].X-b[tc.first][0].X)-(a[tc.second][0].X-a[tc.first][0].X)-6) > 0.1 {
409					t.Fatalf("spacing not between clusters: %v -> %v", a, b)
410				}
411			}
412			if tc.name == "ligature" && len(b[0]) != 1 {
413				t.Fatalf("ligature split by spacing: %v", b[0])
414			}
415			if tc.name == "rtl" {
416				moved := false
417				for cluster, old := range a {
418					if math.Abs(b[cluster][0].X-old[0].X) > 1 {
419						moved = true
420					}
421				}
422				if !moved {
423					t.Fatalf("RTL glyph positions unchanged: %v -> %v", a, b)
424				}
425			}
426			for _, run := range spaced.Lines[0].Runs {
427				sum := 0.0
428				for _, g := range run.Glyphs {
429					sum += g.Advance
430				}
431				if math.Abs(sum-run.Advance) > .02 {
432					t.Fatalf("advance mismatch %v %v", sum, run.Advance)
433				}
434			}
435		})
436	}
437}
438
439// BenchmarkMeasureMissShort measures an uncached short label, the common
440// per-frame miss when a counter or clock changes.
441func BenchmarkMeasureMissShort(b *testing.B) {
442	e := NewEngine(fixture(b))
443	r := Request{Families: []string{"Noto Sans"}, Size: 16}
444	b.ReportAllocs()
445	for b.Loop() {
446		if _, err := e.measure("Value: 42", r, 0); err != nil {
447			b.Fatal(err)
448		}
449	}
450}
451
452// allocBaselineMeasureMiss is the measured allocation count of an uncached
453// short label; most of the remainder is inside go-text segmentation and
454// wrapping. Lower it with each optimization.
455const allocBaselineMeasureMiss = 20
456
457// TestAllocMeasureMissShort covers both catalog kinds: an eager directory
458// catalog and a lazy indexed one, like the system catalog the renderer uses,
459// which runs the on-demand coverage pass.
460func TestAllocMeasureMissShort(t *testing.T) {
461	if raceEnabled {
462		t.Skip("race instrumentation changes allocation counts")
463	}
464	b, err := os.ReadFile("../../testdata/fonts/NotoSans-Regular.ttf")
465	if err != nil {
466		t.Fatal(err)
467	}
468	lazy, err := Load(&countingSource{entries: []FontFile{{Path: "noto", Family: "Noto Sans", Aspect: font.Aspect{Weight: 400, Stretch: 1}}}, data: map[string][]byte{"noto": b}, reads: map[string]int{}})
469	if err != nil {
470		t.Fatal(err)
471	}
472	for name, catalog := range map[string]*Catalog{"directory": fixture(t), "indexed": lazy} {
473		t.Run(name, func(t *testing.T) {
474			e := NewEngine(catalog)
475			r := Request{Families: []string{"Noto Sans"}, Size: 16}
476			measure := func() {
477				if _, err := e.measure("Value: 42", r, 0); err != nil {
478					t.Fatal(err)
479				}
480			}
481			measure() // load fonts and fill scratch buffers
482			if got := testing.AllocsPerRun(50, measure); got > allocBaselineMeasureMiss {
483				t.Fatalf("allocs per uncached measure = %v, baseline %v", got, allocBaselineMeasureMiss)
484			}
485		})
486	}
487}

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