# Orca # # Copyright 2024 Igalia, S.L. # Author: Joanmarie Diggs # # 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. """Wrapper for the Atspi.Hypertext and Hyperlink interfaces.""" import os import re from urllib.parse import urlparse import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi, GLib from . import debug from .ax_object import AXObject class AXHypertext: """Wrapper for the Atspi.Hypertext and Hyperlink interfaces.""" @staticmethod def _get_link_count(obj: Atspi.Accessible) -> int: """Returns the number of hyperlinks in obj.""" if not AXObject.supports_hypertext(obj): return 0 try: count = Atspi.Hypertext.get_n_links(obj) except GLib.GError as error: msg = f"AXHypertext: Exception in _get_link_count: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) return 0 tokens = ["AXHypertext:", obj, f"reports {count} hyperlinks"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return count @staticmethod def _get_link_at_index(obj: Atspi.Accessible, index: int) -> Atspi.Hyperlink | None: """Returns the hyperlink object at the specified index.""" if not AXObject.supports_hypertext(obj): return None try: link = Atspi.Hypertext.get_link(obj, index) except GLib.GError as error: msg = f"AXHypertext: Exception in _get_link_at_index: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) return None return link @staticmethod def get_all_links_in_range( obj: Atspi.Accessible, start_offset: int, end_offset: int, ) -> list[Atspi.Hyperlink]: """Returns all the hyperlinks in obj who started within the specified range.""" links = [] for i in range(AXHypertext._get_link_count(obj)): link = AXHypertext._get_link_at_index(obj, i) if ( start_offset <= AXHypertext.get_link_start_offset(link) < end_offset or start_offset < AXHypertext.get_link_end_offset(link) <= end_offset ): links.append(link) tokens = [ f"AXHypertext: {len(links)} hyperlinks found in", obj, f"between start: {start_offset} and end: {end_offset}", ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return links @staticmethod def get_all_links(obj: Atspi.Accessible) -> list[Atspi.Hyperlink]: """Returns a list of all the hyperlinks in obj.""" links = [] for i in range(AXHypertext._get_link_count(obj)): link = AXHypertext._get_link_at_index(obj, i) if link is not None: links.append(link) tokens = [f"AXHypertext: {len(links)} hyperlinks found in", obj] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return links @staticmethod def get_link_uri(obj: Atspi.Accessible, index: int = 0) -> str: """Returns the URI associated with obj at the specified index.""" try: link = Atspi.Accessible.get_hyperlink(obj) uri = Atspi.Hyperlink.get_uri(link, index) except GLib.GError as error: msg = f"AXHypertext: Exception in get_link_uri: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) return "" tokens = ["AXHypertext: URI of", obj, f"at index {index} is {uri}"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return uri @staticmethod def get_link_start_offset(obj: Atspi.Accessible) -> int: """Returns the start offset of obj in the associated text.""" if isinstance(obj, Atspi.Hyperlink): link = obj obj = Atspi.Hyperlink.get_object(link, 0) else: link = Atspi.Accessible.get_hyperlink(obj) if link is None: tokens = ["AXHypertext: Couldn't get hyperlink for", obj] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return -1 try: offset = Atspi.Hyperlink.get_start_index(link) except GLib.GError as error: msg = f"AXHypertext: Exception in get_link_start_offset: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) return -1 tokens = ["AXHypertext: Start offset of", obj, f"is {offset}"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return offset @staticmethod def get_link_end_offset(obj: Atspi.Accessible) -> int: """Returns the end offset of obj in the associated text.""" if isinstance(obj, Atspi.Hyperlink): link = obj obj = Atspi.Hyperlink.get_object(link, 0) else: link = Atspi.Accessible.get_hyperlink(obj) if link is None: tokens = ["AXHypertext: Couldn't get hyperlink for", obj] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return -1 try: offset = Atspi.Hyperlink.get_end_index(link) except GLib.GError as error: msg = f"AXHypertext: Exception in get_link_end_offset: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) return -1 tokens = ["AXHypertext: End offset of", obj, f"is {offset}"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return offset @staticmethod def get_link_basename( obj: Atspi.Accessible, index: int = 0, remove_extension: bool = False, ) -> str: """Strip directory and suffix off of the URL associated with obj.""" uri = AXHypertext.get_link_uri(obj, index) if not uri: return "" parsed_uri = urlparse(uri) basename = os.path.basename(parsed_uri.path) if remove_extension: basename = os.path.splitext(basename)[0] basename = re.sub(r"[-_]", " ", basename) tokens = ["AXHypertext: Basename for link", obj, f"is '{basename}'"] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return basename @staticmethod def find_child_at_offset(obj: Atspi.Accessible, offset: int) -> Atspi.Accessible | None: """Returns the child at offset, correcting for broken hypertext offset mappings.""" if child := AXHypertext.get_child_at_offset(obj, offset): if AXHypertext.get_character_offset_in_parent(child) == offset: return child tokens = [ f"AXHypertext: Child at offset {offset} in", obj, "is", child, f"but reports offset {AXHypertext.get_character_offset_in_parent(child)}", ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) if child_before := AXHypertext.get_child_at_offset(obj, offset - 1): if AXHypertext.get_character_offset_in_parent(child_before) == offset: tokens = [ f"AXHypertext: Corrected child at offset {offset} in", obj, "is", child_before, f"at offset {offset - 1}", ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return child_before if child_after := AXHypertext.get_child_at_offset(obj, offset + 1): if AXHypertext.get_character_offset_in_parent(child_after) == offset: tokens = [ f"AXHypertext: Corrected child at offset {offset} in", obj, "is", child_after, f"at offset {offset + 1}", ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return child_after for i in range(AXHypertext._get_link_count(obj)): link = AXHypertext._get_link_at_index(obj, i) if link is not None and AXHypertext.get_link_start_offset(link) == offset: try: child = Atspi.Hyperlink.get_object(link, 0) except GLib.GError as error: msg = f"AXHypertext: Exception in find_child_at_offset: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) continue if child is not None: tokens = [ f"AXHypertext: Child at offset {offset} in", obj, "found via link enumeration:", child, ] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return child return None @staticmethod def get_child_at_offset(obj: Atspi.Accessible, offset: int) -> Atspi.Accessible | None: """Returns the embedded-object child of obj at the specified offset.""" if not AXObject.supports_hypertext(obj): return None try: index = Atspi.Hypertext.get_link_index(obj, offset) except GLib.GError as error: msg = f"AXHypertext: Exception in get_child_at_offset: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) return None if index < 0: return None link = AXHypertext._get_link_at_index(obj, index) if link is None: return None try: child = Atspi.Hyperlink.get_object(link, 0) except GLib.GError as error: msg = f"AXHypertext: Exception in get_child_at_offset: {error}" debug.print_message(debug.LEVEL_INFO, msg, True) return None tokens = [f"AXHypertext: Child at offset {offset} in", obj, "is", child] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return child @staticmethod def get_character_offset_in_parent(obj: Atspi.Accessible) -> int: """Returns the offset of the embedded-object obj in the text of its parent.""" if not AXObject.supports_text(AXObject.get_parent(obj)): return -1 return AXHypertext.get_link_start_offset(obj)