Files

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)
}