# Orca
#
# Copyright 2024 Igalia, S.L.
# Copyright 2024 GNOME Foundation Inc.
# Author: Joanmarie Diggs <[email protected]>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
# pylint: disable=broad-exception-caught
# pylint: disable=wrong-import-position
# pylint: disable=too-many-locals
# pylint: disable=too-many-public-methods
# pylint: disable=too-many-branches
# pylint: disable=too-many-lines
# pylint: disable=duplicate-code
"""Utilities for obtaining information about accessible text."""
__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2024 Igalia, S.L." \
"Copyright (c) 2024 GNOME Foundation Inc."
__license__ = "LGPL"
import enum
import locale
import re
from typing import Generator, Optional
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
from . import colornames
from . import debug
from . import messages
from . import settings
from . import text_attribute_names
from .ax_object import AXObject
from .ax_utilities_role import AXUtilitiesRole
from .ax_utilities_state import AXUtilitiesState
class AXTextAttribute(enum.Enum):
"""Enum representing an accessible text attribute."""
# Note: Anything added here should also have an entry in text_attribute_names.py.
# The tuple is (non-localized name, enable by default).
BG_COLOR = ("bg-color", True)
BG_FULL_HEIGHT = ("bg-full-height", False)
BG_STIPPLE = ("bg-stipple", False)
DIRECTION = ("direction", False)
EDITABLE = ("editable", False)
FAMILY_NAME = ("family-name", True)
FG_COLOR = ("fg-color", True)
FG_STIPPLE = ("fg-stipple", False)
FONT_EFFECT = ("font-effect", True)
INDENT = ("indent", True)
INVALID = ("invalid", True)
INVISIBLE = ("invisible", False)
JUSTIFICATION = ("justification", True)
LANGUAGE = ("language", False)
LEFT_MARGIN = ("left-margin", False)
LINE_HEIGHT = ("line-height", False)
PARAGRAPH_STYLE = ("paragraph-style", True)
PIXELS_ABOVE_LINES = ("pixels-above-lines", False)
PIXELS_BELOW_LINES = ("pixels-below-lines", False)
PIXELS_INSIDE_WRAP = ("pixels-inside-wrap", False)
RIGHT_MARGIN = ("right-margin", False)
RISE = ("rise", False)
SCALE = ("scale", False)
SIZE = ("size", True)
STRETCH = ("stretch", False)
STRIKETHROUGH = ("strikethrough", True)
STYLE = ("style", True)
TEXT_DECORATION = ("text-decoration", True)
TEXT_POSITION = ("text-position", False)
TEXT_ROTATION = ("text-rotation", True)
TEXT_SHADOW = ("text-shadow", False)
UNDERLINE = ("underline", True)
VARIANT = ("variant", False)
VERTICAL_ALIGN = ("vertical-align", False)
WEIGHT = ("weight", True)
WRAP_MODE = ("wrap-mode", False)
WRITING_MODE = ("writing-mode", False)
@classmethod
def from_string(cls, string: str) -> Optional["AXTextAttribute"]:
"""Returns the AXTextAttribute for the specified string."""
for attribute in cls:
if attribute.get_attribute_name() == string:
return attribute
return None
@classmethod
def from_localized_string(cls, string: str) -> Optional["AXTextAttribute"]:
"""Returns the AXTextAttribute for the specified localized string."""
for attribute in cls:
if attribute.get_localized_name() == string:
return attribute
return None
def get_attribute_name(self) -> str:
"""Returns the non-localized name of the attribute."""
return self.value[0]
def get_localized_name(self) -> str:
"""Returns the localized name of the attribute."""
name = self.value[0]
return text_attribute_names.attribute_names.get(name, name)
def get_localized_value(self, value) -> str:
"""Returns the localized value of the attribute."""
if value is None:
return ""
if value.endswith("px"):
value = value.split("px")[0]
if locale.localeconv()["decimal_point"] in value:
return messages.pixelCount(float(value))
return messages.pixelCount(int(value))
if self in [AXTextAttribute.BG_COLOR, AXTextAttribute.FG_COLOR]:
if settings.useColorNames:
return colornames.rgb_string_to_color_name(value)
return colornames.normalize_rgb_string(value)
# TODO - JD: Is this still needed?
value = value.replace("-moz", "")
# TODO - JD: Are these still needed?
if self == AXTextAttribute.JUSTIFICATION:
value = value.replace("justify", "fill")
elif self == AXTextAttribute.FAMILY_NAME:
value = value.split(",")[0].strip().strip('"')
return text_attribute_names.attribute_values.get(value, value)
def should_present_by_default(self) -> bool:
"""Returns True if the attribute should be presented by default."""
return self.value[1]
def value_is_default(self, value) -> bool:
"""Returns True if value should be treated as the default value for this attribute."""
null_values = ["0", "0mm", "0px", "none", "false", "normal", "", None]
if value in null_values:
return True
if self == AXTextAttribute.SCALE:
return float(value) == 1.0
if self == AXTextAttribute.TEXT_POSITION:
return value == "baseline"
if self == AXTextAttribute.WEIGHT:
return value == "400"
if self == AXTextAttribute.LANGUAGE:
loc = locale.getlocale()[0] or ""
return value == loc[:2]
return False
class AXText:
"""Utilities for obtaining information about accessible text."""
CACHED_TEXT_SELECTION: dict[int, tuple[str, int, int]] = {}
@staticmethod
def get_character_at_offset(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the character, start, and end for the current or specified offset."""
length = AXText.get_character_count(obj)
if not length:
return "", 0, 0
if offset is None:
offset = AXText.get_caret_offset(obj)
if not 0 <= offset <= length:
msg = f"WARNING: Offset {offset} is not valid. No character can be provided."
debug.print_message(debug.LEVEL_INFO, msg, True)
return "", 0, 0
try:
result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.CHAR)
except Exception as error:
try:
result = Atspi.Text.get_text_at_offset(obj, offset, Atspi.TextBoundaryType.CHAR)
except Exception as error2:
msg = f"AXText: Exception in get_character_at_offset: {error2}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return "", 0, 0
# https://gitlab.gnome.org/GNOME/at-spi2-core/-/issues/161
msg = f"WARNING: String at offset failed; text at offset succeeded: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
debug_string = result.content.replace("\n", "\\n")
tokens = [f"AXText: Character at offset {offset} in", obj,
f"'{debug_string}' ({result.start_offset}-{result.end_offset})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result.content, result.start_offset, result.end_offset
@staticmethod
def get_character_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]:
"""Returns the character, start, and end at the specified point."""
offset = AXText.get_offset_at_point(obj, x, y)
if not 0 <= offset < AXText.get_character_count(obj):
return "", 0, 0
return AXText.get_character_at_offset(obj, offset)
@staticmethod
def iter_character(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by character in obj starting with the character at offset."""
if offset is None:
offset = AXText.get_caret_offset(obj)
last_result = None
length = AXText.get_character_count(obj)
while offset < length:
character, start, end = AXText.get_character_at_offset(obj, offset)
if last_result is None and not character:
return
if character and (character, start, end) != last_result:
yield character, start, end
offset = max(end, offset + 1)
last_result = character, start, end
@staticmethod
def get_word_at_offset(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the word, start, and end for the current or specified offset."""
length = AXText.get_character_count(obj)
if not length:
return "", 0, 0
if offset is None:
offset = AXText.get_caret_offset(obj)
offset = min(max(0, offset), length - 1)
try:
result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.WORD)
except Exception as error:
try:
result = Atspi.Text.get_text_at_offset(
obj, offset, Atspi.TextBoundaryType.WORD_START)
except Exception as error2:
msg = f"AXText: Exception in get_word_at_offset: {error2}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return "", 0, 0
# https://gitlab.gnome.org/GNOME/at-spi2-core/-/issues/161
msg = f"WARNING: String at offset failed; text at offset succeeded: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
tokens = [f"AXText: Word at offset {offset} in", obj,
f"'{result.content}' ({result.start_offset}-{result.end_offset})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result.content, result.start_offset, result.end_offset
@staticmethod
def get_word_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]:
"""Returns the word, start, and end at the specified point."""
offset = AXText.get_offset_at_point(obj, x, y)
if not 0 <= offset < AXText.get_character_count(obj):
return "", 0, 0
return AXText.get_word_at_offset(obj, offset)
@staticmethod
def iter_word(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by word in obj starting with the word at offset."""
if offset is None:
offset = AXText.get_caret_offset(obj)
last_result = None
length = AXText.get_character_count(obj)
while offset < length:
word, start, end = AXText.get_word_at_offset(obj, offset)
if last_result is None and not word:
return
if word and (word, start, end) != last_result:
yield word, start, end
offset = max(end, offset + 1)
last_result = word, start, end
@staticmethod
def get_line_at_offset(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the line, start, and end for the current or specified offset."""
length = AXText.get_character_count(obj)
if not length:
return "", 0, 0
if offset is None:
offset = AXText.get_caret_offset(obj)
# Don't adjust the length in multiline text because we want to say "blank" at the end.
# This may or may not be sufficient. GTK3 seems to give us the correct, empty line. But
# (at least) Chromium does not. See comment below.
if not AXUtilitiesState.is_multi_line(obj) \
and not AXUtilitiesRole.is_paragraph(obj) and not AXUtilitiesRole.is_section(obj):
offset = min(max(0, offset), length - 1)
else:
offset = max(0, offset)
try:
result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.LINE)
except Exception as error:
try:
result = Atspi.Text.get_text_at_offset(
obj, offset, Atspi.TextBoundaryType.LINE_START)
except Exception as error2:
msg = f"AXText: Exception in get_line_at_offset: {error2}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return "", 0, 0
# https://gitlab.gnome.org/GNOME/at-spi2-core/-/issues/161
msg = f"WARNING: String at offset failed; text at offset succeeded: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
else:
# Try again, e.g. Chromium returns "", -1, -1.
if result.start_offset == result.end_offset == -1 and offset == length:
offset -= 1
result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.LINE)
debug_string = result.content.replace("\n", "\\n")
tokens = [f"AXText: Line at offset {offset} in", obj,
f"'{debug_string}' ({result.start_offset}-{result.end_offset})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result.content, result.start_offset, result.end_offset
@staticmethod
def get_line_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]:
"""Returns the line, start, and end at the specified point."""
offset = AXText.get_offset_at_point(obj, x, y)
if not 0 <= offset < AXText.get_character_count(obj):
return "", 0, 0
return AXText.get_line_at_offset(obj, offset)
@staticmethod
def iter_line(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by line in obj starting with the line at offset."""
if offset is None:
offset = AXText.get_caret_offset(obj)
last_result = None
length = AXText.get_character_count(obj)
while offset < length:
line, start, end = AXText.get_line_at_offset(obj, offset)
if last_result is None and not line:
return
if line and (line, start, end) != last_result:
yield line, start, end
offset = max(end, offset + 1)
last_result = line, start, end
@staticmethod
def get_sentence_at_offset(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the sentence, start, and end for the current or specified offset."""
length = AXText.get_character_count(obj)
if not length:
return "", 0, 0
if offset is None:
offset = AXText.get_caret_offset(obj)
offset = min(max(0, offset), length - 1)
try:
result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.SENTENCE)
except Exception as error:
try:
result = Atspi.Text.get_text_at_offset(
obj, offset, Atspi.TextBoundaryType.SENTENCE_START)
except Exception as error2:
msg = f"AXText: Exception in get_sentence_at_offset: {error2}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return "", 0, 0
# https://gitlab.gnome.org/GNOME/at-spi2-core/-/issues/161
msg = f"WARNING: String at offset failed; text at offset succeeded: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
tokens = [f"AXText: Sentence at offset {offset} in", obj,
f"'{result.content}' ({result.start_offset}-{result.end_offset})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result.content, result.start_offset, result.end_offset
@staticmethod
def get_sentence_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]:
"""Returns the sentence, start, and end at the specified point."""
offset = AXText.get_offset_at_point(obj, x, y)
if not 0 <= offset < AXText.get_character_count(obj):
return "", 0, 0
return AXText.get_sentence_at_offset(obj, offset)
@staticmethod
def iter_sentence(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by sentence in obj starting with the sentence at offset."""
if offset is None:
offset = AXText.get_caret_offset(obj)
last_result = None
length = AXText.get_character_count(obj)
while offset < length:
sentence, start, end = AXText.get_sentence_at_offset(obj, offset)
if last_result is None and not sentence:
return
if sentence and (sentence, start, end) != last_result:
yield sentence, start, end
offset = max(end, offset + 1)
last_result = sentence, start, end
@staticmethod
def supports_sentence_iteration(obj: Atspi.Accessible) -> bool:
"""Returns True if sentence iteration is supported on obj."""
if not AXObject.supports_text(obj):
return False
string, start, end = AXText.get_sentence_at_offset(obj, 0)
result = string and 0 <= start < end
tokens = ["AXText: Sentence iteration supported on", obj, f": {result}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return bool(result)
@staticmethod
def get_paragraph_at_offset(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> tuple[str, int, int]:
"""Returns the paragraph, start, and end for the current or specified offset."""
length = AXText.get_character_count(obj)
if not length:
return "", 0, 0
if offset is None:
offset = AXText.get_caret_offset(obj)
offset = min(max(0, offset), length - 1)
try:
result = Atspi.Text.get_string_at_offset(obj, offset, Atspi.TextGranularity.PARAGRAPH)
except Exception as error:
msg = f"AXText: Exception in get_paragraph_at_offset: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return "", 0, 0
tokens = [f"AXText: Paragraph at offset {offset} in", obj,
f"'{result.content}' ({result.start_offset}-{result.end_offset})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result.content, result.start_offset, result.end_offset
@staticmethod
def get_paragraph_at_point(obj: Atspi.Accessible, x: int, y: int) -> tuple[str, int, int]:
"""Returns the paragraph, start, and end at the specified point."""
offset = AXText.get_offset_at_point(obj, x, y)
if not 0 <= offset < AXText.get_character_count(obj):
return "", 0, 0
return AXText.get_paragraph_at_offset(obj, offset)
@staticmethod
def iter_paragraph(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> Generator[tuple[str, int, int], None, None]:
"""Generator to iterate by paragraph in obj starting with the paragraph at offset."""
if offset is None:
offset = AXText.get_caret_offset(obj)
last_result = None
length = AXText.get_character_count(obj)
while offset < length:
paragraph, start, end = AXText.get_paragraph_at_offset(obj, offset)
if last_result is None and not paragraph:
return
if paragraph and (paragraph, start, end) != last_result:
yield paragraph, start, end
offset = max(end, offset + 1)
last_result = paragraph, start, end
@staticmethod
def supports_paragraph_iteration(obj: Atspi.Accessible) -> bool:
"""Returns True if paragraph iteration is supported on obj."""
if not AXObject.supports_text(obj):
return False
string, start, end = AXText.get_paragraph_at_offset(obj, 0)
result = string and 0 <= start < end
tokens = ["AXText: Paragraph iteration supported on", obj, f": {result}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return bool(result)
@staticmethod
def get_character_count(obj: Atspi.Accessible) -> int:
"""Returns the character count of obj."""
if not AXObject.supports_text(obj):
return 0
try:
count = Atspi.Text.get_character_count(obj)
except Exception as error:
msg = f"AXText: Exception in get_character_count: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return 0
tokens = ["AXText:", obj, f"reports {count} characters."]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return count
@staticmethod
def get_caret_offset(obj: Atspi.Accessible) -> int:
"""Returns the caret offset of obj."""
if not AXObject.supports_text(obj):
return -1
try:
offset = Atspi.Text.get_caret_offset(obj)
except Exception as error:
msg = f"AXText: Exception in get_caret_offset: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return -1
tokens = ["AXText:", obj, f"reports caret offset of {offset}."]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return offset
@staticmethod
def set_caret_offset(obj: Atspi.Accessible, offset: int) -> bool:
"""Returns False if we definitely failed to set the offset. True cannot be trusted."""
if not AXObject.supports_text(obj):
return False
try:
result = Atspi.Text.set_caret_offset(obj, offset)
except Exception as error:
msg = f"AXText: Exception in set_caret_offset: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return False
tokens = [f"AXText: Reported result of setting offset to {offset} in", obj, f": {result}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result
@staticmethod
def set_caret_offset_to_start(obj: Atspi.Accessible) -> bool:
"""Returns False if we definitely failed to set the offset. True cannot be trusted."""
return AXText.set_caret_offset(obj, 0)
@staticmethod
def set_caret_offset_to_end(obj: Atspi.Accessible) -> bool:
"""Returns False if we definitely failed to set the offset. True cannot be trusted."""
return AXText.set_caret_offset(obj, AXText.get_character_count(obj))
@staticmethod
def get_substring(obj: Atspi.Accessible, start_offset: int, end_offset: int) -> str:
"""Returns the text of obj within the specified offsets."""
if not AXObject.supports_text(obj):
return ""
if end_offset == -1:
end_offset = AXText.get_character_count(obj)
try:
result = Atspi.Text.get_text(obj, start_offset, end_offset)
except Exception as error:
msg = f"AXText: Exception in get_substring: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True, True)
return ""
debug_string = result.replace("\n", "\\n")
tokens = ["AXText: Text of", obj, f"({start_offset}-{end_offset}): '{debug_string}'"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result
@staticmethod
def get_all_text(obj: Atspi.Accessible) -> str:
"""Returns the text content of obj."""
length = AXText.get_character_count(obj)
if not length:
return ""
try:
result = Atspi.Text.get_text(obj, 0, length)
except Exception as error:
msg = f"AXText: Exception in get_all_text: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return ""
words = result.split()
if len(words) > 10:
debug_string = f"{' '.join(words[:5])} ... {' '.join(words[-5:])}"
else:
debug_string = result
debug_string = debug_string.replace("\n", "\\n")
tokens = ["AXText: Text of", obj, f"'{debug_string}'"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result
@staticmethod
def _get_n_selections(obj: Atspi.Accessible) -> int:
"""Returns the number of reported selected substrings in obj."""
if not AXObject.supports_text(obj):
return 0
try:
result = Atspi.Text.get_n_selections(obj)
except Exception as error:
msg = f"AXText: Exception in _get_n_selections: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return 0
tokens = ["AXText:", obj, f"reports {result} selection(s)."]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result
@staticmethod
def _remove_selection(obj: Atspi.Accessible, selection_number: int) -> None:
"""Attempts to remove the specified selection."""
if not AXObject.supports_text(obj):
return
try:
Atspi.Text.remove_selection(obj, selection_number)
except Exception as error:
msg = f"AXText: Exception in _remove_selection: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return
@staticmethod
def has_selected_text(obj: Atspi.Accessible) -> bool:
"""Returns True if obj has selected text."""
return bool(AXText.get_selected_ranges(obj))
@staticmethod
def is_all_text_selected(obj: Atspi.Accessible) -> bool:
"""Returns True of all the text in obj is selected."""
length = AXText.get_character_count(obj)
if not length:
return False
ranges = AXText.get_selected_ranges(obj)
if not ranges:
return False
return ranges[0][0] == 0 and ranges[-1][1] == length
@staticmethod
def clear_all_selected_text(obj: Atspi.Accessible) -> None:
"""Attempts to clear the selected text."""
for i in range(AXText._get_n_selections(obj)):
AXText._remove_selection(obj, i)
@staticmethod
def get_selection_start_offset(obj: Atspi.Accessible) -> int:
"""Returns the leftmost offset of the selected text."""
ranges = AXText.get_selected_ranges(obj)
if ranges:
return ranges[0][0]
return -1
@staticmethod
def get_selection_end_offset(obj: Atspi.Accessible) -> int:
"""Returns the rightmost offset of the selected text."""
ranges = AXText.get_selected_ranges(obj)
if ranges:
return ranges[-1][1]
return -1
@staticmethod
def get_selected_ranges(obj: Atspi.Accessible) -> list[tuple[int, int]]:
"""Returns a list of (start_offset, end_offset) tuples reflecting the selected text."""
count = AXText._get_n_selections(obj)
if not count:
return []
selections = []
for i in range(count):
try:
result = Atspi.Text.get_selection(obj, i)
except Exception as error:
msg = f"AXText: Exception in get_selected_ranges: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
break
if 0 <= result.start_offset < result.end_offset:
selections.append((result.start_offset, result.end_offset))
tokens = ["AXText:", obj, f"reports selected ranges: {selections}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return selections
@staticmethod
def get_cached_selected_text(obj: Atspi.Accessible) -> tuple[str, int, int]:
"""Returns the last known selected string, start, and end for obj."""
string, start, end = AXText.CACHED_TEXT_SELECTION.get(hash(obj), ("", 0, 0))
debug_string = string.replace("\n", "\\n")
tokens = ["AXText: Cached selection for", obj, f"is '{debug_string}' ({start}, {end})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return string, start, end
@staticmethod
def update_cached_selected_text(obj: Atspi.Accessible) -> None:
"""Updates the last known selected string, start, and end for obj."""
AXText.CACHED_TEXT_SELECTION[hash(obj)] = AXText.get_selected_text(obj)
@staticmethod
def get_selected_text(obj: Atspi.Accessible) -> tuple[str, int, int]:
"""Returns the selected string, start, and end for obj."""
selections = AXText.get_selected_ranges(obj)
if not selections:
return "", 0, 0
strings = []
start_offset = -1
end_offset = -1
for selection in sorted(set(selections)):
strings.append(AXText.get_substring(obj, *selection))
end_offset = selection[1]
if start_offset == -1:
start_offset = selection[0]
text = " ".join(strings)
words = text.split()
if len(text) > 10:
debug_string = f"{' '.join(words[:5])} ... {' '.join(words[-5:])}"
else:
debug_string = text
tokens = ["AXText: Selected text of", obj,
f"'{debug_string}' ({start_offset}-{end_offset})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return text, start_offset, end_offset
@staticmethod
def _add_new_selection(obj: Atspi.Accessible, start_offset: int, end_offset: int) -> bool:
"""Creates a new selection for the specified range in obj."""
if not AXObject.supports_text(obj):
return False
try:
result = Atspi.Text.add_selection(obj, start_offset, end_offset)
except Exception as error:
msg = f"AXText: Exception in _add_selection: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return False
return result
@staticmethod
def _update_existing_selection(
obj: Atspi.Accessible, start_offset: int, end_offset: int, selection_number: int = 0
) -> bool:
"""Modifies specified selection in obj to the specified range."""
if not AXObject.supports_text(obj):
return False
try:
result = Atspi.Text.set_selection(obj, selection_number, start_offset, end_offset)
except Exception as error:
msg = f"AXText: Exception in set_selected_text: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return False
return result
@staticmethod
def set_selected_text(obj: Atspi.Accessible, start_offset: int, end_offset: int) -> bool:
"""Returns False if we definitely failed to set the selection. True cannot be trusted."""
# TODO - JD: For now we always assume and operate on the first selection.
# This preserves the original functionality prior to the refactor. But whether
# that functionality is what it should be needs investigation.
if AXText._get_n_selections(obj) > 0:
result = AXText._update_existing_selection(obj, start_offset, end_offset)
else:
result = AXText._add_new_selection(obj, start_offset, end_offset)
if result and debug.LEVEL_INFO >= debug.debugLevel:
substring = AXText.get_substring(obj, start_offset, end_offset)
selection = AXText.get_selected_text(obj)[0]
if substring != selection:
msg = "AXText: Substring and selected text do not match."
debug.print_message(debug.LEVEL_INFO, msg, True)
return result
# TODO - JD: This should be converted to return AXTextAttribute values.
@staticmethod
def get_text_attributes_at_offset(
obj: Atspi.Accessible, offset: Optional[int] = None
) -> tuple[dict[str, str], int, int]:
"""Returns a (dict, start, end) tuple for attributes at offset in obj."""
if not AXObject.supports_text(obj):
return {}, 0, 0
if offset is None:
offset = AXText.get_caret_offset(obj)
try:
result = Atspi.Text.get_attribute_run(obj, offset, include_defaults=True)
except Exception as error:
msg = f"AXText: Exception in get_text_attributes_at_offset: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return {}, 0, AXText.get_character_count(obj)
tokens = ["AXText: Attributes for", obj, f"at offset {offset} : {result}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result[0] or {}, result[1] or 0, result[2] or AXText.get_character_count(obj)
# TODO - JD: This should be converted to return AXTextAttribute values.
@staticmethod
def get_all_text_attributes(
obj: Atspi.Accessible, start_offset: int = 0, end_offset: int = -1
) -> list[tuple[int, int, dict[str, str]]]:
"""Returns a list of (start, end, attrs dict) tuples for obj."""
if not AXObject.supports_text(obj):
return []
if end_offset == -1:
end_offset = AXText.get_character_count(obj)
tokens = ["AXText: Getting attributes for", obj, f"chars: {start_offset}-{end_offset}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
rv = []
offset = start_offset
while offset < end_offset:
attrs, start, end = AXText.get_text_attributes_at_offset(obj, offset)
if start <= end:
rv.append((max(start, offset), end, attrs))
else:
# TODO - JD: We're sometimes seeing this from WebKit, e.g. in Evo gitlab messages.
msg = f"AXText: Start offset {start} > end offset {end}"
debug.print_message(debug.LEVEL_INFO, msg, True)
offset = max(end, offset + 1)
msg = f"AXText: {len(rv)} attribute ranges found."
debug.print_message(debug.LEVEL_INFO, msg, True)
return rv
@staticmethod
def get_all_supported_text_attributes() -> list[AXTextAttribute]:
"""Returns a set of all supported text attribute names."""
return list(AXTextAttribute)
@staticmethod
def get_offset_at_point(obj: Atspi.Accessible, x: int, y: int) -> int:
"""Returns the character offset in obj at the specified point."""
if not AXObject.supports_text(obj):
return -1
try:
offset = Atspi.Text.get_offset_at_point(obj, x, y, Atspi.CoordType.WINDOW)
except Exception as error:
msg = f"AXText: Exception in get_offset_at_point: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return -1
tokens = ["AXText: Offset in", obj, f"at {x}, {y} is {offset}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return offset
@staticmethod
def get_character_rect(obj: Atspi.Accessible, offset: Optional[int] = None) -> Atspi.Rect:
"""Returns the Atspi rect of the character at the specified offset in obj."""
if not AXObject.supports_text(obj):
return Atspi.Rect()
if offset is None:
offset = AXText.get_caret_offset(obj)
try:
rect = Atspi.Text.get_character_extents(obj, offset, Atspi.CoordType.WINDOW)
except Exception as error:
msg = f"AXText: Exception in get_character_rect: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return Atspi.Rect()
tokens = [f"AXText: Offset {offset} in", obj, "has rect", rect]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return rect
@staticmethod
def get_range_rect(obj: Atspi.Accessible, start: int, end: int) -> Atspi.Rect:
"""Returns the Atspi rect of the string at the specified range in obj."""
if not AXObject.supports_text(obj):
return Atspi.Rect()
try:
rect = Atspi.Text.get_range_extents(obj, start, end, Atspi.CoordType.WINDOW)
except Exception as error:
tokens = ["AXText: Exception in get_range_rect for", obj, f":{ error}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return Atspi.Rect()
tokens = [f"AXText: Range {start}-{end} in", obj, "has rect", rect]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return rect
@staticmethod
def _rect_is_fully_contained_in(rect1: Atspi.Rect, rect2: Atspi.Rect) -> bool:
"""Returns true if rect1 is fully contained in rect2"""
return rect2.x <= rect1.x and rect2.y <= rect1.y \
and rect2.x + rect2.width >= rect1.x + rect1.width \
and rect2.y + rect2.height >= rect1.y + rect1.height
@staticmethod
def _line_comparison(line_rect: Atspi.Rect, clip_rect: Atspi.Rect) -> int:
"""Returns -1 (line above), 1 (line below), or 0 (line inside) clip_rect."""
# https://gitlab.gnome.org/GNOME/gtk/-/issues/6419
clip_rect.y = max(0, clip_rect.y)
if line_rect.y + line_rect.height / 2 < clip_rect.y:
return -1
if line_rect.y + line_rect.height / 2 > clip_rect.y + clip_rect.height:
return 1
return 0
@staticmethod
def get_visible_lines(
obj: Atspi.Accessible, clip_rect: Atspi.Rect
) -> list[tuple[str, int, int]]:
"""Returns a list of (string, start, end) for lines of obj inside clip_rect."""
tokens = ["AXText: Getting visible lines for", obj, "inside", clip_rect]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
line, start, end = AXText.find_first_visible_line(obj, clip_rect)
debug_string = line.replace("\n", "\\n")
tokens = ["AXText: First visible line in", obj, f"is: '{debug_string}' ({start}-{end})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
result = [(line, start, end)]
offset = end
for line, start, end in AXText.iter_line(obj, offset):
line_rect = AXText.get_range_rect(obj, start, end)
if AXText._line_comparison(line_rect, clip_rect) > 0:
break
result.append((line, start, end))
line, start, end = result[-1]
debug_string = line.replace("\n", "\\n")
tokens = ["AXText: Last visible line in", obj, f"is: '{debug_string}' ({start}-{end})"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result
@staticmethod
def find_first_visible_line(
obj: Atspi.Accessible, clip_rect: Atspi.Rect
) -> tuple[str, int, int]:
"""Returns the first (string, start, end) visible line of obj inside clip_rect."""
result = "", 0, 0
length = AXText.get_character_count(obj)
low, high = 0, length
while low < high:
mid = (low + high) // 2
line, start, end = AXText.get_line_at_offset(obj, mid)
if start == 0:
return line, start, end
if start < 0:
tokens = ["AXText: Treating invalid offset as above", clip_rect]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
low = mid + 1
continue
result = line, start, end
previous_line, previous_start, previous_end = AXText.get_line_at_offset(obj, start - 1)
if previous_start <= 0 and previous_end <= 0:
return result
text_rect = AXText.get_range_rect(obj, start, end)
if AXText._line_comparison(text_rect, clip_rect) < 0:
low = mid + 1
continue
if AXText._line_comparison(text_rect, clip_rect) > 0:
high = mid
continue
previous_rect = AXText.get_range_rect(obj, previous_start, previous_end)
if AXText._line_comparison(previous_rect, clip_rect) != 0:
return result
result = previous_line, previous_start, previous_end
high = mid
return result
@staticmethod
def find_last_visible_line(
obj: Atspi.Accessible, clip_rect: Atspi.Rect
) -> tuple[str, int, int]:
"""Returns the last (string, start, end) visible line of obj inside clip_rect."""
result = "", 0, 0
length = AXText.get_character_count(obj)
low, high = 0, length
while low < high:
mid = (low + high) // 2
line, start, end = AXText.get_line_at_offset(obj, mid)
if end >= length:
return line, start, end
if end <= 0:
tokens = ["AXText: Treating invalid offset as below", clip_rect]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
high = mid
continue
result = line, start, end
next_line, next_start, next_end = AXText.get_line_at_offset(obj, end)
if next_start <= 0 and next_end <= 0:
return result
text_rect = AXText.get_range_rect(obj, start, end)
if AXText._line_comparison(text_rect, clip_rect) < 0:
low = mid + 1
continue
if AXText._line_comparison(text_rect, clip_rect) > 0:
high = mid
continue
next_rect = AXText.get_range_rect(obj, next_start, next_end)
if AXText._line_comparison(next_rect, clip_rect) != 0:
return result
result = next_line, next_start, next_end
low = mid + 1
return result
@staticmethod
def is_word_misspelled(obj: Atspi.Accessible, offset: Optional[int] = None) -> bool:
"""Returns True if the text attributes indicate a spelling error."""
attributes = AXText.get_text_attributes_at_offset(obj, offset)[0]
if attributes.get("invalid") == "spelling":
return True
if attributes.get("text-spelling") == "misspelled":
return True
if attributes.get("underline") in ["error", "spelling"]:
return True
return False
@staticmethod
def is_eoc(character: str) -> bool:
"""Returns True if character is an embedded object character (\ufffc)."""
return character == "\ufffc"
@staticmethod
def character_at_offset_is_eoc(obj: Atspi.Accessible, offset: int) -> bool:
"""Returns True if character in obj is an embedded object character (\ufffc)."""
character, _start, _end = AXText.get_character_at_offset(obj, offset)
return AXText.is_eoc(character)
@staticmethod
def is_whitespace_or_empty(obj: Atspi.Accessible) -> bool:
"""Returns True if obj lacks text, or contains only whitespace."""
if not AXObject.supports_text(obj):
return True
return not AXText.get_all_text(obj).strip()
@staticmethod
def has_presentable_text(obj: Atspi.Accessible) -> bool:
"""Returns True if obj has presentable text."""
return bool(re.search(r"\w+", AXText.get_all_text(obj)))
@staticmethod
def scroll_substring_to_point(
obj: Atspi.Accessible, x: int, y: int,
start_offset: Optional[int] = None, end_offset: Optional[int] = None
) -> bool:
"""Attempts to scroll obj to the specified point."""
length = AXText.get_character_count(obj)
if not length:
return False
if start_offset is None:
start_offset = 0
if end_offset is None:
end_offset = length - 1
try:
result = Atspi.Text.scroll_substring_to_point(
obj, start_offset, end_offset, Atspi.CoordType.WINDOW, x, y)
except Exception as error:
msg = f"AXText: Exception in scroll_substring_to_point: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return False
tokens = ["AXText: Scrolled", obj, f"substring ({start_offset}-{end_offset}) to",
f"{x}, {y}: {result}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result
@staticmethod
def scroll_substring_to_location(
obj: Atspi.Accessible, location: Atspi.ScrollType,
start_offset: Optional[int] = None, end_offset: Optional[int] = None
) -> bool:
"""Attempts to scroll the substring to the specified Atspi.ScrollType location."""
length = AXText.get_character_count(obj)
if not length:
return False
if start_offset is None:
start_offset = 0
if end_offset is None:
end_offset = length - 1
try:
result = Atspi.Text.scroll_substring_to(obj, start_offset, end_offset, location)
except Exception as error:
msg = f"AXText: Exception in scroll_substring_to_location: {error}"
debug.print_message(debug.LEVEL_INFO, msg, True)
return False
tokens = ["AXText: Scrolled", obj, f"substring ({start_offset}-{end_offset}) to",
location, f": {result}"]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return result