Skip to content

Commit 1181beb

Browse files
authored
Merge 2fb0160 into d1547ab
2 parents d1547ab + 2fb0160 commit 1181beb

6 files changed

Lines changed: 186 additions & 13 deletions

File tree

source/NVDAObjects/UIA/wordDocument.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,18 @@ def _getTextFromUIARange(self, textRange):
270270
t = t.replace(END_OF_ROW_MARK, "")
271271
return t
272272

273+
def _getTextForCodepointMovement(self) -> str:
274+
"""
275+
#8576, #10960: In Word, list bullets are exposed in text but are ignored when moving by character.
276+
Therefore in `getTextWithFields`, the bullets are stripped from the text and exposed in the `line-prefix` field.
277+
To stay compatible with this, we can't simply use the `text` property,
278+
as it can potentially contain bullets that should be stripped.
279+
"""
280+
t = super()._getTextForCodepointMovement()
281+
if not t:
282+
return t
283+
return "".join(f for f in self.getTextWithFields(formatConfig=dict()) if isinstance(f, str))
284+
273285
def _isEndOfRow(self):
274286
"""Is this textInfo positioned on an end-of-row mark?"""
275287
info = self.copy()
@@ -648,7 +660,7 @@ def _caretMoveBySentenceHelper(self, gesture, direction):
648660
description=_(
649661
# Translators: a description for a script that reports the comment at the caret.
650662
"Reports the text of the comment where the system caret is located."
651-
" If pressed twice, presents the information in a browsable message"
663+
" If pressed twice, presents the information in a browsable message",
652664
),
653665
category=SCRCAT_SYSTEMCARET,
654666
speakOnDemand=True,

source/braille.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1426,14 +1426,39 @@ def update(self):
14261426
self._brailleInputIndStart = None
14271427

14281428
def getTextInfoForBraillePos(self, braillePos: int) -> textInfos.TextInfo:
1429-
"""Fetches a collapsed TextInfo at the specified braille position in the region."""
1429+
"""Fetches a collapsed TextInfo at the specified braille position in the region.
1430+
:param braillePos: The braille position.
1431+
If no textInfo could be found at braillePos,
1432+
try to find one at braillePos - 1 until a position has been found.
1433+
"""
14301434
pos = self._rawToContentPos[self.brailleToRawPos[braillePos]]
14311435
# pos is relative to the start of the reading unit.
1432-
# Therefore, get the start of the reading unit...
1436+
maxIterations = 10
1437+
start_time = time.time()
1438+
for i, curPos in enumerate(range(pos, max(-1, pos - maxIterations), -1)):
1439+
if curPos == 0:
1440+
# Not necessary to find offset.
1441+
break
1442+
# Move curPos code points from the start.
1443+
# Note that, as liblouis uses 32 bit encoding internally,
1444+
# it is really safe to assume that one code point offset is equal to one character within liblouis.
1445+
# If an attempt fails, we try to move to the previous character
1446+
try:
1447+
return self._readingInfo.moveToCodepointOffset(curPos)
1448+
except RuntimeError:
1449+
msg = f"Error in moveToCodepointOffset in iteration {i + 1} (position {curPos}"
1450+
if i + 1 >= maxIterations or (exceeded := time.time() - start_time > 0.5):
1451+
logFunc = log.exception
1452+
curPos = pos
1453+
if exceeded:
1454+
msg += ", exceeded time limit of 0.5 seconds"
1455+
else:
1456+
logFunc = log.debug
1457+
logFunc(msg)
14331458
dest = self._readingInfo.copy()
14341459
dest.collapse()
1435-
# and move pos characters from there.
1436-
dest.move(textInfos.UNIT_CHARACTER, pos)
1460+
if curPos > 0:
1461+
dest.move(textInfos.UNIT_CHARACTER, curPos)
14371462
return dest
14381463

14391464
def routeTo(self, braillePos: int):

