Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix PowerPoint caret reporting when text contains wide characters, and overall improvement of TextInfo implementation #17015

Merged
merged 17 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
173 changes: 118 additions & 55 deletions source/appModules/powerpnt.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2012-2022 NV Access Limited
# Copyright (C) 2012-2024 NV Access Limited, Leonard de Ruijter
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

from typing import (
Any,
Optional,
Dict,
)
Expand All @@ -12,6 +13,8 @@
from comtypes.automation import IDispatch
import comtypes.client
import ctypes

import comtypes.client.lazybind
import oleacc
import comHelper
import ui
Expand Down Expand Up @@ -1043,44 +1046,128 @@ def script_enterChart(self, gesture):


class TextFrameTextInfo(textInfos.offsets.OffsetsTextInfo):
def _getCaretOffset(self):
"""
TextInfo for a PowerPoint text frame,
fetching its information from a TextRange in the PowerPoint object model.
For more information about text ranges, see https://learn.microsoft.com/en-us/office/vba/api/powerpoint.textrange
"""

def _getCaretOffset(self) -> int:
return self.obj.documentWindow.ppSelection.textRange.start - 1

def _getSelectionOffsets(self):
def _getSelectionOffsets(self) -> tuple[int, int]:
sel = self.obj.documentWindow.ppSelection.textRange
start = sel.start - 1
end = start + sel.length
return start, end

def _getTextRange(self, start, end):
# #4619: First let's "normalise" the text, i.e. get rid of the CR/LF mess
text = self.obj.ppObject.textRange.text
text = text.replace("\r\n", "\n")
# Now string slicing will be okay
text = text[start:end].replace("\x0b", "\n")
text = text.replace("\r", "\n")
return text
def _getPptTextRange(
self,
start: int,
end: int,
clamp: bool = False,
) -> comtypes.client.lazybind.Dispatch:
"""
Retrieves a text range from the PowerPoint object, with optional clamping of the start and end indices.
:param start: The starting character index of the text range (zero based).
:param end: The ending character index of the text range(zero based).
:param clamp: If True, the start and end indices will be clamped to valid values within the text range. Defaults to False.
:returns: The text range object as a comtypes.client.lazybind.Dispatch object.
:raises ValueError: If the start index is greater than the end index.
"""
if not (start <= end):
raise ValueError(
f"start must be less than or equal to end. Got {start=}, {end=}.",
stack_info=True,
)
maxLength = self._getStoryLength()
# Having start = maxLength does not make sense, as there will be no selection if this is the case.
if not (0 <= start < maxLength) and clamp:
log.debugWarning(
f"Got out of range {start=} (min 0, max {maxLength - 1}. Clamping.",
stack_info=True,
)
start = max(0, min(start, maxLength - 1))
# Having end = 0 does not make sense, as there will be no selection if this is the case.
if not (0 < end <= maxLength) and clamp:
log.debugWarning(f"Got out of range {end=} (min 1, max {maxLength}. Clamping.", stack_info=True)
end = max(1, min(end, maxLength))
# The TextRange.characters method is 1-indexed.
return self.obj.ppObject.textRange.characters(start + 1, end - start)

def _getTextRange(self, start: int, end: int) -> str:
"""
Retrieves the text content of a PowerPoint text range, replacing any newline characters with standard newline characters.
:param start: The starting character index of the text range (zero based).
:param end: The ending character index (zero based) of the text range.
:returns: The text content of the specified text range, with newline characters normalized.
"""
return self._getPptTextRange(start, end).text.replace("\x0b", "\n")

def _getStoryLength(self):
def _getStoryLength(self) -> int:
return self.obj.ppObject.textRange.length

def _getLineOffsets(self, offset):
# Seems to be no direct way to find the line offsets for a given offset.
# Therefore walk through all the lines until one surrounds the offset.
lines = self.obj.ppObject.textRange.lines()
length = lines.length
# #3403: handle case where offset is at end of the text in in a control with only one line
# The offset should be limited to the last offset in the text, but only if the text does not end in a line feed.
if length and offset >= length and self._getTextRange(length - 1, length) != "\n":
offset = min(offset, length - 1)
for line in lines:
start = line.start - 1
end = start + line.length
if start <= offset < end:
return start, end
def _getCharacterOffsets(self, offset: int) -> tuple[int, int]:
range = self.obj.ppObject.textRange.characters(offset + 1, 1)
start = range.start - 1
end = start + range.length
if start <= offset < end:
return start, end
return offset, offset + 1

