diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 98312c92e59..298b0571e2e 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -8,6 +8,7 @@ from typing import ( Generator, Optional, + TYPE_CHECKING, Tuple, ) import re @@ -35,6 +36,10 @@ import config import NVDAObjects +if TYPE_CHECKING: + from locationHelper import RectLTRB + from mathPres.mathMlNode import MathMlNodePath, MathMlNodeRectInfo + class IA2WebAnnotationTarget(AnnotationTarget): def __init__(self, target: IAccessible): @@ -331,6 +336,73 @@ class EditorChunk(Ia2Web): class Math(Ia2Web): + def _getMathObjChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]: + try: + return tuple(obj.children) + except RuntimeError: + log.debugWarning("Error fetching MathML node children", exc_info=True) + return () + + def _getMathObjAttributes(self, obj: NVDAObjects.NVDAObject) -> dict[str, str]: + try: + return obj.IA2Attributes + except AttributeError: + return {} + + def _getMathElementChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]: + return tuple( + child for child in self._getMathObjChildren(obj) if self._getMathObjAttributes(child).get("tag") + ) + + def _getMathNodeMapRoot(self) -> NVDAObjects.NVDAObject: + if self._getMathObjAttributes(self).get("tag") == "math": + return self + mathChildren = tuple( + child + for child in self._getMathElementChildren(self) + if self._getMathObjAttributes(child).get("tag") == "math" + ) + return mathChildren[0] if len(mathChildren) == 1 else self + + def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> "RectLTRB | None": + try: + if obj.hasIrrelevantLocation: + return None + location = obj.location + except Exception: + log.debugWarning("Error fetching MathML node location", exc_info=True) + return None + if not location or not location.width or not location.height: + return None + return location.toLTRB() + + def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"]: + """Map MathML element paths to tag names and screen rectangles for this IA2 math subtree. + + Paths are tuples where each entry indicates an index of a child node to be traversed from the root. + """ + from mathPres.mathMlNode import MathMlNodeRectInfo + + nodeInfoByPath: dict["MathMlNodePath", "MathMlNodeRectInfo"] = {} + stack: list[tuple[NVDAObjects.NVDAObject, "MathMlNodePath"]] = [ + (self._getMathNodeMapRoot(), ()), + ] + visitedCount = 0 + while stack: + obj, path = stack.pop() + visitedCount += 1 + tag = self._getMathObjAttributes(obj).get("tag") + if rect := self._getMathNodeRectFromObj(obj): + if tag: + nodeInfoByPath[path] = MathMlNodeRectInfo(path=path, tag=tag, rect=rect) + children = self._getMathElementChildren(obj) + stack.extend((child, path + (index,)) for index, child in reversed(tuple(enumerate(children)))) + log.debug( + f"Math highlight built IA2 path map with {len(nodeInfoByPath)} usable rectangles " + f"after visiting {visitedCount} MathML element objects", + ) + return nodeInfoByPath + def _get_mathMl(self): # Chromium browsers now expose a 'math' IAccessible2 attribute, # which contains all the raw MathML. diff --git a/source/browseMode.py b/source/browseMode.py index ed0f671259f..3d569e56220 100644 --- a/source/browseMode.py +++ b/source/browseMode.py @@ -712,7 +712,7 @@ def _activatePosition(self, obj=None): import mathPres try: - return mathPres.interactWithMathMl(obj.mathMl) + return mathPres.interactWithMathMl(obj.mathMl, sourceObj=obj) except (NotImplementedError, LookupError): pass return diff --git a/source/globalCommands.py b/source/globalCommands.py index 5edccc93128..456ec4297e8 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4814,20 +4814,28 @@ def script_toggleConfigProfileTriggers(self, gesture): def script_interactWithMath(self, gesture): import mathPres - mathMl = mathPres.getMathMlFromTextInfo(api.getReviewPosition()) + reviewPosition = api.getReviewPosition() + mathMl = mathPres.getMathMlFromTextInfo(reviewPosition) + sourceObj = None if not mathMl: obj = api.getNavigatorObject() if obj.role == controlTypes.Role.MATH: try: mathMl = obj.mathMl + sourceObj = obj except (NotImplementedError, LookupError): mathMl = None + else: + try: + sourceObj = reviewPosition.NVDAObjectAtStart + except (NotImplementedError, LookupError): + pass if not mathMl: # Translators: Reported when the user attempts math interaction # with something that isn't math. ui.message(_("Not math")) return - mathPres.interactWithMathMl(mathMl) + mathPres.interactWithMathMl(mathMl, sourceObj=sourceObj) @script( # Translators: Describes a command. diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index a6c97306e21..eacda97e96f 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -12,7 +12,7 @@ windll, ) from os import path -from typing import Type +from typing import TYPE_CHECKING, Type import braille import config @@ -20,6 +20,7 @@ import libmathcat_py as libmathcat import speech import ui +import vision import winKernel import winUser from api import getClipData @@ -44,9 +45,21 @@ import mathPres from .localization import getLanguageToUse from .navCommands import NAV_COMMANDS +from .navNodeMapping import ( + NAV_NODE_ID_PREFIX, + prepareMathMlForNavigation, + removeSyntheticIdsFromMathMl, +) from .preferences import applyUserPreferences from .speech import convertSSMLTextForNVDA +if TYPE_CHECKING: + from locationHelper import RectLTRB + from NVDAObjects import NVDAObject + +# Translators: The name of the category of MathCAT navigation commands in the Input Gestures dialog. +SCRCAT_MATHCAT_NAV = _("Math navigation") + class MathCATError(Exception): """MathCAT failure, including Rust panics from PyO3.""" @@ -81,13 +94,19 @@ def __init__( self, provider: mathPres.MathPresentationProvider | None = None, mathMl: str | None = None, + sourceObj: "NVDAObject | None" = None, ): """Initialize the MathCATInteraction object. :param provider: Optional presentation provider. :param mathMl: Optional initial MathML string. + :param sourceObj: Optional source object containing the math. """ - super(MathCATInteraction, self).__init__(provider=provider, mathMl=mathMl) + super().__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj) + self.mathMlForNavigation, self._mathNodeRectsById = prepareMathMlForNavigation( + mathMl or "", + sourceObj, + ) if mathMl is None: self.initMathML = "" else: @@ -99,10 +118,47 @@ def reportFocus(self) -> None: try: text: str = libmathcat.DoNavigateCommand("ZoomIn") speech.speak(convertSSMLTextForNVDA(text)) + self._updateMathHighlight() except Exception: log.exception() # Translators: this message reports an error in starting navigation of math. ui.message(pgettext("math", "Error in starting navigation of math.")) + self._clearMathHighlight() + + def _getHighlightRect(self) -> "RectLTRB | None": + """Get the navigation rectangle for a supported web math source object.""" + sourceObj = self.sourceObj + if not sourceObj: + return None + # Only import Math from ia2Web when it's actually needed (i.e., when a source object is present). + from NVDAObjects.IAccessible.ia2Web import Math as Ia2WebMath + + if not isinstance(sourceObj, Ia2WebMath): + return None + try: + nodeId = libmathcat.GetNavigationMathMLId()[0] + except Exception: + log.debugWarning("Error getting MathCAT navigation node id", exc_info=True) + return None + if nodeId in self._mathNodeRectsById: + return self._mathNodeRectsById[nodeId] + if nodeId.startswith(NAV_NODE_ID_PREFIX): + log.debug( + f"Math highlight found synthetic MathML id {nodeId!r}, but it has no mapped IA2 rectangle", + ) + log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") + if sourceObj.hasIrrelevantLocation: + return None + location = sourceObj.location + return location.toLTRB() if location else None + + def _updateMathHighlight(self) -> None: + if vision.handler: + vision.handler.handleMathNavigation(self._getHighlightRect()) + + def _clearMathHighlight(self) -> None: + if vision.handler: + vision.handler.handleMathNavigation(None) def getBrailleRegions( self, @@ -130,10 +186,12 @@ def _doNavigateCommand(self, commandName: str) -> None: try: text = libmathcat.DoNavigateCommand(commandName) speech.speak(convertSSMLTextForNVDA(text)) + self._updateMathHighlight() except Exception: log.exception() # Translators: this message alerts users to an error in navigating math. ui.message(pgettext("math", "Error in navigating math")) + self._clearMathHighlight() self._updateBraille() @@ -210,7 +268,7 @@ def script_rawdataToClip(self, gesture: KeyboardInputGesture) -> None: mathml = self.initMathML if copyAs == "speech": # save the old MathML, set the navigation MathML as MathMl, get the speech, then reset the MathML - savedMathML: str = self.initMathML + savedMathML: str = self.mathMlForNavigation savedTTS: str = libmathcat.GetPreference("TTS") if savedMathML == "": # shouldn't happen raise Exception("Internal error -- MathML not set for copy") @@ -233,12 +291,11 @@ def script_rawdataToClip(self, gesture: KeyboardInputGesture) -> None: # not a perfect match sequence, but should capture normal MathML _mathTagHasNameSpace: re.Pattern = re.compile("") - _hasAddedId: re.Pattern = re.compile(" id='[^'].+' data-id-added='true'") - _hasDataAttr: re.Pattern = re.compile(" data-[^=]+='[^']*'") + _hasDataAttr: re.Pattern = re.compile(r""" data-[^=]+=(['"]).*?\1""") def _wrapMathMLForClipBoard(self, text: str) -> str: """Cleanup the MathML a little.""" - text = re.sub(self._hasAddedId, "", text) + text = removeSyntheticIdsFromMathMl(text) mathMLWithNS: str = re.sub(self._hasDataAttr, "", text) if not re.match(self._mathTagHasNameSpace, mathMLWithNS): mathMLWithNS = mathMLWithNS.replace( @@ -411,11 +468,20 @@ def getBrailleForMathMl(self, mathml: str) -> str: ui.message(pgettext("math", "Error in brailling math.")) return "" - def interactWithMathMl(self, mathml: str) -> None: + def interactWithMathMl(self, mathml: str, sourceObj: "NVDAObject | None" = None) -> None: """Interact with a MathML string, creating a MathCATInteraction object. :param mathml: The MathML representing the math to interact with. + :param sourceObj: Optional source object containing the math. """ - interaction = MathCATInteraction(provider=self, mathMl=mathml) + interaction = MathCATInteraction(provider=self, mathMl=mathml, sourceObj=sourceObj) + try: + libmathcat.SetMathML(interaction.mathMlForNavigation) + except Exception: + log.exception(f"MathML is {interaction.mathMlForNavigation}") + # Translators: this message reports illegal MathML. + ui.message(pgettext("math", "Invalid MathML found.")) + libmathcat.SetMathML("") + return interaction.setFocus() interaction._updateBraille() diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/navNodeMapping.py new file mode 100644 index 00000000000..8da7db5c2a6 --- /dev/null +++ b/source/mathPres/MathCAT/navNodeMapping.py @@ -0,0 +1,140 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Ryan McCleary +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Map MathCAT NavNode ids to source MathML rectangles.""" + +import xml.etree.ElementTree as ElementTree +from collections.abc import Generator +from typing import TYPE_CHECKING + +import mathPres +from mathPres.mathMlNode import ( + MathMlNodeInfo, + MathMlNodePath, + SyntheticMathMlNodeId, +) +from logHandler import log + +if TYPE_CHECKING: + from locationHelper import RectLTRB + from NVDAObjects import NVDAObject + + +MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML" +NAV_NODE_ID_PREFIX = "nvda-math-node-" +_NAV_NODE_ID_ADDED_ATTR = "data-nvda-math-id-added" +_NAV_NODE_ORIGINAL_ID_ATTR = "data-nvda-math-original-id" + + +def _stripMathMlNamespace(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _getSyntheticNodeId(nodePath: MathMlNodePath) -> str: + if not nodePath: + return f"{NAV_NODE_ID_PREFIX}root" + return f"{NAV_NODE_ID_PREFIX}{'-'.join(str(index) for index in nodePath)}" + + +def _iterMathMlElements( + element: ElementTree.Element, + nodePath: MathMlNodePath, +) -> Generator[tuple[ElementTree.Element, MathMlNodePath], None, None]: + stack = [(element, nodePath)] + while stack: + currentElement, currentNodePath = stack.pop() + yield currentElement, currentNodePath + mathElementChildren = tuple(child for child in currentElement if isinstance(child.tag, str)) + stack.extend( + (child, currentNodePath + (index,)) + for index, child in reversed(tuple(enumerate(mathElementChildren))) + ) + + +def _addSyntheticIdsToMathMl( + mathml: str, +) -> tuple[str, dict[SyntheticMathMlNodeId, MathMlNodeInfo]]: + ElementTree.register_namespace("", MATHML_NAMESPACE) + try: + root = ElementTree.fromstring(mathPres.stripExtraneousXml(mathml)) + except ElementTree.ParseError: + log.debugWarning("Math highlight could not parse MathML for synthetic node ids", exc_info=True) + return mathml, {} + if _stripMathMlNamespace(root.tag) != "math": + log.debug("Math highlight did not add synthetic ids because MathML root is not ") + return mathml, {} + nodeInfoById: dict[SyntheticMathMlNodeId, MathMlNodeInfo] = {} + for element, nodePath in _iterMathMlElements(root, ()): + # MathCAT exposes the current NavNode by MathML id, so add stable ids to this copy only. + nodeId = _getSyntheticNodeId(nodePath) + if originalId := element.get("id"): + element.set(_NAV_NODE_ORIGINAL_ID_ATTR, originalId) + element.set("id", nodeId) + element.set(_NAV_NODE_ID_ADDED_ATTR, "true") + nodeInfoById[nodeId] = MathMlNodeInfo( + path=nodePath, + tag=_stripMathMlNamespace(element.tag), + ) + return ElementTree.tostring(root, encoding="unicode"), nodeInfoById + + +def removeSyntheticIdsFromMathMl(mathml: str) -> str: + try: + root = ElementTree.fromstring(mathml) + except ElementTree.ParseError: + return mathml + for element, _nodePath in _iterMathMlElements(root, ()): + if element.get(_NAV_NODE_ID_ADDED_ATTR) != "true": + continue + originalId = element.attrib.pop(_NAV_NODE_ORIGINAL_ID_ATTR, None) + if originalId is not None: + element.set("id", originalId) + else: + element.attrib.pop("id", None) + element.attrib.pop(_NAV_NODE_ID_ADDED_ATTR, None) + return ElementTree.tostring(root, encoding="unicode") + + +def prepareMathMlForNavigation( + mathml: str, + sourceObj: "NVDAObject | None", +) -> tuple[str, dict[SyntheticMathMlNodeId, "RectLTRB"]]: + """Add synthetic ids to MathML and map those ids to IA2 rectangles.""" + if not sourceObj: + return mathml, {} + # Only import ia2Web when it's actually needed + from NVDAObjects.IAccessible.ia2Web import Math as Ia2WebMath + + if not isinstance(sourceObj, Ia2WebMath): + return mathml, {} + mathmlWithIds, mathMlNodeInfoById = _addSyntheticIdsToMathMl(mathml) + if not mathMlNodeInfoById: + return mathml, {} + try: + ia2NodeInfoByPath = sourceObj.getMathNodeInfoByPath() + except Exception: + log.debugWarning("Math highlight could not build IA2 rectangle map", exc_info=True) + return mathml, {} + nodeRectsById: dict[SyntheticMathMlNodeId, "RectLTRB"] = {} + missingPathCount = 0 + tagMismatchCount = 0 + for nodeId, mathMlNodeInfo in mathMlNodeInfoById.items(): + try: + ia2NodeInfo = ia2NodeInfoByPath[mathMlNodeInfo.path] + except KeyError: + missingPathCount += 1 + continue + if ia2NodeInfo.tag != mathMlNodeInfo.tag: + tagMismatchCount += 1 + continue + nodeRectsById[nodeId] = ia2NodeInfo.rect + log.debug( + f"Math highlight added synthetic ids to {len(mathMlNodeInfoById)} MathML nodes; " + f"mapped {len(nodeRectsById)} ids to IA2 rectangles; " + f"missing IA2 paths: {missingPathCount}; tag mismatches: {tagMismatchCount}", + ) + if not nodeRectsById: + return mathml, {} + return mathmlWithIds, nodeRectsById diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index bb54f277c49..a27739c6cca 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -7,7 +7,7 @@ Three types of presentation are supported: speech, braille and interaction. All of these accept MathML markup. Plugins can register their own implementation for any or all of these -using L{registerProvider}. +using ``registerProvider``. """ import re @@ -18,44 +18,47 @@ import controlTypes import api import virtualBuffers +import vision import eventHandler from logHandler import log import ui import textInfos if typing.TYPE_CHECKING: + from NVDAObjects import NVDAObject from speech.commands import SpeechCommand # noqa F401: type-checking only -class MathPresentationProvider(object): +class MathPresentationProvider: """Implements presentation of math content. A single provider does not need to implement all presentation types. """ def getSpeechForMathMl(self, mathMl: str) -> List[Union[str, "SpeechCommand"]]: """Get speech output for specified MathML markup. - @param mathMl: The MathML markup. - @return: A speech sequence. + :param mathMl: The MathML markup. + :return: A speech sequence. """ raise NotImplementedError def getBrailleForMathMl(self, mathMl: str) -> str: """Get braille output for specified MathML markup. - @param mathMl: The MathML markup. - @return: A string of Unicode braille. + :param mathMl: The MathML markup. + :return: A string of Unicode braille. """ raise NotImplementedError - def interactWithMathMl(self, mathMl: str) -> None: + def interactWithMathMl(self, mathMl: str, sourceObj: "NVDAObject | None" = None) -> None: """Begin interaction with specified MathML markup. - @param mathMl: The MathML markup. + :param mathMl: The MathML markup. + :param sourceObj: The source object containing the math, if known. """ raise NotImplementedError -speechProvider: Optional[MathPresentationProvider] = None -brailleProvider: Optional[MathPresentationProvider] = None -interactionProvider: Optional[MathPresentationProvider] = None +speechProvider: MathPresentationProvider | None = None +brailleProvider: MathPresentationProvider | None = None +interactionProvider: MathPresentationProvider | None = None def registerProvider( @@ -65,10 +68,10 @@ def registerProvider( interaction: bool = False, ): """Register a math presentation provider. - @param provider: The provider to register. - @param speech: Whether this provider supports speech output. - @param braille: Whether this provider supports braille output. - @param interaction: Whether this provider supports interaction. + :param provider: The provider to register. + :param speech: Whether this provider supports speech output. + :param braille: Whether this provider supports braille output. + :param interaction: Whether this provider supports interaction. """ global speechProvider, brailleProvider, interactionProvider if speech: @@ -110,7 +113,7 @@ class MathInteractionNVDAObject(Window): """Base class for a fake NVDAObject which can be focused while interacting with math. Subclasses can bind commands to interact with the content and produce speech and braille output as they wish. - To begin interaction, call L{setFocus}. + To begin interaction, call ``setFocus``. Pressing escape exits interaction. """ @@ -120,9 +123,15 @@ class MathInteractionNVDAObject(Window): # Any tree interceptor should not apply here. treeInterceptor = None - def __init__(self, provider=None, mathMl=None): + def __init__( + self, + provider: MathPresentationProvider | None = None, + mathMl: str | None = None, + sourceObj: "NVDAObject | None" = None, + ): self.parent = parent = api.getFocusObject() self.provider = provider + self.sourceObj = sourceObj super(MathInteractionNVDAObject, self).__init__(windowHandle=parent.windowHandle) def setFocus(self): @@ -135,6 +144,8 @@ def setFocus(self): eventHandler.executeEvent("gainFocus", self) def script_exit(self, gesture): + if vision.handler: + vision.handler.handleMathNavigation(None) eventHandler.executeEvent("gainFocus", self.parent) # Translators: Describes a command. @@ -158,8 +169,8 @@ def stripExtraneousXml(xml): def getMathMlFromTextInfo(pos: textInfos.TextInfo) -> Optional[str]: """Get MathML (if any) at the start of a TextInfo. - @param pos: The TextInfo in question. - @return: The MathML or C{None} if there is no math. + :param pos: The TextInfo in question. + :return: The MathML or ``None`` if there is no math. """ pos = pos.copy() pos.expand(textInfos.UNIT_CHARACTER) @@ -176,19 +187,20 @@ def getMathMlFromTextInfo(pos: textInfos.TextInfo) -> Optional[str]: return None -def interactWithMathMl(mathMl: str) -> None: +def interactWithMathMl(mathMl: str, sourceObj: "NVDAObject | None" = None) -> None: """Begin interaction with specified MathML markup, reporting any errors to the user. This is intended to be called from scripts. If interaction isn't supported, this will be reported to the user. The script should return after calling this function. - @param mathMl: The MathML markup. + :param mathMl: The MathML markup. + :param sourceObj: The source object containing the math, if known. """ if not interactionProvider: # Translators: Reported when the user attempts math interaction # but math interaction is not supported. ui.message(_("Math interaction not supported.")) return - return interactionProvider.interactWithMathMl(mathMl) + return interactionProvider.interactWithMathMl(mathMl, sourceObj=sourceObj) RE_MATH_LANG = re.compile(r"""""") @@ -196,8 +208,7 @@ def interactWithMathMl(mathMl: str) -> None: def getLanguageFromMath(mathMl): """Get the language specified in a math tag. - @return: The language or C{None} if unspeicifed. - @rtype: str + :return: The language or ``None`` if unspeicifed. """ m = RE_MATH_LANG.search(mathMl) if m: diff --git a/source/mathPres/mathMlNode.py b/source/mathPres/mathMlNode.py new file mode 100644 index 00000000000..d3613b2d21b --- /dev/null +++ b/source/mathPres/mathMlNode.py @@ -0,0 +1,29 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Ryan McCleary +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Shared types for identifying MathML nodes.""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from locationHelper import RectLTRB + + +MathMlNodePath = tuple[int, ...] +SyntheticMathMlNodeId = str + + +@dataclass(frozen=True) +class MathMlNodeInfo: + path: MathMlNodePath + tag: str + + +@dataclass(frozen=True) +class MathMlNodeRectInfo: + path: MathMlNodePath + tag: str + rect: "RectLTRB" diff --git a/source/vision/visionHandler.py b/source/vision/visionHandler.py index e3aa82d6941..bbc5ba17cc7 100644 --- a/source/vision/visionHandler.py +++ b/source/vision/visionHandler.py @@ -12,6 +12,7 @@ from . import providerInfo from .constants import Context +from locationHelper import RectLTRB from .providerBase import VisionEnhancementProvider from .visionHandlerExtensionPoints import EventExtensionPoints import importlib @@ -323,6 +324,9 @@ def handleCaretMove(self, obj) -> None: def handleReviewMove(self, context: Context = Context.REVIEW) -> None: self.extensionPoints.post_reviewMove.notify(context=context) + def handleMathNavigation(self, rect: RectLTRB | None) -> None: + self.extensionPoints.post_mathNavigation.notify(rect=rect) + def handleMouseMove(self, obj, x: int, y: int) -> None: # For now, mouse moves execute once per core cycle. self.extensionPoints.post_mouseMove.notify(obj=obj, x=x, y=y) diff --git a/source/vision/visionHandlerExtensionPoints.py b/source/vision/visionHandlerExtensionPoints.py index 6a0c9ba592a..2dacff5e3bf 100644 --- a/source/vision/visionHandlerExtensionPoints.py +++ b/source/vision/visionHandlerExtensionPoints.py @@ -73,6 +73,15 @@ class EventExtensionPoints: #: @param obj: The cursor manager that changed it virtual caret position. #: @type obj: L{cursorManager.CursorManager} post_browseModeMove: Action = field(default_factory=Action, init=False) + + post_mathNavigation: Action = field(default_factory=Action, init=False) + """ + Notifies a vision enhancement provider when the math navigation position has changed. + This allows a vision enhancement provider to track the current position while interacting with math. + Handlers are called with one argument. + :param rect: The screen rectangle of the current math navigation position, or ``None`` if math navigation has exited. + """ + #: Notifies a vision enhancement provider when the position of the review cursor has changed. #: This allows a vision enhancement provider to take an action #: when the review position has changed. diff --git a/source/visionEnhancementProviders/NVDAHighlighter.py b/source/visionEnhancementProviders/NVDAHighlighter.py index 4e90020ac1d..6f8a84f9c75 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -513,6 +513,7 @@ def registerEventExtensionPoints( extensionPoints.post_focusChange.register(self.handleFocusChange) extensionPoints.post_reviewMove.register(self.handleReviewMove) extensionPoints.post_browseModeMove.register(self.handleBrowseModeMove) + extensionPoints.post_mathNavigation.register(self.handleMathNavigation) def __init__(self): super().__init__() @@ -599,6 +600,13 @@ def handleReviewMove(self, context: Context) -> None: def handleBrowseModeMove(self, obj: "CursorManager | None" = None) -> None: self.updateContextRect(context=Context.BROWSEMODE) + def handleMathNavigation(self, rect: RectLTRB | None) -> None: + if rect is None: + self.contextToRectMap.pop(Context.BROWSEMODE, None) + self.updateContextRect(context=Context.BROWSEMODE) + return + self.updateContextRect(context=Context.BROWSEMODE, rect=rect) + def refresh(self) -> None: """Refreshes the screen positions of the enabled highlights.""" if self._window and self._window.handle: diff --git a/tests/unit/test_mathPres/__init__.py b/tests/unit/test_mathPres/__init__.py new file mode 100644 index 00000000000..055f89d33bb --- /dev/null +++ b/tests/unit/test_mathPres/__init__.py @@ -0,0 +1,5 @@ +# A part of NonVisual Desktop Access (NVDA) +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Unit tests for math presentation modules.""" diff --git a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py new file mode 100644 index 00000000000..bc0847a123a --- /dev/null +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -0,0 +1,63 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Ryan McCleary +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Unit tests for MathCAT NavNode mapping helpers.""" + +import unittest +import xml.etree.ElementTree as ElementTree + +from mathPres.MathCAT import navNodeMapping + + +class TestMathCatNavNodeMapping(unittest.TestCase): + def test_stripMathMlNamespace(self): + testCases = [ + ("{http://www.w3.org/1998/Math/MathML}mi", "mi"), + ("{http://www.w3.org/1998/Math/MathML}math", "math"), + ("mi", "mi"), + ] + for tag, expectedTag in testCases: + with self.subTest(tag=tag): + self.assertEqual(navNodeMapping._stripMathMlNamespace(tag), expectedTag) + + def test_prepareMathMlForNavigation_withoutSourceObj_returnsOriginalMathMl(self): + testCases = [ + "x", + "1", + ] + for mathml in testCases: + with self.subTest(mathml=mathml): + self.assertEqual( + navNodeMapping.prepareMathMlForNavigation(mathml, sourceObj=None), + (mathml, {}), + ) + + def test_removeSyntheticIdsFromMathMl(self): + testCases = [ + ( + 'x', + {}, + ), + ( + 'x', + {"id": "author-id"}, + ), + ( + 'x', + {"id": "author-id"}, + ), + ] + for mathml, expectedAttrs in testCases: + with self.subTest(mathml=mathml): + result = navNodeMapping.removeSyntheticIdsFromMathMl(mathml) + mi = ElementTree.fromstring(result).find("mi") + assert mi is not None + self.assertEqual(mi.attrib, expectedAttrs) + + def test_removeSyntheticIdsFromMathMl_parseError_returnsOriginalMathMl(self): + mathml = "x" + self.assertEqual(navNodeMapping.removeSyntheticIdsFromMathMl(mathml), mathml) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 69e3758b1b6..2b52b2664a6 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -14,6 +14,7 @@ * On supported braille displays, pressing multiple routing keys simultaneously can now be bound to a new "multi routing" gesture. (#20001, @LeonarddeR) * The "select range" command, which selects the text from the first up to the last pressed routing key, is bound to this gesture by default on supporting drivers. * Drivers with built-in support for multi routing: ALVA, Albatross (only when combined with `home1` or `home2`), Baum (and compatible), Freedom Scientific Focus/PAC Mate, HumanWare Brailliant BI/B series, Handy Tech, NLS eReader Zoomax, Seika Notetaker, and Standard HID Braille displays. +* When navigating math on the web, Visual Highlight now follows the current subpart of the expression using the browse mode cursor highlighter. (#19191, @RyanMcCleary) * The braille "word wrap" option has been replaced with a four-valued "Text wrap" option: Off, Show mark when words are cut, At word boundaries, and At word or syllable boundaries. (#17010, @LeonarddeR) * In modes that show a continuation mark, when a word is cut across rows, the last cell of the row now shows a continuation mark (braille dots 7-8) so it is clear that the word continues on the next row. * The "At word or syllable boundaries" option uses hyphenation dictionaries to split long words at syllable boundaries when they do not fit on the display. diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index c2355d178b2..3aa22357e9d 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -1287,6 +1287,7 @@ By default, the review cursor follows the system caret, so you can usually use t At this point, NVDA will enter Math mode, where you can use commands such as the arrow keys to explore the expression. For example, you can move through the expression with the left and right arrow keys and zoom into a portion of the expression such as a fraction using the down arrow key. +When [Visual Highlight](#VisionFocusHighlight) is enabled and the browse mode cursor highlighter is enabled, the current subpart of MathML on the web is also exposed visually. When you wish to return to the document, simply press the escape key. @@ -1542,7 +1543,7 @@ These positions are highlighted with a coloured rectangle outline. * Solid blue highlights a combined navigator object and system focus location (e.g. because [the navigator object follows the system focus](#ReviewCursorFollowFocus)). * Dashed blue highlights just the system focus object. * Solid pink highlights just the navigator object. -* Solid yellow highlights the virtual caret used in browse mode (where there is no physical caret such as in web browsers). +* Solid yellow highlights the virtual caret used in browse mode (where there is no physical caret such as in web browsers), the cursor in OCR recognition results, and the current subpart while navigating math on the web. When Visual Highlight is enabled in the [vision category](#VisionSettings) of the [NVDA Settings](#NVDASettings) dialog, you can [change whether or not to highlight the focus, navigator object or browse mode caret](#VisionSettingsFocusHighlight). @@ -2860,7 +2861,7 @@ The check boxes in the Visual Highlight grouping control the behaviour of NVDA's * Enable Highlighting: Toggles Visual Highlight on and off. * Highlight system focus: toggles whether the [system focus](#SystemFocus) will be highlighted. * Highlight navigator object: toggles whether the [navigator object](#ObjectNavigation) will be highlighted. -* Highlight browse mode cursor: Toggles whether the [virtual browse mode cursor](#BrowseMode) will be highlighted. +* Highlight browse mode cursor: Toggles whether the [virtual browse mode cursor](#BrowseMode) will be highlighted, including the cursor in OCR recognition results and the current subpart while navigating math on the web. Note that checking and unchecking the "Enable Highlighting" check box will also change the state of the three other check boxes accordingly. Therefore, if "Enable Highlighting" is off and you check this check box, the other three check boxes will also be checked automatically.