283 lines
8.5 KiB
Go
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
|
|
}
|