def _getFormatFieldAndOffsets(self, offset, formatConfig, calculateOffsets=True):
@staticmethod
def _getOffsets(ranges: comtypes.client.lazybind.Dispatch, offset: int) -> tuple[int, int, int]:
"""
Retrieves the start and end offsets of a range of elements
(e.g. words, lines, paragraphs) within a text range, given a specific offset.
:param ranges: The collection of elements (e.g. words, lines, paragraphs) to search.
These are retrieved by calling a method on the text range object, such as textRange.words(), textRange.lines(), or textRange.paragraphs().
:param offset: The zero based character offset to search for within the text range.
:Returns: A tuple containing the index of the element that contains the given offset, the zero based start offset of that element, and the zero based end offset of that element.
"""
for i, chunk in enumerate(ranges):
start = chunk.start - 1
end = start + chunk.length
if start <= offset < end:
return i, start, end
return 0, offset, offset + 1

def _getWordOffsets(self, offset) -> tuple[int, int]:
return self._getOffsets(self.obj.ppObject.textRange.words(), offset)[1:]

def _getLineNumFromOffset(self, offset: int) -> int:
return self._getOffsets(self.obj.ppObject.textRange.lines(), offset)[0]

def _getLineOffsets(self, offset: int) -> tuple[int, int]:
return self._getOffsets(self.obj.ppObject.textRange.lines(), offset)[1:]

def _getParagraphOffsets(self, offset: int) -> tuple[int, int]:
return self._getOffsets(self.obj.ppObject.textRange.paragraphs(), offset)[1:]

def _getSentenceOffsets(self, offset: int) -> tuple[int, int]:
return self._getOffsets(self.obj.ppObject.textRange.sentences(), offset)[1:]

def _getBoundingRectFromOffset(self, offset: int) -> RectLTRB:
range = self.obj.ppObject.textRange.characters(offset + 1, 1)
try:
rangeLeft = range.BoundLeft
rangeTop = range.boundTop
rangeWidth = range.BoundWidth
rangeHeight = range.BoundHeight
except comtypes.COMError as e:
raise LookupError from e
left = self.obj.documentWindow.ppObjectModel.pointsToScreenPixelsX(rangeLeft)
top = self.obj.documentWindow.ppObjectModel.pointsToScreenPixelsY(rangeTop)
right = self.obj.documentWindow.ppObjectModel.pointsToScreenPixelsX(rangeLeft + rangeWidth)
bottom = self.obj.documentWindow.ppObjectModel.pointsToScreenPixelsY(rangeTop + rangeHeight)
return RectLTRB(left, top, right, bottom)

def _getFormatFieldAndOffsets(
self,
offset: int,
formatConfig: dict[str, Any],
calculateOffsets: bool = True,
) -> tuple[textInfos.FormatField, tuple[int, int]]:
formatField = textInfos.FormatField()
curRun = None
if calculateOffsets:
Expand Down Expand Up @@ -1128,35 +1215,11 @@ def _getFormatFieldAndOffsets(self, offset, formatConfig, calculateOffsets=True)
formatField["link"] = True
return formatField, (startOffset, endOffset)

def _setCaretOffset(self, offset: int):
if not (0 <= offset <= (maxLength := self._getStoryLength())):
log.debugWarning(
f"Got out of range {offset=} (min 0, max {maxLength}. Clamping.",
stack_info=True,
)
offset = max(0, min(offset, maxLength))
# Use the TextRange.select method to move the text caret to a 0-length TextRange.
# The TextRange.characters method is 1-indexed.
self.obj.ppObject.textRange.characters(offset + 1, 0).select()
def _setCaretOffset(self, offset: int) -> None:
return self._setSelectionOffsets(offset, offset)

def _setSelectionOffsets(self, start: int, end: int):
if not start < end:
log.debug(f"start must be less than end. Got {start=}, {end=}.", stack_info=True)
return
maxLength = self._getStoryLength()
# Having start = maxLength does not make sense, as there will be no selection if this is the case.
if not (0 <= start < maxLength):
log.debugWarning(
f"Got out of range {start=} (min 0, max {maxLength - 1}. Clamping.",
stack_info=True,
)
start = max(0, min(start, maxLength - 1))
# Having end = 0 does not make sense, as there will be no selection if this is the case.
if not (0 < end <= maxLength):
log.debugWarning(f"Got out of range {end=} (min 1, max {maxLength}. Clamping.", stack_info=True)
end = max(1, min(end, maxLength))
# The TextRange.characters method is 1-indexed.
self.obj.ppObject.textRange.characters(start + 1, end - start).select()
def _setSelectionOffsets(self, start: int, end: int) -> None:
self._getPptTextRange(start, end, clamp=True).select()


