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}