v0.1.0 47aaa0f

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 || l.MinContent > l.MaxContent+1 {
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 || narrow.MinContent != wide.MinContent || wide.MaxContent != narrow.MaxContent {
94		t.Errorf("wrap monotonicity: %+v %+v", narrow, wide)
95	}
96}
97func TestCorruptAndMatching(t *testing.T) {
98	if _, err := Load(sourceMap{"broken.ttf": []byte("not a font")}); err == nil {
99		t.Fatal("accepted corrupt font")
100	}
101	c := fixture(t)
102	if f := c.primary(Request{Families: []string{"missing", "sans-serif"}}); f.Family != "Noto Sans" {
103		t.Fatalf("generic mismatch %s", f.Family)
104	}
105	if f := c.primary(Request{Families: []string{"IBM Plex Sans JP"}}); f.Family != "IBM Plex Sans JP" {
106		t.Fatalf("family mismatch %s", f.Family)
107	}
108	if _, err := Load(sourceMap{}); err == nil {
109		t.Fatal("accepted empty catalog")
110	}
111}
112
113type sourceMap map[string][]byte
114
115func (s sourceMap) Fonts() (map[string][]byte, error) { return s, nil }
116func TestRasterCoverage(t *testing.T) {
117	c := fixture(t)
118	f := c.primary(Request{Families: []string{"Noto Sans"}})
119	gid, ok := f.Shape.NominalGlyph('H')
120	if !ok {
121		t.Fatal("no H")
122	}
123	key, err := GlyphKey(f, gid, 32, 0, nil)
124	if err != nil {
125		t.Fatal(err)
126	}
127	mask, err := Rasterize(f, key)
128	if err != nil {
129		t.Fatal(err)
130	}
131	if mask.Alpha.Rect.Dx() == 0 {
132		t.Fatal("empty mask")
133	}
134	coverage := 0.0
135	for _, v := range mask.Alpha.Pix {
136		coverage += float64(v) / 255
137	}
138	// Independent outline area oracle: flatten Beziers, sum signed contour areas.
139	// Uses go-text font-unit outlines; rasterizer uses sfnt 26.6 outlines.
140	outline, ok := f.Shape.GlyphData(gid).(font.GlyphOutline)
141	if !ok {
142		t.Fatal("outline")
143	}
144	area := 0.0
145	var start, prev [2]float64
146	contour := [][2]float64{}
147	flush := func() {
148		if len(contour) < 2 {
149			return
150		}
151		for i, p := range contour {
152			q := contour[(i+1)%len(contour)]
153			area += p[0]*q[1] - q[0]*p[1]
154		}
155		contour = nil
156	}
157	for _, s := range outline.Segments {
158		p := s.ArgsSlice()
159		conv := func(pt ot.SegmentPoint) [2]float64 {
160			return [2]float64{float64(pt.X) * 32 / float64(f.Shape.Upem()), float64(pt.Y) * 32 / float64(f.Shape.Upem())}
161		}
162		switch s.Op {
163		case ot.SegmentOpMoveTo:
164			flush()
165			start = conv(p[0])
166			prev = start
167			contour = append(contour, start)
168		case ot.SegmentOpLineTo:
169			prev = conv(p[0])
170			contour = append(contour, prev)
171		case ot.SegmentOpQuadTo:
172			ctrl, end := conv(p[0]), conv(p[1])
173			from := prev
174			for i := 1; i <= 24; i++ {
175				v := float64(i) / 24
176				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]}
177				contour = append(contour, point)
178			}
179			prev = end
180		case ot.SegmentOpCubeTo:
181			a, b, end := conv(p[0]), conv(p[1]), conv(p[2])
182			from := prev
183			for i := 1; i <= 24; i++ {
184				v := float64(i) / 24
185				u := 1 - v
186				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]})
187			}
188			prev = end
189		}
190		_ = start
191	}
192	flush()
193	area = math.Abs(area) / 2
194	if math.Abs(coverage-area) > area*0.14+2 {
195		t.Fatalf("coverage %.2f outline area %.2f", coverage, area)
196	}
197	for _, phase := range []float64{0, 0.26, 0.51, 0.76} {
198		k, _ := GlyphKey(f, gid, 32, phase, nil)
199		m, err := Rasterize(f, k)
200		if err != nil || m.Alpha.Rect.Dx() == 0 {
201			t.Fatalf("phase %v: %v", phase, err)
202		}
203	}
204	if _, err := Rasterize(f, Key{}); err == nil {
205		t.Fatal("invalid key accepted")
206	}
207}
208func TestAtlas(t *testing.T) {
209	a, _ := NewAtlas(32, 2)
210	m := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 11, 11))}
211	seen := map[int][]image.Rectangle{}
212	for i := 0; i < 8; i++ {
213		p, err := a.Insert(Key{FaceID: string(rune('a' + i))}, m)
214		if err != nil {
215			t.Fatal(err)
216		}
217		for _, rect := range seen[p.Page] {
218			if p.Rect.Overlaps(rect) {
219				t.Fatalf("overlap %v %v", p.Rect, rect)
220			}
221		}
222		seen[p.Page] = append(seen[p.Page], p.Rect)
223	}
224	if _, err := a.Insert(Key{FaceID: "overflow"}, m); err == nil {
225		t.Error("evicted in active frame")
226	}
227	if up := a.Uploads(); len(up) != 2 {
228		t.Fatalf("coalesced uploads %d", len(up))
229	}
230	a.BeginFrame()
231	p, err := a.Insert(Key{FaceID: "new"}, m)
232	if err != nil || p.Page < 0 {
233		t.Fatalf("eviction: %v", err)
234	}
235	up := a.Uploads()
236	if len(up) != 1 || up[0].Rect != image.Rect(0, 0, 32, 32) {
237		t.Fatalf("missing coalesced clear: %+v", up)
238	}
239	if len(up[0].Bytes) > 32*32 {
240		t.Fatal("upload exceeded page size")
241	}
242}
243func TestVariableFixture(t *testing.T) {
244	c := fixture(t)
245	f := c.primary(Request{Families: []string{"Adwaita Sans"}})
246	r := Request{Families: []string{"Adwaita Sans"}, Size: 20, Variations: []font.Variation{{Tag: ot.MustNewTag("wght"), Value: 700}}}
247	l, err := NewEngine(c).Measure("Hello", r, 0)
248	if err != nil || len(l.Lines) == 0 {
249		t.Fatalf("variable shaping: %v", err)
250	}
251	if len(l.Lines[0].Runs) == 0 || len(l.Lines[0].Runs[0].Face.Shape.Coords()) == 0 {
252		t.Fatal("variation axis not active")
253	}
254	glyph := l.Lines[0].Runs[0].Glyphs[0]
255	variable := l.Lines[0].Runs[0].Face
256	k, _ := GlyphKey(variable, glyph.ID, 20, 0, r.Variations)
257	if k.Variations == VariationHash(nil) {
258		t.Fatal("lost variation identity")
259	}
260	m, err := Rasterize(variable, k)
261	if err != nil || m.Alpha.Rect.Dx() == 0 {
262		t.Fatalf("variable raster: %v", err)
263	}
264	if _, err := Rasterize(f, k); err == nil {
265		t.Fatal("default face accepted non-default key")
266	}
267}
268
269// ShapeParagraph measures uncached shaping; ShapeParagraphCached measures a
270// Measure call that hits the per-frame cache.
271func BenchmarkShapeParagraph(b *testing.B) {
272	c := fixture(b)
273	e := NewEngine(c)
274	r := Request{Families: []string{"Noto Sans"}, Size: 16}
275	s := strings.Repeat("Hello سلام कक्षा 日本語! ", 8)
276	b.ReportAllocs()
277	b.ResetTimer()
278	for i := 0; i < b.N; i++ {
279		if _, err := e.measure(s, r, 240); err != nil {
280			b.Fatal(err)
281		}
282	}
283}
284func BenchmarkShapeParagraphCached(b *testing.B) {
285	e := NewEngine(fixture(b))
286	r := Request{Families: []string{"Noto Sans"}, Size: 16}
287	s := strings.Repeat("Hello سلام कक्षा 日本語! ", 8)
288	if _, err := e.Measure(s, r, 240); err != nil {
289		b.Fatal(err)
290	}
291	b.ReportAllocs()
292	b.ResetTimer()
293	for i := 0; i < b.N; i++ {
294		if _, err := e.Measure(s, r, 240); err != nil {
295			b.Fatal(err)
296		}
297	}
298}
299func BenchmarkAtlasInsert(b *testing.B) {
300	a, _ := NewAtlas(1024, 4)
301	m := Mask{Alpha: image.NewAlpha(image.Rect(0, 0, 12, 16))}
302	b.ResetTimer()
303	for i := 0; i < b.N; i++ {
304		a.BeginFrame()
305		k := Key{Glyph: font.GID(i)}
306		if _, err := a.Insert(k, m); err != nil {
307			b.Fatal(err)
308		}
309		a.Uploads()
310	}
311}
312func TestFixtureHashes(t *testing.T) {
313	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"}
314	for name, hash := range expected {
315		b, err := os.ReadFile(filepath.Join("../../testdata/fonts", name))
316		if err != nil {
317			t.Fatal(err)
318		}
319		sum := sha256.Sum256(b)
320		if fmt.Sprintf("%x", sum) != hash {
321			t.Errorf("fixture %s hash mismatch", name)
322		}
323	}
324}
325
326func TestLetterSpacingWrap(t *testing.T) {
327	e := NewEngine(fixture(t))
328	r := Request{Families: []string{"Noto Sans"}, Size: 16}
329	plain, err := e.Measure("hello world", r, 1000)
330	if err != nil {
331		t.Fatal(err)
332	}
333	r.LetterSpacing = 4
334	spaced, err := e.Measure("hello world", r, 1000)
335	if err != nil {
336		t.Fatal(err)
337	}
338	if spaced.Width <= plain.Width+10 {
339		t.Fatalf("spacing width: %v -> %v", plain.Width, spaced.Width)
340	}
341	width := (plain.Width + spaced.Width) / 2
342	r.LetterSpacing = 0
343	unwrapped, err := e.Measure("hello world", r, width)
344	if err != nil {
345		t.Fatal(err)
346	}
347	r.LetterSpacing = 4
348	wrapped, err := e.Measure("hello world", r, width)
349	if err != nil {
350		t.Fatal(err)
351	}
352	if len(unwrapped.Lines) != 1 || len(wrapped.Lines) < 2 {
353		t.Fatalf("spacing wrap: %d -> %d at %v", len(unwrapped.Lines), len(wrapped.Lines), width)
354	}
355	r.LetterSpacing = math.NaN()
356	if _, err = e.Measure("x", r, width); err == nil {
357		t.Fatal("accepted NaN spacing")
358	}
359}
360
361func TestLetterSpacingClusterGeometry(t *testing.T) {
362	e := NewEngine(fixture(t))
363	r := Request{Families: []string{"Noto Sans"}, Size: 20}
364	for _, tc := range []struct {
365		name, input   string
366		first, second int
367	}{{"rtl", "سلام", 0, 1}, {"combining", "a\u0301b", 0, 2}, {"ligature", "fib", 0, 2}} {
368		t.Run(tc.name, func(t *testing.T) {
369			r.LetterSpacing = 0
370			plain, err := e.Measure(tc.input, r, 0)
371			if err != nil {
372				t.Fatal(err)
373			}
374			r.LetterSpacing = 6
375			spaced, err := e.Measure(tc.input, r, 0)
376			if err != nil {
377				t.Fatal(err)
378			}
379			if len(plain.Lines) != 1 || len(spaced.Lines) != 1 {
380				t.Fatalf("lines: %d %d", len(plain.Lines), len(spaced.Lines))
381			}
382			if tc.name == "rtl" && math.Abs(spaced.Width-plain.Width-6*float64(len([]rune(tc.input))-1)) > 0.1 {
383				t.Fatalf("RTL spacing width: %v -> %v", plain.Width, spaced.Width)
384			}
385			// Compare visually adjacent cluster origins rather than rune order: RTL is
386			// painted in visual order. Glyphs in the same cluster must not gain space.
387			clusters := func(l Layout) map[int][]Glyph {
388				m := map[int][]Glyph{}
389				for _, run := range l.Lines[0].Runs {
390					for _, g := range run.Glyphs {
391						m[g.Cluster] = append(m[g.Cluster], g)
392					}
393				}
394				return m
395			}
396			a, b := clusters(plain), clusters(spaced)
397			if len(b) <= 1 || len(a) != len(b) || spaced.Width <= plain.Width+2 {
398				t.Fatalf("clusters/width: %v -> %v", a, b)
399			}
400			if tc.name == "combining" || tc.name == "ligature" {
401				if _, ok := b[tc.second]; !ok {
402					t.Fatalf("missing next cluster %d: %v", tc.second, b)
403				}
404				if _, ok := b[tc.first]; !ok {
405					t.Fatalf("missing first cluster %d: %v", tc.first, b)
406				}
407				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 {
408					t.Fatalf("spacing not between clusters: %v -> %v", a, b)
409				}
410			}
411			if tc.name == "ligature" && len(b[0]) != 1 {
412				t.Fatalf("ligature split by spacing: %v", b[0])
413			}
414			if tc.name == "rtl" {
415				moved := false
416				for cluster, old := range a {
417					if math.Abs(b[cluster][0].X-old[0].X) > 1 {
418						moved = true
419					}
420				}
421				if !moved {
422					t.Fatalf("RTL glyph positions unchanged: %v -> %v", a, b)
423				}
424			}
425			for _, run := range spaced.Lines[0].Runs {
426				sum := 0.0
427				for _, g := range run.Glyphs {
428					sum += g.Advance
429				}
430				if math.Abs(sum-run.Advance) > .02 {
431					t.Fatalf("advance mismatch %v %v", sum, run.Advance)
432				}
433			}
434		})
435	}
436}

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