test(hints): assert underline detection on real renders
- commit
- aed7ca486b217bb4654273bf6ff237d08a6cdcab
- author
- brice · · 2026-10-19 15:57 UTC
- parent
- 73f9772
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email - refs
- feat/hint-hybrid
2 files · +77 -10
A internal/adapters/hints/testdata/underline.png +0 -0 binary file: no text diff
binary file: no text diff
M internal/adapters/hints/underline_test.go +77 -10
| @@ -143,11 +143,13 @@ func TestUnderlinedURLIsOneTextTarget(t *testing.T) { | |||
| 143 | 143 | require.True(t, got[0].Text) | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | - | func TestUnderlineOnBrowserTestdata(t *testing.T) { | |
| 146 | + | // ocrTestdata reads the words of a testdata image with the real tesseract. | |
| 147 | + | func ocrTestdata(t *testing.T, name string) (*image.Gray, []OCRWord) { | |
| 148 | + | t.Helper() | |
| 147 | 149 | if _, err := exec.LookPath("tesseract"); err != nil { | |
| 148 | 150 | t.Skip("tesseract not on PATH") | |
| 149 | 151 | } | |
| 150 | - | f, err := os.Open("testdata/browser.png") | |
| 152 | + | f, err := os.Open("testdata/" + name) | |
| 151 | 153 | require.NoError(t, err) | |
| 152 | 154 | defer f.Close() | |
| 153 | 155 | img, _, err := image.Decode(f) | |
| @@ -160,19 +162,84 @@ func TestUnderlineOnBrowserTestdata(t *testing.T) { | |||
| 160 | 162 | } | |
| 161 | 163 | words, _, err := readWords(t.Context(), g) | |
| 162 | 164 | require.NoError(t, err) | |
| 163 | - | var under, urls int | |
| 164 | - | for _, w := range words { | |
| 165 | - | if w.Underlined { | |
| 166 | - | under++ | |
| 167 | - | t.Logf("underlined: %q %v", w.Text, w.Rect) | |
| 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. | |
| 171 | + | func 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 | + | } | |
| 168 | 187 | } | |
| 169 | 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. | |
| 196 | + | func 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 | |
| 170 | 202 | for _, tg := range textLinks(words, 1) { | |
| 171 | - | if tg.Text { | |
| 172 | - | urls++ | |
| 203 | + | if !tg.Text { | |
| 204 | + | under = append(under, tg) | |
| 173 | 205 | } | |
| 174 | 206 | } | |
| 175 | - | t.Logf("words=%d underlined=%d url targets=%d total targets=%d", len(words), under, urls, len(textLinks(words, 1))) | |
| 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. | |
| 218 | + | func 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 | + | } | |
| 176 | 243 | } | |
| 177 | 244 | ||
| 178 | 245 | // A single glyph with a flat bottom stroke (8, L) is not underlined text. | |
| @@ -143,11 +143,13 @@ func TestUnderlinedURLIsOneTextTarget(t *testing.T) { | |||||
| 143 | require.True(t, got[0].Text) | 143 | require.True(t, got[0].Text) | ||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | - | func TestUnderlineOnBrowserTestdata(t *testing.T) { | 146 | + | // ocrTestdata reads the words of a testdata image with the real tesseract. |
| 147 | + | func ocrTestdata(t *testing.T, name string) (*image.Gray, []OCRWord) { | |||
| 148 | + | t.Helper() | |||
| 147 | if _, err := exec.LookPath("tesseract"); err != nil { | 149 | if _, err := exec.LookPath("tesseract"); err != nil { | ||
| 148 | t.Skip("tesseract not on PATH") | 150 | t.Skip("tesseract not on PATH") | ||
| 149 | } | 151 | } | ||
| 150 | - | f, err := os.Open("testdata/browser.png") | 152 | + | f, err := os.Open("testdata/" + name) |
| 151 | require.NoError(t, err) | 153 | require.NoError(t, err) | ||
| 152 | defer f.Close() | 154 | defer f.Close() | ||
| 153 | img, _, err := image.Decode(f) | 155 | img, _, err := image.Decode(f) | ||
| @@ -160,19 +162,84 @@ func TestUnderlineOnBrowserTestdata(t *testing.T) { | |||||
| 160 | } | 162 | } | ||
| 161 | words, _, err := readWords(t.Context(), g) | 163 | words, _, err := readWords(t.Context(), g) | ||
| 162 | require.NoError(t, err) | 164 | require.NoError(t, err) | ||
| 163 | - | var under, urls int | 165 | + | return g, words |
| 164 | - | for _, w := range words { | 166 | + | } |
| 165 | - | if w.Underlined { | 167 | + | |
| 166 | - | under++ | 168 | + | // headingRule finds the thin horizontal rule under the heading of |
| 167 | - | t.Logf("underlined: %q %v", w.Text, w.Rect) | 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. | |||
| 171 | + | func 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 | + | } | |||
| 168 | } | 187 | } | ||
| 169 | } | 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. | |||
| 196 | + | func 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 | |||
| 170 | for _, tg := range textLinks(words, 1) { | 202 | for _, tg := range textLinks(words, 1) { | ||
| 171 | - | if tg.Text { | 203 | + | if !tg.Text { |
| 172 | - | urls++ | 204 | + | under = append(under, tg) |
| 173 | } | 205 | } | ||
| 174 | } | 206 | } | ||
| 175 | - | t.Logf("words=%d underlined=%d url targets=%d total targets=%d", len(words), under, urls, len(textLinks(words, 1))) | 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. | |||
| 218 | + | func 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 | + | } | |||
| 176 | } | 243 | } | ||
| 177 | 244 | ||||
| 178 | // A single glyph with a flat bottom stroke (8, L) is not underlined text. | 245 | // A single glyph with a flat bottom stroke (8, L) is not underlined text. | ||