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