1# Orca 2# 3# Copyright (C) 2011-2013 Igalia, S.L. 4# 5# Author: Joanmarie Diggs <jdiggs@igalia.com> 6# 7# This library is free software; you can redistribute it and/or 8# modify it under the terms of the GNU Lesser General Public 9# License as published by the Free Software Foundation; either 10# version 2.1 of the License, or (at your option) any later version. 11# 12# This library is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 15# Lesser General Public License for more details. 16# 17# You should have received a copy of the GNU Lesser General Public 18# License along with this library; if not, write to the 19# Free Software Foundation, Inc., Franklin Street, Fifth Floor, 20# Boston MA 02110-1301 USA. 21 22"""Heuristic means to infer the functional/displayed label of a widget.""" 23 24__id__ = "$Id$" 25__version__ = "$Revision$" 26__date__ = "$Date$" 27__copyright__ = "Copyright (C) 2011-2013 Igalia, S.L." 28__license__ = "LGPL" 29 30import pyatspi 31 32from . import debug 33 34class LabelInference: 35 36 def __init__(self, script): 37 """Creates an instance of the LabelInference class. 38 39 Arguments: 40 - script: the script with which this instance is associated. 41 """ 42 43 self._script = script 44 self._lineCache = {} 45 self._extentsCache = {} 46 self._isWidgetCache = {} 47 48 def infer(self, obj, focusedOnly=True): 49 """Attempt to infer the functional/displayed label of obj. 50 51 Arguments 52 - obj: the unlabeled widget 53 - focusedOnly: If True, only infer if the widget has focus. 54 55 Returns the text which we think is the label, or None. 56 """ 57 58 debug.println(debug.LEVEL_INFO, "INFER label for: %s" % obj, True) 59 if not obj: 60 return None, [] 61 62 if focusedOnly and not obj.getState().contains(pyatspi.STATE_FOCUSED): 63 debug.println(debug.LEVEL_INFO, "INFER - object not focused", True) 64 return None, [] 65 66 result, objects = None, [] 67 if not result: 68 result, objects = self.inferFromTextLeft(obj) 69 debug.println(debug.LEVEL_INFO, "INFER - Text Left: %s" % result, True) 70 if not result or self._preferRight(obj): 71 result, objects = self.inferFromTextRight(obj) or result 72 debug.println(debug.LEVEL_INFO, "INFER - Text Right: %s" % result, True) 73 if not result: 74 result, objects = self.inferFromTable(obj) 75 debug.println(debug.LEVEL_INFO, "INFER - Table: %s" % result, True) 76 if not result: 77 result, objects = self.inferFromTextAbove(obj) 78 debug.println(debug.LEVEL_INFO, "INFER - Text Above: %s" % result, True) 79 if not result: 80 result, objects = self.inferFromTextBelow(obj) 81 debug.println(debug.LEVEL_INFO, "INFER - Text Below: %s" % result, True) 82 83 # TODO - We probably do not wish to "infer" from these. Instead, we 84 # should ensure that this content gets presented as part of the widget. 85 # (i.e. the label is something on screen. Widget name and description 86 # are each something other than a label.) 87 if not result: 88 result, objects = obj.name, [] 89 debug.println(debug.LEVEL_INFO, "INFER - Name: %s" % result, True) 90 if result: 91 result = result.strip() 92 result = result.replace("\n", " ") 93 94 # Desperate times call for desperate measures.... 95 if not result: 96 result, objects = self.inferFromTextLeft(obj, proximity=200) 97 debug.println(debug.LEVEL_INFO, "INFER - Text Left with proximity of 200: %s" % result, True) 98 99 self.clearCache() 100 return result, objects 101 102 def clearCache(self): 103 """Dumps whatever we've stored for performance purposes.""" 104 105 self._lineCache = {} 106 self._extentsCache = {} 107 self._isWidgetCache = {} 108 109 def _preferRight(self, obj): 110 """Returns True if we should prefer text on the right, rather than the 111 left, for the object obj.""" 112 113 onRightRoles = [pyatspi.ROLE_CHECK_BOX, pyatspi.ROLE_RADIO_BUTTON] 114 return obj.getRole() in onRightRoles 115 116 def _preventRight(self, obj): 117 """Returns True if we should not permit inference based on text to 118 the right for the object obj.""" 119 120 roles = [pyatspi.ROLE_COMBO_BOX, 121 pyatspi.ROLE_LIST, 122 pyatspi.ROLE_LIST_BOX] 123 124 return obj.getRole() in roles 125 126 def _preferTop(self, obj): 127 """Returns True if we should prefer text above, rather than below for 128 the object obj.""" 129 130 roles = [pyatspi.ROLE_COMBO_BOX, 131 pyatspi.ROLE_LIST, 132 pyatspi.ROLE_LIST_BOX] 133 134 return obj.getRole() in roles 135 136 def _preventBelow(self, obj): 137 """Returns True if we should not permit inference based on text below 138 the object obj.""" 139 140 roles = [pyatspi.ROLE_ENTRY, 141 pyatspi.ROLE_PASSWORD_TEXT] 142 143 return obj.getRole() not in roles 144 145 def _isSimpleObject(self, obj): 146 """Returns True if the given object has 'simple' contents, such as text 147 without embedded objects or a single embedded object without text.""" 148 149 if not obj: 150 return False 151 152 isMatch = lambda x: x and not self._script.utilities.isStaticTextLeaf(x) 153 154 try: 155 children = [child for child in obj if isMatch(child)] 156 except (LookupError, RuntimeError): 157 debug.println(debug.LEVEL_INFO, 'Dead Accessible in %s' % obj, True) 158 return False 159 160 children = [x for x in children if x.getRole() != pyatspi.ROLE_LINK] 161 if len(children) > 1: 162 return False 163 164 try: 165 text = obj.queryText() 166 except NotImplementedError: 167 return True 168 169 string = text.getText(0, -1).strip() 170 if string.count(self._script.EMBEDDED_OBJECT_CHARACTER) > 1: 171 return False 172 173 return True 174 175 def _cannotLabel(self, obj): 176 """Returns True if the given object should not be treated as a label.""" 177 178 if not obj: 179 return True 180 181 nonLabelTextRoles = [pyatspi.ROLE_HEADING, pyatspi.ROLE_LIST_ITEM] 182 if obj.getRole() in nonLabelTextRoles: 183 return True 184 185 return self._isWidget(obj) 186 187 def _isWidget(self, obj): 188 """Returns True if the given object is a widget.""" 189 190 if not obj: 191 return False 192 193 rv = self._isWidgetCache.get(hash(obj)) 194 if rv is not None: 195 return rv 196 197 widgetRoles = [pyatspi.ROLE_CHECK_BOX, 198 pyatspi.ROLE_RADIO_BUTTON, 199 pyatspi.ROLE_TOGGLE_BUTTON, 200 pyatspi.ROLE_COMBO_BOX, 201 pyatspi.ROLE_LIST, 202 pyatspi.ROLE_LIST_BOX, 203 pyatspi.ROLE_MENU, 204 pyatspi.ROLE_MENU_ITEM, 205 pyatspi.ROLE_ENTRY, 206 pyatspi.ROLE_PASSWORD_TEXT, 207 pyatspi.ROLE_PUSH_BUTTON] 208 209 isWidget = obj.getRole() in widgetRoles 210 if not isWidget and obj.getState().contains(pyatspi.STATE_EDITABLE): 211 isWidget = True 212 213 self._isWidgetCache[hash(obj)] = isWidget 214 return isWidget 215 216 def _getExtents(self, obj, startOffset=0, endOffset=-1): 217 """Returns (x, y, width, height) of the text at the given offsets 218 if the object implements accessible text, or just the extents of 219 the object if it doesn't implement accessible text.""" 220 221 if not obj: 222 return 0, 0, 0, 0 223 224 rv = self._extentsCache.get((hash(obj), startOffset, endOffset)) 225 if rv: 226 return rv 227 228 extents = 0, 0, 0, 0 229 text = self._script.utilities.queryNonEmptyText(obj) 230 if text: 231 skipTextExtents = [pyatspi.ROLE_ENTRY, pyatspi.ROLE_PASSWORD_TEXT] 232 if not obj.getRole() in skipTextExtents: 233 if endOffset == -1: 234 try: 235 endOffset = text.characterCount 236 except: 237 msg = "ERROR: Exception getting character count for %s" % obj 238 debug.println(debug.LEVEL_INFO, msg, True) 239 return extents 240 241 extents = text.getRangeExtents(startOffset, endOffset, 0) 242 243 if not (extents[2] and extents[3]): 244 try: 245 ext = obj.queryComponent().getExtents(0) 246 except NotImplementedError: 247 msg = "INFO: %s does not implement the component interface" % obj 248 debug.println(debug.LEVEL_INFO, msg, True) 249 except: 250 msg = "ERROR: Exception getting extents for %s" % obj 251 debug.println(debug.LEVEL_INFO, msg, True) 252 else: 253 extents = ext.x, ext.y, ext.width, ext.height 254 255 self._extentsCache[(hash(obj), startOffset, endOffset)] = extents 256 return extents 257 258 def _createLabelFromContents(self, obj): 259 """Gets the functional label text associated with the object obj.""" 260 261 if not self._isSimpleObject(obj): 262 return None, [] 263 264 if self._cannotLabel(obj): 265 return None, [] 266 267 contents = self._script.utilities.getObjectContentsAtOffset(obj, useCache=False) 268 objects = [content[0] for content in contents] 269 if list(filter(self._isWidget, objects)): 270 return None, [] 271 272 strings = [content[3] for content in contents] 273 return ''.join(strings), objects 274 275 def _getLineContents(self, obj, start=0): 276 """Get the (obj, startOffset, endOffset, string) tuples for the line 277 containing the object, obj.""" 278 279 rv = self._lineCache.get(hash(obj)) 280 if rv: 281 return rv 282 283 key = hash(obj) 284 if self._isWidget(obj): 285 start, end = self._script.utilities.getHyperlinkRange(obj) 286 obj = obj.parent 287 288 rv = self._script.utilities.getLineContentsAtOffset(obj, start, True, False) 289 self._lineCache[key] = rv 290 291 return rv 292 293 def inferFromTextLeft(self, obj, proximity=75): 294 """Attempt to infer the functional/displayed label of obj by 295 looking at the contents of the current line, which are to the 296 left of this object 297 298 Arguments 299 - obj: the unlabeled widget 300 - proximity: pixels expected for a match 301 302 Returns the text which we think is the label, or None. 303 """ 304 305 extents = self._getExtents(obj) 306 contents = self._getLineContents(obj) 307 content = [o for o in contents if o[0] == obj] 308 try: 309 index = contents.index(content[0]) 310 except IndexError: 311 index = len(contents) 312 313 onLeft = contents[0:index] 314 start = 0 315 for i in range(len(onLeft) - 1, -1, -1): 316 lObj, lStart, lEnd, lString = onLeft[i] 317 lExtents = self._getExtents(lObj) 318 if lExtents[0] > extents[0] or self._cannotLabel(lObj): 319 start = i + 1 320 break 321 322 onLeft = onLeft[start:] 323 if not (onLeft and onLeft[0]): 324 return None, [] 325 326 lObj, start, end, string = onLeft[-1] 327 lExtents = self._getExtents(lObj, start, end) 328 distance = extents[0] - (lExtents[0] + lExtents[2]) 329 if 0 <= distance <= proximity: 330 strings = [content[3] for content in onLeft] 331 result = ''.join(strings).strip() 332 if result: 333 return result, [content[0] for content in onLeft] 334 335 return None, [] 336 337 def inferFromTextRight(self, obj, proximity=25): 338 """Attempt to infer the functional/displayed label of obj by 339 looking at the contents of the current line, which are to the 340 right of this object 341 342 Arguments 343 - obj: the unlabeled widget 344 - proximity: pixels expected for a match 345 346 Returns the text which we think is the label, or None. 347 """ 348 349 if self._preventRight(obj): 350 return None, [] 351 352 extents = self._getExtents(obj) 353 contents = self._getLineContents(obj) 354 content = [o for o in contents if o[0] == obj] 355 try: 356 index = contents.index(content[0]) 357 except IndexError: 358 index = len(contents) 359 360 onRight = contents[min(len(contents), index+1):] 361 end = len(onRight) 362 for i, item in enumerate(onRight): 363 if self._cannotLabel(item[0]): 364 if not self._preferRight(obj): 365 return None, [] 366 end = i + 1 367 break 368 369 onRight = onRight[0:end] 370 if not (onRight and onRight[0]): 371 return None, [] 372 373 rObj, start, end, string = onRight[0] 374 rExtents = self._getExtents(rObj, start, end) 375 distance = rExtents[0] - (extents[0] + extents[2]) 376 if distance <= proximity or self._preferRight(obj): 377 strings = [content[3] for content in onRight] 378 result = ''.join(strings).strip() 379 if result: 380 return result, [content[0] for content in onRight] 381 382 return None, [] 383 384 def inferFromTextAbove(self, obj, proximity=20): 385 """Attempt to infer the functional/displayed label of obj by 386 looking at the contents of the line above the line containing 387 the object obj. 388 389 Arguments 390 - obj: the unlabeled widget 391 - proximity: pixels expected for a match 392 393 Returns the text which we think is the label, or None. 394 """ 395 396 thisLine = self._getLineContents(obj) 397 content = [o for o in thisLine if o[0] == obj] 398 try: 399 index = thisLine.index(content[0]) 400 except IndexError: 401 return None, [] 402 if index > 0: 403 return None, [] 404 405 prevObj, prevOffset = self._script.utilities.previousContext( 406 thisLine[0][0], thisLine[0][1], True) 407 prevLine = self._getLineContents(prevObj, prevOffset) 408 if len(prevLine) != 1: 409 return None, [] 410 411 prevObj, start, end, string = prevLine[0] 412 if self._cannotLabel(prevObj): 413 return None, [] 414 415 if string.strip(): 416 x, y, width, height = self._getExtents(prevObj, start, end) 417 objX, objY, objWidth, objHeight = self._getExtents(obj) 418 distance = objY - (y + height) 419 if 0 <= distance <= proximity and x <= objX: 420 return string.strip(), [prevObj] 421 422 return None, [] 423 424 def inferFromTextBelow(self, obj, proximity=20): 425 """Attempt to infer the functional/displayed label of obj by 426 looking at the contents of the line above the line containing 427 the object obj. 428 429 Arguments 430 - obj: the unlabeled widget 431 - proximity: pixels expected for a match 432 433 Returns the text which we think is the label, or None. 434 """ 435 436 if self._preventBelow(obj): 437 return None, [] 438 439 thisLine = self._getLineContents(obj) 440 content = [o for o in thisLine if o[0] == obj] 441 try: 442 index = thisLine.index(content[0]) 443 except IndexError: 444 return None, [] 445 if index > 0: 446 return None, [] 447 448 nextObj, nextOffset = self._script.utilities.nextContext( 449 thisLine[-1][0], thisLine[-1][2] - 1, True) 450 nextLine = self._getLineContents(nextObj, nextOffset) 451 if len(nextLine) != 1: 452 return None, [] 453 454 nextObj, start, end, string = nextLine[0] 455 if self._cannotLabel(nextObj): 456 return None, [] 457 458 if string.strip(): 459 x, y, width, height = self._getExtents(nextObj, start, end) 460 objX, objY, objWidth, objHeight = self._getExtents(obj) 461 distance = y - (objY + objHeight) 462 if 0 <= distance <= proximity: 463 return string.strip(), [nextObj] 464 465 return None, [] 466 467 def _isTable(self, obj): 468 if not obj: 469 return False 470 471 if obj.getRole() == pyatspi.ROLE_TABLE: 472 return True 473 474 return self._getTag(obj) == 'table' 475 476 def _isRow(self, obj): 477 if not obj: 478 return False 479 480 if obj.getRole() == pyatspi.ROLE_TABLE_ROW: 481 return True 482 483 return self._getTag(obj) == 'tr' 484 485 def _isCell(self, obj): 486 if not obj: 487 return False 488 489 if obj.getRole() == pyatspi.ROLE_TABLE_CELL: 490 return True 491 492 return self._getTag(obj) in ['td', 'th'] 493 494 def _getCellFromTable(self, table, rowindex, colindex): 495 if "Table" not in pyatspi.listInterfaces(table): 496 return NOne 497 498 if rowindex < 0 or colindex < 0: 499 return None 500 501 iface = table.queryTable() 502 if rowindex >= iface.nRows or colindex >= iface.nColumns: 503 return None 504 505 return table.queryTable().getAccessibleAt(rowindex, colindex) 506 507 def _getCellFromRow(self, row, colindex): 508 if 0 <= colindex < row.childCount: 509 return row[colindex] 510 511 return None 512 513 def _getTag(self, obj): 514 attrs = self._script.utilities.objectAttributes(obj) 515 return attrs.get('tag') 516 517 def inferFromTable(self, obj, proximityForRight=50): 518 """Attempt to infer the functional/displayed label of obj by looking 519 at the contents of the surrounding table cells. Note that this approach 520 assumes a simple table in which the widget is the sole occupant of its 521 cell. 522 523 Arguments 524 - obj: the unlabeled widget 525 526 Returns the text which we think is the label, or None. 527 """ 528 529 cell = pyatspi.findAncestor(obj, self._isCell) 530 if not self._isSimpleObject(cell): 531 return None, [] 532 533 if not cell in [obj.parent, obj.parent.parent]: 534 return None, [] 535 536 grid = pyatspi.findAncestor(cell, self._isTable) 537 if not grid: 538 return None, [] 539 540 cellLeft = cellRight = cellAbove = cellBelow = None 541 gridrow = pyatspi.findAncestor(cell, self._isRow) 542 rowindex, colindex = self._script.utilities.coordinatesForCell(cell) 543 if colindex > -1: 544 cellLeft = self._getCellFromTable(grid, rowindex, colindex - 1) 545 cellRight = self._getCellFromTable(grid, rowindex, colindex + 1) 546 cellAbove = self._getCellFromTable(grid, rowindex - 1, colindex) 547 cellBelow = self._getCellFromTable(grid, rowindex + 1, colindex) 548 elif gridrow and cell.parent == gridrow: 549 cellindex = cell.getIndexInParent() 550 cellLeft = self._getCellFromRow(gridrow, cellindex - 1) 551 cellRight = self._getCellFromRow(gridrow, cellindex + 1) 552 rowindex = gridrow.getIndexInParent() 553 if rowindex > 0: 554 cellAbove = self._getCellFromRow(gridrow.parent[rowindex - 1], cellindex) 555 if rowindex + 1 < grid.childCount: 556 cellBelow = self._getCellFromRow(gridrow.parent[rowindex + 1], cellindex) 557 558 if cellLeft and not self._preferRight(obj): 559 label, sources = self._createLabelFromContents(cellLeft) 560 if label: 561 return label.strip(), sources 562 563 objX, objY, objWidth, objHeight = self._getExtents(obj) 564 565 if cellRight and not self._preventRight(obj): 566 x, y, width, height = self._getExtents(cellRight) 567 distance = x - (objX + objWidth) 568 if distance <= proximityForRight or self._preferRight(obj): 569 label, sources = self._createLabelFromContents(cellRight) 570 if label: 571 return label.strip(), sources 572 573 labelAbove = labelBelow = None 574 if cellAbove: 575 labelAbove, sourcesAbove = self._createLabelFromContents(cellAbove) 576 if labelAbove and self._preferTop(obj): 577 return labelAbove.strip(), sourcesAbove 578 579 if cellBelow and not self._preventBelow(obj): 580 labelBelow, sourcesBelow = self._createLabelFromContents(cellBelow) 581 582 if labelAbove and labelBelow: 583 aboveX, aboveY, aboveWidth, aboveHeight = self._getExtents(cellAbove) 584 belowX, belowY, belowWidth, belowHeight = self._getExtents(cellBelow) 585 dAbove = objY - (aboveY + aboveHeight) 586 dBelow = belowY - (objY + objHeight) 587 if dAbove <= dBelow: 588 return labelAbove.strip(), sourcesAbove 589 return labelBelow.strip(), sourcesBelow 590 591 if labelAbove: 592 return labelAbove.strip(), sourcesAbove 593 if labelBelow: 594 return labelBelow.strip(), sourcesBelow 595 596 # None of the cells immediately surrounding this cell seem to be serving 597 # as a functional label. Therefore, see if this table looks like a grid 598 # of widgets with the functional labels in the first row. 599 600 try: 601 table = grid.queryTable() 602 except NotImplementedError: 603 return None, [] 604 605 firstRow = [table.getAccessibleAt(0, i) for i in range(table.nColumns)] 606 if not firstRow or list(filter(self._isWidget, firstRow)): 607 return None, [] 608 609 if colindex < 0: 610 return None, [] 611 612 cells = [table.getAccessibleAt(i, colindex) for i in range(1, table.nRows)] 613 cells = [x for x in cells if x is not None] 614 if [x for x in cells if x.childCount and x[0].getRole() != obj.getRole()]: 615 return None, [] 616 617 label, sources = self._createLabelFromContents(firstRow[colindex]) 618 if label: 619 return label.strip(), sources 620 621 return None, [] 622