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