diff --git a/source/NVDAObjects/UIA/wordDocument.py b/source/NVDAObjects/UIA/wordDocument.py index 96ca9fd4aec..a1836880f85 100644 --- a/source/NVDAObjects/UIA/wordDocument.py +++ b/source/NVDAObjects/UIA/wordDocument.py @@ -270,6 +270,18 @@ def _getTextFromUIARange(self, textRange): t = t.replace(END_OF_ROW_MARK, "") return t + def _getTextForCodepointMovement(self) -> str: + """ + #8576, #10960: In Word, list bullets are exposed in text but are ignored when moving by character. + Therefore in `getTextWithFields`, the bullets are stripped from the text and exposed in the `line-prefix` field. + To stay compatible with this, we can't simply use the `text` property, + as it can potentially contain bullets that should be stripped. + """ + t = super()._getTextForCodepointMovement() + if not t: + return t + return "".join(f for f in self.getTextWithFields(formatConfig=dict()) if isinstance(f, str)) + def _isEndOfRow(self): """Is this textInfo positioned on an end-of-row mark?""" info = self.copy() @@ -648,7 +660,7 @@ def _caretMoveBySentenceHelper(self, gesture, direction): description=_( # Translators: a description for a script that reports the comment at the caret. "Reports the text of the comment where the system caret is located." - " If pressed twice, presents the information in a browsable message" + " If pressed twice, presents the information in a browsable message", ), category=SCRCAT_SYSTEMCARET, speakOnDemand=True, diff --git a/source/braille.py b/source/braille.py index 2a1757ed578..48247bc132a 100644 --- a/source/braille.py +++ b/source/braille.py @@ -1426,14 +1426,39 @@ def update(self): self._brailleInputIndStart = None def getTextInfoForBraillePos(self, braillePos: int) -> textInfos.TextInfo: - """Fetches a collapsed TextInfo at the specified braille position in the region.""" + """Fetches a collapsed TextInfo at the specified braille position in the region. + :param braillePos: The braille position. + If no textInfo could be found at braillePos, + try to find one at braillePos - 1 until a position has been found. + """ pos = self._rawToContentPos[self.brailleToRawPos[braillePos]] # pos is relative to the start of the reading unit. - # Therefore, get the start of the reading unit... + maxIterations = 10 + startTime = time.time() + for i, curPos in enumerate(range(pos, max(-1, pos - maxIterations), -1)): + if curPos == 0: + # Not necessary to find offset. + break + # Move curPos code points from the start. + # Note that, as liblouis uses 32 bit encoding internally, + # it is really safe to assume that one code point offset is equal to one character within liblouis. + # If an attempt fails, we try to move to the previous character + try: + return self._readingInfo.moveToCodepointOffset(curPos) + except RuntimeError: + msg = f"Error in moveToCodepointOffset in iteration {i + 1} (position {curPos}" + if i + 1 >= maxIterations or (exceeded := time.time() - startTime > 0.5): + logFunc = log.exception + curPos = pos + if exceeded: + msg += ", exceeded time limit of 0.5 seconds" + else: + logFunc = log.debug + logFunc(msg) dest = self._readingInfo.copy() dest.collapse() - # and move pos characters from there. - dest.move(textInfos.UNIT_CHARACTER, pos) + if curPos > 0: + dest.move(textInfos.UNIT_CHARACTER, curPos) return dest def routeTo(self, braillePos: int): diff --git a/source/textInfos/__init__.py b/source/textInfos/__init__.py index 56d487f6d91..3d5dfdbe494 100755 --- a/source/textInfos/__init__.py +++ b/source/textInfos/__init__.py @@ -711,6 +711,10 @@ def getMathMl(self, field): """ raise NotImplementedError + def _getTextForCodepointMovement(self) -> str: + """Gets the text as used in moveToCodepointOffset.""" + return self.text + def moveToCodepointOffset( self, codepointOffset: int, @@ -803,7 +807,7 @@ def moveToCodepointOffset( we reduce the count of characters in order to make sure the algorithm makes some progress on each iteration. """ - text = self.text + text = self._getTextForCodepointMovement() if codepointOffset < 0 or codepointOffset > len(text): raise ValueError if codepointOffset == 0 or codepointOffset == len(text): @@ -845,7 +849,7 @@ def moveToCodepointOffset( moveCharacters = codepointOffsetLeft code = tmpInfo.move(UNIT_CHARACTER, moveCharacters, endPoint="end") lastMove = moveCharacters - tmpText = tmpInfo.text + tmpText = tmpInfo._getTextForCodepointMovement() actualCodepointOffset = len(tmpText) if not text.startswith(tmpText): raise RuntimeError( @@ -865,7 +869,7 @@ def moveToCodepointOffset( moveCharacters = -codepointOffsetRight code = tmpInfo.move(UNIT_CHARACTER, moveCharacters, endPoint="start") lastMove = moveCharacters - tmpText = tmpInfo.text + tmpText = tmpInfo._getTextForCodepointMovement() actualCodepointOffset = totalCodepointOffset - len(tmpText) if not text.endswith(tmpText): raise RuntimeError( diff --git a/tests/unit/test_braille/test_routing.py b/tests/unit/test_braille/test_routing.py index 5c984dfe013..debbeb72a98 100644 --- a/tests/unit/test_braille/test_routing.py +++ b/tests/unit/test_braille/test_routing.py @@ -1,16 +1,16 @@ # 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) 2023 NV Access Limited, Leonard de Ruijter +# Copyright (C) 2023-2024 NV Access Limited, Leonard de Ruijter -"""Unit tests for the move system caret when routing review cursor braille setting.""" +"""Unit tests for braille cursor routing.""" import config import braille import textInfos import api import controlTypes -from ..textProvider import CursorManager +from ..textProvider import CursorManager, BasicTextProvider import unittest import time from config.featureFlagEnums import ReviewRoutingMovesSystemCaretFlag @@ -147,3 +147,58 @@ def test_moveCaret_always_instantActivate(self): self.assertGreaterEqual(self.cm.lastActivateTime, curTime) caret = self.cm.makeTextInfo(textInfos.POSITION_CARET) self.assertEquals(caret, review) + + +class TestTextInfoRegionRouting(unittest.TestCase): + """A test for TextInfoRegion.getTextInfoForBraillePos, which is used in braille cursor routing. + This test ensures that braille routes to the expected character when dealing with emoji + or other composites. + These glyphs are threated as one character by uniscribe, however they span multiple characters + on a braille display. + Note that due to the nature of this test, it relies on uniscribe to be available. + """ + + def test_routeToEmoji(self): + testText = "⚠️test" + obj = BasicTextProvider(text=testText) + ti = obj.makeTextInfo(textInfos.POSITION_CARET) + ti.expand(textInfos.UNIT_CHARACTER) + self.assertEqual(ti.text, testText[:2]) + ti.collapse(end=True) + ti.expand(textInfos.UNIT_CHARACTER) + self.assertEqual(ti.text, testText[2]) + region = braille.TextInfoRegion(obj) + region.update() + index = 3 # Position of e + pos = region.rawToBraillePos[index] + region.routeTo(pos) + ti = obj.makeTextInfo(textInfos.POSITION_CARET) + ti.expand(textInfos.UNIT_CHARACTER) + self.assertEqual(ti.text, testText[index]) + + def test_routeToComposite(self): + testText = "רבְּר" + obj = BasicTextProvider(text=testText) + ti = obj.makeTextInfo(textInfos.POSITION_CARET) + ti.expand(textInfos.UNIT_CHARACTER) + self.assertEqual(ti.text, testText[0]) + ti.collapse(end=True) + ti.expand(textInfos.UNIT_CHARACTER) + self.assertEqual(ti.text, testText[1:4]) + ti.collapse(end=True) + ti.expand(textInfos.UNIT_CHARACTER) + self.assertEqual(ti.text, testText[4]) + region = braille.TextInfoRegion(obj) + region.update() + index = 1 # Position of ב + pos = region.rawToBraillePos[index] + region.routeTo(pos) + ti = obj.makeTextInfo(textInfos.POSITION_CARET) + ti.expand(textInfos.UNIT_CHARACTER) + self.assertEqual(ti.text, testText[1:4]) + index = 3 # Position of ּ (\u5bc) + pos = region.rawToBraillePos[index] + region.routeTo(pos) + ti = obj.makeTextInfo(textInfos.POSITION_CARET) + ti.expand(textInfos.UNIT_CHARACTER) + self.assertEqual(ti.text, testText[1:4]) diff --git a/tests/unit/textProvider.py b/tests/unit/textProvider.py index 6b3d08bd909..8e072f047ae 100644 --- a/tests/unit/textProvider.py +++ b/tests/unit/textProvider.py @@ -18,8 +18,6 @@ class BasicTextInfo(NVDAObjectTextInfo): - # NVDAHelper is not initialized, so we can't use Uniscribe. - useUniscribe = False # Most of our code use UTF-16 as internal encoding. # Mimic this behavior, so we can also implicitly test textUtils module code encoding = textUtils.WCHAR_ENCODING diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index c7babad78b0..9e026288b25 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -19,6 +19,7 @@ * UIA in Windows Terminal. (#16873, @codeofdusk) * When accessing Microsoft Word without UI Automation, NVDA no longer outputs garbage characters in braille in table headers defined with the set row and column header commands. (#7212) * The Seika Notetaker driver now correctly generates braille input for space, backspace and dots with space/backspace gestures. (#16642, @school510587) +* Braille cursor routing is now much more reliable when a line contains one or more Unicode variation selectors or decomposed characters. (#10960, @mltony, @LeonarddeR) * 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)