Files

283 lines
8.5 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"context"
"errors"
"sort"
"strings"
"unicode/utf8"
)
var errReviewViewportScanLimit = errors.New("review viewport scan limit")
type reviewSpanSource interface {
bounds(context.Context, int, int) (Rectangle, error)
text_span(context.Context, int, int) (string, error)
}
type reviewPointSource interface {
reviewSpanSource
offset_at_point(context.Context, int32, int32) (int, error)
}
// Walk visible glyph rectangles rather than logical offsets. Logical offsets
// need not increase left-to-right (bidi), and a single line can be enormous.
// Check every intervening pixel when a provider cannot identify a glyph. A
// finite query budget rejects sparse or defective providers without omissions.
func point_review_viewport(ctx context.Context, source reviewPointSource, start, end int, clip Rectangle) ([]TextRange, error) {
if end <= start {
return nil, nil
}
first, err := review_endpoint_bounds(ctx, source, start, end, false)
if err != nil {
return nil, err
}
last, err := review_endpoint_bounds(ctx, source, start, end, true)
if err != nil {
return nil, err
}
top := max(int64(clip.Y), int64(first.Y))
bottom := min(int64(clip.Y)+int64(clip.Height), int64(last.Y)+int64(last.Height))
right := int64(clip.X) + int64(clip.Width)
offsets := make(map[int]Rectangle)
queries := 0
for y := top; y < bottom; {
nextRow := bottom
found := false
rowCovered := true
for x := int64(clip.X); x < right; {
if err := ctx.Err(); err != nil {
return nil, err
}
if queries >= 4096 {
return nil, errReviewViewportScanLimit
}
queries++
offset, err := source.offset_at_point(ctx, int32(x), int32(y))
if err != nil {
return nil, err
}
if offset < start || offset >= end {
rowCovered = false
x++
continue
}
bounds, known := offsets[offset]
if !known {
bounds, err = source.bounds(ctx, offset, offset+1)
if err != nil {
return nil, err
}
}
if !bounds.Valid() || x < int64(bounds.X) || x >= int64(bounds.X)+int64(bounds.Width) || y < int64(bounds.Y) || y >= int64(bounds.Y)+int64(bounds.Height) {
rowCovered = false
x++
continue
}
offsets[offset] = bounds
found = true
nextRow = min(nextRow, int64(bounds.Y)+int64(bounds.Height))
x = min(right, int64(bounds.X)+int64(bounds.Width))
}
if found && rowCovered {
y = nextRow
} else {
y++
}
}
ordered := make([]int, 0, len(offsets))
for offset := range offsets {
ordered = append(ordered, offset)
}
sort.Ints(ordered)
var ranges []TextRange
for index := 0; index < len(ordered); {
if len(ranges) >= maxReviewRanges {
return nil, errors.New("review viewport range limit")
}
first := ordered[index]
bounds := offsets[first]
next := index + 1
for next < len(ordered) && ordered[next] == ordered[next-1]+1 && next-index < maxReviewBoundsCharacters && offsets[ordered[next]].Y == bounds.Y && offsets[ordered[next]].Height == bounds.Height {
next++
}
last := ordered[next-1] + 1
content, err := source.text_span(ctx, first, last)
if err != nil {
return nil, err
}
// A mark or joiner may have no point of its own. Retain a bounded
// continuation of a visible base without including hidden letters.
limit := min(end, last+maxReviewBoundsCharacters)
if next < len(ordered) {
limit = min(limit, ordered[next])
}
if last < limit {
tail, err := source.text_span(ctx, last, limit)
if err != nil {
return nil, err
}
for _, character := range tail {
if !review_cluster_continuation(character) {
break
}
bounds, err := source.bounds(ctx, last, last+1)
if err != nil {
return nil, err
}
if _, visible := bounds.Intersection(clip); bounds.Valid() && !visible {
break
}
content += string(character)
last++
}
if last == limit && limit < end && (next == len(ordered) || limit < ordered[next]) {
following, err := source.text_span(ctx, last, last+1)
if err != nil {
return nil, err
}
for _, character := range following {
if review_cluster_continuation(character) {
return nil, errReviewViewportScanLimit
}
}
}
}
ranges = append(ranges, TextRange{Start: first, End: last, Text: content})
index = next
}
return ranges, ctx.Err()
}
// Trailing newlines and formatting characters can have no glyph rectangle.
// Look only within a bounded edge block, rather than discarding a long line
// or scanning arbitrary offscreen text to find an endpoint.
func review_endpoint_bounds(ctx context.Context, source reviewSpanSource, start, end int, backwards bool) (Rectangle, error) {
for index := 0; index < min(end-start, maxReviewBoundsCharacters); index++ {
if err := ctx.Err(); err != nil {
return Rectangle{}, err
}
offset := start + index
if backwards {
offset = end - index - 1
}
bounds, err := source.bounds(ctx, offset, offset+1)
if err != nil {
return Rectangle{}, err
}
if bounds.Valid() {
return bounds, nil
}
}
return Rectangle{}, errors.New("unavailable review viewport endpoint geometry")
}
// Line rectangles can be usable when single-character extents are missing.
// Move by complete line boundaries on each binary-search step; never scan
// scrollback or accept a non-progressing/provider-clamped line as a match.
func visible_line_offsets(ctx context.Context, source reviewTextSource, count int, clip Rectangle) (int, int, error) {
if !clip.Valid() {
return 0, 0, errors.New("invalid review text clip")
}
search := func(bottom bool) (int, error) {
low, high := 0, count
for low < high {
if err := ctx.Err(); err != nil {
return 0, err
}
middle := low + (high-low)/2
line, err := source.line(ctx, middle)
if err != nil {
return 0, err
}
if line.Start < 0 || line.Start > middle || line.End <= middle || line.End > count || line.End-line.Start > maxReviewLineCharacters {
return 0, errors.New("invalid review viewport line offsets")
}
bounds, err := source.bounds(ctx, line.Start, line.End)
if err != nil {
return 0, err
}
if !bounds.Valid() {
return 0, errors.New("invalid review line bounds")
}
after := int64(bounds.Y)+int64(bounds.Height) > int64(clip.Y)
if bottom {
after = int64(bounds.Y) >= int64(clip.Y)+int64(clip.Height)
}
if after {
high = max(low, line.Start)
} else {
low = min(high, line.End)
}
}
return low, nil
}
start, err := search(false)
if err != nil || start == count {
return start, start, err
}
end, err := search(true)
if err == nil && end < start {
err = errors.New("invalid review line viewport order")
}
return start, end, err
}
func capture_review_viewport(ctx context.Context, source reviewTextSource, count int, clip Rectangle, reason TextCaptureIssue) (VisibleText, error) {
if spatial, ok := source.(reviewViewportSource); ok {
ranges, err := spatial.viewport(ctx, 0, count, clip)
if ctx.Err() != nil {
return VisibleText{}, ctx.Err()
}
if err == nil && len(ranges) > 0 {
return capture_review_lines(ctx, source, count, 0, count, clip, ranges)
}
if errors.Is(err, errReviewViewportScanLimit) {
reason = TextViewportScanLimit
}
}
return VisibleText{Limited: true, Issue: reason}, ctx.Err()
}
// Validate all spatial output before accepting any of it. Native offsets can
// be large, but the amount of copied text and the number of spans cannot.
func validate_viewport_ranges(ranges []TextRange, count int) error {
if len(ranges) > maxReviewRanges {
return errors.New("review viewport range limit")
}
previous, characters := 0, 0
for _, span := range ranges {
if span.Start < previous || span.End <= span.Start || span.End > count || !utf8.ValidString(span.Text) || utf8.RuneCountInString(span.Text) != span.End-span.Start {
return errors.New("invalid review viewport text")
}
characters += span.End - span.Start
if characters > maxReviewCharacters {
return errReviewLineTooLong
}
previous = span.End
}
return nil
}
// Provider spatial ranges need not stop at line breaks. Split without making
// another whole-line native request; wrapping is handled by glyph geometry.
func split_viewport_lines(ranges []TextRange) []TextRange {
var lines []TextRange
for _, span := range ranges {
start := span.Start
for _, content := range strings.SplitAfter(span.Text, "\n") {
if content == "" {
continue
}
end := start + utf8.RuneCountInString(content)
lines = append(lines, TextRange{Start: start, End: end, Text: content})
start = end
}
}
return lines
}