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