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