139 lines
4.2 KiB
Go
139 lines
4.2 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package ocr
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const tsvHeader = "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext"
|
|
|
|
// ParseTSV assembles text and coordinates from a single Tesseract TSV stream.
|
|
// Malformed output is rejected atomically, with no recognized text in errors.
|
|
// The supplied dimensions must describe the actual recognition image.
|
|
func ParseTSV(input io.Reader, width, height int) (Result, error) {
|
|
if !valid_dimensions(width, height) {
|
|
return Result{}, ErrInvalidImage
|
|
}
|
|
limited := &io.LimitedReader{R: input, N: maxTSVBytes + 1}
|
|
scanner := bufio.NewScanner(limited)
|
|
scanner.Buffer(make([]byte, 4096), 64<<10)
|
|
if !scanner.Scan() || scanner.Text() != tsvHeader {
|
|
if errors.Is(scanner.Err(), bufio.ErrTooLong) {
|
|
return Result{}, ErrLimit
|
|
}
|
|
return Result{}, fmt.Errorf("%w: TSV header", ErrInvalidOutput)
|
|
}
|
|
result := Result{Width: width, Height: height}
|
|
var text strings.Builder
|
|
closedLines := make(map[LineID]bool)
|
|
offset, rows := 0, 0
|
|
for scanner.Scan() {
|
|
rows++
|
|
if rows > maxRows || limited.N <= 0 {
|
|
return Result{}, ErrLimit
|
|
}
|
|
fields := strings.SplitN(scanner.Text(), "\t", 12)
|
|
if len(fields) != 12 {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
var numbers [10]int
|
|
for index := range numbers {
|
|
number, err := strconv.ParseInt(fields[index], 10, 32)
|
|
if err != nil || number < 0 {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
numbers[index] = int(number)
|
|
}
|
|
level := numbers[0]
|
|
if level < 1 || level > 5 || numbers[1] < 1 {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
for index := 2; index <= 5; index++ {
|
|
if (index <= level && numbers[index] < 1) || (index > level && numbers[index] != 0) {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
}
|
|
confidence, err := strconv.ParseFloat(fields[10], 64)
|
|
if err != nil || math.IsNaN(confidence) || math.IsInf(confidence, 0) || confidence < -1 || confidence > 100 {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
// Subtraction avoids overflowing 32-bit additions on ARM builds.
|
|
left, top, boxWidth, boxHeight := numbers[6], numbers[7], numbers[8], numbers[9]
|
|
if left > width || top > height || boxWidth > width-left || boxHeight > height-top {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
wordText := fields[11]
|
|
if !utf8.ValidString(wordText) || strings.ContainsFunc(wordText, unicode.IsControl) {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
if level != 5 {
|
|
if wordText != "" || confidence != -1 {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
continue
|
|
}
|
|
if strings.TrimSpace(wordText) == "" {
|
|
continue
|
|
}
|
|
bounds := image.Rect(left, top, left+boxWidth, top+boxHeight)
|
|
if !valid_word_bounds(bounds, width, height) || strings.TrimSpace(wordText) != wordText {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
separatorBytes := 0
|
|
if len(result.Words) != 0 {
|
|
separatorBytes = 1
|
|
}
|
|
if len(result.Words) >= maxWords || text.Len()+len(wordText)+separatorBytes > MaxTextBytes {
|
|
return Result{}, ErrLimit
|
|
}
|
|
id := LineID{numbers[1], numbers[2], numbers[3], numbers[4]}
|
|
if len(result.Lines) == 0 || result.Lines[len(result.Lines)-1].ID != id {
|
|
if closedLines[id] {
|
|
return Result{}, invalid_row(rows)
|
|
}
|
|
if len(result.Lines) != 0 {
|
|
closedLines[result.Lines[len(result.Lines)-1].ID] = true
|
|
text.WriteByte('\n')
|
|
offset++
|
|
}
|
|
result.Lines = append(result.Lines, Line{ID: id, Span: Span{Start: offset}, WordStart: len(result.Words)})
|
|
} else {
|
|
text.WriteByte(' ')
|
|
offset++
|
|
}
|
|
start := offset
|
|
text.WriteString(wordText)
|
|
offset += rune_length(wordText)
|
|
result.Words = append(result.Words, Word{wordText, confidence, bounds, Span{start, offset}})
|
|
line := &result.Lines[len(result.Lines)-1]
|
|
line.End, line.WordEnd = offset, len(result.Words)
|
|
}
|
|
if limited.N <= 0 {
|
|
return Result{}, ErrLimit
|
|
}
|
|
if scanner.Err() != nil {
|
|
if errors.Is(scanner.Err(), bufio.ErrTooLong) {
|
|
return Result{}, ErrLimit
|
|
}
|
|
return Result{}, fmt.Errorf("%w: reading TSV", ErrInvalidOutput)
|
|
}
|
|
result.Text = text.String()
|
|
return result, nil
|
|
}
|
|
|
|
func invalid_row(row int) error {
|
|
return fmt.Errorf("%w: TSV row %d", ErrInvalidOutput, row)
|
|
}
|