158 lines
4.9 KiB
Go
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) }
|