class Table(Shape):
Expand Down
57 changes: 39 additions & 18 deletions source/textInfos/offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# 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-2019 NV Access Limited, Babbage B.V.
# Copyright (C) 2006-2024 NV Access Limited, Babbage B.V., Leonard de Ruijter

from abc import abstractmethod
import re
Expand Down Expand Up @@ -443,6 +443,15 @@ def _getLineOffsets(self, offset):
end = findEndOfLine(text, offset)
return [start, end]

def _getSentenceOffsets(self, offset: int) -> tuple[int, int]:
"""
Gets the start and end offsets of the sentence containing the given offset.
:param offset: The offset of the character within the sentence.
:return: A tuple of the start and end offsets of the sentence.
:raise NotImplementedError: If the method is not implemented.
"""
raise NotImplementedError

def _getParagraphOffsets(self, offset):
return self._getLineOffsets(offset)

Expand Down Expand Up @@ -508,23 +517,35 @@ def __init__(self, obj, position):
def _get_NVDAObjectAtStart(self):
return self._getNVDAObjectFromOffset(self._startOffset)

def _getUnitOffsets(self, unit, offset):
if unit == textInfos.UNIT_CHARACTER:
offsetsFunc = self._getCharacterOffsets
elif unit == textInfos.UNIT_WORD:
offsetsFunc = self._getWordOffsets
elif unit == textInfos.UNIT_LINE:
offsetsFunc = self._getLineOffsets
elif unit == textInfos.UNIT_PARAGRAPH:
offsetsFunc = self._getParagraphOffsets
elif unit == textInfos.UNIT_READINGCHUNK:
offsetsFunc = self._getReadingChunkOffsets
elif unit == textInfos.UNIT_STORY:
return 0, self._getStoryLength()
elif unit == textInfos.UNIT_OFFSET:
return offset, offset + 1
else:
raise ValueError("unknown unit: %s" % unit)
def _getUnitOffsets(self, unit: str, offset: int) -> tuple[int, int]:
"""Gets the start and end offsets of the unit containing the given offset.

:param unit: Any of UNIT_CHARACTER, UNIT_WORD, UNIT_LINE, UNIT_SENTENCE, UNIT_PARAGRAPH,
UNIT_READINGCHUNK, UNIT_STORY, or UNIT_OFFSET as defined in textInfos.
:param offset: The offset of the character within the text unit.
:return: A tuple of the start and end offsets of the unit.
:raises ValueError: If the unit is not recognised.
:raises NotImplementedError: If the offset getter for the given unit is not implemented.
"""
match unit:
case textInfos.UNIT_CHARACTER:
offsetsFunc = self._getCharacterOffsets
case textInfos.UNIT_WORD:
offsetsFunc = self._getWordOffsets
case textInfos.UNIT_LINE:
offsetsFunc = self._getLineOffsets
case textInfos.UNIT_SENTENCE:
offsetsFunc = self._getSentenceOffsets
case textInfos.UNIT_PARAGRAPH:
offsetsFunc = self._getParagraphOffsets
case textInfos.UNIT_READINGCHUNK:
offsetsFunc = self._getReadingChunkOffsets
case textInfos.UNIT_STORY:
return 0, self._getStoryLength()
case textInfos.UNIT_OFFSET:
return offset, offset + 1
case _:
raise ValueError(f"unknown unit: {unit!r}")
return offsetsFunc(offset)

def _get_pointAtStart(self):
Expand Down
4 changes: 4 additions & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

### New Features

* When editing in Microsoft PowerPoint text boxes, you can now move per sentence with `alt+upArrow`/`alt+downArrow`. (#17015, @LeonarddeR)
* In Mozilla Firefox, NVDA will report the highlighted text when a URL containing a text fragment is visited. (#16910, @jcsteh)

### Changes
Expand All @@ -16,6 +17,9 @@
### Bug Fixes

* Native support for the Dot Pad tactile graphics device from Dot Inc as a multiline braille display. (#17007)
* Improvements when editing in Microsoft PowerPoint:
* Caret reporting no longer breaks when text contains wide characters, such as emoji. (#17006 , @LeonarddeR)
* Character location reporting is now accurate (e.g. when pressing `NVDA+Delete`. (#9941, @LeonarddeR)

### Changes for Developers

Expand Down