main f2d166b

underline_test.go RawHistoryFolder

1package hints
2
3import (
4	"image"
5	"image/color"
6	"os"
7	"os/exec"
8	"testing"
9
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/neferwl/internal/ports"
13)
14
15// canvas is a synthetic grayscale band: bg everywhere, ink for text.
16type canvas struct {
17	g       *image.Gray
18	bg, ink uint8
19	s       int // scale
20}
21
22func newCanvas(w, h int, bg, ink uint8, s int) *canvas {
23	g := image.NewGray(image.Rect(0, 0, w*s, h*s))
24	for i := range g.Pix {
25		g.Pix[i] = bg
26	}
27	return &canvas{g, bg, ink, s}
28}
29
30func (c *canvas) fill(x0, y0, x1, y1 int, v uint8) {
31	for y := y0 * c.s; y < y1*c.s; y++ {
32		for x := x0 * c.s; x < x1*c.s; x++ {
33			c.g.SetGray(x, y, color.Gray{v})
34		}
35	}
36}
37
38// word draws a 10 px high word of x-height body at (x,y) with the given
39// width: text-like noisy ink (a stem every 3 px) and optional descenders.
40// It returns the OCR box (descenders excluded, as a box around the body).
41func (c *canvas) word(x, y, w int, descender bool) OCRWord {
42	for i := 0; i < w; i += 3 {
43		c.fill(x+i, y, x+i+1, y+10, c.ink)
44	}
45	box := image.Rect(x*c.s, y*c.s, (x+w)*c.s, (y+10)*c.s)
46	if descender {
47		for i := 0; i < w; i += 6 {
48			c.fill(x+i, y+10, x+i+1, y+13, c.ink)
49		}
50		box.Max.Y = (y + 13) * c.s
51	}
52	return OCRWord{Text: "word", Rect: box}
53}
54
55func (c *canvas) underline(x0, x1, y int) { c.fill(x0, y, x1, y+1, c.ink) }
56
57func scanUnder(c *canvas, words ...OCRWord) []ports.HintTarget {
58	markUnderlined(c.g, words)
59	return textLinks(words, float64(c.s))
60}
61
62func TestUnderline(t *testing.T) {
63	for _, tc := range []struct {
64		name    string
65		bg, ink uint8
66	}{{"dark-bg", 10, 204}, {"light-bg", 245, 30}} {
67		for _, s := range []int{1, 2} {
68			t.Run(tc.name, func(t *testing.T) {
69				mk := func() *canvas { return newCanvas(200, 60, tc.bg, tc.ink, s) }
70
71				c := mk()
72				w := c.word(10, 20, 40, false)
73				c.underline(10, 50, 32)
74				got := scanUnder(c, w)
75				require.Len(t, got, 1)
76				require.Equal(t, ports.HintLink, got[0].Kind)
77				require.False(t, got[0].Text)
78				require.Equal(t, ports.WindowID(0), got[0].Window)
79				require.Equal(t, 10, got[0].Rect.X)
80
81				c = mk()
82				require.Empty(t, scanUnder(c, c.word(10, 20, 40, false)), "plain word")
83
84				c = mk()
85				require.Empty(t, scanUnder(c, c.word(10, 20, 40, true)), "descenders")
86
87				c = mk()
88				w = c.word(10, 20, 40, false)
89				c.underline(0, 200, 32)
90				require.Empty(t, scanUnder(c, w), "rule across")
91
92				c = mk()
93				w = c.word(10, 20, 40, false)
94				c.fill(10, 24, 50, 25, c.ink)
95				require.Empty(t, scanUnder(c, w), "strikethrough")
96
97				// Two words with a continuous underline: one target.
98				c = mk()
99				a, b := c.word(10, 20, 40, false), c.word(56, 20, 40, false)
100				c.underline(10, 96, 32)
101				got = scanUnder(c, a, b)
102				require.Len(t, got, 1)
103				require.Equal(t, 10, got[0].Rect.X)
104				require.Equal(t, 86, got[0].Rect.W)
105
106				// A gap in the underline: two targets.
107				c = mk()
108				a, b = c.word(10, 20, 40, false), c.word(56, 20, 40, false)
109				c.underline(10, 50, 32)
110				c.underline(56, 96, 32)
111				require.Len(t, scanUnder(c, a, b), 2)
112			})
113		}
114	}
115}
116
117// A box border under a word, with the word a part of a table row, is not a
118// link; neither is an underline that continues beyond into unrelated text.
119func TestUnderlineRejectsBox(t *testing.T) {
120	c := newCanvas(300, 60, 10, 204, 1)
121	w := c.word(100, 20, 40, false)
122	c.underline(20, 280, 32)
123	c.fill(20, 10, 21, 33, 204)
124	c.fill(279, 10, 280, 33, 204)
125	require.Empty(t, scanUnder(c, w))
126}
127
128// The line must be thin.
129func TestUnderlineRejectsBlock(t *testing.T) {
130	c := newCanvas(100, 60, 10, 204, 1)
131	w := c.word(10, 20, 40, false)
132	c.fill(10, 31, 50, 36, 204)
133	require.Empty(t, scanUnder(c, w))
134}
135
136func TestUnderlinedURLIsOneTextTarget(t *testing.T) {
137	c := newCanvas(300, 60, 10, 204, 1)
138	w := c.word(10, 20, 100, false)
139	w.Text = "example.org"
140	c.underline(10, 110, 32)
141	got := scanUnder(c, w)
142	require.Len(t, got, 1)
143	require.True(t, got[0].Text)
144}
145
146// ocrTestdata reads the words of a testdata image with the real tesseract.
147func ocrTestdata(t *testing.T, name string) (*image.Gray, []OCRWord) {
148	t.Helper()
149	if _, err := exec.LookPath("tesseract"); err != nil {
150		t.Skip("tesseract not on PATH")
151	}
152	f, err := os.Open("testdata/" + name)
153	require.NoError(t, err)
154	defer f.Close()
155	img, _, err := image.Decode(f)
156	require.NoError(t, err)
157	g := image.NewGray(img.Bounds())
158	for y := 0; y < g.Rect.Dy(); y++ {
159		for x := 0; x < g.Rect.Dx(); x++ {
160			g.Set(x, y, img.At(x, y))
161		}
162	}
163	words, _, err := readWords(t.Context(), g)
164	require.NoError(t, err)
165	return g, words
166}
167
168// headingRule finds the thin horizontal rule under the heading of
169// browser.png: the first row, from y=150, with a 900 px run of non-white
170// pixels. It returns the rule as a rect.
171func headingRule(t *testing.T, g *image.Gray) image.Rectangle {
172	t.Helper()
173	for y := 150; y < 220; y++ {
174		run, start := 0, 0
175		for x := 0; x < g.Rect.Dx(); x++ {
176			if g.GrayAt(x, y).Y < 240 {
177				if run == 0 {
178					start = x
179				}
180				run++
181				if run >= 900 {
182					return image.Rect(start, y, start+run, y+1)
183				}
184			} else {
185				run = 0
186			}
187		}
188	}
189	t.Fatal("heading rule not found")
190	return image.Rectangle{}
191}
192
193// browser.png has blue links and no underlined link: the only underline-only
194// target is a known false positive, and the heading rule is not taken for an
195// underline.
196func TestUnderlineOnBrowserTestdata(t *testing.T) {
197	g, words := ocrTestdata(t, "browser.png")
198	rule := headingRule(t, g)
199	// Known false positive: the word "as" (flat glyph bottoms, ~(878,353)) is
200	// flagged underlined by the detector. Anything else is a regression.
201	var under []ports.HintTarget
202	for _, tg := range textLinks(words, 1) {
203		if !tg.Text {
204			under = append(under, tg)
205		}
206	}
207	require.Len(t, under, 1, "underline-only targets: %v", under)
208	require.True(t, under[0].Rect.X > 860 && under[0].Rect.X < 900 && under[0].Rect.Y > 340 && under[0].Rect.Y < 370, "unexpected target %v", under[0].Rect)
209	for _, tg := range textLinks(words, 1) {
210		r := image.Rect(tg.Rect.X, tg.Rect.Y, tg.Rect.X+tg.Rect.W, tg.Rect.Y+tg.Rect.H)
211		require.False(t, r.Overlaps(rule), "target %v overlaps the heading rule %v", tg.Rect, rule)
212	}
213}
214
215// underline.png is a real render (Chromium) of a generic page: underlined
216// links `demo`, `source` and `ermite-runner hosts`, a table with row rules
217// and an input box, which must yield nothing.
218func TestUnderlineOnRealRender(t *testing.T) {
219	_, words := ocrTestdata(t, "underline.png")
220	near := func(r ports.Rect, x0, y0, x1, y1 int) bool {
221		const tol = 8
222		abs := func(v int) int { return max(v, -v) }
223		return abs(r.X-x0) <= tol && abs(r.Y-y0) <= tol && abs(r.X+r.W-x1) <= tol && abs(r.Y+r.H-y1) <= tol
224	}
225	var got []ports.HintTarget
226	for _, tg := range textLinks(words, 1) {
227		if !tg.Text {
228			got = append(got, tg)
229		}
230	}
231	require.Len(t, got, 3, "targets: %v", got)
232	for _, want := range [][4]int{{148, 122, 196, 137}, {306, 126, 364, 137}, {122, 165, 294, 180}} {
233		found := false
234		for _, tg := range got {
235			found = found || near(tg.Rect, want[0], want[1], want[2], want[3])
236		}
237		require.True(t, found, "no target near %v in %v", want, got)
238	}
239	// The table (y 215..335) and the input box (y 360..410) give nothing.
240	for _, tg := range textLinks(words, 1) {
241		require.False(t, tg.Rect.Y > 200 && tg.Rect.Y < 420, "unexpected target %v", tg.Rect)
242	}
243}
244
245// A single glyph with a flat bottom stroke (8, L) is not underlined text.
246func TestUnderlineIgnoresSingleGlyph(t *testing.T) {
247	c := newCanvas(100, 60, 10, 204, 1)
248	w := c.word(10, 20, 10, false)
249	w.Text = "8"
250	c.underline(10, 20, 32)
251	require.Empty(t, scanUnder(c, w))
252}

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 25

Tags 10

↑↓ moveenter openesc close