%PDF- %PDF-
| Direktori : /lib/python3/dist-packages/orca/ |
| Current File : //lib/python3/dist-packages/orca/ax_table.py |
# Utilities for obtaining information about accessible tables.
#
# Copyright 2023 Igalia, S.L.
# Copyright 2023 GNOME Foundation Inc.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# 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.
"""
Utilities for obtaining information about accessible tables.
These utilities are app-type- and toolkit-agnostic. Utilities that might have
different implementations or results depending on the type of app (e.g. terminal,
chat, web) or toolkit (e.g. Qt, Gtk) should be in script_utilities.py file(s).
N.B. There are currently utilities that should never have custom implementations
that live in script_utilities.py files. These will be moved over time.
"""
__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2023 Igalia, S.L." \
"Copyright (c) 2023 GNOME Foundation Inc."
__license__ = "LGPL"
import threading
import time
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
from . import debug
from . import messages
from .ax_object import AXObject
from .ax_utilities import AXUtilities
class AXTable:
"""Utilities for obtaining information about accessible tables."""
# Things we cache.
CAPTIONS = {}
PHYSICAL_COORDINATES_FROM_CELL = {}
PHYSICAL_COORDINATES_FROM_TABLE = {}
PHYSICAL_SPANS_FROM_CELL = {}
PHYSICAL_SPANS_FROM_TABLE = {}
PHYSICAL_COLUMN_COUNT = {}
PHYSICAL_ROW_COUNT = {}
PRESENTABLE_COORDINATES = {}
PRESENTABLE_COORDINATES_LABELS = {}
PRESENTABLE_SPANS = {}
PRESENTABLE_COLUMN_COUNT = {}
PRESENTABLE_ROW_COUNT = {}
COLUMN_HEADERS_FOR_CELL = {}
ROW_HEADERS_FOR_CELL = {}
# Things which have to be explicitly cleared.
DYNAMIC_COLUMN_HEADERS_ROW = {}
DYNAMIC_ROW_HEADERS_COLUMN = {}
_lock = threading.Lock()
@staticmethod
def start_cache_clearing_thread():
"""Starts thread to periodically clear cached details."""
thread = threading.Thread(target=AXTable._clear_stored_data)
thread.daemon = True
thread.start()
@staticmethod
def _clear_stored_data():
"""Clears any data we have cached for objects"""
while True:
time.sleep(60)
AXTable._clear_all_dictionaries()
@staticmethod
def _clear_all_dictionaries(reason=""):
msg = "AXTable: Clearing cache."
if reason:
msg += f" Reason: {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
with AXTable._lock:
AXTable.CAPTIONS.clear()
AXTable.PHYSICAL_COORDINATES_FROM_CELL.clear()
AXTable.PHYSICAL_COORDINATES_FROM_TABLE.clear()
AXTable.PHYSICAL_SPANS_FROM_CELL.clear()
AXTable.PHYSICAL_SPANS_FROM_TABLE.clear()
AXTable.PHYSICAL_COLUMN_COUNT.clear()
AXTable.PHYSICAL_ROW_COUNT.clear()
AXTable.PRESENTABLE_COORDINATES.clear()
AXTable.PRESENTABLE_COORDINATES_LABELS.clear()
AXTable.PRESENTABLE_COLUMN_COUNT.clear()
AXTable.PRESENTABLE_ROW_COUNT.clear()
AXTable.COLUMN_HEADERS_FOR_CELL.clear()
AXTable.ROW_HEADERS_FOR_CELL.clear()
@staticmethod
def clear_cache_now(reason=""):
"""Clears all cached information immediately."""
AXTable._clear_all_dictionaries(reason)
@staticmethod
def get_caption(table):
"""Returns the accessible object containing the caption of table."""
if not AXObject.supports_table(table):
return None
if hash(table) in AXTable.CAPTIONS:
return AXTable.CAPTIONS.get(hash(table))
try:
caption = Atspi.Table.get_caption(table)
except Exception as error:
msg = f"AXTable: Exception in get_caption: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return None
tokens = ["AXTable: Caption for", table, "is", caption]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.CAPTIONS[hash(table)] = caption
return caption
@staticmethod
def get_column_count(table, prefer_attribute=True):
"""Returns the column count of table."""
if not AXObject.supports_table(table):
return -1
if prefer_attribute:
count = AXTable._get_column_count_from_attribute(table)
if count is not None:
return count
count = AXTable.PHYSICAL_COLUMN_COUNT.get(hash(table))
if count is not None:
return count
try:
count = Atspi.Table.get_n_columns(table)
except Exception as error:
msg = f"AXTable: Exception in get_column_count: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return -1
tokens = ["AXTable: Column count for", table, "is", count]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PHYSICAL_COLUMN_COUNT[hash(table)] = count
return count
@staticmethod
def _get_column_count_from_attribute(table):
"""Returns the value of the 'colcount' object attribute or None if not found."""
if hash(table) in AXTable.PRESENTABLE_COLUMN_COUNT:
return AXTable.PRESENTABLE_COLUMN_COUNT.get(hash(table))
attrs = AXObject.get_attributes_dict(table)
attr = attrs.get("colcount")
count = None
if attr is not None:
count = int(attr)
tokens = ["AXTable: Column count attribute for", table, "is", count]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PRESENTABLE_COLUMN_COUNT[hash(table)] = count
return count
@staticmethod
def get_row_count(table, prefer_attribute=True):
"""Returns the row count of table."""
if not AXObject.supports_table(table):
return -1
if prefer_attribute:
count = AXTable._get_row_count_from_attribute(table)
if count is not None:
return count
count = AXTable.PHYSICAL_ROW_COUNT.get(hash(table))
if count is not None:
return count
try:
count = Atspi.Table.get_n_rows(table)
except Exception as error:
msg = f"AXTable: Exception in get_row_count: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return -1
tokens = ["AXTable: Row count for", table, "is", count]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PHYSICAL_ROW_COUNT[hash(table)] = count
return count
@staticmethod
def _get_row_count_from_attribute(table):
"""Returns the value of the 'rowcount' object attribute or None if not found."""
if hash(table) in AXTable.PRESENTABLE_ROW_COUNT:
return AXTable.PRESENTABLE_ROW_COUNT.get(hash(table))
attrs = AXObject.get_attributes_dict(table)
attr = attrs.get("rowcount")
count = None
if attr is not None:
count = int(attr)
tokens = ["AXTable: Row count attribute for", table, "is", count]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PRESENTABLE_ROW_COUNT[hash(table)] = count
return count
@staticmethod
def is_non_uniform_table(table, max_rows=25, max_cols=25):
"""Returns True if table has at least one cell with a span > 1."""
for row in range(min(max_rows, AXTable.get_row_count(table, False))):
for col in range(min(max_cols, AXTable.get_column_count(table, False))):
try:
if Atspi.Table.get_row_extent_at(table, row, col) > 1:
return True
if Atspi.Table.get_column_extent_at(table, row, col) > 1:
return True
except Exception as error:
msg = f"AXTable: Exception in is_non_uniform_table: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
return False
@staticmethod
def get_selected_column_count(table):
"""Returns the number of selected columns in table."""
if not AXObject.supports_table(table):
return []
try:
count = Atspi.Table.get_n_selected_columns(table)
except Exception as error:
msg = f"AXTable: Exception in get_selected_column_count {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return []
tokens = ["AXTable: Selected column count for", table, "is", count]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return count
@staticmethod
def get_selected_columns(table):
"""Returns a list of column indices for the selected columns in table."""
if not AXObject.supports_table(table):
return []
try:
columns = Atspi.Table.get_selected_columns(table)
except Exception as error:
msg = f"AXTable: Exception in get_selected_columns: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return []
tokens = ["AXTable: Selected columns for", table, "are", columns]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return columns
@staticmethod
def get_selected_row_count(table):
"""Returns the number of selected rows in table."""
if not AXObject.supports_table(table):
return []
try:
count = Atspi.Table.get_n_selected_rows(table)
except Exception as error:
msg = f"AXTable: Exception in get_selected_row_count {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return []
tokens = ["AXTable: Selected row count for", table, "is", count]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return count
@staticmethod
def get_selected_rows(table):
"""Returns a list of row indices for the selected rows in table."""
if not AXObject.supports_table(table):
return []
try:
rows = Atspi.Table.get_selected_rows(table)
except Exception as error:
msg = f"AXTable: Exception in get_selected_rows: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return []
tokens = ["AXTable: Selected rows for", table, "are", rows]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return rows
@staticmethod
def all_cells_are_selected(table):
"""Returns True if all cells in table are selected."""
if not AXObject.supports_table(table):
return False
rows = AXTable.get_row_count(table, prefer_attribute=False)
if rows <= 0:
return False
if AXTable.get_selected_row_count(table) == rows:
return True
cols = AXTable.get_column_count(table, prefer_attribute=False)
return AXTable.get_selected_column_count(table) == cols
@staticmethod
def get_cell_at(table, row, column):
"""Returns the cell at the 0-indexed row and column."""
if not AXObject.supports_table(table):
return None
try:
cell = Atspi.Table.get_accessible_at(table, row, column)
except Exception as error:
tokens = [f"AXTable: Exception getting cell at row: {row} col: {column} in", table,
":", error]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return None
tokens = [f"AXTable: Cell at row: {row} col: {column} in", table, "is", cell]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return cell
@staticmethod
def _get_cell_index(cell):
"""Returns the index of cell to be used with the table interface."""
index = AXObject.get_attribute(cell, "table-cell-index")
if index is not None and index != "":
return int(index)
# We might have nested cells. So far this has only been seen in Gtk,
# where the parent of a table cell is also a table cell. We need the
# index of the parent for use with the table interface.
parent = AXObject.get_parent(cell)
if AXObject.get_role(parent) == Atspi.Role.TABLE_CELL:
cell = parent
return AXObject.get_index_in_parent(cell)
@staticmethod
def get_cell_spans(cell, prefer_attribute=True):
"""Returns the row and column spans."""
if not AXUtilities.is_table_cell_or_header(cell):
return -1, -1
if AXObject.supports_table_cell(cell):
row_span, col_span = AXTable._get_cell_spans_from_table_cell(cell)
else:
row_span, col_span = AXTable._get_cell_spans_from_table(cell)
if not prefer_attribute:
return row_span, col_span
rowspan_attr, colspan_attr = AXTable._get_cell_spans_from_attribute(cell)
if rowspan_attr is not None:
row_span = int(rowspan_attr)
if colspan_attr is not None:
col_span = int(colspan_attr)
return row_span, col_span
@staticmethod
def _get_cell_spans_from_attribute(cell):
"""Returns the row and column spans exposed via object attribute, or None, None."""
if hash(cell) in AXTable.PRESENTABLE_SPANS:
return AXTable.PRESENTABLE_SPANS.get(hash(cell))
attrs = AXObject.get_attributes_dict(cell)
row_span = attrs.get("rowspan")
col_span = attrs.get("colspan")
tokens = ["AXTable: Row and col span attributes for", cell, ":", row_span, ",", col_span]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PRESENTABLE_SPANS[hash(cell)] = row_span, col_span
return row_span, col_span
@staticmethod
def _get_cell_spans_from_table(cell):
"""Returns the row and column spans of cell via the table interface."""
if hash(cell) in AXTable.PHYSICAL_SPANS_FROM_TABLE:
return AXTable.PHYSICAL_SPANS_FROM_TABLE.get(hash(cell))
index = AXTable._get_cell_index(cell)
if index < 0:
return -1, -1
table = AXTable.get_table(cell)
if table is None:
return -1, -1
if not AXObject.supports_table(table):
return -1, -1
# Cells in a tree are expected to not span multiple rows or columns.
# Also this: https://bugreports.qt.io/browse/QTBUG-119167
if AXUtilities.is_tree(table):
return 1, 1
try:
result = Atspi.Table.get_row_column_extents_at_index(table, index)
except Exception as error:
msg = f"AXTable: Exception in _get_cell_spans_from_table: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return -1, -1
if not result[0]:
return -1, -1
row_span = result.row_extents
row_count = AXTable.get_row_count(table, False)
if row_span > row_count:
tokens = ["AXTable: Table iface row span for", cell,
f"{row_span} is greater than row count: {row_count}"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
row_span = 1
col_span = result.col_extents
col_count = AXTable.get_column_count(table, False)
if col_span > col_count:
tokens = ["AXTable: Table iface col span for", cell,
f"{col_span} is greater than col count: {col_count}"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
col_span = 1
tokens = ["AXTable: Table iface spans for", cell,
f"are rowspan: {row_span}, colspan: {col_span}"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PHYSICAL_SPANS_FROM_TABLE[hash(cell)] = row_span, col_span
return row_span, col_span
@staticmethod
def _get_cell_spans_from_table_cell(cell):
"""Returns the row and column spans of cell via the table cell interface."""
if hash(cell) in AXTable.PHYSICAL_SPANS_FROM_CELL:
return AXTable.PHYSICAL_SPANS_FROM_CELL.get(hash(cell))
if not AXObject.supports_table_cell(cell):
return -1, -1
try:
# TODO - JD: We get the spans individually due to
# https://bugzilla.mozilla.org/show_bug.cgi?id=1862437
row_span = Atspi.TableCell.get_row_span(cell)
col_span = Atspi.TableCell.get_column_span(cell)
except Exception as error:
msg = f"AXTable: Exception in _get_cell_spans_from_table_cell: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return -1, -1
tokens = ["AXTable: TableCell iface spans for", cell,
f"are rowspan: {row_span}, colspan: {col_span}"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PHYSICAL_SPANS_FROM_CELL[hash(cell)] = row_span, col_span
return row_span, col_span
@staticmethod
def _get_column_headers_from_table(table, column):
"""Returns the column headers of the indexed column via the table interface."""
if not AXObject.supports_table(table):
return []
if column < 0:
return []
try:
header = Atspi.Table.get_column_header(table, column)
except Exception as error:
msg = f"AXTable: Exception in _get_column_headers_from_table: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return []
tokens = [f"AXTable: Table iface header for column {column} of", table, "is", header]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
if header is not None:
return [header]
return []
@staticmethod
def _get_column_headers_from_table_cell(cell):
"""Returns the column headers for cell via the table cell interface."""
if not AXObject.supports_table_cell(cell):
return []
try:
headers = Atspi.TableCell.get_column_header_cells(cell)
except Exception as error:
msg = f"AXTable: Exception in _get_column_headers_from_table_cell: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return []
tokens = ["AXTable: TableCell iface column headers for cell are:", headers]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return headers
@staticmethod
def _get_row_headers_from_table(table, row):
"""Returns the row headers of the indexed row via the table interface."""
if not AXObject.supports_table(table):
return []
if row < 0:
return []
try:
header = Atspi.Table.get_row_header(table, row)
except Exception as error:
msg = f"AXTable: Exception in _get_row_headers_from_table: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return []
tokens = [f"AXTable: Table iface header for row {row} of", table, "is", header]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
if header is not None:
return [header]
return []
@staticmethod
def _get_row_headers_from_table_cell(cell):
"""Returns the row headers for cell via the table cell interface."""
if not AXObject.supports_table_cell(cell):
return []
try:
headers = Atspi.TableCell.get_row_header_cells(cell)
except Exception as error:
msg = f"AXTable: Exception in _get_row_headers_from_table_cell: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return []
tokens = ["AXTable: TableCell iface row headers for cell are:", headers]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return headers
@staticmethod
def get_new_row_headers(cell, old_cell):
"""Returns row headers of cell that are not also headers of old_cell. """
headers = AXTable.get_row_headers(cell)
if old_cell is None:
return headers
old_headers = AXTable.get_row_headers(old_cell)
return list(set(headers).difference(set(old_headers)))
@staticmethod
def get_new_column_headers(cell, old_cell):
"""Returns column headers of cell that are not also headers of old_cell. """
headers = AXTable.get_column_headers(cell)
if old_cell is None:
return headers
old_headers = AXTable.get_column_headers(old_cell)
return list(set(headers).difference(set(old_headers)))
@staticmethod
def get_row_headers(cell):
"""Returns the row headers for cell, doing extra work to ensure we have them all."""
if not AXUtilities.is_table_cell(cell):
return []
dynamic_header = AXTable.get_dynamic_row_header(cell)
if dynamic_header is not None:
return [dynamic_header]
# Firefox has the following implementation:
# 1. Only gives us the innermost/closest header for a cell
# 2. Supports returning the header of a header
# Chromium has the following implementation:
# 1. Gives us all the headers for a cell
# 2. Does NOT support returning the header of a header
# The Firefox implementation means we can get all the headers with some work.
# The Chromium implementation means less work, but makes it hard to present
# the changed outer header when navigating among nested row/column headers.
# TODO - JD: Figure out what the rest do, and then try to get the implementations
# aligned.
result = AXTable.ROW_HEADERS_FOR_CELL.get(hash(cell))
if result is not None:
return result
result = AXTable._get_row_headers(cell)
# There either are no headers, or we got all of them.
if len(result) != 1:
AXTable.ROW_HEADERS_FOR_CELL[hash(cell)] = result
return result
others = AXTable._get_row_headers(result[0])
while len(others) == 1 and others[0] not in result:
result.insert(0, others[0])
others = AXTable._get_row_headers(result[0])
AXTable.ROW_HEADERS_FOR_CELL[hash(cell)] = result
return result
@staticmethod
def _get_row_headers(cell):
"""Returns the row headers for cell."""
if AXObject.supports_table_cell(cell):
return AXTable._get_row_headers_from_table_cell(cell)
row, column = AXTable._get_cell_coordinates_from_table(cell)
if row < 0 or column < 0:
return []
table = AXTable.get_table(cell)
if table is None:
return []
headers = []
rowspan = AXTable._get_cell_spans_from_table(cell)[0]
for index in range(row, row + rowspan):
headers.extend(AXTable._get_row_headers_from_table(table, index))
return headers
@staticmethod
def has_row_headers(table, stop_after=10):
"""Returns True if table has any headers for rows 0-stop_after."""
if not AXObject.supports_table(table):
return False
stop_after = min(stop_after + 1, AXTable.get_row_count(table))
for i in range(stop_after):
if AXTable._get_row_headers_from_table(table, i):
return True
return False
@staticmethod
def get_column_headers(cell):
"""Returns the column headers for cell, doing extra work to ensure we have them all."""
if not AXUtilities.is_table_cell(cell):
return []
dynamic_header = AXTable.get_dynamic_column_header(cell)
if dynamic_header is not None:
return [dynamic_header]
# Firefox has the following implementation:
# 1. Only gives us the innermost/closest header for a cell
# 2. Supports returning the header of a header
# Chromium has the following implementation:
# 1. Gives us all the headers for a cell
# 2. Does NOT support returning the header of a header
# The Firefox implementation means we can get all the headers with some work.
# The Chromium implementation means less work, but makes it hard to present
# the changed outer header when navigating among nested row/column headers.
# TODO - JD: Figure out what the rest do, and then try to get the implementations
# aligned.
result = AXTable.COLUMN_HEADERS_FOR_CELL.get(hash(cell))
if result is not None:
return result
result = AXTable._get_column_headers(cell)
# There either are no headers, or we got all of them.
if len(result) != 1:
AXTable.COLUMN_HEADERS_FOR_CELL[hash(cell)] = result
return result
others = AXTable._get_column_headers(result[0])
while len(others) == 1 and others[0] not in result:
result.insert(0, others[0])
others = AXTable._get_column_headers(result[0])
AXTable.COLUMN_HEADERS_FOR_CELL[hash(cell)] = result
return result
@staticmethod
def _get_column_headers(cell):
"""Returns the column headers for cell."""
if AXObject.supports_table_cell(cell):
return AXTable._get_column_headers_from_table_cell(cell)
row, column = AXTable._get_cell_coordinates_from_table(cell)
if row < 0 or column < 0:
return []
table = AXTable.get_table(cell)
if table is None:
return []
headers = []
colspan = AXTable._get_cell_spans_from_table(cell)[1]
for index in range(column, column + colspan):
headers.extend(AXTable._get_column_headers_from_table(table, index))
return headers
@staticmethod
def has_column_headers(table, stop_after=10):
"""Returns True if table has any headers for columns 0-stop_after."""
if not AXObject.supports_table(table):
return False
stop_after = min(stop_after + 1, AXTable.get_column_count(table))
for i in range(stop_after):
if AXTable._get_column_headers_from_table(table, i):
return True
return False
@staticmethod
def get_cell_coordinates(cell, prefer_attribute=True, find_cell=False):
"""Returns the 0-based row and column indices."""
if not AXUtilities.is_table_cell_or_header(cell) and find_cell:
cell = AXObject.find_ancestor(cell, AXUtilities.is_table_cell_or_header)
if not AXUtilities.is_table_cell_or_header(cell):
return -1, -1
if AXObject.supports_table_cell(cell):
row, col = AXTable._get_cell_coordinates_from_table_cell(cell)
else:
row, col = AXTable._get_cell_coordinates_from_table(cell)
if not prefer_attribute:
return row, col
row_index, col_index = AXTable._get_cell_coordinates_from_attribute(cell)
if row_index is not None:
row = int(row_index) - 1
if col_index is not None:
col = int(col_index) - 1
return row, col
@staticmethod
def _get_cell_coordinates_from_table(cell):
"""Returns the row and column indices of cell via the table interface."""
if hash(cell) in AXTable.PHYSICAL_COORDINATES_FROM_TABLE:
return AXTable.PHYSICAL_COORDINATES_FROM_TABLE.get(hash(cell))
index = AXTable._get_cell_index(cell)
if index < 0:
return -1, -1
table = AXTable.get_table(cell)
if table is None:
tokens = ["AXTable: Couldn't find table-implementing ancestor for", cell]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return -1, -1
try:
row = Atspi.Table.get_row_at_index(table, index)
column = Atspi.Table.get_column_at_index(table, index)
except Exception as error:
msg = f"AXTable: Exception in _get_cell_coordinates_from_table: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return -1, -1
tokens = ["AXTable: Table iface coords for", cell, f"are row: {row}, col: {column}"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PHYSICAL_COORDINATES_FROM_TABLE[hash(cell)] = row, column
return row, column
@staticmethod
def _get_cell_coordinates_from_table_cell(cell):
"""Returns the row and column indices of cell via the table cell interface."""
if hash(cell) in AXTable.PHYSICAL_COORDINATES_FROM_CELL:
return AXTable.PHYSICAL_COORDINATES_FROM_CELL.get(hash(cell))
if not AXObject.supports_table_cell(cell):
return -1, -1
try:
success, row, column = Atspi.TableCell.get_position(cell)
except Exception as error:
msg = f"AXTable: Exception in _get_cell_coordinates_from_table_cell: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return -1, -1
if not success:
return -1, -1
tokens = ["AXTable: TableCell iface coords for", cell, f"are row: {row}, col: {column}"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PHYSICAL_COORDINATES_FROM_CELL[hash(cell)] = row, column
return row, column
@staticmethod
def _get_cell_coordinates_from_attribute(cell):
"""Returns the 1-based indices for cell exposed via object attribute, or None, None."""
if cell is None:
return None, None
if hash(cell) in AXTable.PRESENTABLE_COORDINATES:
return AXTable.PRESENTABLE_COORDINATES.get(hash(cell))
attrs = AXObject.get_attributes_dict(cell)
row_index = attrs.get("rowindex")
col_index = attrs.get("colindex")
tokens = ["AXTable: Row and col index attributes for", cell, ":", row_index, ",", col_index]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PRESENTABLE_COORDINATES[hash(cell)] = row_index, col_index
if row_index is not None and col_index is not None:
return row_index, col_index
row = AXObject.find_ancestor(cell, AXUtilities.is_table_row)
if row is None:
return row_index, col_index
attrs = AXObject.get_attributes_dict(row)
row_index = attrs.get("rowindex", row_index)
col_index = attrs.get("colindex", col_index)
tokens = ["AXTable: Updated attributes based on", row, ":", row_index, col_index]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PRESENTABLE_COORDINATES[hash(cell)] = row_index, col_index
return row_index, col_index
@staticmethod
def get_table(obj):
"""Returns obj if it is a table, otherwise returns the ancestor table of obj."""
if obj is None:
return None
if AXObject.supports_table_cell(obj):
try:
table = Atspi.TableCell.get_table(obj)
except Exception as error:
msg = f"AXTable: Exception in get_table: {error}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
else:
if AXObject.supports_table(table):
return table
def is_table(x):
if AXUtilities.is_table(x) or AXUtilities.is_tree_table(x) or AXUtilities.is_tree(x):
return AXObject.supports_table(x)
return False
if is_table(obj):
return obj
return AXObject.find_ancestor(obj, is_table)
@staticmethod
def get_table_description_for_presentation(table):
"""Returns an end-user-consumable string which describes the table."""
if not AXObject.supports_table(table):
return ""
result = messages.tableSize(AXTable.get_row_count(table), AXTable.get_column_count(table))
if AXTable.is_non_uniform_table(table):
result = f"{messages.TABLE_NON_UNIFORM} {result}"
return result
@staticmethod
def get_first_cell(table):
"""Returns the first cell in table."""
row, col = 0, 0
return AXTable.get_cell_at(table, row, col)
@staticmethod
def get_last_cell(table):
"""Returns the last cell in table."""
row, col = AXTable.get_row_count(table) - 1, AXTable.get_column_count(table) - 1
return AXTable.get_cell_at(table, row, col)
@staticmethod
def get_cell_above(cell):
"""Returns the cell above cell in table."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
row -= 1
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_cell_below(cell):
"""Returns the cell below cell in table."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
row += AXTable.get_cell_spans(cell, prefer_attribute=False)[0]
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_cell_on_left(cell):
"""Returns the cell to the left."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
col -= 1
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_cell_on_right(cell):
"""Returns the cell to the right."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
col += AXTable.get_cell_spans(cell, prefer_attribute=False)[1]
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_start_of_row(cell):
"""Returns the cell at the start of cell's row."""
row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0]
return AXTable.get_cell_at(AXTable.get_table(cell), row, 0)
@staticmethod
def get_end_of_row(cell):
"""Returns the cell at the end of cell's row."""
row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0]
table = AXTable.get_table(cell)
col = AXTable.get_column_count(table) - 1
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_top_of_column(cell):
"""Returns the cell at the top of cell's column."""
col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1]
return AXTable.get_cell_at(AXTable.get_table(cell), 0, col)
@staticmethod
def get_bottom_of_column(cell):
"""Returns the cell at the bottom of cell's column."""
col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1]
table = AXTable.get_table(cell)
row = AXTable.get_row_count(table) - 1
return AXTable.get_cell_at(AXTable.get_table(cell), row, col)
@staticmethod
def get_cell_formula(cell):
"""Returns the formula associated with this cell."""
attrs = AXObject.get_attributes_dict(cell, use_cache=False)
return attrs.get("formula", attrs.get("Formula"))
@staticmethod
def is_first_cell(cell):
"""Returns True if this is the first cell in its table."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
return row == 0 and col == 0
@staticmethod
def is_last_cell(cell):
"""Returns True if this is the last cell in its table."""
row, col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)
if row < 0 or col < 0:
return False
table = AXTable.get_table(cell)
if table is None:
return False
return row + 1 == AXTable.get_row_count(table, prefer_attribute=False) \
and col + 1 == AXTable.get_column_count(table, prefer_attribute=False)
@staticmethod
def is_start_of_row(cell):
"""Returns True if this is the first cell in its row."""
col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1]
return col == 0
@staticmethod
def is_end_of_row(cell):
"""Returns True if this is the last cell in its row."""
col = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[1]
if col < 0:
return False
table = AXTable.get_table(cell)
if table is None:
return False
return col + 1 == AXTable.get_column_count(table, prefer_attribute=False)
@staticmethod
def is_top_of_column(cell):
"""Returns True if this is the first cell in its column."""
row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0]
return row == 0
@staticmethod
def is_bottom_of_column(cell):
"""Returns True if this is the last cell in its column."""
row = AXTable.get_cell_coordinates(cell, prefer_attribute=False)[0]
if row < 0:
return False
table = AXTable.get_table(cell)
if table is None:
return False
return row + 1 == AXTable.get_row_count(table, prefer_attribute=False)
@staticmethod
def is_layout_table(table):
"""Returns True if this table should be treated as layout only."""
result, reason = False, "Not enough information"
attrs = AXObject.get_attributes_dict(table)
if AXUtilities.is_table(table):
if attrs.get("layout-guess") == "true":
result, reason = True, "The layout-guess attribute is true."
elif not AXObject.supports_table(table):
result, reason = True, "Doesn't support table interface."
elif attrs.get("xml-roles") == "table" or attrs.get("tag") == "table":
result, reason = False, "Is a web table without layout-guess set to true."
elif AXTable.has_column_headers(table) or AXTable.has_row_headers(table):
result, reason = False, "Has headers"
elif AXObject.get_name(table) or AXObject.get_description(table):
result, reason = False, "Has name or description"
elif AXTable.get_caption(table):
result, reason = False, "Has caption"
tokens = ["AXTable:", table, f"is layout only: {result} ({reason})"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
return result
@staticmethod
def get_label_for_cell_coordinates(cell):
"""Returns the text that should be used instead of the numeric indices."""
if hash(cell) in AXTable.PRESENTABLE_COORDINATES_LABELS:
return AXTable.PRESENTABLE_COORDINATES_LABELS.get(hash(cell))
attrs = AXObject.get_attributes_dict(cell)
result = ""
# The attribute officially has the word "index" in it for clarity.
# TODO - JD: Google Sheets needs to start using the correct attribute name.
col_label = attrs.get("colindextext", attrs.get("coltext"))
row_label = attrs.get("rowindextext", attrs.get("rowtext"))
if col_label is not None and row_label is not None:
result = f"{col_label}{row_label}"
tokens = ["AXTable: Coordinates label for", cell, f": {result}"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PRESENTABLE_COORDINATES_LABELS[hash(cell)] = result
if result:
return result
row = AXObject.find_ancestor(cell, AXUtilities.is_table_row)
if row is None:
return result
attrs = AXObject.get_attributes_dict(row)
col_label = attrs.get("colindextext", attrs.get("coltext", col_label))
row_label = attrs.get("rowindextext", attrs.get("rowtext", row_label))
if col_label is not None and row_label is not None:
result = f"{col_label}{row_label}"
tokens = ["AXTable: Updated coordinates label based on", row, f": {result}"]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
AXTable.PRESENTABLE_COORDINATES_LABELS[hash(cell)] = result
return result
@staticmethod
def get_dynamic_row_header(cell):
"""Returns the user-set row header for cell."""
table = AXTable.get_table(cell)
headers_column = AXTable.DYNAMIC_ROW_HEADERS_COLUMN.get(hash(table))
if headers_column is None:
return None
cell_row, cell_column = AXTable.get_cell_coordinates(cell)
if cell_column == headers_column:
return None
return AXTable.get_cell_at(table, cell_row, headers_column)
@staticmethod
def get_dynamic_column_header(cell):
"""Returns the user-set column header for cell."""
table = AXTable.get_table(cell)
headers_row = AXTable.DYNAMIC_COLUMN_HEADERS_ROW.get(hash(table))
if headers_row is None:
return None
cell_row, cell_column = AXTable.get_cell_coordinates(cell)
if cell_row == headers_row:
return None
return AXTable.get_cell_at(table, headers_row, cell_column)
@staticmethod
def set_dynamic_row_headers_column(table, column):
"""Sets the dynamic row headers column of table to column."""
AXTable.DYNAMIC_ROW_HEADERS_COLUMN[hash(table)] = column
@staticmethod
def set_dynamic_column_headers_row(table, row):
"""Sets the dynamic column headers row of table to row."""
AXTable.DYNAMIC_COLUMN_HEADERS_ROW[hash(table)] = row
@staticmethod
def clear_dynamic_row_headers_column(table):
"""Clears the dynamic row headers column of table."""
if hash(table) not in AXTable.DYNAMIC_ROW_HEADERS_COLUMN:
return
AXTable.DYNAMIC_ROW_HEADERS_COLUMN.pop(hash(table))
@staticmethod
def clear_dynamic_column_headers_row(table):
"""Clears the dynamic column headers row of table."""
if hash(table) not in AXTable.DYNAMIC_COLUMN_HEADERS_ROW:
return
AXTable.DYNAMIC_COLUMN_HEADERS_ROW.pop(hash(table))
AXTable.start_cache_clearing_thread()