# Orca # # Copyright 2007-2009 Sun Microsystems Inc. and 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. """Custom script for acroread""" __id__ = "$Id$" __version__ = "$Revision$" __date__ = "$Date$" __copyright__ = "Copyright (c) 2007-2009 Sun Microsystems Inc. Joanmarie Diggs" __license__ = "LGPL" import pyatspi import orca.debug as debug import orca.scripts.default as default import orca.input_event as input_event import orca.orca as orca import orca.rolenames as rolenames import orca.orca_state as orca_state import orca.settings as settings import orca.speech as speech from orca.orca_i18n import _ # for gettext support from orca.orca_i18n import ngettext ######################################################################## # # # The acroread script class. # # # ######################################################################## class Script(default.Script): ROLE_DOCUMENT = "Document" ROLE_LINK = "Link" def __init__(self, app): """Creates a new script for the given application. Arguments: - app: the application to create a script for. """ self.debugLevel = debug.LEVEL_FINEST default.Script.__init__(self, app) # Acroread documents are contained in an object whose rolename # is "Document". "Link" is also capitalized in acroread. We # need to make these known to Orca for speech and braille output. # rolenames.rolenames[self.ROLE_DOCUMENT] = \ rolenames.Rolename(self.ROLE_DOCUMENT, # Translators: short braille for the # rolename of a document. # _("doc"), # Translators: long braille for the # rolename of a document. # _("Document"), # Translators: spoken words for the # rolename of a document. # _("document")) rolenames.rolenames[self.ROLE_LINK] = \ rolenames.rolenames[pyatspi.ROLE_LINK] # To handle the multiple, identical object:text-caret-moved events # and possible focus events that result from a single key press # self.currentInputEvent = None # To handle the case when we get an object:text-caret-moved event # for some text we just left, but which is still showing on the # screen. # self.lastCaretMovedLine = None # To minimize chattiness related to focused events when the Find # toolbar is active. # self.findToolbarActive = False self.findToolbarName = None self.preFindLine = None def setupInputEventHandlers(self): """Defines InputEventHandler fields for this script that can be called by the key and braille bindings. In this particular case, we just want to be able to define our own sayAll() method. """ default.Script.setupInputEventHandlers(self) self.inputEventHandlers["sayAllHandler"] = \ input_event.InputEventHandler( Script.sayAll, _("Speaks entire document.")) def getListeners(self): """Sets up the AT-SPI event listeners for this script. """ listeners = default.Script.getListeners(self) listeners["object:state-changed:checked"] = \ self.onStateChanged listeners["object:state-changed:focused"] = \ self.onStateChanged return listeners def getDocument(self, locusOfFocus): """ Obtains the Document object that contains the locusOfFocus. Arguments: - locusOfFocus: the locusOfFocus Returns: the Document object, if found. """ document = None obj = locusOfFocus while obj.getRole() != pyatspi.ROLE_UNKNOWN: obj = obj.parent # This is probably it, but the parent of a text object # in a table also has a role of 'unknown' which in turn # has a parent with a role of 'unknown'. The parent of # the Document object is a drawing area. # if obj.parent.getRole() == pyatspi.ROLE_DRAWING_AREA: document = obj else: while obj.getRole() != pyatspi.ROLE_TABLE: obj = obj.parent # For now, let's assume no nested tables! :-) # while obj.getRole() != pyatspi.ROLE_UNKNOWN: obj = obj.parent document = obj return document def findNodeInDocument(self, obj): """ Obtains the location of an object with respect to the Document object. Arguments: - obj: the accessible whose location we're trying to obtain Returns: a list that represents the object's position, ordered from child to parent """ nodeList = [] document = self.getDocument(obj) while obj != document: nodeList.append(obj.getIndexInParent()) obj = obj.parent return nodeList def getNextTextObject(self, obj, nodeList=None): """A generator of objects with text in the Document object. Acroread organizes document content into a collection of individual objects that contain (or have associated) text and drawing areas which contain such objects along with additional drawing areas. The depth of the drawing areas in any given document or drawing area is unknown. Arguments: - obj: an Accessible that contains children - nodeList: a list reflecting the current object's position """ if nodeList: index = nodeList.pop() else: index = 0 for i in range(index, obj.childCount): child = obj[i] for nextObject in self.getNextTextObject(child, nodeList): yield nextObject yield child def getTableAndDimensions(self, obj): """Get the table that this text object is in, along with its dimensions. Arguments: - obj: a text object within the Document object. Returns the table that this text object cell is in, along with the number of rows and columns. """ table = None rows = 0 columns = 0 # HACK: Rows, columns, and cells are not labeled or assigned # roles. However, the table structure and what can claim focus # SEEM to be consistent. So let's punt until things get properly # labeled. # rolesList = [pyatspi.ROLE_TEXT, pyatspi.ROLE_UNKNOWN, pyatspi.ROLE_UNKNOWN, pyatspi.ROLE_TABLE] if self.utilities.hasMatchingHierarchy(obj, rolesList): table = obj.parent.parent.parent rows = table.childCount columns = table[0].childCount return [table, rows, columns] def getCellCoordinates(self, table, cell): """Get the coordinates of the specified text object with respect to the table that contains it. Arguments: - obj: a text object within a table Returns the row number and column number. """ # HACK: Again, these things are not labeled or assigned roles, # so we're punting for now. # column = cell.parent.getIndexInParent() + 1 row = cell.parent.parent.getIndexInParent() + 1 return [row, column] def checkForTableBoundary (self, oldFocus, newFocus): """Check to see if we've crossed any table boundaries, speaking the appropriate details when we have. Arguments: - oldFocus: Accessible that is the old locus of focus - newFocus: Accessible that is the new locus of focus """ if oldFocus == None or newFocus == None: return [oldFocusIsTable, oldFocusRows, oldFocusColumns] = \ self.getTableAndDimensions(oldFocus) [newFocusIsTable, newFocusRows, newFocusColumns] = \ self.getTableAndDimensions(newFocus) # [[[TODO: JD - It is possible to move focus into the object # that contains the object that contains the text object. We # need to detect this and adjust accordingly.]]] if not oldFocusIsTable and newFocusIsTable: # Translators: this represents the number of rows in a table. # rowString = ngettext("table with %d row", "table with %d rows", newFocusRows) % newFocusRows # Translators: this represents the number of columns in a table. # colString = ngettext("%d column", "%d columns", newFocusColumns) % newFocusColumns line = rowString + " " + colString self.presentMessage(line) elif oldFocusIsTable and not newFocusIsTable: # We've left a table. Announce this fact. # self.presentMessage(_("leaving table.")) elif oldFocusIsTable and newFocusIsTable: # See if we've crossed a cell boundary. If so, speak # what has changed (per Mike). # [oldRow, oldCol] = \ self.getCellCoordinates(oldFocusIsTable, oldFocus) [newRow, newCol] = \ self.getCellCoordinates(newFocusIsTable, newFocus) # We can't count on being in the first/last cell # of the new row -- only the first/last cell of # the new row that contains data. # if newRow != oldRow: # Translators: this represents the row and column we're # on in a table. # line = _("row %(row)d, column %(column)d") \ % {"row": newRow, "column": newCol} self.presentMessage(line) elif newCol != oldCol: # Translators: this represents the column we're # on in a table. # line = _("column %d") % newCol self.presentMessage(line) def isInFindToolbar(self, obj): """Examines the current object to identify if it is in the Find tool bar. If so, it also sets findToolbarName so that we can identify this frame by name independent of localization. Arguments: - obj: an Accessible Returns True if the object is in the Find tool bar. """ inFindToolbar = False rolesList = [pyatspi.ROLE_DRAWING_AREA, pyatspi.ROLE_DRAWING_AREA, pyatspi.ROLE_DRAWING_AREA, pyatspi.ROLE_TOOL_BAR, pyatspi.ROLE_PANEL, pyatspi.ROLE_PANEL, pyatspi.ROLE_FRAME] try: while obj.getRole() != pyatspi.ROLE_DRAWING_AREA: obj = obj.parent if self.utilities.hasMatchingHierarchy(obj, rolesList): inFindToolbar = True frame = self.utilities.ancestorWithRole( obj, [pyatspi.ROLE_FRAME], []) self.findToolbarName = frame.name except: pass return inFindToolbar def onFocus(self, event): """Called whenever an object gets focus. Overridden in this script because we sometimes get a focus event in addition to caret-moved events when we change from one area in the document to another. We want to minimize the repetition of text along with the unnecessary speaking of object types (e.g. drawing area, text, etc.). Arguments: - event: the Event """ self.currentInputEvent = None # We sometimes get focus events for items that don't -- # or don't yet) have focus. Ignore these. # if event.source.getRole() in [pyatspi.ROLE_CHECK_BOX, pyatspi.ROLE_PUSH_BUTTON, pyatspi.ROLE_RADIO_BUTTON] \ and not event.source.getState().contains(pyatspi.STATE_FOCUSED): return if not event.source.getState().contains(pyatspi.STATE_SHOWING): return if not self.findToolbarActive \ and event.source.getRole() == pyatspi.ROLE_TEXT: parent = event.source.parent if parent and parent.getRole() in [pyatspi.ROLE_DRAWING_AREA, pyatspi.ROLE_UNKNOWN]: # We're going to get at least one (and likely several) # caret-moved events which will cause this to get spoken, # so skip it for now. # return if event.source.getRole() == pyatspi.ROLE_DRAWING_AREA: # A drawing area can claim focus when visually what has focus is # a text object that is a child of the drawing area. When this # occurs, Orca doesn't see the text. Therefore, try to figure out # where we are based on where we were and what key we pressed. # Then set the event.source accordingly before handing things off # to the default script. # debug.println(self.debugLevel, "acroread: Drawing area bug") lastKey, mods = self.utilities.lastKeyAndModifiers() locusOfFocusIndex = orca_state.locusOfFocus.getIndexInParent() childIndex = None # [[[TODO: JD - These aren't all of the possibilities. This is # very much a work in progress and of testing.]]] # if lastKey == "Up": childIndex = locusOfFocusIndex - 1 elif lastKey == "Down": childIndex = locusOfFocusIndex + 1 elif lastKey == "Right" or lastKey == "End": childIndex = locusOfFocusIndex elif lastKey == "Left" or lastKey == "Home": childIndex = locusOfFocusIndex if (childIndex >= 0): child = event.source[childIndex] event.source = child default.Script.onFocus(self, event) def locusOfFocusChanged(self, event, oldLocusOfFocus, newLocusOfFocus): """Called when the visual object with focus changes. Overridden in this script to minimize the repetition of text along with the unnecessary speaking of object types. Arguments: - event: if not None, the Event that caused the change - oldLocusOfFocus: Accessible that is the old locus of focus - newLocusOfFocus: Accessible that is the new locus of focus """ if not newLocusOfFocus or (oldLocusOfFocus == newLocusOfFocus): return # Eliminate unnecessary chattiness related to the Find toolbar. # if self.findToolbarActive: if newLocusOfFocus.getRole() == pyatspi.ROLE_TEXT: newText = self.getTextLineAtCaret(newLocusOfFocus) if newText == self.preFindLine: orca.setLocusOfFocus( event, oldLocusOfFocus, notifyScript=False) return if newLocusOfFocus.getRole() == pyatspi.ROLE_DRAWING_AREA: orca.setLocusOfFocus(event, oldLocusOfFocus, notifyScript=False) return utterances = \ self.speechGenerator.generateSpeech(newLocusOfFocus) speech.speak(utterances) self.displayBrailleForObject(newLocusOfFocus) orca.setLocusOfFocus(event, newLocusOfFocus, notifyScript=False) return # Eliminate unnecessary chattiness in the Search panel. # if newLocusOfFocus.getRole() == pyatspi.ROLE_PUSH_BUTTON \ and oldLocusOfFocus \ and oldLocusOfFocus.getRole() == self.ROLE_LINK \ and newLocusOfFocus.name == oldLocusOfFocus.name: return # Eliminate general document chattiness. # if newLocusOfFocus.getRole() in [self.ROLE_DOCUMENT, pyatspi.ROLE_DRAWING_AREA]: orca.setLocusOfFocus(event, newLocusOfFocus, notifyScript=False) return elif newLocusOfFocus.getRole() == self.ROLE_LINK: # It seems that this will be the only event we will get. But # the default script's onFocus will result in unnecessary # verboseness: reporting the drawing area(s) in which this link # is contained, speaking the periods in a table of contents, etc. # utterances = self.speechGenerator.generateSpeech(newLocusOfFocus) adjustedUtterances = [] for utterance in utterances: adjustedUtterances.append( self.utilities.adjustForRepeats(utterance)) speech.speak(adjustedUtterances) self.displayBrailleForObject(newLocusOfFocus) orca.setLocusOfFocus(event, newLocusOfFocus, notifyScript=False) return default.Script.locusOfFocusChanged(self, event, oldLocusOfFocus, newLocusOfFocus) def onCaretMoved(self, event): """Called whenever the caret moves. Overridden in this script because we want to minimize the repetition of text and the speaking of erroneous events. Arguments: - event: the Event """ lastInputEvent = orca_state.lastInputEvent lastKey, mods = self.utilities.lastKeyAndModifiers() # A single keypress usually results in multiple, not necessarily # identical, caret-moved events. Check to see if the events are # identical or very closely timed (time chosen based on testing). # if self.currentInputEvent and lastInputEvent: timeDiff = abs(self.currentInputEvent.time - lastInputEvent.time) if self.currentInputEvent == lastInputEvent or timeDiff < 0.2: return # Changing pages sometimes results in a caret-moved event for # text that may or may NOT have had focus recently. Sometimes # we luck out and it's not showing. # if not event.source.getState().contains(pyatspi.STATE_SHOWING): return # Other times, it's showing, but happens to be the text we just # left. Since this SEEMS limited to page up/page down, let's be # conservative until we have evidence to the contrary. # textLine = self.getTextLineAtCaret(event.source) isOldLine = textLine == self.lastCaretMovedLine and \ (lastKey == "Page_Down" or lastKey == "Page_Up") if isOldLine: self.lastCaretMovedLine = None return else: self.lastCaretMovedLine = textLine # [[[TODO: JD - Sometimes it's showing AND we didn't just leave # it. This also seems to occur sometimes with the Find toolbar.]]] self.currentInputEvent = orca_state.lastInputEvent self.checkForTableBoundary(orca_state.locusOfFocus, event.source) default.Script.onCaretMoved(self, event) def onStateChanged(self, event): """Called whenever an object's state changes. Arguments: - event: the Event """ if event.type.startswith("object:state-changed:checked") \ and event.source.getRole() == pyatspi.ROLE_RADIO_BUTTON: # Radio buttons in the Search panel are not automatically # selected when you arrow to them. You have to press Space # to select the current radio button. Watch for this. # self.visualAppearanceChanged(event, event.source) return elif event.type.startswith("object:state-changed:focused") \ and event.detail1 == 1: if event.source.getRole() == pyatspi.ROLE_PUSH_BUTTON: # Try to minimize chattiness in the Search panel # utterances = \ self.speechGenerator.generateSpeech(event.source) speech.speak(utterances) self.displayBrailleForObject(event.source) orca.setLocusOfFocus(event, event.source, notifyScript=False) return elif event.source.getRole() == pyatspi.ROLE_TEXT: # There's an excellent chance that the Find toolbar just # gained focus. Check. # if self.isInFindToolbar(event.source): self.findToolbarActive = True default.Script.onStateChanged(self, event) def onWindowDeactivated(self, event): """Called whenever a toplevel window is deactivated. Overridden in this script to deal with significant chattiness surrounding the use of the Find toolbar. Arguments: - event: the Event """ locusOfFocus = orca_state.locusOfFocus if event.source.name == self.findToolbarName: self.findToolbarActive = False else: try: locusOfFocus.queryText() except NotImplementedError: pass else: self.preFindLine = self.getTextLineAtCaret(locusOfFocus) default.Script.onWindowDeactivated(self, event) def textLinesFromNodeList(self, obj, nodeList=None): """A generator that can be used to iterate over each line of a text object, starting at the caret offset. Overridden here because we are not getting any RELATION_FLOWS_TO from acroread. Arguments: - obj: An Accessible that contains children. Initially, the document itself. - nodeList: A list reflecting the position of the current text object """ for textObj in self.getNextTextObject(obj, nodeList): for [context, acss] in default.Script.textLines(self, textObj): yield [context, acss] def sayAll(self, inputEvent): """Speaks the contents of the document beginning with the present location. Overridden in this script because the default sayAll only speaks the current text object. Arguments: - inputEvent: if not None, the input event that caused this action. """ if orca_state.locusOfFocus: nodeList = self.findNodeInDocument(orca_state.locusOfFocus) document = self.getDocument(orca_state.locusOfFocus) # Note: We get the correct progress callback, but acroread # doesn't seem to respond to setCaretOffset, so we cannot # update our location when sayAll is interrupted or finished. # speech.sayAll(self.textLinesFromNodeList(document, nodeList), self.__sayAllProgressCallback) else: default.Script.sayAll(self, inputEvent) return True def sayWord(self, obj): """Speaks the word at the caret. Overridden here because we seem to be getting the details of the word we just left when moving forward with Control Right Arrow. Control Left Arrow works as expected with the default script with the exception of crossing over a blank line (which sometimes causes the word with focus to be repeated). Both problems are addressed here. Arguments: - obj: an Accessible object that implements the AccessibleText interface """ if not (obj.parent.getRole() in [pyatspi.ROLE_DRAWING_AREA, pyatspi.ROLE_UNKNOWN]): default.Script.sayWord(self, obj) else: text = obj.queryText() offset = text.caretOffset lastKey, mods = self.utilities.lastKeyAndModifiers() if lastKey == "Right": penultimateWord = orca_state.lastWord [lastWord, startOffset, endOffset] = \ text.getTextAtOffset(offset, pyatspi.TEXT_BOUNDARY_WORD_START) [word, startOffset, endOffset] = \ text.getTextAfterOffset(endOffset+1, pyatspi.TEXT_BOUNDARY_WORD_START) if len(penultimateWord) > 0: lastCharPW = penultimateWord[len(penultimateWord) - 1] if lastCharPW == "\n": voice = self.voices[settings.DEFAULT_VOICE] speech.speakCharacter("\n", voice) if penultimateWord != lastWord: word = lastWord if lastKey == "Left": lastWord = orca_state.lastWord [word, startOffset, endOffset] = \ text.getTextAtOffset(offset, pyatspi.TEXT_BOUNDARY_WORD_START) if len(word) > 0: lastChar = word[len(word) - 1] if lastChar == "\n" and lastWord != word: voice = self.voices[settings.DEFAULT_VOICE] speech.speakCharacter("\n", voice) if lastWord == word: return if self.utilities.linkIndex(obj, offset) >= 0: voice = self.voices[settings.HYPERLINK_VOICE] elif word.decode("UTF-8").isupper(): voice = self.voices[settings.UPPERCASE_VOICE] else: voice = self.voices[settings.DEFAULT_VOICE] word = self.utilities.adjustForRepeats(word) orca_state.lastWord = word speech.speak(word, voice) self.speakTextSelectionState(obj, startOffset, endOffset)