Skip to content

Commit 46e3841

Browse files
authored
Merge 2a50c21 into 64ba6c6
2 parents 64ba6c6 + 2a50c21 commit 46e3841

6 files changed

Lines changed: 105 additions & 26 deletions

File tree

source/NVDAObjects/window/winword.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@
8282
wdMaximumNumberOfColumns = 18
8383

8484

85+
class MsoHyperlink(IntEnum):
86+
# See https://learn.microsoft.com/en-us/office/vba/api/office.msohyperlinktype
87+
RANGE = 0
88+
SHAPE = 1
89+
INLINE_SHAPE = 2
90+
91+
8592
class WdUnderline(DisplayStringIntEnum):
8693
# Word underline styles
8794
# see https://docs.microsoft.com/en-us/office/vba/api/word.wdunderline
@@ -804,14 +811,14 @@ def activate(self):
804811
initialDocument=self.obj,
805812
),
806813
)
814+
807815
# Handle activating links.
816+
link = self._getLinkAtCaretPosition()
817+
if link:
818+
link.follow()
819+
return
808820
# It is necessary to expand to word to get a link as the link's first character is never actually in the link!
809821
tempRange = self._rangeObj.duplicate
810-
tempRange.expand(wdWord)
811-
links = tempRange.hyperlinks
812-
if links.count > 0:
813-
links[1].follow()
814-
return
815822
tempRange.expand(wdParagraph)
816823
fields = tempRange.fields
817824
for field in (fields.item(i) for i in range(1, fields.count + 1)):
@@ -847,6 +854,42 @@ def activate(self):
847854
braille.handler.handleCaretMove(self)
848855
return
849856

857+
def _getLinkAtCaretPosition(self) -> comtypes.client.lazybind.Dispatch | None:
858+
# It is necessary to expand to word to get a link as the link's first character is never actually in the link!
859+
tempRange = self._rangeObj.duplicate
860+
tempRange.expand(wdWord)
861+
links = tempRange.hyperlinks
862+
if links.count > 0:
863+
return links[1]
864+
return None
865+
866+
def _getShapeAtCaretPosition(self) -> comtypes.client.lazybind.Dispatch | None:
867+
# It is necessary to expand to word to get a shape as the link's first character is never actually in the link!
868+
tempRange = self._rangeObj.duplicate
869+
tempRange.expand(wdWord)
870+
shapes = tempRange.InlineShapes
871+
if shapes.count > 0:
872+
return shapes[1]
873+
return None
874+
875+
def _getLinkDataAtCaretPosition(self) -> textInfos._Link | None:
876+
link = self._getLinkAtCaretPosition()
877+
if not link:
878+
return None
879+
match link.Type:
880+
case MsoHyperlink.RANGE:
881+
text = link.TextToDisplay
882+
case MsoHyperlink.INLINE_SHAPE:
883+
shape = self._getShapeAtCaretPosition()
884+
text = shape.AlternativeText
885+
case _:
886+
log.debugWarning(f"No text to display for link type {link.Type}")
887+
text = None
888+
return textInfos._Link(
889+
displayText=text,
890+
destination=link.Address,
891+
)
892+
850893
def _expandToLineAtCaret(self):
851894
lineStart = ctypes.c_int()
852895
lineEnd = ctypes.c_int()

source/globalCommands.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
from base64 import b16encode
6868
import vision
6969
from utils.security import objectBelowLockScreenAndWindowsIsLocked
70+
from utils.urlUtils import _getLinkDataAtCaretPosition
7071
import audio
7172
from audio import appsVolume
7273

@@ -4192,38 +4193,36 @@ def script_reportLinkDestination(
41924193
except RuntimeError:
41934194
log.debugWarning("Unable to get the caret position.", exc_info=True)
41944195
ti: textInfos.TextInfo = api.getFocusObject().makeTextInfo(textInfos.POSITION_FIRST)
4195-
ti.expand(textInfos.UNIT_CHARACTER)
4196-
obj: NVDAObject = ti.NVDAObjectAtStart
4196+
try:
4197+
link = ti._getLinkDataAtCaretPosition()
4198+
except NotImplementedError:
4199+
link = _getLinkDataAtCaretPosition(ti)
41974200
presses = scriptHandler.getLastScriptRepeatCount()
4198-
if obj.role == controlTypes.role.Role.GRAPHIC and (
4199-
obj.parent and obj.parent.role == controlTypes.role.Role.LINK
4200-
):
4201-
# In Firefox, graphics with a parent link also expose the parents link href value.
4202-
# In Chromium, the link href value must be fetched from the parent object. (#14779)
4203-
obj = obj.parent
4204-
if (
4205-
obj.role == controlTypes.role.Role.LINK # If it's a link, or
4206-
or controlTypes.state.State.LINKED in obj.states # if it isn't a link but contains one
4207-
):
4208-
linkDestination = obj.value
4209-
if linkDestination is None:
4201+
if link:
4202+
if link.destination is None:
42104203
# Translators: Informs the user that the link has no destination
42114204
ui.message(_("Link has no apparent destination"))
42124205
return
42134206
if (
42144207
presses == 1 # If pressed twice, or
42154208
or forceBrowseable # if a browseable message is preferred unconditionally
42164209
):
4210+
text = link.displayText
4211+
if text is None:
4212+
# Translators: Title of the browseable message when requesting the destination of a graphical link.
4213+
text = _("Graphic")
42174214
ui.browseableMessage(
4218-
linkDestination,
4215+
link.destination,
42194216
# Translators: Informs the user that the window contains the destination of the
42204217
# link with given title
4221-
title=_("Destination of: {name}").format(name=obj.name),
4222-
closeButton=True,
4223-
copyButton=True,
4218+
title=_("Destination of: {name}").format(
4219+
name=text,
4220+
closeButton=True,
4221+
copyButton=True,
4222+
),
42244223
)
42254224
elif presses == 0: # One press
4226-
ui.message(linkDestination) # Speak the link
4225+
ui.message(link.destination) # Speak the link
42274226
else: # Some other number of presses
42284227
return # Do nothing
42294228
else:

source/textInfos/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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) 2006-2022 NV Access Limited, Babbage B.V., Accessolutions, Julien Cochuyt
4+
# Copyright (C) 2006-2024 NV Access Limited, Babbage B.V., Accessolutions, Julien Cochuyt, Cyrille Bougot
55

