forked from nvaccess/nvda
-
Notifications
You must be signed in to change notification settings - Fork 0
/
soffice.py
executable file
·410 lines (359 loc) · 14.7 KB
/
soffice.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# A part of NonVisual Desktop Access (NVDA)
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2006-2022 NV Access Limited, Bill Dengler, Leonard de Ruijter
from typing import (
Optional,
Union
)
from comtypes import COMError
import comtypes.client
import oleacc
from IAccessibleHandler import IA2, splitIA2Attribs
import appModuleHandler
import controlTypes
from controlTypes import TextPosition
import textInfos
import colors
from compoundDocuments import CompoundDocument, TreeCompoundTextInfo
from NVDAObjects import NVDAObject
from NVDAObjects.IAccessible import IAccessible, IA2TextTextInfo
from NVDAObjects.behaviors import EditableText
from logHandler import log
import speech
import api
import braille
import inputCore
import languageHandler
import vision
class SymphonyTextInfo(IA2TextTextInfo):
def _getFormatFieldAndOffsets(self,offset,formatConfig,calculateOffsets=True):
obj = self.obj
try:
startOffset,endOffset,attribsString=obj.IAccessibleTextObject.attributes(offset)
except COMError:
log.debugWarning("could not get attributes",exc_info=True)
return textInfos.FormatField(),(self._startOffset,self._endOffset)
formatField=textInfos.FormatField()
if not attribsString and offset>0:
try:
attribsString=obj.IAccessibleTextObject.attributes(offset-1)[2]
except COMError:
pass
if attribsString:
formatField.update(splitIA2Attribs(attribsString))
try:
escapement = int(formatField["CharEscapement"])
if escapement < 0:
formatField["text-position"] = TextPosition.SUBSCRIPT
elif escapement > 0:
formatField["text-position"] = TextPosition.SUPERSCRIPT
else:
formatField["text-position"] = TextPosition.BASELINE
except KeyError:
pass
try:
formatField["font-name"] = formatField["CharFontName"]
except KeyError:
pass
try:
# Translators: Abbreviation for points, a measurement of font size.
formatField["font-size"] = pgettext("font size", "%s pt") % formatField["CharHeight"]
except KeyError:
pass
try:
formatField["italic"] = formatField["CharPosture"] == "2"
except KeyError:
pass
try:
formatField["strikethrough"] = formatField["CharStrikeout"] == "1"
except KeyError:
pass
try:
underline = formatField["CharUnderline"]
if underline == "10":
# Symphony doesn't provide for semantic communication of spelling errors, so we have to rely on the WAVE underline type.
formatField["invalid-spelling"] = True
else:
formatField["underline"] = underline != "0"
except KeyError:
pass
try:
formatField["bold"] = float(formatField["CharWeight"]) > 100
except KeyError:
pass
try:
color=formatField.pop('CharColor')
except KeyError:
color=None
if color:
formatField['color']=colors.RGB.fromString(color)
try:
backgroundColor=formatField.pop('CharBackColor')
except KeyError:
backgroundColor=None
if backgroundColor:
formatField['background-color']=colors.RGB.fromString(backgroundColor)
# optimisation: Assume a hyperlink occupies a full attribute run.
try:
if obj.IAccessibleTextObject.QueryInterface(
IA2.IAccessibleHypertext
).hyperlinkIndex(offset) != -1:
formatField["link"] = True
except COMError:
pass
if offset == 0:
# Only include the list item prefix on the first line of the paragraph.
numbering = formatField.get("Numbering")
if numbering:
formatField["line-prefix"] = numbering.get("NumberingPrefix") or numbering.get("BulletChar")
if obj.hasFocus:
# Symphony exposes some information for the caret position as attributes on the document object.
# optimisation: Use the tree interceptor to get the document.
try:
docAttribs = obj.treeInterceptor.rootNVDAObject.IA2Attributes
except AttributeError:
# No tree interceptor, so we can't efficiently fetch this info.
pass
else:
try:
formatField["page-number"] = docAttribs["page-number"]
except KeyError:
pass
try:
formatField["line-number"] = docAttribs["line-number"]
except KeyError:
pass
return formatField,(startOffset,endOffset)
def _getLineOffsets(self, offset):
start, end = super(SymphonyTextInfo, self)._getLineOffsets(offset)
if offset == 0 and start == 0 and end == 0:
# HACK: Symphony doesn't expose any characters at all on empty lines, but this means we don't ever fetch the list item prefix in this case.
# Fake a character so that the list item prefix will be spoken on empty lines.
return (0, 1)
return start, end
def _getStoryLength(self):
# HACK: Account for the character faked in _getLineOffsets() so that move() will work.
return max(super(SymphonyTextInfo, self)._getStoryLength(), 1)
class SymphonyText(IAccessible, EditableText):
TextInfo = SymphonyTextInfo
def _get_positionInfo(self):
level = self.IA2Attributes.get("heading-level")
if level:
return {"level": int(level)}
return super(SymphonyText, self).positionInfo
class SymphonyTableCell(IAccessible):
"""Silences particular states, and redundant column/row numbers"""
TextInfo=SymphonyTextInfo
def _get_cellCoordsText(self):
return super(SymphonyTableCell,self).name
name=None
def _get_hasSelection(self):
return (
self.selectionContainer
and 1 < self.selectionContainer.getSelectedItemsCount()
)
def _get_states(self):
states=super(SymphonyTableCell,self).states
states.discard(controlTypes.State.MULTILINE)
states.discard(controlTypes.State.EDITABLE)
if controlTypes.State.SELECTED not in states and controlTypes.State.FOCUSED in states:
# #8988: Cells in Libre Office do not have the selected state when a single cell is selected (i.e. has focus).
# Since #8898, the negative selected state is announced for table cells with the selectable state.
if self.hasSelection:
# The selected state is never added to a focused object, even though it is selected.
# We assume our focus is in the selection.
states.add(controlTypes.State.SELECTED)
else:
# Remove SELECTABLE to ensure the negative SELECTED state isn't spoken for focused cells.
states.discard(controlTypes.State.SELECTABLE)
if self.IA2Attributes.get('Formula'):
# #860: Recent versions of Calc expose has formula state via IAccessible 2.
states.add(controlTypes.State.HASFORMULA)
return states
class SymphonyIATableCell(SymphonyTableCell):
"""An overlay class for cells implementing IAccessibleTableCell"""
def event_selectionAdd(self):
curFocus = api.getFocusObject()
if (
self.table
and self.table == curFocus.table
and self.table.IAccessibleTable2Object.nSelectedCells > 0
):
curFocus.announceSelectionChange()
def event_selectionRemove(self):
self.event_selectionAdd()
def announceSelectionChange(self):
if self is api.getFocusObject():
speech.speakObjectProperties(
self,
states=True,
cellCoordsText=True,
reason=controlTypes.OutputReason.CHANGE
)
braille.handler.handleUpdate(self)
vision.handler.handleUpdate(self, property="states")
def _get_cellCoordsText(self):
if self.hasSelection and controlTypes.State.FOCUSED in self.states:
count = self.table.IAccessibleTable2Object.nSelectedCells
selection = self.table.IAccessibleObject.accSelection
enumObj = selection.QueryInterface(oleacc.IEnumVARIANT)
firstChild: Union[int, comtypes.client.dynamic._Dispatch]
firstChild, _retrievedCount = enumObj.Next(1)
# skip over all except the last element
enumObj.Skip(count - 2)
lastChild: Union[int, comtypes.client.dynamic._Dispatch]
lastChild, _retrieveCount = enumObj.Next(1)
# in LibreOffice 7.3.0, the IEnumVARIANT returns a child ID,
# in LibreOffice >= 7.4, it returns an IDispatch
if isinstance(firstChild, int):
tableAccessible = self.table.IAccessibleTable2Object.QueryInterface(IA2.IAccessible2)
firstAccessible = tableAccessible.accChild(firstChild).QueryInterface(IA2.IAccessible2)
lastAccessible = tableAccessible.accChild(lastChild).QueryInterface(IA2.IAccessible2)
elif isinstance(firstChild, comtypes.client.dynamic._Dispatch):
firstAccessible = firstChild.QueryInterface(IA2.IAccessible2)
lastAccessible = lastChild.QueryInterface(IA2.IAccessible2)
else:
raise RuntimeError(f"Unexpected LibreOffice object {firstChild}, type: {type(firstChild)}")
firstAddress = firstAccessible.accName(0)
firstValue = firstAccessible.accValue(0) or ''
lastAddress = lastAccessible.accName(0)
lastValue = lastAccessible.accValue(0) or ''
# Translators: LibreOffice, report selected range of cell coordinates with their values
return _("{firstAddress} {firstValue} through {lastAddress} {lastValue}").format(
firstAddress=firstAddress,
firstValue=firstValue,
lastAddress=lastAddress,
lastValue=lastValue
)
elif self.rowSpan > 1 or self.columnSpan > 1:
lastSelected = (
(self.rowNumber - 1) + (self.rowSpan - 1),
(self.columnNumber - 1) + (self.columnSpan - 1)
)
lastCellUnknown = self.table.IAccessibleTable2Object.cellAt(*lastSelected)
lastAccessible = lastCellUnknown.QueryInterface(IA2.IAccessible2)
lastAddress = lastAccessible.accName(0)
# Translators: LibreOffice, report range of cell coordinates
return _("{firstAddress} through {lastAddress}").format(
firstAddress=self._get_name(),
lastAddress=lastAddress
)
return super().cellCoordsText
class SymphonyTable(IAccessible):
def event_selectionWithIn(self):
curFocus = api.getFocusObject()
if self == curFocus.table:
curFocus.announceSelectionChange()
class SymphonyParagraph(SymphonyText):
"""Removes redundant information that can be retreaved in other ways."""
value=None
description=None
def getDistanceTextForTwips(twips):
"""Returns a text representation of the distance given in twips,
converted to the local measurement unit."""
if languageHandler.useImperialMeasurements():
val = twips / 1440.0
# Translators: a measurement in inches
valText = _("{val:.2f} inches").format(val=val)
else:
val = twips * 0.0017638889
# Translators: a measurement in centimetres
valText = _("{val:.2f} centimetres").format(val=val)
return valText
class SymphonyDocumentTextInfo(TreeCompoundTextInfo):
def _get_locationText(self):
try:
# if present, use document attributes to get cursor position relative to page
docAttribs = self.obj.rootNVDAObject.IA2Attributes
horizontalPos = int(docAttribs["cursor-position-in-page-horizontal"])
horizontalDistanceText = getDistanceTextForTwips(horizontalPos)
verticalPos = int(docAttribs["cursor-position-in-page-vertical"])
verticalDistanceText = getDistanceTextForTwips(verticalPos)
return _(
# Translators: LibreOffice, report cursor position in the current page
"cursor positioned {horizontalDistance} from left edge of page, {verticalDistance} from top edge of page"
).format(horizontalDistance=horizontalDistanceText, verticalDistance=verticalDistanceText)
except (AttributeError, KeyError):
return super(SymphonyDocumentTextInfo, self)._get_locationText()
class SymphonyDocument(CompoundDocument):
TextInfo = SymphonyDocumentTextInfo
# override base class implementation because that one assumes
# that the text retrieved from the text info for the text unit
# is the same as the text that actually gets removed, which at
# least isn't true for Writer paragraphs when removing a word
# followed by whitespace using Ctrl+Backspace
def _backspaceScriptHelper(self, unit: str, gesture: inputCore.InputGesture):
try:
oldInfo = self.makeTextInfo(textInfos.POSITION_CARET)
ia2TextObj = oldInfo._start.obj.IAccessibleTextObject
oldCaretOffset = ia2TextObj.caretOffset
oldText = ia2TextObj.text(0, ia2TextObj.nCharacters)
except NotImplementedError:
gesture.send()
return
gesture.send()
newInfo = self.makeTextInfo(textInfos.POSITION_CARET)
ia2TextObj = newInfo._start.obj.IAccessibleTextObject
newCaretOffset = ia2TextObj.caretOffset
newText = ia2TextObj.text(0, ia2TextObj.nCharacters)
# double-check check that text between previous and current
# caret position was deleted and announce it
deletedText = oldText[newCaretOffset:oldCaretOffset]
if newText == oldText[0:newCaretOffset] + oldText[oldCaretOffset:]:
if len(deletedText) > 1:
speech.speakMessage(deletedText)
else:
speech.speakSpelling(deletedText)
self._caretScriptPostMovedHelper(None, gesture, newInfo)
else:
log.warning('Backspace did not remove text as expected.')
class AppModule(appModuleHandler.AppModule):
def chooseNVDAObjectOverlayClasses(self, obj, clsList):
role=obj.role
windowClassName=obj.windowClassName
if isinstance(obj, IAccessible) and windowClassName in ("SALTMPSUBFRAME", "SALSUBFRAME", "SALFRAME"):
if role == controlTypes.Role.TABLECELL:
if obj._IATableCell:
clsList.insert(0, SymphonyIATableCell)
else:
clsList.insert(0, SymphonyTableCell)
elif role == controlTypes.Role.TABLE and (
hasattr(obj, "IAccessibleTable2Object")
or hasattr(obj, "IAccessibleTableObject")
):
clsList.insert(0, SymphonyTable)
elif hasattr(obj, "IAccessibleTextObject"):
clsList.insert(0, SymphonyText)
if role == controlTypes.Role.PARAGRAPH:
clsList.insert(0, SymphonyParagraph)
def event_NVDAObject_init(self, obj):
windowClass = obj.windowClassName
if windowClass in ("SALTMPSUBFRAME", "SALFRAME") and obj.role in (controlTypes.Role.DOCUMENT,controlTypes.Role.TEXTFRAME) and obj.description:
# This is a word processor document.
obj.description = None
obj.treeInterceptorClass = SymphonyDocument
def searchStatusBar(self, obj: NVDAObject, max_depth: int = 5) -> Optional[NVDAObject]:
"""Searches for and returns the status bar object
if either the object itself or one of its recursive children
(up to the given depth) has the corresponding role."""
if obj.role == controlTypes.Role.STATUSBAR:
return obj
if max_depth < 1 or obj.role not in {controlTypes.Role.ROOTPANE, controlTypes.Role.WINDOW}:
return None
for child in obj.children:
status_bar = self.searchStatusBar(child, max_depth - 1)
if status_bar:
return status_bar
return None
def _get_statusBar(self) -> Optional[NVDAObject]:
return self.searchStatusBar(api.getForegroundObject())
def getStatusBarText(self, obj: NVDAObject) -> str:
text = ""
for child in obj.children:
textObj = child.IAccessibleTextObject
if textObj:
if text:
text += " "
text += textObj.textAtOffset(0, IA2.IA2_TEXT_BOUNDARY_ALL)[2]
return text