Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion source/NVDAObjects/UIA/wordDocument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 29 additions & 4 deletions source/braille.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
start_time = time.time()
Comment thread
LeonarddeR marked this conversation as resolved.
Resolved
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() - start_time > 0.5):
Comment thread
LeonarddeR marked this conversation as resolved.
Resolved
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)
Comment thread
LeonarddeR marked this conversation as resolved.
Resolved
return dest

def routeTo(self, braillePos: int):
Expand Down
10 changes: 7 additions & 3 deletions source/textInfos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Comment thread
LeonarddeR marked this conversation as resolved.
Resolved
if codepointOffset < 0 or codepointOffset > len(text):
raise ValueError
if codepointOffset == 0 or codepointOffset == len(text):
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
139 changes: 136 additions & 3 deletions tests/unit/test_braille/test_routing.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -147,3 +147,136 @@ 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 ּ
Comment thread
LeonarddeR marked this conversation as resolved.
Resolved
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])
Comment thread
LeonarddeR marked this conversation as resolved.
Resolved

def test_routeToMultipleEmoji(self):
testText = "👩🏽‍🚀👨🏻‍🚒"
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:4])
region = braille.TextInfoRegion(obj)
region.update()
index = 4 # Position of the second emoji
pos = region.rawToBraillePos[index]
region.routeTo(pos)
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[4:6])

def test_routeToZeroWidthJoiner(self):
testText = "👨‍👩‍👧‍👦"
obj = BasicTextProvider(text=testText)
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[:1])
ti.collapse(end=True)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[1:2])
region = braille.TextInfoRegion(obj)
region.update()
index = 2 # Position of the second family member
pos = region.rawToBraillePos[index]
region.routeTo(pos)
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[2:3])

def test_routeToVariationSelector(self):
testText = "✌️"
obj = BasicTextProvider(text=testText)
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[:1])
ti.collapse(end=True)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[1:2])
region = braille.TextInfoRegion(obj)
region.update()
index = 1 # Position of the variation selector
pos = region.rawToBraillePos[index]
region.routeTo(pos)
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[1:2])

def test_routeToMixedContent(self):
testText = "Hello 👋, how are you? רָבּ"
obj = BasicTextProvider(text=testText)
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[:1])
ti.collapse(end=True)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[1:2])
region = braille.TextInfoRegion(obj)
region.update()
index = 6 # Position of the emoji
pos = region.rawToBraillePos[index]
region.routeTo(pos)
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[6:7])
index = 18 # Position of the Hebrew composite character
pos = region.rawToBraillePos[index]
region.routeTo(pos)
ti = obj.makeTextInfo(textInfos.POSITION_CARET)
ti.expand(textInfos.UNIT_CHARACTER)
self.assertEqual(ti.text, testText[18:21])
2 changes: 0 additions & 2 deletions tests/unit/textProvider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* UIA for XAML and WPF text controls. (#16817, @LeonarddeR)
* IAccessible2 for browsers such as Firefox and Chromium based browsers. (#11545, #16815, @LeonarddeR)
* UIA in Windows Terminal. (#16873, @codeofdusk)
* Braille cursor routing is now much more reliable when a line contains one or more Unicode variation selectors or decomposed characters. (#10960, @mltony, @LeonarddeR)
* The Seika Notetaker driver now correctly generates braille input for space, backspace and dots with space/backspace gestures. (#16642, @school510587)
* 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)

Expand Down