66
"""Framework for accessing text content in widgets.
77
The core component of this framework is the L{TextInfo} class.
@@ -11,6 +11,7 @@
1111

1212
from abc import abstractmethod
1313
from enum import Enum
14+
from dataclasses import dataclass
1415
import weakref
1516
import re
1617
import typing
@@ -704,6 +705,9 @@ def activate(self):
704705
mouseHandler.doPrimaryClick()
705706
winUser.setCursorPos(oldX, oldY)
706707

708+
def _getLinkDataAtCaretPosition(self):
709+
raise NotImplementedError
710+
707711
def getMathMl(self, field):
708712
"""Get MathML for a math control field.
709713
This will only be called for control fields with a role of L{controlTypes.Role.MATH}.
@@ -1010,3 +1014,11 @@ class CommentType(Enum):
10101014
GENERAL = "general"
10111015
DRAFT = "draft"
10121016
RESOLVED = "resolved"
1017+
1018+
1019+
@dataclass
1020+
class _Link:
1021+
"""Class to store information on a link in text."""
1022+
1023+
displayText: str | None
1024+
destination: str

source/treeInterceptorHandler.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# A part of NonVisual Desktop Access (NVDA)
2-
# Copyright (C) 2006-2022 NV Access Limited, Davy Kager, Accessolutions, Julien Cochuyt
2+
# Copyright (C) 2006-2024 NV Access Limited, Davy Kager, Accessolutions, Julien Cochuyt, Cyrille Bougot
33
# This file is covered by the GNU General Public License.
44
# See the file COPYING for more details.
55

@@ -228,6 +228,9 @@ def find(self, text, caseSensitive=False, reverse=False):
228228
def activate(self):
229229
return self.innerTextInfo.activate()
230230

231+
def _getLinkDataAtCaretPosition(self) -> textInfos._Link | None:
232+
return self.innerTextInfo._getLinkDataAtCaretPosition()
233+
231234
def compareEndPoints(self, other, which):
232235
return self.innerTextInfo.compareEndPoints(other.innerTextInfo, which)
233236

source/utils/urlUtils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import controlTypes
77
from urllib.parse import ParseResult, urlparse, urlunparse
88
from logHandler import log
9+
from NVDAObjects import textInfos, NVDAObject
910

1011

1112
def getLinkType(targetURL: str, rootURL: str) -> controlTypes.State | None:
@@ -52,3 +53,23 @@ def isSamePageURL(targetURLOnPage: str, rootURL: str) -> bool:
5253
return targetURLOnPageWithoutFragments == rootURLWithoutFragments and not any(
5354
char in parsedTargetURLOnPage.fragment for char in fragmentInvalidChars
5455
)
56+
57+
58+
def _getLinkDataAtCaretPosition(ti: textInfos.TextInfo) -> textInfos._Link | None:
59+
ti.expand(textInfos.UNIT_CHARACTER)
60+
obj: NVDAObject = ti.NVDAObjectAtStart
61+
if obj.role == controlTypes.role.Role.GRAPHIC and (
62+
obj.parent and obj.parent.role == controlTypes.role.Role.LINK
63+
):
64+
# In Firefox, graphics with a parent link also expose the parents link href value.
65+
# In Chromium, the link href value must be fetched from the parent object. (#14779)
66+
obj = obj.parent
67+
if (
68+
obj.role == controlTypes.role.Role.LINK # If it's a link, or
69+
or controlTypes.state.State.LINKED in obj.states # if it isn't a link but contains one
70+
):
71+
return textInfos._Link(
72+
displayText=obj.name,
73+
destination=obj.value,
74+
)
75+
return None

user_docs/en/changes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ To use this feature, "allow NVDA to control the volume of other applications" mu
5353
* When spelling, unicode normalization now works more appropriately:
5454
* After reporting a normalized character, NVDA no longer incorrectly reports subsequent characters as normalized. (#17286, @LeonarddeR)
5555
* Composite characters (such as é) are now reported correctly. (#17295, @LeonarddeR)
56+
* In Word or Outlook, when using legacy object model, the command to report the destination URL of a link does not report any longer "Not a link" when there is one to report. (#17292, @CyrilleB79)
5657

5758
### Changes for Developers
5859

0 commit comments

Comments
 (0)