Files

158 lines
4.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
// Package ocr provides local recognition without build-time OCR dependencies.
// Captures, review sessions and native input remain separate owners.
package ocr
import (
"errors"
"image"
"math"
"sort"
"unicode"
"unicode/utf8"
)
var (
ErrUnavailable = errors.New("OCR not available")
ErrInvalidImage = errors.New("invalid OCR image")
ErrInvalidOutput = errors.New("invalid OCR output")
ErrLimit = errors.New("OCR limit exceeded")
ErrRecognition = errors.New("OCR recognition failed")
)
const (
MaxPixels = 16_000_000
MaxTextBytes = 1 << 20
maxTSVBytes = 8 << 20
maxWords = 100_000
maxRows = 200_000
)
// LineID preserves the full engine hierarchy, including line numbers which
// restart in each paragraph. Result order is engine order, not screen Y/X order.
type LineID struct {
Page, Block, Paragraph, Line int
}
// Span is a half-open range of Unicode code points, never UTF-8 bytes.
// Combining marks retain their original offsets; text is not normalized.
type Span struct {
Start, End int
}
type Word struct {
Text string
Confidence float64
Bounds image.Rectangle
Span
}
type Line struct {
ID LineID
Span
WordStart, WordEnd int
}
// Result owns plain text and word rectangles in recognition-image pixels.
// An empty document is a successful recognition with no text.
type Result struct {
Text string
Width, Height int
Words []Word
Lines []Line
}
// WordAt deliberately rejects separators and end-of-document positions.
// A later activation owner must validate source freshness before clicking.
func (result Result) WordAt(offset int) (Word, bool) {
index := sort.Search(len(result.Words), func(index int) bool {
return result.Words[index].End > offset
})
if index == len(result.Words) || offset < result.Words[index].Start {
return Word{}, false
}
word := result.Words[index]
runeIndex := 0
for _, char := range word.Text {
if runeIndex == offset-word.Start {
return word, !unicode.IsSpace(char)
}
runeIndex++
}
return Word{}, false
}
// ByteOffset explicitly bridges the reader's rune offsets to Go string slices.
// The offset at the end of the document is valid.
func (result Result) ByteOffset(offset int) (int, bool) {
if offset < 0 {
return 0, false
}
runeIndex := 0
for byteIndex := range result.Text {
if runeIndex == offset {
return byteIndex, true
}
runeIndex++
}
return len(result.Text), runeIndex == offset
}
// Transform maps the content rectangle within the processed image to the
// clipped capture rectangle on screen. ImageContent accounts for padding;
// independent ratios account for rounded scaling in each axis. A clipboard
// image has HasScreenMapping=false and cannot supply screen coordinates.
// This mapping says nothing about source identity, freshness or monitor gaps.
type Transform struct {
ImageContent image.Rectangle
ScreenBounds image.Rectangle
HasScreenMapping bool
}
// ScreenRectangle rounds outward and clips padding to the actual capture.
// Invalid, empty and entirely padded rectangles have no screen mapping.
func (transform Transform) ScreenRectangle(bounds image.Rectangle) (image.Rectangle, bool) {
content, screen := transform.ImageContent, transform.ScreenBounds
if !transform.HasScreenMapping || !valid_dimensions(content.Dx(), content.Dy()) ||
content.Min.X < 0 || content.Min.Y < 0 || content.Max.X > MaxPixels || content.Max.Y > MaxPixels ||
!valid_screen_rectangle(screen) {
return image.Rectangle{}, false
}
bounds = bounds.Intersect(content)
if bounds.Empty() {
return image.Rectangle{}, false
}
mapX := func(x int) float64 {
return float64(screen.Min.X) + float64(x-content.Min.X)*float64(screen.Dx())/float64(content.Dx())
}
mapY := func(y int) float64 {
return float64(screen.Min.Y) + float64(y-content.Min.Y)*float64(screen.Dy())/float64(content.Dy())
}
mapped := image.Rect(int(math.Floor(mapX(bounds.Min.X))), int(math.Floor(mapY(bounds.Min.Y))),
int(math.Ceil(mapX(bounds.Max.X))), int(math.Ceil(mapY(bounds.Max.Y))))
mapped = mapped.Intersect(screen)
return mapped, !mapped.Empty()
}
func valid_screen_rectangle(bounds image.Rectangle) bool {
return bounds.Min.X > math.MinInt32 && bounds.Min.Y > math.MinInt32 &&
bounds.Max.X <= math.MaxInt32 && bounds.Max.Y <= math.MaxInt32 &&
bounds.Max.X > bounds.Min.X && bounds.Max.Y > bounds.Min.Y &&
int64(bounds.Max.X)-int64(bounds.Min.X) <= MaxPixels &&
int64(bounds.Max.Y)-int64(bounds.Min.Y) <= MaxPixels
}
func valid_dimensions(width, height int) bool {
return width > 0 && height > 0 && height <= MaxPixels && width <= MaxPixels/height
}
func valid_word_bounds(bounds image.Rectangle, width, height int) bool {
return bounds.Min.X >= 0 && bounds.Min.Y >= 0 && !bounds.Empty() &&
bounds.Max.X <= width && bounds.Max.Y <= height
}
func rune_length(text string) int { return utf8.RuneCountInString(text) }