source/textInfos/__init__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,10 @@ def getMathMl(self, field):
711711
"""
712712
raise NotImplementedError
713713

714+
def _getTextForCodepointMovement(self) -> str:
715+
"""Gets the text as used in moveToCodepointOffset."""
716+
return self.text
717+
714718
def moveToCodepointOffset(
715719
self,
716720
codepointOffset: int,
@@ -803,7 +807,7 @@ def moveToCodepointOffset(
803807
we reduce the count of characters in order to make sure
804808
the algorithm makes some progress on each iteration.
805809
"""
806-
text = self.text
810+
text = self._getTextForCodepointMovement()
807811
if codepointOffset < 0 or codepointOffset > len(text):
808812
raise ValueError
809813
if codepointOffset == 0 or codepointOffset == len(text):
@@ -845,7 +849,7 @@ def moveToCodepointOffset(
845849
moveCharacters = codepointOffsetLeft
846850
code = tmpInfo.move(UNIT_CHARACTER, moveCharacters, endPoint="end")
847851
lastMove = moveCharacters
848-
tmpText = tmpInfo.text
852+
tmpText = tmpInfo._getTextForCodepointMovement()
849853
actualCodepointOffset = len(tmpText)
850854
if not text.startswith(tmpText):
851855
raise RuntimeError(
@@ -865,7 +869,7 @@ def moveToCodepointOffset(
865869
moveCharacters = -codepointOffsetRight
866870
code = tmpInfo.move(UNIT_CHARACTER, moveCharacters, endPoint="start")
867871
lastMove = moveCharacters
868-
tmpText = tmpInfo.text
872+
tmpText = tmpInfo._getTextForCodepointMovement()
869873
actualCodepointOffset = totalCodepointOffset - len(tmpText)
870874
if not text.endswith(tmpText):
871875
raise RuntimeError(

tests/unit/test_braille/test_routing.py

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
# A part of NonVisual Desktop Access (NVDA)
22
# This file is covered by the GNU General Public License.
33
# See the file COPYING for more details.
4-
# Copyright (C) 2023 NV Access Limited, Leonard de Ruijter
4+
# Copyright (C) 2023-2024 NV Access Limited, Leonard de Ruijter
55

6-
"""Unit tests for the move system caret when routing review cursor braille setting."""
6+
"""Unit tests for braille cursor routing."""
77

88
import config
99
import braille
1010
import textInfos
1111
import api
1212
import controlTypes
13-
from ..textProvider import CursorManager
13+
from ..textProvider import CursorManager, BasicTextProvider
1414
import unittest
1515
import time
1616
from config.featureFlagEnums import ReviewRoutingMovesSystemCaretFlag
@@ -147,3 +147,136 @@ def test_moveCaret_always_instantActivate(self):
147147
self.assertGreaterEqual(self.cm.lastActivateTime, curTime)
148148
caret = self.cm.makeTextInfo(textInfos.POSITION_CARET)
149149
self.assertEquals(caret, review)
150+
151+
152+
class TestTextInfoRegionRouting(unittest.TestCase):
153+
"""A test for TextInfoRegion.getTextInfoForBraillePos, which is used in braille cursor routing.
154+
This test ensures that braille routes to the expected character when dealing with emoji
155+
or other composites.
156+
These glyphs are threated as one character by uniscribe, however they span multiple characters
157+
on a braille display.
158+
Note that due to the nature of this test, it relies on uniscribe to be available.
159+
"""
160+
161+
def test_routeToEmoji(self):
162+
testText = "⚠️test"
163+
obj = BasicTextProvider(text=testText)
164+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
165+
ti.expand(textInfos.UNIT_CHARACTER)
166+
self.assertEqual(ti.text, testText[:2])
167+
ti.collapse(end=True)
168+
ti.expand(textInfos.UNIT_CHARACTER)
169+
self.assertEqual(ti.text, testText[2])
170+
region = braille.TextInfoRegion(obj)
171+
region.update()
172+
index = 3 # Position of e
173+
pos = region.rawToBraillePos[index]
174+
region.routeTo(pos)
175+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
176+
ti.expand(textInfos.UNIT_CHARACTER)
177+
self.assertEqual(ti.text, testText[index])
178+
179+
def test_routeToComposite(self):
180+
testText = "רבְּר"
181+
obj = BasicTextProvider(text=testText)
182+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
183+
ti.expand(textInfos.UNIT_CHARACTER)
184+
self.assertEqual(ti.text, testText[0])
185+
ti.collapse(end=True)
186+
ti.expand(textInfos.UNIT_CHARACTER)
187+
self.assertEqual(ti.text, testText[1:4])
188+
ti.collapse(end=True)
189+
ti.expand(textInfos.UNIT_CHARACTER)
190+
self.assertEqual(ti.text, testText[4])
191+
region = braille.TextInfoRegion(obj)
192+
region.update()
193+
index = 1 # Position of ב
194+
pos = region.rawToBraillePos[index]
195+
region.routeTo(pos)
196+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
197+
ti.expand(textInfos.UNIT_CHARACTER)
198+
self.assertEqual(ti.text, testText[1:4])
199+
index = 3 # Position of ּ
200+
pos = region.rawToBraillePos[index]
201+
region.routeTo(pos)
202+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
203+
ti.expand(textInfos.UNIT_CHARACTER)
204+
self.assertEqual(ti.text, testText[1:4])
205+
206+
def test_routeToMultipleEmoji(self):
207+
testText = "👩🏽‍🚀👨🏻‍🚒"
208+
obj = BasicTextProvider(text=testText)
209+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
210+
ti.expand(textInfos.UNIT_CHARACTER)
211+
self.assertEqual(ti.text, testText[:2])
212+
ti.collapse(end=True)
213+
ti.expand(textInfos.UNIT_CHARACTER)
214+
self.assertEqual(ti.text, testText[2:4])
215+
region = braille.TextInfoRegion(obj)
216+
region.update()
217+
index = 4 # Position of the second emoji
218+
pos = region.rawToBraillePos[index]
219+
region.routeTo(pos)
220+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
221+
ti.expand(textInfos.UNIT_CHARACTER)
222+
self.assertEqual(ti.text, testText[4:6])
223+
224+
def test_routeToZeroWidthJoiner(self):
225+
testText = "👨‍👩‍👧‍👦"
226+
obj = BasicTextProvider(text=testText)
227+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
228+
ti.expand(textInfos.UNIT_CHARACTER)
229+
self.assertEqual(ti.text, testText[:1])
230+
ti.collapse(end=True)
231+
ti.expand(textInfos.UNIT_CHARACTER)
232+
self.assertEqual(ti.text, testText[1:2])
233+
region = braille.TextInfoRegion(obj)
234+
region.update()
235+
index = 2 # Position of the second family member
236+
pos = region.rawToBraillePos[index]
237+
region.routeTo(pos)
238+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
239+
ti.expand(textInfos.UNIT_CHARACTER)
240+
self.assertEqual(ti.text, testText[2:3])
241+
242+
def test_routeToVariationSelector(self):
243+
testText = "✌️"
244+
obj = BasicTextProvider(text=testText)
245+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
246+
ti.expand(textInfos.UNIT_CHARACTER)
247+
self.assertEqual(ti.text, testText[:1])
248+
ti.collapse(end=True)
249+
ti.expand(textInfos.UNIT_CHARACTER)
250+
self.assertEqual(ti.text, testText[1:2])
251+
region = braille.TextInfoRegion(obj)
252+
region.update()
253+
index = 1 # Position of the variation selector
254+
pos = region.rawToBraillePos[index]
255+
region.routeTo(pos)
256+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
257+
ti.expand(textInfos.UNIT_CHARACTER)
258+
self.assertEqual(ti.text, testText[1:2])
259+
260+
def test_routeToMixedContent(self):
261+
testText = "Hello 👋, how are you? רָבּ"
262+
obj = BasicTextProvider(text=testText)
263+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
264+
ti.expand(textInfos.UNIT_CHARACTER)
265+
self.assertEqual(ti.text, testText[:1])
266+
ti.collapse(end=True)
267+
ti.expand(textInfos.UNIT_CHARACTER)
268+
self.assertEqual(ti.text, testText[1:2])
269+
region = braille.TextInfoRegion(obj)
270+
region.update()
271+
index = 6 # Position of the emoji
272+
pos = region.rawToBraillePos[index]
273+
region.routeTo(pos)
274+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
275+
ti.expand(textInfos.UNIT_CHARACTER)
276+
self.assertEqual(ti.text, testText[6:7])
277+
index = 18 # Position of the Hebrew composite character
278+
pos = region.rawToBraillePos[index]
279+
region.routeTo(pos)
280+
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
281+
ti.expand(textInfos.UNIT_CHARACTER)
282+
self.assertEqual(ti.text, testText[18:21])

tests/unit/textProvider.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@
1818

1919

2020
class BasicTextInfo(NVDAObjectTextInfo):
21-
# NVDAHelper is not initialized, so we can't use Uniscribe.
22-
useUniscribe = False
2321
# Most of our code use UTF-16 as internal encoding.
2422
# Mimic this behavior, so we can also implicitly test textUtils module code
2523
encoding = textUtils.WCHAR_ENCODING

user_docs/en/changes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* UIA for XAML and WPF text controls. (#16817, @LeonarddeR)
1818
* IAccessible2 for browsers such as Firefox and Chromium based browsers. (#11545, #16815, @LeonarddeR)
1919
* UIA in Windows Terminal. (#16873, @codeofdusk)
20+
* Braille cursor routing is now much more reliable when a line contains one or more Unicode variation selectors or decomposed characters. (#10960, @mltony, @LeonarddeR)
2021
* The Seika Notetaker driver now correctly generates braille input for space, backspace and dots with space/backspace gestures. (#16642, @school510587)
2122
* In on-demand speech mode, NVDA does not talk anymore when a message is opened in Outlook, when a new page is loaded in a browser or during the slideshow in PowerPoint. (#16825, @CyrilleB79)
2223

0 commit comments

Comments
 (0)