From 10cfd8fd1898601289fbe46e877b3b7097a715db Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 18 Jun 2026 11:41:28 -0400 Subject: [PATCH 01/44] Wire up math navigation to visual highlighting --- source/browseMode.py | 2 +- source/globalCommands.py | 12 ++++- source/mathPres/MathCAT/MathCAT.py | 51 +++++++++++++++++-- source/mathPres/__init__.py | 22 ++++++-- source/vision/constants.py | 1 + source/vision/visionHandler.py | 4 ++ source/vision/visionHandlerExtensionPoints.py | 6 +++ .../NVDAHighlighter.py | 12 ++++- 8 files changed, 99 insertions(+), 11 deletions(-) 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 6ec0e421737..57e94fbcb6f 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4811,20 +4811,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 06d37640182..0bcdba2771f 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 Optional, TYPE_CHECKING, Type import braille import config @@ -46,6 +46,10 @@ 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 = _("MathCat navigation") @@ -68,13 +72,15 @@ def __init__( self, provider: mathPres.MathPresentationProvider | None = None, mathMl: str | None = None, + sourceObj: Optional["NVDAObject"] = 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(MathCATInteraction, self).__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj) if mathMl is None: self.initMathML = "" else: @@ -86,10 +92,42 @@ 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 _getSourceRect(self) -> Optional["RectLTRB"]: + """Get the whole-equation rectangle for a supported web math source object.""" + sourceObj = self.sourceObj + if not sourceObj: + return None + from NVDAObjects.IAccessible.ia2Web import Math as Ia2WebMath + + if not isinstance(sourceObj, Ia2WebMath): + return None + try: + if sourceObj.hasIrrelevantLocation: + return None + location = sourceObj.location + except Exception: + log.debugWarning("Error getting math source location", exc_info=True) + return None + return location.toLTRB() if location else None + + def _updateMathHighlight(self) -> None: + import vision + + if vision.handler: + vision.handler.handleMathNavigation(self._getSourceRect()) + + def _clearMathHighlight(self) -> None: + import vision + + if vision.handler: + vision.handler.handleMathNavigation(None) def getBrailleRegions( self, @@ -117,10 +155,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() @@ -297,6 +337,8 @@ def _setClipboardData(self, format: int, data: str) -> None: class MathCAT(mathPres.MathPresentationProvider): + supportsInteractionSourceObj: bool = True + def __init__(self): """Initializes MathCAT, loading the rules specified in the rules directory.""" @@ -398,11 +440,12 @@ 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: Optional["NVDAObject"] = 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) interaction.setFocus() interaction._updateBraille() diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index bb54f277c49..134e0bd38fe 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -24,6 +24,7 @@ import textInfos if typing.TYPE_CHECKING: + from NVDAObjects import NVDAObject from speech.commands import SpeechCommand # noqa F401: type-checking only @@ -31,6 +32,7 @@ class MathPresentationProvider(object): """Implements presentation of math content. A single provider does not need to implement all presentation types. """ + supportsInteractionSourceObj: bool = False def getSpeechForMathMl(self, mathMl: str) -> List[Union[str, "SpeechCommand"]]: """Get speech output for specified MathML markup. @@ -46,9 +48,10 @@ def getBrailleForMathMl(self, mathMl: str) -> str: """ raise NotImplementedError - def interactWithMathMl(self, mathMl: str) -> None: + def interactWithMathMl(self, mathMl: str, sourceObj: Optional["NVDAObject"] = None) -> None: """Begin interaction with specified MathML markup. @param mathMl: The MathML markup. + @param sourceObj: The source object containing the math, if known. """ raise NotImplementedError @@ -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: Optional["NVDAObject"] = 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,10 @@ def setFocus(self): eventHandler.executeEvent("gainFocus", self) def script_exit(self, gesture): + import vision + + if vision.handler: + vision.handler.handleMathNavigation(None) eventHandler.executeEvent("gainFocus", self.parent) # Translators: Describes a command. @@ -176,18 +189,21 @@ def getMathMlFromTextInfo(pos: textInfos.TextInfo) -> Optional[str]: return None -def interactWithMathMl(mathMl: str) -> None: +def interactWithMathMl(mathMl: str, sourceObj: Optional["NVDAObject"] = 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 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 + if sourceObj is not None and getattr(interactionProvider, "supportsInteractionSourceObj", False): + return interactionProvider.interactWithMathMl(mathMl, sourceObj=sourceObj) return interactionProvider.interactWithMathMl(mathMl) diff --git a/source/vision/constants.py b/source/vision/constants.py index f9e6a66365a..7fa0f791ffe 100644 --- a/source/vision/constants.py +++ b/source/vision/constants.py @@ -21,5 +21,6 @@ class Context(str, Enum): FOCUS_NAVIGATOR = "focusNavigator" CARET = "caret" BROWSEMODE = "browseMode" + MATH = "math" REVIEW = "review" MOUSE = "mouse" 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..5ad6695c776 100644 --- a/source/vision/visionHandlerExtensionPoints.py +++ b/source/vision/visionHandlerExtensionPoints.py @@ -73,6 +73,12 @@ 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) + #: 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 C{None} to clear it. + #: @type rect: Optional[L{locationHelper.RectLTRB}] + post_mathNavigation: Action = field(default_factory=Action, init=False) #: 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..e7d1d9bf426 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -308,9 +308,12 @@ def refresh(self) -> None: # Translators: shown for a highlighter setting that toggles # highlighting the navigator object. Context.NAVIGATOR: _("Highlight navigator &object"), + # Translators: shown for a highlighter setting that toggles + # highlighting the current math navigation position. + Context.MATH: _("Highlight math &navigation"), } -_supportedContexts = (Context.FOCUS, Context.NAVIGATOR, Context.BROWSEMODE) +_supportedContexts = (Context.FOCUS, Context.NAVIGATOR, Context.BROWSEMODE, Context.MATH) class NVDAHighlighterSettings(providerBase.VisionEnhancementProviderSettings): @@ -318,6 +321,7 @@ class NVDAHighlighterSettings(providerBase.VisionEnhancementProviderSettings): highlightFocus = False highlightNavigator = False highlightBrowseMode = False + highlightMath = False @override @classmethod @@ -482,6 +486,7 @@ class NVDAHighlighter(providerBase.VisionEnhancementProvider): Context.NAVIGATOR: SOLID_PINK, Context.FOCUS_NAVIGATOR: SOLID_BLUE, Context.BROWSEMODE: SOLID_YELLOW, + Context.MATH: SOLID_YELLOW, } _refreshInterval = 100 customWindowClass = HighlightWindow @@ -513,6 +518,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__() @@ -587,6 +593,7 @@ def updateContextRect( self.contextToRectMap[context] = rect def handleFocusChange(self, obj: "NVDAObject") -> None: + self.contextToRectMap.pop(Context.MATH, None) self.updateContextRect(context=Context.FOCUS, obj=obj) if not api.isObjectInActiveTreeInterceptor(obj): self.contextToRectMap.pop(Context.BROWSEMODE, None) @@ -599,6 +606,9 @@ 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: + self.updateContextRect(context=Context.MATH, rect=rect) + def refresh(self) -> None: """Refreshes the screen positions of the enabled highlights.""" if self._window and self._window.handle: From a55dc3a004209b967878bcbab06164a9ba1a40cb Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 18 Jun 2026 11:56:10 -0400 Subject: [PATCH 02/44] Implement highlight for web IA2 --- source/NVDAObjects/IAccessible/ia2Web.py | 30 ++++++++++++++++++++++++ source/mathPres/MathCAT/MathCAT.py | 15 +++++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 98312c92e59..61398aa579f 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,9 @@ import config import NVDAObjects +if TYPE_CHECKING: + from locationHelper import RectLTRB + class IA2WebAnnotationTarget(AnnotationTarget): def __init__(self, target: IAccessible): @@ -331,6 +335,32 @@ class EditorChunk(Ia2Web): class Math(Ia2Web): + _MATH_ID_ATTRS = ("id", "xml-id") + + def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: + """Get the screen rectangle for a descendant MathML node with the given id.""" + if not nodeId: + raise LookupError + stack: list[NVDAObjects.NVDAObject] = [self] + while stack: + obj = stack.pop() + try: + attrs = obj.IA2Attributes + except AttributeError: + attrs = {} + if any(attrs.get(attr) == nodeId for attr in self._MATH_ID_ATTRS): + if obj.hasIrrelevantLocation: + raise LookupError + location = obj.location + if not location or not location.width or not location.height: + raise LookupError + return location.toLTRB() + try: + stack.extend(reversed(obj.children)) + except RuntimeError: + log.debugWarning("Error fetching MathML node children", exc_info=True) + raise LookupError + def _get_mathMl(self): # Chromium browsers now expose a 'math' IAccessible2 attribute, # which contains all the raw MathML. diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 0bcdba2771f..5e00622a1a6 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -99,8 +99,8 @@ def reportFocus(self) -> None: ui.message(pgettext("math", "Error in starting navigation of math.")) self._clearMathHighlight() - def _getSourceRect(self) -> Optional["RectLTRB"]: - """Get the whole-equation rectangle for a supported web math source object.""" + def _getHighlightRect(self) -> Optional["RectLTRB"]: + """Get the navigation rectangle for a supported web math source object.""" sourceObj = self.sourceObj if not sourceObj: return None @@ -108,6 +108,15 @@ def _getSourceRect(self) -> Optional["RectLTRB"]: 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) + else: + try: + return sourceObj.getMathNodeRectById(nodeId) + except LookupError: + pass try: if sourceObj.hasIrrelevantLocation: return None @@ -121,7 +130,7 @@ def _updateMathHighlight(self) -> None: import vision if vision.handler: - vision.handler.handleMathNavigation(self._getSourceRect()) + vision.handler.handleMathNavigation(self._getHighlightRect()) def _clearMathHighlight(self) -> None: import vision From a0cd4f36632a1f60c6376ad262d839cadfbcf448 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 18 Jun 2026 13:05:41 -0400 Subject: [PATCH 03/44] Debug logging --- source/NVDAObjects/IAccessible/ia2Web.py | 9 ++++++++- source/mathPres/MathCAT/MathCAT.py | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 61398aa579f..27ed97bc4bc 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -342,23 +342,30 @@ def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: if not nodeId: raise LookupError stack: list[NVDAObjects.NVDAObject] = [self] + visitedCount = 0 while stack: obj = stack.pop() + visitedCount += 1 try: attrs = obj.IA2Attributes except AttributeError: attrs = {} - if any(attrs.get(attr) == nodeId for attr in self._MATH_ID_ATTRS): + matchedAttr = next((attr for attr in self._MATH_ID_ATTRS if attrs.get(attr) == nodeId), None) + if matchedAttr: if obj.hasIrrelevantLocation: + log.debug(f"Math highlight matched {matchedAttr}={nodeId!r}, but object has irrelevant location") raise LookupError location = obj.location if not location or not location.width or not location.height: + log.debug(f"Math highlight matched {matchedAttr}={nodeId!r}, but object has no usable location") raise LookupError + log.debug(f"Math highlight matched {matchedAttr}={nodeId!r} after visiting {visitedCount} IA2 objects") return location.toLTRB() try: stack.extend(reversed(obj.children)) except RuntimeError: log.debugWarning("Error fetching MathML node children", exc_info=True) + log.debug(f"Math highlight found no IA2 object for id {nodeId!r} after visiting {visitedCount} IA2 objects") raise LookupError def _get_mathMl(self): diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 5e00622a1a6..4330396b9a1 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -113,9 +113,11 @@ def _getHighlightRect(self) -> Optional["RectLTRB"]: except Exception: log.debugWarning("Error getting MathCAT navigation node id", exc_info=True) else: + log.debug(f"Math highlight resolving MathCAT navigation node id {nodeId!r}") try: return sourceObj.getMathNodeRectById(nodeId) except LookupError: + log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") pass try: if sourceObj.hasIrrelevantLocation: From 302b4943caccd1bb0cba82c721855b5a248bfbf6 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 18 Jun 2026 13:12:28 -0400 Subject: [PATCH 04/44] More debug logging --- source/NVDAObjects/IAccessible/ia2Web.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 27ed97bc4bc..4d339e902f6 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -343,6 +343,7 @@ def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: raise LookupError stack: list[NVDAObjects.NVDAObject] = [self] visitedCount = 0 + visitedDetails: list[str] = [] while stack: obj = stack.pop() visitedCount += 1 @@ -350,6 +351,13 @@ def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: attrs = obj.IA2Attributes except AttributeError: attrs = {} + if len(visitedDetails) < 30: + location = obj.location + visitedDetails.append( + f"role={obj.role!r}, name={obj.name!r}, " + f"tag={attrs.get('tag')!r}, id={attrs.get('id')!r}, " + f"xml-id={attrs.get('xml-id')!r}, location={location!r}", + ) matchedAttr = next((attr for attr in self._MATH_ID_ATTRS if attrs.get(attr) == nodeId), None) if matchedAttr: if obj.hasIrrelevantLocation: @@ -365,7 +373,10 @@ def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: stack.extend(reversed(obj.children)) except RuntimeError: log.debugWarning("Error fetching MathML node children", exc_info=True) - log.debug(f"Math highlight found no IA2 object for id {nodeId!r} after visiting {visitedCount} IA2 objects") + log.debug( + f"Math highlight found no IA2 object for id {nodeId!r} after visiting {visitedCount} IA2 objects. " + f"Visited IA2 math objects: {' | '.join(visitedDetails)}", + ) raise LookupError def _get_mathMl(self): From 1b708763ad0f74166051fa1f2ef17df920ad77b7 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 18 Jun 2026 14:07:07 -0400 Subject: [PATCH 05/44] Store copy of mathML, generating IDs where not already present --- source/NVDAObjects/IAccessible/ia2Web.py | 89 +++++++++++++--- source/mathPres/MathCAT/MathCAT.py | 130 +++++++++++++++++++++-- 2 files changed, 197 insertions(+), 22 deletions(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 4d339e902f6..8dc8772eae4 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -337,13 +337,83 @@ class EditorChunk(Ia2Web): class Math(Ia2Web): _MATH_ID_ATTRS = ("id", "xml-id") + 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) -> Optional["RectLTRB"]: + 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[tuple[int, ...], tuple[str, "RectLTRB"]]: + """Map MathML element paths to tag names and screen rectangles for this IA2 math subtree. + + Paths are based on MathML element child indexes only, ignoring static text + accessibles exposed below token elements. + """ + nodeInfoByPath: dict[tuple[int, ...], tuple[str, "RectLTRB"]] = {} + stack: list[tuple[NVDAObjects.NVDAObject, tuple[int, ...]]] = [ + (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] = (tag, 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 getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: """Get the screen rectangle for a descendant MathML node with the given id.""" if not nodeId: raise LookupError stack: list[NVDAObjects.NVDAObject] = [self] visitedCount = 0 - visitedDetails: list[str] = [] while stack: obj = stack.pop() visitedCount += 1 @@ -351,13 +421,6 @@ def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: attrs = obj.IA2Attributes except AttributeError: attrs = {} - if len(visitedDetails) < 30: - location = obj.location - visitedDetails.append( - f"role={obj.role!r}, name={obj.name!r}, " - f"tag={attrs.get('tag')!r}, id={attrs.get('id')!r}, " - f"xml-id={attrs.get('xml-id')!r}, location={location!r}", - ) matchedAttr = next((attr for attr in self._MATH_ID_ATTRS if attrs.get(attr) == nodeId), None) if matchedAttr: if obj.hasIrrelevantLocation: @@ -369,14 +432,8 @@ def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: raise LookupError log.debug(f"Math highlight matched {matchedAttr}={nodeId!r} after visiting {visitedCount} IA2 objects") return location.toLTRB() - try: - stack.extend(reversed(obj.children)) - except RuntimeError: - log.debugWarning("Error fetching MathML node children", exc_info=True) - log.debug( - f"Math highlight found no IA2 object for id {nodeId!r} after visiting {visitedCount} IA2 objects. " - f"Visited IA2 math objects: {' | '.join(visitedDetails)}", - ) + stack.extend(reversed(self._getMathObjChildren(obj))) + log.debug(f"Math highlight found no IA2 object for id {nodeId!r} after visiting {visitedCount} IA2 objects") raise LookupError def _get_mathMl(self): diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 4330396b9a1..1815e307083 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -4,6 +4,7 @@ # For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt import re +import xml.etree.ElementTree as ElementTree from collections.abc import Generator from ctypes import ( Array, @@ -50,6 +51,10 @@ from locationHelper import RectLTRB from NVDAObjects import NVDAObject + +_MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML" +_NAV_NODE_ID_PREFIX = "nvda-math-node-" + # Translators: The name of the category of MathCAT navigation commands in the Input Gestures dialog. SCRCAT_MATHCAT_NAV = _("MathCat navigation") @@ -73,19 +78,109 @@ def __init__( provider: mathPres.MathPresentationProvider | None = None, mathMl: str | None = None, sourceObj: Optional["NVDAObject"] = None, + mathNodeRectsById: Optional[dict[str, "RectLTRB"]] = None, + originalMathMl: str | 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. + :param mathNodeRectsById: Map of synthetic MathML ids to source rectangles. + :param originalMathMl: Unmodified MathML, used for copy commands. """ super(MathCATInteraction, self).__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj) - if mathMl is None: + self._mathNodeRectsById = mathNodeRectsById or {} + if originalMathMl is not None: + self.initMathML = originalMathMl + elif mathMl is None: self.initMathML = "" else: self.initMathML = mathMl + @staticmethod + def _stripMathMlNamespace(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + @classmethod + def _getSyntheticNodeId(cls, nodePath: tuple[int, ...]) -> str: + if not nodePath: + return f"{_NAV_NODE_ID_PREFIX}root" + return f"{_NAV_NODE_ID_PREFIX}{'-'.join(str(index) for index in nodePath)}" + + @classmethod + def _iterMathMlElements( + cls, + element: ElementTree.Element, + nodePath: tuple[int, ...], + ) -> Generator[tuple[ElementTree.Element, tuple[int, ...]], None, None]: + yield element, nodePath + mathElementChildren = tuple(child for child in element if isinstance(child.tag, str)) + for index, child in enumerate(mathElementChildren): + yield from cls._iterMathMlElements(child, nodePath + (index,)) + + @classmethod + def _addSyntheticIdsToMathMl( + cls, + mathml: str, + ) -> tuple[str, dict[str, tuple[tuple[int, ...], str]]]: + 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 cls._stripMathMlNamespace(root.tag) != "math": + log.debug("Math highlight did not add synthetic ids because MathML root is not ") + return mathml, {} + nodeInfoById: dict[str, tuple[tuple[int, ...], str]] = {} + for element, nodePath in cls._iterMathMlElements(root, ()): + nodeId = cls._getSyntheticNodeId(nodePath) + element.set("id", nodeId) + nodeInfoById[nodeId] = (nodePath, cls._stripMathMlNamespace(element.tag)) + return ElementTree.tostring(root, encoding="unicode"), nodeInfoById + + @classmethod + def prepareMathMlForNavigation( + cls, + mathml: str, + sourceObj: Optional["NVDAObject"], + ) -> tuple[str, dict[str, "RectLTRB"]]: + """Add synthetic ids to MathML and map those ids to IA2 rectangles.""" + if not sourceObj: + return mathml, {} + from NVDAObjects.IAccessible.ia2Web import Math as Ia2WebMath + + if not isinstance(sourceObj, Ia2WebMath): + return mathml, {} + mathmlWithIds, mathMlNodeInfoById = cls._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 mathmlWithIds, {} + nodeRectsById: dict[str, "RectLTRB"] = {} + missingPathCount = 0 + tagMismatchCount = 0 + for nodeId, (nodePath, mathMlTag) in mathMlNodeInfoById.items(): + try: + ia2Tag, rect = ia2NodeInfoByPath[nodePath] + except KeyError: + missingPathCount += 1 + continue + if ia2Tag != mathMlTag: + tagMismatchCount += 1 + continue + nodeRectsById[nodeId] = 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}", + ) + return mathmlWithIds, nodeRectsById + def reportFocus(self) -> None: """Calls MathCAT's ZoomIn command and speaks the resulting text.""" super(MathCATInteraction, self).reportFocus() @@ -114,11 +209,19 @@ def _getHighlightRect(self) -> Optional["RectLTRB"]: log.debugWarning("Error getting MathCAT navigation node id", exc_info=True) else: log.debug(f"Math highlight resolving MathCAT navigation node id {nodeId!r}") - try: - return sourceObj.getMathNodeRectById(nodeId) - except LookupError: + if nodeId in self._mathNodeRectsById: + log.debug(f"Math highlight matched synthetic MathML id {nodeId!r}") + 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}") - pass + else: + log.debug("Math highlight MathCAT navigation id was not an NVDA synthetic id") + try: + return sourceObj.getMathNodeRectById(nodeId) + except LookupError: + log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") + pass try: if sourceObj.hasIrrelevantLocation: return None @@ -457,6 +560,21 @@ def interactWithMathMl(self, mathml: str, sourceObj: Optional["NVDAObject"] = No :param mathml: The MathML representing the math to interact with. :param sourceObj: Optional source object containing the math. """ - interaction = MathCATInteraction(provider=self, mathMl=mathml, sourceObj=sourceObj) + originalMathMl = mathml + mathml, mathNodeRectsById = MathCATInteraction.prepareMathMlForNavigation(mathml, sourceObj) + try: + libmathcat.SetMathML(mathml) + except Exception: + log.exception(f"MathML is {mathml}") + # Translators: this message reports illegal MathML. + ui.message(pgettext("math", "Illegal MathML found.")) + libmathcat.SetMathML("") + interaction = MathCATInteraction( + provider=self, + mathMl=mathml, + sourceObj=sourceObj, + mathNodeRectsById=mathNodeRectsById, + originalMathMl=originalMathMl, + ) interaction.setFocus() interaction._updateBraille() From 946697a3db107688cce935e842faa34b46b1063c Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 18 Jun 2026 14:18:56 -0400 Subject: [PATCH 06/44] Fixed math highlight settings --- source/visionEnhancementProviders/NVDAHighlighter.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/visionEnhancementProviders/NVDAHighlighter.py b/source/visionEnhancementProviders/NVDAHighlighter.py index e7d1d9bf426..b0dd6c82142 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -367,6 +367,7 @@ def __init__( settingsToCheck = [ settingsStorage.highlightBrowseMode, settingsStorage.highlightFocus, + settingsStorage.highlightMath, settingsStorage.highlightNavigator, ] if any(settingsToCheck): @@ -376,6 +377,7 @@ def __init__( ) settingsStorage.highlightBrowseMode = False settingsStorage.highlightFocus = False + settingsStorage.highlightMath = False settingsStorage.highlightNavigator = False super().__init__(parent) @@ -432,6 +434,7 @@ def _updateEnabledState(self) -> None: settingsToTriggerActivation = [ settingsStorage.highlightBrowseMode, settingsStorage.highlightFocus, + settingsStorage.highlightMath, settingsStorage.highlightNavigator, ] isAnyEnabled = any(settingsToTriggerActivation) @@ -450,6 +453,7 @@ def _onEnableFailure(self) -> None: settingsStorage: NVDAHighlighterSettings = self._getSettingsStorage() settingsStorage.highlightBrowseMode = False settingsStorage.highlightFocus = False + settingsStorage.highlightMath = False settingsStorage.highlightNavigator = False self.updateDriverSettings() self._updateEnabledState() @@ -468,6 +472,7 @@ def _onCheckEvent(self, evt: wx.CommandEvent) -> None: isEnableAllChecked = evt.IsChecked() settingsStorage.highlightBrowseMode = isEnableAllChecked settingsStorage.highlightFocus = isEnableAllChecked + settingsStorage.highlightMath = isEnableAllChecked settingsStorage.highlightNavigator = isEnableAllChecked if not self._ensureEnableState(isEnableAllChecked) and isEnableAllChecked: self._onEnableFailure() From c971fe179a14e343aa1392ecb19af48fa301daa5 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 18 Jun 2026 14:51:22 -0400 Subject: [PATCH 07/44] Change highlight color to orange --- source/mathPres/MathCAT/MathCAT.py | 25 +++++++++++++++++-- .../NVDAHighlighter.py | 4 ++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 1815e307083..a92d0ea413a 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -54,6 +54,8 @@ _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" # Translators: The name of the category of MathCAT navigation commands in the Input Gestures dialog. SCRCAT_MATHCAT_NAV = _("MathCat navigation") @@ -136,10 +138,30 @@ def _addSyntheticIdsToMathMl( nodeInfoById: dict[str, tuple[tuple[int, ...], str]] = {} for element, nodePath in cls._iterMathMlElements(root, ()): nodeId = cls._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] = (nodePath, cls._stripMathMlNamespace(element.tag)) return ElementTree.tostring(root, encoding="unicode"), nodeInfoById + @classmethod + def _removeSyntheticIdsFromMathMl(cls, mathml: str) -> str: + try: + root = ElementTree.fromstring(mathml) + except ElementTree.ParseError: + return mathml + for element, _nodePath in cls._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") + @classmethod def prepareMathMlForNavigation( cls, @@ -374,12 +396,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-[^=]+='[^']*'") def _wrapMathMLForClipBoard(self, text: str) -> str: """Cleanup the MathML a little.""" - text = re.sub(self._hasAddedId, "", text) + text = self._removeSyntheticIdsFromMathMl(text) mathMLWithNS: str = re.sub(self._hasDataAttr, "", text) if not re.match(self._mathTagHasNameSpace, mathMLWithNS): mathMLWithNS = mathMLWithNS.replace( diff --git a/source/visionEnhancementProviders/NVDAHighlighter.py b/source/visionEnhancementProviders/NVDAHighlighter.py index b0dd6c82142..40161819af8 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -68,10 +68,12 @@ class HighlightStyle(NamedTuple): BLUE = RGB(0x03, 0x36, 0xFF) PINK = RGB(0xFF, 0x02, 0x66) YELLOW = RGB(0xFF, 0xDE, 0x03) +ORANGE = RGB(0xFF, 0xA5, 0x00) DASH_BLUE = HighlightStyle(BLUE, 5, winGDI.DashStyleDash, 5) SOLID_PINK = HighlightStyle(PINK, 5, winGDI.DashStyleSolid, 5) SOLID_BLUE = HighlightStyle(BLUE, 5, winGDI.DashStyleSolid, 5) SOLID_YELLOW = HighlightStyle(YELLOW, 2, winGDI.DashStyleSolid, 2) +SOLID_ORANGE = HighlightStyle(ORANGE, 2, winGDI.DashStyleSolid, 2) class HighlightWindow(CustomWindow): @@ -491,7 +493,7 @@ class NVDAHighlighter(providerBase.VisionEnhancementProvider): Context.NAVIGATOR: SOLID_PINK, Context.FOCUS_NAVIGATOR: SOLID_BLUE, Context.BROWSEMODE: SOLID_YELLOW, - Context.MATH: SOLID_YELLOW, + Context.MATH: SOLID_ORANGE, } _refreshInterval = 100 customWindowClass = HighlightWindow From ce5ed03ceddba612e5d2c6a834b7809547d16576 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Sun, 21 Jun 2026 14:07:26 -0400 Subject: [PATCH 08/44] Clean up types and move some classmethods into standalone functions --- source/NVDAObjects/IAccessible/ia2Web.py | 17 +- source/mathPres/MathCAT/MathCAT.py | 277 ++++++++++++----------- source/mathPres/__init__.py | 19 +- 3 files changed, 167 insertions(+), 146 deletions(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 8dc8772eae4..076601866e4 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -38,6 +38,7 @@ if TYPE_CHECKING: from locationHelper import RectLTRB + from mathPres import MathMlNodeInfo, MathMlNodePath class IA2WebAnnotationTarget(AnnotationTarget): @@ -379,13 +380,15 @@ def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> Optional["Rect return None return location.toLTRB() - def getMathNodeInfoByPath(self) -> dict[tuple[int, ...], tuple[str, "RectLTRB"]]: + def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeInfo"]: """Map MathML element paths to tag names and screen rectangles for this IA2 math subtree. Paths are based on MathML element child indexes only, ignoring static text accessibles exposed below token elements. """ - nodeInfoByPath: dict[tuple[int, ...], tuple[str, "RectLTRB"]] = {} + from mathPres import MathMlNodeInfo + + nodeInfoByPath: dict["MathMlNodePath", "MathMlNodeInfo"] = {} stack: list[tuple[NVDAObjects.NVDAObject, tuple[int, ...]]] = [ (self._getMathNodeMapRoot(), ()), ] @@ -396,7 +399,7 @@ def getMathNodeInfoByPath(self) -> dict[tuple[int, ...], tuple[str, "RectLTRB"]] tag = self._getMathObjAttributes(obj).get("tag") if rect := self._getMathNodeRectFromObj(obj): if tag: - nodeInfoByPath[path] = (tag, rect) + nodeInfoByPath[path] = MathMlNodeInfo(path=path, tag=tag, rect=rect) children = self._getMathElementChildren(obj) stack.extend( (child, path + (index,)) @@ -423,15 +426,11 @@ def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: attrs = {} matchedAttr = next((attr for attr in self._MATH_ID_ATTRS if attrs.get(attr) == nodeId), None) if matchedAttr: - if obj.hasIrrelevantLocation: - log.debug(f"Math highlight matched {matchedAttr}={nodeId!r}, but object has irrelevant location") - raise LookupError - location = obj.location - if not location or not location.width or not location.height: + if not (rect := self._getMathNodeRectFromObj(obj)): log.debug(f"Math highlight matched {matchedAttr}={nodeId!r}, but object has no usable location") raise LookupError log.debug(f"Math highlight matched {matchedAttr}={nodeId!r} after visiting {visitedCount} IA2 objects") - return location.toLTRB() + return rect stack.extend(reversed(self._getMathObjChildren(obj))) log.debug(f"Math highlight found no IA2 object for id {nodeId!r} after visiting {visitedCount} IA2 objects") raise LookupError diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index a92d0ea413a..07cef20069d 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -13,7 +13,7 @@ windll, ) from os import path -from typing import Optional, TYPE_CHECKING, Type +from typing import TYPE_CHECKING, Type import braille import config @@ -57,6 +57,115 @@ _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: mathPres.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: mathPres.MathMlNodePath, +) -> Generator[tuple[ElementTree.Element, mathPres.MathMlNodePath], None, None]: + yield element, nodePath + mathElementChildren = tuple(child for child in element if isinstance(child.tag, str)) + for index, child in enumerate(mathElementChildren): + yield from _iterMathMlElements(child, nodePath + (index,)) + + +def _addSyntheticIdsToMathMl( + mathml: str, +) -> tuple[str, dict[mathPres.SyntheticMathMlNodeId, mathPres.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[mathPres.SyntheticMathMlNodeId, mathPres.MathMlNodeInfo] = {} + for element, nodePath in _iterMathMlElements(root, ()): + 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] = mathPres.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[mathPres.SyntheticMathMlNodeId, "RectLTRB"]]: + """Add synthetic ids to MathML and map those ids to IA2 rectangles.""" + if not sourceObj: + return mathml, {} + 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[mathPres.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 + if ia2NodeInfo.rect is None: + missingPathCount += 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 + + # Translators: The name of the category of MathCAT navigation commands in the Input Gestures dialog. SCRCAT_MATHCAT_NAV = _("MathCat navigation") @@ -79,130 +188,24 @@ def __init__( self, provider: mathPres.MathPresentationProvider | None = None, mathMl: str | None = None, - sourceObj: Optional["NVDAObject"] = None, - mathNodeRectsById: Optional[dict[str, "RectLTRB"]] = None, - originalMathMl: 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. - :param mathNodeRectsById: Map of synthetic MathML ids to source rectangles. - :param originalMathMl: Unmodified MathML, used for copy commands. """ super(MathCATInteraction, self).__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj) - self._mathNodeRectsById = mathNodeRectsById or {} - if originalMathMl is not None: - self.initMathML = originalMathMl - elif mathMl is None: + self.mathMlForNavigation, self._mathNodeRectsById = _prepareMathMlForNavigation( + mathMl or "", + sourceObj, + ) + if mathMl is None: self.initMathML = "" else: self.initMathML = mathMl - @staticmethod - def _stripMathMlNamespace(tag: str) -> str: - return tag.rsplit("}", 1)[-1] - - @classmethod - def _getSyntheticNodeId(cls, nodePath: tuple[int, ...]) -> str: - if not nodePath: - return f"{_NAV_NODE_ID_PREFIX}root" - return f"{_NAV_NODE_ID_PREFIX}{'-'.join(str(index) for index in nodePath)}" - - @classmethod - def _iterMathMlElements( - cls, - element: ElementTree.Element, - nodePath: tuple[int, ...], - ) -> Generator[tuple[ElementTree.Element, tuple[int, ...]], None, None]: - yield element, nodePath - mathElementChildren = tuple(child for child in element if isinstance(child.tag, str)) - for index, child in enumerate(mathElementChildren): - yield from cls._iterMathMlElements(child, nodePath + (index,)) - - @classmethod - def _addSyntheticIdsToMathMl( - cls, - mathml: str, - ) -> tuple[str, dict[str, tuple[tuple[int, ...], str]]]: - 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 cls._stripMathMlNamespace(root.tag) != "math": - log.debug("Math highlight did not add synthetic ids because MathML root is not ") - return mathml, {} - nodeInfoById: dict[str, tuple[tuple[int, ...], str]] = {} - for element, nodePath in cls._iterMathMlElements(root, ()): - nodeId = cls._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] = (nodePath, cls._stripMathMlNamespace(element.tag)) - return ElementTree.tostring(root, encoding="unicode"), nodeInfoById - - @classmethod - def _removeSyntheticIdsFromMathMl(cls, mathml: str) -> str: - try: - root = ElementTree.fromstring(mathml) - except ElementTree.ParseError: - return mathml - for element, _nodePath in cls._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") - - @classmethod - def prepareMathMlForNavigation( - cls, - mathml: str, - sourceObj: Optional["NVDAObject"], - ) -> tuple[str, dict[str, "RectLTRB"]]: - """Add synthetic ids to MathML and map those ids to IA2 rectangles.""" - if not sourceObj: - return mathml, {} - from NVDAObjects.IAccessible.ia2Web import Math as Ia2WebMath - - if not isinstance(sourceObj, Ia2WebMath): - return mathml, {} - mathmlWithIds, mathMlNodeInfoById = cls._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 mathmlWithIds, {} - nodeRectsById: dict[str, "RectLTRB"] = {} - missingPathCount = 0 - tagMismatchCount = 0 - for nodeId, (nodePath, mathMlTag) in mathMlNodeInfoById.items(): - try: - ia2Tag, rect = ia2NodeInfoByPath[nodePath] - except KeyError: - missingPathCount += 1 - continue - if ia2Tag != mathMlTag: - tagMismatchCount += 1 - continue - nodeRectsById[nodeId] = 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}", - ) - return mathmlWithIds, nodeRectsById - def reportFocus(self) -> None: """Calls MathCAT's ZoomIn command and speaks the resulting text.""" super(MathCATInteraction, self).reportFocus() @@ -216,7 +219,7 @@ def reportFocus(self) -> None: ui.message(pgettext("math", "Error in starting navigation of math.")) self._clearMathHighlight() - def _getHighlightRect(self) -> Optional["RectLTRB"]: + def _getHighlightRect(self) -> "RectLTRB | None": """Get the navigation rectangle for a supported web math source object.""" sourceObj = self.sourceObj if not sourceObj: @@ -243,7 +246,9 @@ def _getHighlightRect(self) -> Optional["RectLTRB"]: return sourceObj.getMathNodeRectById(nodeId) except LookupError: log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") - pass + except Exception: + log.debugWarning("Error resolving MathCAT navigation id to IA2 object", exc_info=True) + log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") try: if sourceObj.hasIrrelevantLocation: return None @@ -254,16 +259,27 @@ def _getHighlightRect(self) -> Optional["RectLTRB"]: return location.toLTRB() if location else None def _updateMathHighlight(self) -> None: - import vision + try: + rect = self._getHighlightRect() + except Exception: + log.debugWarning("Error updating math highlight", exc_info=True) + rect = None + try: + import vision - if vision.handler: - vision.handler.handleMathNavigation(self._getHighlightRect()) + if vision.handler: + vision.handler.handleMathNavigation(rect) + except Exception: + log.debugWarning("Error sending math highlight update", exc_info=True) def _clearMathHighlight(self) -> None: - import vision + try: + import vision - if vision.handler: - vision.handler.handleMathNavigation(None) + if vision.handler: + vision.handler.handleMathNavigation(None) + except Exception: + log.debugWarning("Error clearing math highlight", exc_info=True) def getBrailleRegions( self, @@ -400,7 +416,7 @@ def script_rawdataToClip(self, gesture: KeyboardInputGesture) -> None: def _wrapMathMLForClipBoard(self, text: str) -> str: """Cleanup the MathML a little.""" - text = self._removeSyntheticIdsFromMathMl(text) + text = _removeSyntheticIdsFromMathMl(text) mathMLWithNS: str = re.sub(self._hasDataAttr, "", text) if not re.match(self._mathTagHasNameSpace, mathMLWithNS): mathMLWithNS = mathMLWithNS.replace( @@ -575,27 +591,20 @@ def getBrailleForMathMl(self, mathml: str) -> str: ui.message(pgettext("math", "Error in brailling math.")) return "" - def interactWithMathMl(self, mathml: str, sourceObj: Optional["NVDAObject"] = None) -> 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. """ - originalMathMl = mathml - mathml, mathNodeRectsById = MathCATInteraction.prepareMathMlForNavigation(mathml, sourceObj) + interaction = MathCATInteraction(provider=self, mathMl=mathml, sourceObj=sourceObj) try: - libmathcat.SetMathML(mathml) + libmathcat.SetMathML(interaction.mathMlForNavigation) except Exception: - log.exception(f"MathML is {mathml}") + log.exception(f"MathML is {interaction.mathMlForNavigation}") # Translators: this message reports illegal MathML. ui.message(pgettext("math", "Illegal MathML found.")) libmathcat.SetMathML("") - interaction = MathCATInteraction( - provider=self, - mathMl=mathml, - sourceObj=sourceObj, - mathNodeRectsById=mathNodeRectsById, - originalMathMl=originalMathMl, - ) + return interaction.setFocus() interaction._updateBraille() diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index 134e0bd38fe..8a9121c2381 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -12,6 +12,7 @@ import re import typing +from dataclasses import dataclass from typing import List, Optional, Union from NVDAObjects.window import Window @@ -24,10 +25,22 @@ import textInfos if typing.TYPE_CHECKING: + from locationHelper import RectLTRB from NVDAObjects import NVDAObject from speech.commands import SpeechCommand # noqa F401: type-checking only +MathMlNodePath = tuple[int, ...] +SyntheticMathMlNodeId = str + + +@dataclass(frozen=True) +class MathMlNodeInfo: + path: MathMlNodePath + tag: str + rect: "RectLTRB | None" = None + + class MathPresentationProvider(object): """Implements presentation of math content. A single provider does not need to implement all presentation types. @@ -48,7 +61,7 @@ def getBrailleForMathMl(self, mathMl: str) -> str: """ raise NotImplementedError - def interactWithMathMl(self, mathMl: str, sourceObj: Optional["NVDAObject"] = None) -> None: + def interactWithMathMl(self, mathMl: str, sourceObj: "NVDAObject | None" = None) -> None: """Begin interaction with specified MathML markup. @param mathMl: The MathML markup. @param sourceObj: The source object containing the math, if known. @@ -127,7 +140,7 @@ def __init__( self, provider: MathPresentationProvider | None = None, mathMl: str | None = None, - sourceObj: Optional["NVDAObject"] = None, + sourceObj: "NVDAObject | None" = None, ): self.parent = parent = api.getFocusObject() self.provider = provider @@ -189,7 +202,7 @@ def getMathMlFromTextInfo(pos: textInfos.TextInfo) -> Optional[str]: return None -def interactWithMathMl(mathMl: str, sourceObj: Optional["NVDAObject"] = None) -> 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. From 26b22cf49ace8e2eb67f5b4c468107d72b5906b0 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Sun, 21 Jun 2026 16:11:31 -0400 Subject: [PATCH 09/44] Refactor nav node mapping --- source/NVDAObjects/IAccessible/ia2Web.py | 16 +-- source/mathPres/MathCAT/MathCAT.py | 134 ++---------------- source/mathPres/MathCAT/navNodeMapping.py | 134 ++++++++++++++++++ source/mathPres/__init__.py | 13 -- source/mathPres/mathMlNode.py | 29 ++++ .../NVDAHighlighter.py | 3 + 6 files changed, 184 insertions(+), 145 deletions(-) create mode 100644 source/mathPres/MathCAT/navNodeMapping.py create mode 100644 source/mathPres/mathMlNode.py diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 076601866e4..1cdc78742ac 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -38,7 +38,7 @@ if TYPE_CHECKING: from locationHelper import RectLTRB - from mathPres import MathMlNodeInfo, MathMlNodePath + from mathPres.mathMlNode import MathMlNodePath, MathMlNodeRectInfo class IA2WebAnnotationTarget(AnnotationTarget): @@ -368,7 +368,7 @@ def _getMathNodeMapRoot(self) -> NVDAObjects.NVDAObject: ) return mathChildren[0] if len(mathChildren) == 1 else self - def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> Optional["RectLTRB"]: + def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> "RectLTRB | None": try: if obj.hasIrrelevantLocation: return None @@ -380,16 +380,16 @@ def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> Optional["Rect return None return location.toLTRB() - def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeInfo"]: + def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"]: """Map MathML element paths to tag names and screen rectangles for this IA2 math subtree. Paths are based on MathML element child indexes only, ignoring static text accessibles exposed below token elements. """ - from mathPres import MathMlNodeInfo + from mathPres.mathMlNode import MathMlNodeRectInfo - nodeInfoByPath: dict["MathMlNodePath", "MathMlNodeInfo"] = {} - stack: list[tuple[NVDAObjects.NVDAObject, tuple[int, ...]]] = [ + nodeInfoByPath: dict["MathMlNodePath", "MathMlNodeRectInfo"] = {} + stack: list[tuple[NVDAObjects.NVDAObject, "MathMlNodePath"]] = [ (self._getMathNodeMapRoot(), ()), ] visitedCount = 0 @@ -399,7 +399,7 @@ def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeInfo"]: tag = self._getMathObjAttributes(obj).get("tag") if rect := self._getMathNodeRectFromObj(obj): if tag: - nodeInfoByPath[path] = MathMlNodeInfo(path=path, tag=tag, rect=rect) + nodeInfoByPath[path] = MathMlNodeRectInfo(path=path, tag=tag, rect=rect) children = self._getMathElementChildren(obj) stack.extend( (child, path + (index,)) @@ -411,7 +411,7 @@ def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeInfo"]: ) return nodeInfoByPath - def getMathNodeRectById(self, nodeId: str) -> Optional["RectLTRB"]: + def getMathNodeRectById(self, nodeId: str) -> "RectLTRB": """Get the screen rectangle for a descendant MathML node with the given id.""" if not nodeId: raise LookupError diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 07cef20069d..f5dd5f4ebee 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -4,7 +4,6 @@ # For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt import re -import xml.etree.ElementTree as ElementTree from collections.abc import Generator from ctypes import ( Array, @@ -44,6 +43,11 @@ 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 @@ -51,121 +55,6 @@ 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: mathPres.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: mathPres.MathMlNodePath, -) -> Generator[tuple[ElementTree.Element, mathPres.MathMlNodePath], None, None]: - yield element, nodePath - mathElementChildren = tuple(child for child in element if isinstance(child.tag, str)) - for index, child in enumerate(mathElementChildren): - yield from _iterMathMlElements(child, nodePath + (index,)) - - -def _addSyntheticIdsToMathMl( - mathml: str, -) -> tuple[str, dict[mathPres.SyntheticMathMlNodeId, mathPres.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[mathPres.SyntheticMathMlNodeId, mathPres.MathMlNodeInfo] = {} - for element, nodePath in _iterMathMlElements(root, ()): - 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] = mathPres.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[mathPres.SyntheticMathMlNodeId, "RectLTRB"]]: - """Add synthetic ids to MathML and map those ids to IA2 rectangles.""" - if not sourceObj: - return mathml, {} - 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[mathPres.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 - if ia2NodeInfo.rect is None: - missingPathCount += 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 - - # Translators: The name of the category of MathCAT navigation commands in the Input Gestures dialog. SCRCAT_MATHCAT_NAV = _("MathCat navigation") @@ -197,7 +86,7 @@ def __init__( :param sourceObj: Optional source object containing the math. """ super(MathCATInteraction, self).__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj) - self.mathMlForNavigation, self._mathNodeRectsById = _prepareMathMlForNavigation( + self.mathMlForNavigation, self._mathNodeRectsById = prepareMathMlForNavigation( mathMl or "", sourceObj, ) @@ -233,15 +122,12 @@ def _getHighlightRect(self) -> "RectLTRB | None": except Exception: log.debugWarning("Error getting MathCAT navigation node id", exc_info=True) else: - log.debug(f"Math highlight resolving MathCAT navigation node id {nodeId!r}") if nodeId in self._mathNodeRectsById: - log.debug(f"Math highlight matched synthetic MathML id {nodeId!r}") return self._mathNodeRectsById[nodeId] - if nodeId.startswith(_NAV_NODE_ID_PREFIX): + 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}") else: - log.debug("Math highlight MathCAT navigation id was not an NVDA synthetic id") try: return sourceObj.getMathNodeRectById(nodeId) except LookupError: @@ -389,7 +275,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") @@ -412,11 +298,11 @@ def script_rawdataToClip(self, gesture: KeyboardInputGesture) -> None: # not a perfect match sequence, but should capture normal MathML _mathTagHasNameSpace: re.Pattern = re.compile("") - _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 = _removeSyntheticIdsFromMathMl(text) + text = removeSyntheticIdsFromMathMl(text) mathMLWithNS: str = re.sub(self._hasDataAttr, "", text) if not re.match(self._mathTagHasNameSpace, mathMLWithNS): mathMLWithNS = mathMLWithNS.replace( diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/navNodeMapping.py new file mode 100644 index 00000000000..e95c568ec98 --- /dev/null +++ b/source/mathPres/MathCAT/navNodeMapping.py @@ -0,0 +1,134 @@ +# 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]: + yield element, nodePath + mathElementChildren = tuple(child for child in element if isinstance(child.tag, str)) + for index, child in enumerate(mathElementChildren): + yield from _iterMathMlElements(child, nodePath + (index,)) + + +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, {} + 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 8a9121c2381..c4e6db5f6a2 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -12,7 +12,6 @@ import re import typing -from dataclasses import dataclass from typing import List, Optional, Union from NVDAObjects.window import Window @@ -25,22 +24,10 @@ import textInfos if typing.TYPE_CHECKING: - from locationHelper import RectLTRB from NVDAObjects import NVDAObject from speech.commands import SpeechCommand # noqa F401: type-checking only -MathMlNodePath = tuple[int, ...] -SyntheticMathMlNodeId = str - - -@dataclass(frozen=True) -class MathMlNodeInfo: - path: MathMlNodePath - tag: str - rect: "RectLTRB | None" = None - - class MathPresentationProvider(object): """Implements presentation of math content. A single provider does not need to implement all presentation types. 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/visionEnhancementProviders/NVDAHighlighter.py b/source/visionEnhancementProviders/NVDAHighlighter.py index 40161819af8..cc9d2c18944 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -614,6 +614,9 @@ 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.MATH, None) + return self.updateContextRect(context=Context.MATH, rect=rect) def refresh(self) -> None: From 83a159a18d6308f4f3d3e6360173849ef691d00e Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Sun, 21 Jun 2026 16:22:06 -0400 Subject: [PATCH 10/44] Remove lookup by original ID --- source/NVDAObjects/IAccessible/ia2Web.py | 26 ------------------------ source/mathPres/MathCAT/MathCAT.py | 10 +-------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 1cdc78742ac..62135db58c0 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -336,8 +336,6 @@ class EditorChunk(Ia2Web): class Math(Ia2Web): - _MATH_ID_ATTRS = ("id", "xml-id") - def _getMathObjChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]: try: return tuple(obj.children) @@ -411,30 +409,6 @@ def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"]: ) return nodeInfoByPath - def getMathNodeRectById(self, nodeId: str) -> "RectLTRB": - """Get the screen rectangle for a descendant MathML node with the given id.""" - if not nodeId: - raise LookupError - stack: list[NVDAObjects.NVDAObject] = [self] - visitedCount = 0 - while stack: - obj = stack.pop() - visitedCount += 1 - try: - attrs = obj.IA2Attributes - except AttributeError: - attrs = {} - matchedAttr = next((attr for attr in self._MATH_ID_ATTRS if attrs.get(attr) == nodeId), None) - if matchedAttr: - if not (rect := self._getMathNodeRectFromObj(obj)): - log.debug(f"Math highlight matched {matchedAttr}={nodeId!r}, but object has no usable location") - raise LookupError - log.debug(f"Math highlight matched {matchedAttr}={nodeId!r} after visiting {visitedCount} IA2 objects") - return rect - stack.extend(reversed(self._getMathObjChildren(obj))) - log.debug(f"Math highlight found no IA2 object for id {nodeId!r} after visiting {visitedCount} IA2 objects") - raise LookupError - def _get_mathMl(self): # Chromium browsers now expose a 'math' IAccessible2 attribute, # which contains all the raw MathML. diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index f5dd5f4ebee..7957f90236e 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -126,15 +126,7 @@ def _getHighlightRect(self) -> "RectLTRB | None": 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}") - else: - try: - return sourceObj.getMathNodeRectById(nodeId) - except LookupError: - log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") - except Exception: - log.debugWarning("Error resolving MathCAT navigation id to IA2 object", exc_info=True) - log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") + log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") try: if sourceObj.hasIrrelevantLocation: return None From 043b31f3b105e099895e9120f0f7dcd28a0a67d6 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Sun, 21 Jun 2026 16:22:06 -0400 Subject: [PATCH 11/44] Remove lookup by original ID --- source/NVDAObjects/IAccessible/ia2Web.py | 26 ----------- source/mathPres/MathCAT/MathCAT.py | 10 +--- tests/unit/test_mathPres/__init__.py | 5 ++ .../test_mathCatNavNodeMapping.py | 46 +++++++++++++++++++ 4 files changed, 52 insertions(+), 35 deletions(-) create mode 100644 tests/unit/test_mathPres/__init__.py create mode 100644 tests/unit/test_mathPres/test_mathCatNavNodeMapping.py diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 1cdc78742ac..62135db58c0 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -336,8 +336,6 @@ class EditorChunk(Ia2Web): class Math(Ia2Web): - _MATH_ID_ATTRS = ("id", "xml-id") - def _getMathObjChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]: try: return tuple(obj.children) @@ -411,30 +409,6 @@ def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"]: ) return nodeInfoByPath - def getMathNodeRectById(self, nodeId: str) -> "RectLTRB": - """Get the screen rectangle for a descendant MathML node with the given id.""" - if not nodeId: - raise LookupError - stack: list[NVDAObjects.NVDAObject] = [self] - visitedCount = 0 - while stack: - obj = stack.pop() - visitedCount += 1 - try: - attrs = obj.IA2Attributes - except AttributeError: - attrs = {} - matchedAttr = next((attr for attr in self._MATH_ID_ATTRS if attrs.get(attr) == nodeId), None) - if matchedAttr: - if not (rect := self._getMathNodeRectFromObj(obj)): - log.debug(f"Math highlight matched {matchedAttr}={nodeId!r}, but object has no usable location") - raise LookupError - log.debug(f"Math highlight matched {matchedAttr}={nodeId!r} after visiting {visitedCount} IA2 objects") - return rect - stack.extend(reversed(self._getMathObjChildren(obj))) - log.debug(f"Math highlight found no IA2 object for id {nodeId!r} after visiting {visitedCount} IA2 objects") - raise LookupError - def _get_mathMl(self): # Chromium browsers now expose a 'math' IAccessible2 attribute, # which contains all the raw MathML. diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index f5dd5f4ebee..7957f90236e 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -126,15 +126,7 @@ def _getHighlightRect(self) -> "RectLTRB | None": 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}") - else: - try: - return sourceObj.getMathNodeRectById(nodeId) - except LookupError: - log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") - except Exception: - log.debugWarning("Error resolving MathCAT navigation id to IA2 object", exc_info=True) - log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") + log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}") try: if sourceObj.hasIrrelevantLocation: return None 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..916101fc288 --- /dev/null +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -0,0 +1,46 @@ +# 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 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): + self.assertEqual( + navNodeMapping._stripMathMlNamespace("{http://www.w3.org/1998/Math/MathML}mi"), + "mi", + ) + self.assertEqual(navNodeMapping._stripMathMlNamespace("mi"), "mi") + + def test_prepareMathMlForNavigation_withoutSourceObj_returnsOriginalMathMl(self): + mathml = "x" + self.assertEqual(navNodeMapping.prepareMathMlForNavigation(mathml, sourceObj=None), (mathml, {})) + + def test_removeSyntheticIdsFromMathMl_removesGeneratedId(self): + mathml = ( + 'x' + ) + result = navNodeMapping.removeSyntheticIdsFromMathMl(mathml) + mi = ElementTree.fromstring(result).find("mi") + assert mi is not None + self.assertNotIn("id", mi.attrib) + self.assertNotIn("data-nvda-math-id-added", mi.attrib) + + def test_removeSyntheticIdsFromMathMl_restoresOriginalId(self): + mathml = ( + 'x' + ) + result = navNodeMapping.removeSyntheticIdsFromMathMl(mathml) + mi = ElementTree.fromstring(result).find("mi") + assert mi is not None + self.assertEqual(mi.get("id"), "author-id") + self.assertNotIn("data-nvda-math-id-added", mi.attrib) + self.assertNotIn("data-nvda-math-original-id", mi.attrib) From 9c51e9c3a9d845da05aefb10fdfcd6538c40e689 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Sun, 21 Jun 2026 19:12:17 -0400 Subject: [PATCH 12/44] Add more test cases --- .../test_mathCatNavNodeMapping.py | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py index 916101fc288..69f3af4a0ee 100644 --- a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -12,35 +12,48 @@ class TestMathCatNavNodeMapping(unittest.TestCase): def test_stripMathMlNamespace(self): - self.assertEqual( - navNodeMapping._stripMathMlNamespace("{http://www.w3.org/1998/Math/MathML}mi"), - "mi", - ) - self.assertEqual(navNodeMapping._stripMathMlNamespace("mi"), "mi") + 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): - mathml = "x" - self.assertEqual(navNodeMapping.prepareMathMlForNavigation(mathml, sourceObj=None), (mathml, {})) - - def test_removeSyntheticIdsFromMathMl_removesGeneratedId(self): - mathml = ( - 'x' - ) - result = navNodeMapping.removeSyntheticIdsFromMathMl(mathml) - mi = ElementTree.fromstring(result).find("mi") - assert mi is not None - self.assertNotIn("id", mi.attrib) - self.assertNotIn("data-nvda-math-id-added", mi.attrib) - - def test_removeSyntheticIdsFromMathMl_restoresOriginalId(self): - mathml = ( - 'x' - ) - result = navNodeMapping.removeSyntheticIdsFromMathMl(mathml) - mi = ElementTree.fromstring(result).find("mi") - assert mi is not None - self.assertEqual(mi.get("id"), "author-id") - self.assertNotIn("data-nvda-math-id-added", mi.attrib) - self.assertNotIn("data-nvda-math-original-id", mi.attrib) + 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) From 7a6c02e718b91e7822511176ed3ec48d0dceb0cd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:45:00 +0000 Subject: [PATCH 13/44] Pre-commit auto-fix --- source/NVDAObjects/IAccessible/ia2Web.py | 9 ++------- source/mathPres/MathCAT/MathCAT.py | 4 +++- source/mathPres/__init__.py | 1 + tests/unit/test_mathPres/test_mathCatNavNodeMapping.py | 4 +++- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 62135db58c0..a04ab21bd74 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -351,9 +351,7 @@ def _getMathObjAttributes(self, obj: NVDAObjects.NVDAObject) -> dict[str, str]: def _getMathElementChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]: return tuple( - child - for child in self._getMathObjChildren(obj) - if self._getMathObjAttributes(child).get("tag") + child for child in self._getMathObjChildren(obj) if self._getMathObjAttributes(child).get("tag") ) def _getMathNodeMapRoot(self) -> NVDAObjects.NVDAObject: @@ -399,10 +397,7 @@ def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"]: 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))) - ) + 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", diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 7957f90236e..500c7ac8ac4 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -125,7 +125,9 @@ def _getHighlightRect(self) -> "RectLTRB | 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 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}") try: if sourceObj.hasIrrelevantLocation: diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index c4e6db5f6a2..c2a6deee3e0 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -32,6 +32,7 @@ class MathPresentationProvider(object): """Implements presentation of math content. A single provider does not need to implement all presentation types. """ + supportsInteractionSourceObj: bool = False def getSpeechForMathMl(self, mathMl: str) -> List[Union[str, "SpeechCommand"]]: diff --git a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py index 69f3af4a0ee..b763acb8552 100644 --- a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -28,7 +28,9 @@ def test_prepareMathMlForNavigation_withoutSourceObj_returnsOriginalMathMl(self) ] for mathml in testCases: with self.subTest(mathml=mathml): - self.assertEqual(navNodeMapping.prepareMathMlForNavigation(mathml, sourceObj=None), (mathml, {})) + self.assertEqual( + navNodeMapping.prepareMathMlForNavigation(mathml, sourceObj=None), (mathml, {}) + ) def test_removeSyntheticIdsFromMathMl(self): testCases = [ From 86b691f875d0b4479852eb53530f547f4c4fe756 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 22 Jun 2026 10:41:04 -0400 Subject: [PATCH 14/44] Fold math context into browse mode context --- source/vision/constants.py | 1 - source/vision/visionHandlerExtensionPoints.py | 2 +- .../NVDAHighlighter.py | 20 ++++--------------- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/source/vision/constants.py b/source/vision/constants.py index 7fa0f791ffe..f9e6a66365a 100644 --- a/source/vision/constants.py +++ b/source/vision/constants.py @@ -21,6 +21,5 @@ class Context(str, Enum): FOCUS_NAVIGATOR = "focusNavigator" CARET = "caret" BROWSEMODE = "browseMode" - MATH = "math" REVIEW = "review" MOUSE = "mouse" diff --git a/source/vision/visionHandlerExtensionPoints.py b/source/vision/visionHandlerExtensionPoints.py index 5ad6695c776..2b44b8eab80 100644 --- a/source/vision/visionHandlerExtensionPoints.py +++ b/source/vision/visionHandlerExtensionPoints.py @@ -76,7 +76,7 @@ class EventExtensionPoints: #: 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 C{None} to clear it. + #: @param rect: The screen rectangle of the current math navigation position, or C{None} if there is no current position. #: @type rect: Optional[L{locationHelper.RectLTRB}] post_mathNavigation: Action = field(default_factory=Action, init=False) #: Notifies a vision enhancement provider when the position of the review cursor has changed. diff --git a/source/visionEnhancementProviders/NVDAHighlighter.py b/source/visionEnhancementProviders/NVDAHighlighter.py index cc9d2c18944..6f8a84f9c75 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -68,12 +68,10 @@ class HighlightStyle(NamedTuple): BLUE = RGB(0x03, 0x36, 0xFF) PINK = RGB(0xFF, 0x02, 0x66) YELLOW = RGB(0xFF, 0xDE, 0x03) -ORANGE = RGB(0xFF, 0xA5, 0x00) DASH_BLUE = HighlightStyle(BLUE, 5, winGDI.DashStyleDash, 5) SOLID_PINK = HighlightStyle(PINK, 5, winGDI.DashStyleSolid, 5) SOLID_BLUE = HighlightStyle(BLUE, 5, winGDI.DashStyleSolid, 5) SOLID_YELLOW = HighlightStyle(YELLOW, 2, winGDI.DashStyleSolid, 2) -SOLID_ORANGE = HighlightStyle(ORANGE, 2, winGDI.DashStyleSolid, 2) class HighlightWindow(CustomWindow): @@ -310,12 +308,9 @@ def refresh(self) -> None: # Translators: shown for a highlighter setting that toggles # highlighting the navigator object. Context.NAVIGATOR: _("Highlight navigator &object"), - # Translators: shown for a highlighter setting that toggles - # highlighting the current math navigation position. - Context.MATH: _("Highlight math &navigation"), } -_supportedContexts = (Context.FOCUS, Context.NAVIGATOR, Context.BROWSEMODE, Context.MATH) +_supportedContexts = (Context.FOCUS, Context.NAVIGATOR, Context.BROWSEMODE) class NVDAHighlighterSettings(providerBase.VisionEnhancementProviderSettings): @@ -323,7 +318,6 @@ class NVDAHighlighterSettings(providerBase.VisionEnhancementProviderSettings): highlightFocus = False highlightNavigator = False highlightBrowseMode = False - highlightMath = False @override @classmethod @@ -369,7 +363,6 @@ def __init__( settingsToCheck = [ settingsStorage.highlightBrowseMode, settingsStorage.highlightFocus, - settingsStorage.highlightMath, settingsStorage.highlightNavigator, ] if any(settingsToCheck): @@ -379,7 +372,6 @@ def __init__( ) settingsStorage.highlightBrowseMode = False settingsStorage.highlightFocus = False - settingsStorage.highlightMath = False settingsStorage.highlightNavigator = False super().__init__(parent) @@ -436,7 +428,6 @@ def _updateEnabledState(self) -> None: settingsToTriggerActivation = [ settingsStorage.highlightBrowseMode, settingsStorage.highlightFocus, - settingsStorage.highlightMath, settingsStorage.highlightNavigator, ] isAnyEnabled = any(settingsToTriggerActivation) @@ -455,7 +446,6 @@ def _onEnableFailure(self) -> None: settingsStorage: NVDAHighlighterSettings = self._getSettingsStorage() settingsStorage.highlightBrowseMode = False settingsStorage.highlightFocus = False - settingsStorage.highlightMath = False settingsStorage.highlightNavigator = False self.updateDriverSettings() self._updateEnabledState() @@ -474,7 +464,6 @@ def _onCheckEvent(self, evt: wx.CommandEvent) -> None: isEnableAllChecked = evt.IsChecked() settingsStorage.highlightBrowseMode = isEnableAllChecked settingsStorage.highlightFocus = isEnableAllChecked - settingsStorage.highlightMath = isEnableAllChecked settingsStorage.highlightNavigator = isEnableAllChecked if not self._ensureEnableState(isEnableAllChecked) and isEnableAllChecked: self._onEnableFailure() @@ -493,7 +482,6 @@ class NVDAHighlighter(providerBase.VisionEnhancementProvider): Context.NAVIGATOR: SOLID_PINK, Context.FOCUS_NAVIGATOR: SOLID_BLUE, Context.BROWSEMODE: SOLID_YELLOW, - Context.MATH: SOLID_ORANGE, } _refreshInterval = 100 customWindowClass = HighlightWindow @@ -600,7 +588,6 @@ def updateContextRect( self.contextToRectMap[context] = rect def handleFocusChange(self, obj: "NVDAObject") -> None: - self.contextToRectMap.pop(Context.MATH, None) self.updateContextRect(context=Context.FOCUS, obj=obj) if not api.isObjectInActiveTreeInterceptor(obj): self.contextToRectMap.pop(Context.BROWSEMODE, None) @@ -615,9 +602,10 @@ def handleBrowseModeMove(self, obj: "CursorManager | None" = None) -> None: def handleMathNavigation(self, rect: RectLTRB | None) -> None: if rect is None: - self.contextToRectMap.pop(Context.MATH, None) + self.contextToRectMap.pop(Context.BROWSEMODE, None) + self.updateContextRect(context=Context.BROWSEMODE) return - self.updateContextRect(context=Context.MATH, rect=rect) + self.updateContextRect(context=Context.BROWSEMODE, rect=rect) def refresh(self) -> None: """Refreshes the screen positions of the enabled highlights.""" From 2f8d2b4e5f782e4e004ac8dba16ad6578c4772b0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:43:22 +0000 Subject: [PATCH 15/44] Pre-commit auto-fix --- source/mathPres/MathCAT/MathCAT.py | 2 +- tests/unit/test_mathPres/test_mathCatNavNodeMapping.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 500c7ac8ac4..ffdf2061f34 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -126,7 +126,7 @@ def _getHighlightRect(self) -> "RectLTRB | None": 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" + 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}") try: diff --git a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py index b763acb8552..5d92bded4ec 100644 --- a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -29,7 +29,8 @@ def test_prepareMathMlForNavigation_withoutSourceObj_returnsOriginalMathMl(self) for mathml in testCases: with self.subTest(mathml=mathml): self.assertEqual( - navNodeMapping.prepareMathMlForNavigation(mathml, sourceObj=None), (mathml, {}) + navNodeMapping.prepareMathMlForNavigation(mathml, sourceObj=None), + (mathml, {}), ) def test_removeSyntheticIdsFromMathMl(self): From f4a16177190f817adb6d627b20a631422b065478 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 22 Jun 2026 10:59:07 -0400 Subject: [PATCH 16/44] Changelog entry and user docs update --- user_docs/en/changes.md | 1 + user_docs/en/userGuide.md | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 87bbad61d99..0c3c8d24394 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -15,6 +15,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 aba8e3c4beb..05b8c2d3b62 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -1290,6 +1290,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. @@ -1545,7 +1546,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). @@ -2867,7 +2868,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. From ae267e982b9e646687cb295ec3cb34df0811c9e7 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 22 Jun 2026 16:15:02 -0400 Subject: [PATCH 17/44] Clean up log messages and docstrings --- source/NVDAObjects/IAccessible/ia2Web.py | 3 +-- source/mathPres/MathCAT/MathCAT.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index a04ab21bd74..298b0571e2e 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -379,8 +379,7 @@ def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> "RectLTRB | No def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"]: """Map MathML element paths to tag names and screen rectangles for this IA2 math subtree. - Paths are based on MathML element child indexes only, ignoring static text - accessibles exposed below token elements. + Paths are tuples where each entry indicates an index of a child node to be traversed from the root. """ from mathPres.mathMlNode import MathMlNodeRectInfo diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index ffdf2061f34..d4c8c0beee6 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -483,7 +483,7 @@ def interactWithMathMl(self, mathml: str, sourceObj: "NVDAObject | None" = None) except Exception: log.exception(f"MathML is {interaction.mathMlForNavigation}") # Translators: this message reports illegal MathML. - ui.message(pgettext("math", "Illegal MathML found.")) + ui.message(pgettext("math", "Invalid MathML found.")) libmathcat.SetMathML("") return interaction.setFocus() From 48711234598486ac2e9f44fc38f8af11cf20e3a5 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 22 Jun 2026 23:22:17 -0400 Subject: [PATCH 18/44] Update source/mathPres/MathCAT/MathCAT.py Co-authored-by: Sean Budd --- source/mathPres/MathCAT/MathCAT.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index d4c8c0beee6..bdba8811850 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -85,7 +85,7 @@ def __init__( :param mathMl: Optional initial MathML string. :param sourceObj: Optional source object containing the math. """ - super(MathCATInteraction, self).__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj) + super().__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj) self.mathMlForNavigation, self._mathNodeRectsById = prepareMathMlForNavigation( mathMl or "", sourceObj, From 25d1d660bafb09940c5b65b9171ba1a664ed84c6 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 22 Jun 2026 23:24:30 -0400 Subject: [PATCH 19/44] Update source/mathPres/__init__.py Co-authored-by: Sean Budd --- source/mathPres/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index c2a6deee3e0..8b1bc5aaf38 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -196,7 +196,7 @@ def interactWithMathMl(mathMl: str, sourceObj: "NVDAObject | None" = None) -> No 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 sourceObj: The source object containing the math, if known. + :param sourceObj: The source object containing the math, if known. """ if not interactionProvider: # Translators: Reported when the user attempts math interaction From 78684a9a3818922b3f074c189435b41f7bc2fbf6 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 22 Jun 2026 23:25:54 -0400 Subject: [PATCH 20/44] Update source/mathPres/__init__.py Co-authored-by: Sean Budd --- source/mathPres/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index 8b1bc5aaf38..ac3019896c5 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -52,7 +52,7 @@ def getBrailleForMathMl(self, mathMl: str) -> str: def interactWithMathMl(self, mathMl: str, sourceObj: "NVDAObject | None" = None) -> None: """Begin interaction with specified MathML markup. @param mathMl: The MathML markup. - @param sourceObj: The source object containing the math, if known. + :param sourceObj: The source object containing the math, if known. """ raise NotImplementedError From b58914f12f44f49351be264b2bb38f71bc84c677 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 22 Jun 2026 23:27:39 -0400 Subject: [PATCH 21/44] Update source/mathPres/__init__.py Co-authored-by: Sean Budd --- source/mathPres/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index ac3019896c5..9074987cec2 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -28,7 +28,7 @@ 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. """ From 5fcd25214ef4141dd6db7824d73966f3f2e633d3 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 22 Jun 2026 23:47:17 -0400 Subject: [PATCH 22/44] Fix copyright header --- tests/unit/test_mathPres/test_mathCatNavNodeMapping.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py index 5d92bded4ec..bc0847a123a 100644 --- a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -1,4 +1,5 @@ # 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 From 3a97a799056408398042075b0d5a562926e7629a Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 25 Jun 2026 13:58:38 -0400 Subject: [PATCH 23/44] Fix vision handler extension point --- source/vision/visionHandlerExtensionPoints.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/source/vision/visionHandlerExtensionPoints.py b/source/vision/visionHandlerExtensionPoints.py index 2b44b8eab80..2dacff5e3bf 100644 --- a/source/vision/visionHandlerExtensionPoints.py +++ b/source/vision/visionHandlerExtensionPoints.py @@ -73,12 +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) - #: 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 C{None} if there is no current position. - #: @type rect: Optional[L{locationHelper.RectLTRB}] + 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. From 5ba601d094728aec3d9ee1246f0d9b8269b02525 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 25 Jun 2026 14:19:46 -0400 Subject: [PATCH 24/44] Move 'import vision' to top level --- source/mathPres/MathCAT/MathCAT.py | 5 +---- source/mathPres/__init__.py | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 553324147c7..f98ea977792 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -20,6 +20,7 @@ import libmathcat_py as libmathcat import speech import ui +import vision import winKernel import winUser from api import getClipData @@ -145,8 +146,6 @@ def _updateMathHighlight(self) -> None: log.debugWarning("Error updating math highlight", exc_info=True) rect = None try: - import vision - if vision.handler: vision.handler.handleMathNavigation(rect) except Exception: @@ -154,8 +153,6 @@ def _updateMathHighlight(self) -> None: def _clearMathHighlight(self) -> None: try: - import vision - if vision.handler: vision.handler.handleMathNavigation(None) except Exception: diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index 9074987cec2..d23fc67c39f 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -18,6 +18,7 @@ import controlTypes import api import virtualBuffers +import vision import eventHandler from logHandler import log import ui @@ -145,8 +146,6 @@ def setFocus(self): eventHandler.executeEvent("gainFocus", self) def script_exit(self, gesture): - import vision - if vision.handler: vision.handler.handleMathNavigation(None) eventHandler.executeEvent("gainFocus", self.parent) From 61f5629fdb5ea922749177e1f9e507ada5d89a09 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Thu, 25 Jun 2026 16:42:28 -0400 Subject: [PATCH 25/44] Remove unnecessary try/except, add justification for local import --- source/mathPres/MathCAT/MathCAT.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index f98ea977792..7289df0955d 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -114,6 +114,7 @@ def _getHighlightRect(self) -> "RectLTRB | None": 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): @@ -122,21 +123,17 @@ def _getHighlightRect(self) -> "RectLTRB | None": nodeId = libmathcat.GetNavigationMathMLId()[0] except Exception: log.debugWarning("Error getting MathCAT navigation node id", exc_info=True) - else: - 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}") - try: - if sourceObj.hasIrrelevantLocation: - return None - location = sourceObj.location - except Exception: - log.debugWarning("Error getting math source location", 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: From 2bc054501a0a325d2ea6bb32e81f986e1994771b Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Fri, 26 Jun 2026 14:06:34 -0400 Subject: [PATCH 26/44] Cleanup highlight update / clear methods --- source/mathPres/MathCAT/MathCAT.py | 19 ++++--------------- source/mathPres/MathCAT/navNodeMapping.py | 1 + 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 7289df0955d..e09a9fce20b 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -137,23 +137,12 @@ def _getHighlightRect(self) -> "RectLTRB | None": return location.toLTRB() if location else None def _updateMathHighlight(self) -> None: - try: - rect = self._getHighlightRect() - except Exception: - log.debugWarning("Error updating math highlight", exc_info=True) - rect = None - try: - if vision.handler: - vision.handler.handleMathNavigation(rect) - except Exception: - log.debugWarning("Error sending math highlight update", exc_info=True) + if vision.handler: + vision.handler.handleMathNavigation(self._getHighlightRect()) def _clearMathHighlight(self) -> None: - try: - if vision.handler: - vision.handler.handleMathNavigation(None) - except Exception: - log.debugWarning("Error clearing math highlight", exc_info=True) + if vision.handler: + vision.handler.handleMathNavigation(None) def getBrailleRegions( self, diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/navNodeMapping.py index e95c568ec98..892c1de317e 100644 --- a/source/mathPres/MathCAT/navNodeMapping.py +++ b/source/mathPres/MathCAT/navNodeMapping.py @@ -99,6 +99,7 @@ def prepareMathMlForNavigation( """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): From 254363668ea4eecf2d8a3f2d3cf400d0d317117a Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Fri, 26 Jun 2026 14:06:34 -0400 Subject: [PATCH 27/44] Cleanup highlight update / clear methods; refactor supportsInteractionSourceObj --- source/mathPres/MathCAT/MathCAT.py | 21 +++-------- source/mathPres/MathCAT/navNodeMapping.py | 1 + source/mathPres/__init__.py | 43 ++++++++++------------- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 7289df0955d..e7e81e13763 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -137,23 +137,12 @@ def _getHighlightRect(self) -> "RectLTRB | None": return location.toLTRB() if location else None def _updateMathHighlight(self) -> None: - try: - rect = self._getHighlightRect() - except Exception: - log.debugWarning("Error updating math highlight", exc_info=True) - rect = None - try: - if vision.handler: - vision.handler.handleMathNavigation(rect) - except Exception: - log.debugWarning("Error sending math highlight update", exc_info=True) + if vision.handler: + vision.handler.handleMathNavigation(self._getHighlightRect()) def _clearMathHighlight(self) -> None: - try: - if vision.handler: - vision.handler.handleMathNavigation(None) - except Exception: - log.debugWarning("Error clearing math highlight", exc_info=True) + if vision.handler: + vision.handler.handleMathNavigation(None) def getBrailleRegions( self, @@ -362,8 +351,6 @@ def _setClipboardData(self, format: int, data: str) -> None: class MathCAT(mathPres.MathPresentationProvider): - supportsInteractionSourceObj: bool = True - def __init__(self): """Initializes MathCAT, loading the rules specified in the rules directory.""" diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/navNodeMapping.py index e95c568ec98..892c1de317e 100644 --- a/source/mathPres/MathCAT/navNodeMapping.py +++ b/source/mathPres/MathCAT/navNodeMapping.py @@ -99,6 +99,7 @@ def prepareMathMlForNavigation( """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): diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index d23fc67c39f..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 @@ -34,33 +34,31 @@ class MathPresentationProvider: A single provider does not need to implement all presentation types. """ - supportsInteractionSourceObj: bool = False - 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, 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( @@ -70,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: @@ -115,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. """ @@ -171,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) @@ -194,7 +192,7 @@ def interactWithMathMl(mathMl: str, sourceObj: "NVDAObject | None" = None) -> No 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: @@ -202,9 +200,7 @@ def interactWithMathMl(mathMl: str, sourceObj: "NVDAObject | None" = None) -> No # but math interaction is not supported. ui.message(_("Math interaction not supported.")) return - if sourceObj is not None and getattr(interactionProvider, "supportsInteractionSourceObj", False): - return interactionProvider.interactWithMathMl(mathMl, sourceObj=sourceObj) - return interactionProvider.interactWithMathMl(mathMl) + return interactionProvider.interactWithMathMl(mathMl, sourceObj=sourceObj) RE_MATH_LANG = re.compile(r"""""") @@ -212,8 +208,7 @@ def interactWithMathMl(mathMl: str, sourceObj: "NVDAObject | None" = None) -> No 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: From d11a100450b95190935689dff1853a6e309bcad4 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Wed, 1 Jul 2026 13:19:41 -0400 Subject: [PATCH 28/44] Use a stack for _iterMathMlElements --- source/mathPres/MathCAT/navNodeMapping.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/navNodeMapping.py index 892c1de317e..8da7db5c2a6 100644 --- a/source/mathPres/MathCAT/navNodeMapping.py +++ b/source/mathPres/MathCAT/navNodeMapping.py @@ -42,10 +42,15 @@ def _iterMathMlElements( element: ElementTree.Element, nodePath: MathMlNodePath, ) -> Generator[tuple[ElementTree.Element, MathMlNodePath], None, None]: - yield element, nodePath - mathElementChildren = tuple(child for child in element if isinstance(child.tag, str)) - for index, child in enumerate(mathElementChildren): - yield from _iterMathMlElements(child, nodePath + (index,)) + 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( From 0e34c9c37a8d200b0b81b79ef8a98d08dd053dfa Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Sun, 19 Jul 2026 01:46:13 -0400 Subject: [PATCH 29/44] Use new interactWithMathMlFromSource and leave interactWithMathMl method for compatibility with addons --- source/mathPres/MathCAT/MathCAT.py | 23 +++++++++++++++++++++-- source/mathPres/__init__.py | 20 +++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index cd5732323f6..3de4e9114ed 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -470,8 +470,13 @@ def getBrailleForMathMl(self, mathml: str) -> str: ui.message(pgettext("math", "Error in brailling math.")) return "" - def interactWithMathMl(self, mathml: str, sourceObj: "NVDAObject | None" = None) -> None: - """Interact with a MathML string, creating a MathCATInteraction object. + def _startMathInteraction( + self, + mathml: str, + sourceObj: "NVDAObject | None" = None, + ) -> None: + """Start interacting with a MathML string. + This is a helper called by ``interactWithMathMl`` and ``interactWithMathMlFromSource``. :param mathml: The MathML representing the math to interact with. :param sourceObj: Optional source object containing the math. @@ -487,3 +492,17 @@ def interactWithMathMl(self, mathml: str, sourceObj: "NVDAObject | None" = None) return interaction.setFocus() interaction._updateBraille() + + def interactWithMathMl(self, mathml: str) -> None: + """Interact with a MathML string, creating a MathCATInteraction object. + + :param mathml: The MathML representing the math to interact with. + """ + self._startMathInteraction(mathml, sourceObj=None) + + def interactWithMathMlFromSource( + self, + mathml: str, + sourceObj: "NVDAObject", + ) -> None: + self._startMathInteraction(mathml, sourceObj=sourceObj) diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index a27739c6cca..3ba6e9428ca 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -48,13 +48,24 @@ def getBrailleForMathMl(self, mathMl: str) -> str: """ raise NotImplementedError - def interactWithMathMl(self, mathMl: str, sourceObj: "NVDAObject | None" = None) -> None: + def interactWithMathMl(self, mathMl: str) -> None: """Begin interaction with specified MathML markup. :param mathMl: The MathML markup. - :param sourceObj: The source object containing the math, if known. """ raise NotImplementedError + def interactWithMathMlFromSource( + self, + mathMl: str, + sourceObj: "NVDAObject", + ) -> None: + """Begin interaction with specified MathML markup from the given source object. + The default implementation simply forwards to ``interactWithMathMl``; this should be overridden in subclasses. + + :param mathMl: The MathML markup. + :param sourceObj: The source object containing the math, if known. + """ + speechProvider: MathPresentationProvider | None = None brailleProvider: MathPresentationProvider | None = None @@ -200,7 +211,10 @@ def interactWithMathMl(mathMl: str, sourceObj: "NVDAObject | None" = None) -> No # but math interaction is not supported. ui.message(_("Math interaction not supported.")) return - return interactionProvider.interactWithMathMl(mathMl, sourceObj=sourceObj) + if sourceObj is not None: + return interactionProvider.interactWithMathMlFromSource(mathMl, sourceObj=sourceObj) + else: + return interactionProvider.interactWithMathMl(mathMl) RE_MATH_LANG = re.compile(r"""""") From 32b9526fb1d5c9446580900cba2483877975bead Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Sun, 19 Jul 2026 01:52:54 -0400 Subject: [PATCH 30/44] fix interactWithMathMlFromSource default implementation --- source/mathPres/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index 3ba6e9428ca..4517eee68e2 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -65,6 +65,7 @@ def interactWithMathMlFromSource( :param mathMl: The MathML markup. :param sourceObj: The source object containing the math, if known. """ + self.interactWithMathMl(mathMl) speechProvider: MathPresentationProvider | None = None From 37e208b20022b25ff5fde5dcc970f9e17dd37d52 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 01:28:53 -0400 Subject: [PATCH 31/44] Strip added MathCAT IDs so they don't leak to clipboard --- source/mathPres/MathCAT/navNodeMapping.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/navNodeMapping.py index 8da7db5c2a6..2e5df3afb21 100644 --- a/source/mathPres/MathCAT/navNodeMapping.py +++ b/source/mathPres/MathCAT/navNodeMapping.py @@ -26,6 +26,7 @@ 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" +_MATHCAT_ID_ADDED_ATTR = "data-id-added" def _stripMathMlNamespace(tag: str) -> str: @@ -86,6 +87,10 @@ def removeSyntheticIdsFromMathMl(mathml: str) -> str: except ElementTree.ParseError: return mathml for element, _nodePath in _iterMathMlElements(root, ()): + if element.get(_MATHCAT_ID_ADDED_ATTR) == "true": + element.attrib.pop("id", None) + element.attrib.pop(_MATHCAT_ID_ADDED_ATTR, None) + continue if element.get(_NAV_NODE_ID_ADDED_ATTR) != "true": continue originalId = element.attrib.pop(_NAV_NODE_ORIGINAL_ID_ATTR, None) From a80693c96d4f2bf9f7009545bf0bc8430f9fe8cc Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 02:28:05 -0400 Subject: [PATCH 32/44] Update changelog and fix header comment --- tests/unit/test_mathPres/__init__.py | 1 + user_docs/en/changes.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/tests/unit/test_mathPres/__init__.py b/tests/unit/test_mathPres/__init__.py index 055f89d33bb..2cc7ad678f0 100644 --- a/tests/unit/test_mathPres/__init__.py +++ b/tests/unit/test_mathPres/__init__.py @@ -1,4 +1,5 @@ # 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 diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 64e752cb233..49e79a58eb9 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -60,6 +60,10 @@ Previously these keys had no function when pressed on their own. (#20366, @fla-r Please refer to [the developer guide](https://download.nvaccess.org/documentation/developerGuide.html#API) for information on NVDA's API deprecation and removal process. +* `mathPres.interactWithMathMl` now accepts an optional `sourceObj` argument. + Math presentation providers can override `MathPresentationProvider.interactWithMathMlFromSource` to use the source object when starting interaction. + The default implementation forwards to `interactWithMathMl`, preserving compatibility with existing providers. (#20372, @RyanMcCleary) +* Vision enhancement providers can register with `vision.handler.extensionPoints.post_mathNavigation` to receive the screen rectangle of the current math navigation position, or `None` when math navigation exits. (#20372, @RyanMcCleary) * The local Git hook runner has been switched from [pre-commit](https://pre-commit.com/) to [prek](https://prek.j178.dev/), a faster, drop-in compatible alternative. (#20305, @LeonarddeR) * The [pre-commit.ci](https://pre-commit.ci/) integration will be dropped entirely;. Linting and autofixing now run via GitHub Actions, using an autofix-or-fail workflow plus an automatic `prek auto-update` workflow. From b1c1605faf8df71e43f1d585445fb27a34a288ee Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 03:01:50 -0400 Subject: [PATCH 33/44] Update comments about ia2Web import --- source/mathPres/MathCAT/MathCAT.py | 2 +- source/mathPres/MathCAT/navNodeMapping.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 3de4e9114ed..ca91ba801a4 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -132,7 +132,7 @@ def _getHighlightRect(self) -> "RectLTRB | None": 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). + # Avoid importing ia2Web at startup. from NVDAObjects.IAccessible.ia2Web import Math as Ia2WebMath if not isinstance(sourceObj, Ia2WebMath): diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/navNodeMapping.py index 2e5df3afb21..0e9836552fc 100644 --- a/source/mathPres/MathCAT/navNodeMapping.py +++ b/source/mathPres/MathCAT/navNodeMapping.py @@ -109,7 +109,7 @@ def prepareMathMlForNavigation( """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 + # Avoid importing ia2Web at startup. from NVDAObjects.IAccessible.ia2Web import Math as Ia2WebMath if not isinstance(sourceObj, Ia2WebMath): From 0b1bf5367ac88fbb2996aae5c15d4836b21d3dd8 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 11:10:15 -0400 Subject: [PATCH 34/44] Strip extraneous XML and register namespace --- source/mathPres/MathCAT/navNodeMapping.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/navNodeMapping.py index 0e9836552fc..032ea4e8ee4 100644 --- a/source/mathPres/MathCAT/navNodeMapping.py +++ b/source/mathPres/MathCAT/navNodeMapping.py @@ -82,8 +82,9 @@ def _addSyntheticIdsToMathMl( def removeSyntheticIdsFromMathMl(mathml: str) -> str: + ElementTree.register_namespace("", MATHML_NAMESPACE) try: - root = ElementTree.fromstring(mathml) + root = ElementTree.fromstring(mathPres.stripExtraneousXml(mathml)) except ElementTree.ParseError: return mathml for element, _nodePath in _iterMathMlElements(root, ()): From 41ec2d9d35e92fbd4166f5b77a40995dc74159da Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 11:46:16 -0400 Subject: [PATCH 35/44] rename private symbols with underscore prefix --- source/NVDAObjects/IAccessible/ia2Web.py | 6 +++--- source/mathPres/MathCAT/MathCAT.py | 17 +++++++---------- .../{navNodeMapping.py => _navNodeMapping.py} | 16 ++++++++-------- .../mathPres/{mathMlNode.py => _mathMlNode.py} | 2 +- .../test_mathPres/test_mathCatNavNodeMapping.py | 2 +- 5 files changed, 20 insertions(+), 23 deletions(-) rename source/mathPres/MathCAT/{navNodeMapping.py => _navNodeMapping.py} (91%) rename source/mathPres/{mathMlNode.py => _mathMlNode.py} (93%) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 298b0571e2e..31d36227e69 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -38,7 +38,7 @@ if TYPE_CHECKING: from locationHelper import RectLTRB - from mathPres.mathMlNode import MathMlNodePath, MathMlNodeRectInfo + from mathPres._mathMlNode import MathMlNodePath, MathMlNodeRectInfo class IA2WebAnnotationTarget(AnnotationTarget): @@ -376,12 +376,12 @@ def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> "RectLTRB | No return None return location.toLTRB() - def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"]: + 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 + from mathPres._mathMlNode import MathMlNodeRectInfo nodeInfoByPath: dict["MathMlNodePath", "MathMlNodeRectInfo"] = {} stack: list[tuple[NVDAObjects.NVDAObject, "MathMlNodePath"]] = [ diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index ca91ba801a4..4c80cd0715a 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -47,8 +47,8 @@ import mathPres from .localization import getLanguageToUse from .navCommands import NAV_COMMANDS -from .navNodeMapping import ( - NAV_NODE_ID_PREFIX, +from ._navNodeMapping import ( + _NAV_NODE_ID_PREFIX, prepareMathMlForNavigation, removeSyntheticIdsFromMathMl, ) @@ -59,9 +59,6 @@ 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.""" @@ -105,7 +102,7 @@ def __init__( :param sourceObj: Optional source object containing the math. """ super().__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj) - self.mathMlForNavigation, self._mathNodeRectsById = prepareMathMlForNavigation( + self._mathMlForNavigation, self._mathNodeRectsById = prepareMathMlForNavigation( mathMl or "", sourceObj, ) @@ -144,7 +141,7 @@ def _getHighlightRect(self) -> "RectLTRB | None": return None if nodeId in self._mathNodeRectsById: return self._mathNodeRectsById[nodeId] - if nodeId.startswith(NAV_NODE_ID_PREFIX): + 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", ) @@ -270,7 +267,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.mathMlForNavigation + savedMathML: str = self._mathMlForNavigation savedTTS: str = libmathcat.GetPreference("TTS") if savedMathML == "": # shouldn't happen raise Exception("Internal error -- MathML not set for copy") @@ -483,9 +480,9 @@ def _startMathInteraction( """ interaction = MathCATInteraction(provider=self, mathMl=mathml, sourceObj=sourceObj) try: - libmathcat.SetMathML(interaction.mathMlForNavigation) + libmathcat.SetMathML(interaction._mathMlForNavigation) except Exception: - log.exception(f"MathML is {interaction.mathMlForNavigation}") + log.exception(f"MathML is {interaction._mathMlForNavigation}") # Translators: this message reports illegal MathML. ui.message(pgettext("math", "Invalid MathML found.")) libmathcat.SetMathML("") diff --git a/source/mathPres/MathCAT/navNodeMapping.py b/source/mathPres/MathCAT/_navNodeMapping.py similarity index 91% rename from source/mathPres/MathCAT/navNodeMapping.py rename to source/mathPres/MathCAT/_navNodeMapping.py index 032ea4e8ee4..19ac3b36ec9 100644 --- a/source/mathPres/MathCAT/navNodeMapping.py +++ b/source/mathPres/MathCAT/_navNodeMapping.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING import mathPres -from mathPres.mathMlNode import ( +from mathPres._mathMlNode import ( MathMlNodeInfo, MathMlNodePath, SyntheticMathMlNodeId, @@ -22,8 +22,8 @@ from NVDAObjects import NVDAObject -MATHML_NAMESPACE = "http://www.w3.org/1998/Math/MathML" -NAV_NODE_ID_PREFIX = "nvda-math-node-" +_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" _MATHCAT_ID_ADDED_ATTR = "data-id-added" @@ -35,8 +35,8 @@ def _stripMathMlNamespace(tag: str) -> str: 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)}" + return f"{_NAV_NODE_ID_PREFIX}root" + return f"{_NAV_NODE_ID_PREFIX}{'-'.join(str(index) for index in nodePath)}" def _iterMathMlElements( @@ -57,7 +57,7 @@ def _iterMathMlElements( def _addSyntheticIdsToMathMl( mathml: str, ) -> tuple[str, dict[SyntheticMathMlNodeId, MathMlNodeInfo]]: - ElementTree.register_namespace("", MATHML_NAMESPACE) + ElementTree.register_namespace("", _MATHML_NAMESPACE) try: root = ElementTree.fromstring(mathPres.stripExtraneousXml(mathml)) except ElementTree.ParseError: @@ -82,7 +82,7 @@ def _addSyntheticIdsToMathMl( def removeSyntheticIdsFromMathMl(mathml: str) -> str: - ElementTree.register_namespace("", MATHML_NAMESPACE) + ElementTree.register_namespace("", _MATHML_NAMESPACE) try: root = ElementTree.fromstring(mathPres.stripExtraneousXml(mathml)) except ElementTree.ParseError: @@ -119,7 +119,7 @@ def prepareMathMlForNavigation( if not mathMlNodeInfoById: return mathml, {} try: - ia2NodeInfoByPath = sourceObj.getMathNodeInfoByPath() + ia2NodeInfoByPath = sourceObj._getMathNodeInfoByPath() except Exception: log.debugWarning("Math highlight could not build IA2 rectangle map", exc_info=True) return mathml, {} diff --git a/source/mathPres/mathMlNode.py b/source/mathPres/_mathMlNode.py similarity index 93% rename from source/mathPres/mathMlNode.py rename to source/mathPres/_mathMlNode.py index d3613b2d21b..6f5bff90001 100644 --- a/source/mathPres/mathMlNode.py +++ b/source/mathPres/_mathMlNode.py @@ -3,7 +3,7 @@ # 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.""" +"""Internal types for identifying MathML nodes.""" from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py index bc0847a123a..29b82ffc609 100644 --- a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -8,7 +8,7 @@ import unittest import xml.etree.ElementTree as ElementTree -from mathPres.MathCAT import navNodeMapping +from mathPres.MathCAT import _navNodeMapping as navNodeMapping class TestMathCatNavNodeMapping(unittest.TestCase): From 42e4714078905f23999fb1662a1316b0d500d64b Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 13:33:17 -0400 Subject: [PATCH 36/44] Fix bug where math was not being highlighted on initial focus --- source/mathPres/MathCAT/MathCAT.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 4c80cd0715a..5f0c4bda576 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -106,6 +106,7 @@ def __init__( mathMl or "", sourceObj, ) + self._shouldUpdateMathHighlight: bool = False if mathMl is None: self.initMathML = "" else: @@ -113,17 +114,24 @@ def __init__( def reportFocus(self) -> None: """Calls MathCAT's ZoomIn command and speaks the resulting text.""" + self._shouldUpdateMathHighlight = False super(MathCATInteraction, self).reportFocus() try: text: str = libmathcat.DoNavigateCommand("ZoomIn") speech.speak(convertSSMLTextForNVDA(text)) - self._updateMathHighlight() + self._shouldUpdateMathHighlight = True 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 event_gainFocus(self) -> None: + super().event_gainFocus() + if self._shouldUpdateMathHighlight: + self._shouldUpdateMathHighlight = False + self._updateMathHighlight() + def _getHighlightRect(self) -> "RectLTRB | None": """Get the navigation rectangle for a supported web math source object.""" sourceObj = self.sourceObj From 9d0214ba10bcb45a32ea934887cabcdbb6a197af Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 14:53:11 -0400 Subject: [PATCH 37/44] IA2 cleanup --- source/NVDAObjects/IAccessible/ia2Web.py | 30 +++++++----------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 31d36227e69..50725e49ebb 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -336,23 +336,13 @@ 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: + if not isinstance(obj, IAccessible): return {} + return obj.IA2Attributes def _getMathElementChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]: - return tuple( - child for child in self._getMathObjChildren(obj) if self._getMathObjAttributes(child).get("tag") - ) + return tuple(child for child in obj.children if self._getMathObjAttributes(child).get("tag")) def _getMathNodeMapRoot(self) -> NVDAObjects.NVDAObject: if self._getMathObjAttributes(self).get("tag") == "math": @@ -365,13 +355,9 @@ def _getMathNodeMapRoot(self) -> NVDAObjects.NVDAObject: 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) + if obj.hasIrrelevantLocation: return None + location = obj.location if not location or not location.width or not location.height: return None return location.toLTRB() @@ -381,6 +367,7 @@ def _getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"] Paths are tuples where each entry indicates an index of a child node to be traversed from the root. """ + # Avoid importing mathPres at startup. from mathPres._mathMlNode import MathMlNodeRectInfo nodeInfoByPath: dict["MathMlNodePath", "MathMlNodeRectInfo"] = {} @@ -392,9 +379,8 @@ def _getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"] 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) + if tag and (rect := self._getMathNodeRectFromObj(obj)): + 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( From ca78ba53fdb0549b92e3e8ed4546e22aa9260989 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 15:32:55 -0400 Subject: [PATCH 38/44] Fix documentation, formatting, exception types --- source/mathPres/MathCAT/MathCAT.py | 5 +++-- source/mathPres/MathCAT/_navNodeMapping.py | 2 +- source/mathPres/__init__.py | 5 ++++- source/vision/visionHandlerExtensionPoints.py | 2 +- user_docs/en/changes.md | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 5f0c4bda576..238ee2f0152 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -193,12 +193,13 @@ 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() + else: + self._updateMathHighlight() self._updateBraille() @@ -490,7 +491,7 @@ def _startMathInteraction( try: libmathcat.SetMathML(interaction._mathMlForNavigation) except Exception: - log.exception(f"MathML is {interaction._mathMlForNavigation}") + log.exception("Error setting MathML for navigation") # Translators: this message reports illegal MathML. ui.message(pgettext("math", "Invalid MathML found.")) libmathcat.SetMathML("") diff --git a/source/mathPres/MathCAT/_navNodeMapping.py b/source/mathPres/MathCAT/_navNodeMapping.py index 19ac3b36ec9..9b44843ce73 100644 --- a/source/mathPres/MathCAT/_navNodeMapping.py +++ b/source/mathPres/MathCAT/_navNodeMapping.py @@ -120,7 +120,7 @@ def prepareMathMlForNavigation( return mathml, {} try: ia2NodeInfoByPath = sourceObj._getMathNodeInfoByPath() - except Exception: + except RuntimeError: log.debugWarning("Math highlight could not build IA2 rectangle map", exc_info=True) return mathml, {} nodeRectsById: dict[SyntheticMathMlNodeId, "RectLTRB"] = {} diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index 4517eee68e2..21d94282dd7 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -156,9 +156,12 @@ def setFocus(self): eventHandler.executeEvent("gainFocus", self) def script_exit(self, gesture): + eventHandler.executeEvent("gainFocus", self.parent) + + def event_loseFocus(self) -> None: if vision.handler: vision.handler.handleMathNavigation(None) - eventHandler.executeEvent("gainFocus", self.parent) + super().event_loseFocus() # Translators: Describes a command. script_exit.__doc__ = _("Exit math interaction") diff --git a/source/vision/visionHandlerExtensionPoints.py b/source/vision/visionHandlerExtensionPoints.py index 2dacff5e3bf..e935aa02154 100644 --- a/source/vision/visionHandlerExtensionPoints.py +++ b/source/vision/visionHandlerExtensionPoints.py @@ -79,7 +79,7 @@ class EventExtensionPoints: 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. + :param rect: The screen rectangle of the current math navigation position, or ``None`` if no rectangle is available, including after math navigation exits. """ #: Notifies a vision enhancement provider when the position of the review cursor has changed. diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 698b3650a73..6f55c647c5b 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -65,7 +65,7 @@ Please refer to [the developer guide](https://download.nvaccess.org/documentatio * `mathPres.interactWithMathMl` now accepts an optional `sourceObj` argument. Math presentation providers can override `MathPresentationProvider.interactWithMathMlFromSource` to use the source object when starting interaction. The default implementation forwards to `interactWithMathMl`, preserving compatibility with existing providers. (#20372, @RyanMcCleary) -* Vision enhancement providers can register with `vision.handler.extensionPoints.post_mathNavigation` to receive the screen rectangle of the current math navigation position, or `None` when math navigation exits. (#20372, @RyanMcCleary) +* Vision enhancement providers can register with `vision.handler.extensionPoints.post_mathNavigation` to receive the screen rectangle of the current math navigation position, or `None` when no rectangle is available. (#20372, @RyanMcCleary) * The local Git hook runner has been switched from [pre-commit](https://pre-commit.com/) to [prek](https://prek.j178.dev/), a faster, drop-in compatible alternative. (#20305, @LeonarddeR) * The [pre-commit.ci](https://pre-commit.ci/) integration will be dropped entirely;. Linting and autofixing now run via GitHub Actions, using an autofix-or-fail workflow plus an automatic `prek auto-update` workflow. From e2cc2ffe619d8fb6edacceedd5224986cabe6fe4 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 15:42:33 -0400 Subject: [PATCH 39/44] Clean up docstrings and annotations --- source/mathPres/MathCAT/MathCAT.py | 9 +++++++- source/mathPres/__init__.py | 23 +++++++++++++++---- source/vision/visionHandler.py | 4 ++++ .../NVDAHighlighter.py | 4 ++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/source/mathPres/MathCAT/MathCAT.py b/source/mathPres/MathCAT/MathCAT.py index 238ee2f0152..ed99e76f0e3 100644 --- a/source/mathPres/MathCAT/MathCAT.py +++ b/source/mathPres/MathCAT/MathCAT.py @@ -94,7 +94,7 @@ def __init__( provider: mathPres.MathPresentationProvider | None = None, mathMl: str | None = None, sourceObj: "NVDAObject | None" = None, - ): + ) -> None: """Initialize the MathCATInteraction object. :param provider: Optional presentation provider. @@ -127,6 +127,7 @@ def reportFocus(self) -> None: self._clearMathHighlight() def event_gainFocus(self) -> None: + """Update the math highlight after inherited focus handling has completed.""" super().event_gainFocus() if self._shouldUpdateMathHighlight: self._shouldUpdateMathHighlight = False @@ -482,6 +483,7 @@ def _startMathInteraction( sourceObj: "NVDAObject | None" = None, ) -> None: """Start interacting with a MathML string. + This is a helper called by ``interactWithMathMl`` and ``interactWithMathMlFromSource``. :param mathml: The MathML representing the math to interact with. @@ -511,4 +513,9 @@ def interactWithMathMlFromSource( mathml: str, sourceObj: "NVDAObject", ) -> None: + """Interact with MathML from the given source object. + + :param mathml: The MathML representing the math to interact with. + :param sourceObj: The source object containing the math. + """ self._startMathInteraction(mathml, sourceObj=sourceObj) diff --git a/source/mathPres/__init__.py b/source/mathPres/__init__.py index 21d94282dd7..8056d72d94e 100644 --- a/source/mathPres/__init__.py +++ b/source/mathPres/__init__.py @@ -36,6 +36,7 @@ class MathPresentationProvider: 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. """ @@ -43,6 +44,7 @@ def getSpeechForMathMl(self, mathMl: str) -> List[Union[str, "SpeechCommand"]]: def getBrailleForMathMl(self, mathMl: str) -> str: """Get braille output for specified MathML markup. + :param mathMl: The MathML markup. :return: A string of Unicode braille. """ @@ -50,6 +52,7 @@ def getBrailleForMathMl(self, mathMl: str) -> str: def interactWithMathMl(self, mathMl: str) -> None: """Begin interaction with specified MathML markup. + :param mathMl: The MathML markup. """ raise NotImplementedError @@ -60,6 +63,7 @@ def interactWithMathMlFromSource( sourceObj: "NVDAObject", ) -> None: """Begin interaction with specified MathML markup from the given source object. + The default implementation simply forwards to ``interactWithMathMl``; this should be overridden in subclasses. :param mathMl: The MathML markup. @@ -78,8 +82,9 @@ def registerProvider( speech: bool = False, braille: bool = False, interaction: bool = False, -): +) -> None: """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. @@ -140,10 +145,16 @@ def __init__( provider: MathPresentationProvider | None = None, mathMl: str | None = None, sourceObj: "NVDAObject | None" = None, - ): + ) -> None: + """Initialize a math interaction object. + + :param provider: The presentation provider. + :param mathMl: The MathML being presented. + :param sourceObj: The source object containing the math, if known. + """ self.parent = parent = api.getFocusObject() self.provider = provider - self.sourceObj = sourceObj + self.sourceObj: "NVDAObject | None" = sourceObj super(MathInteractionNVDAObject, self).__init__(windowHandle=parent.windowHandle) def setFocus(self): @@ -184,6 +195,7 @@ 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 ``None`` if there is no math. """ @@ -204,9 +216,11 @@ def getMathMlFromTextInfo(pos: textInfos.TextInfo) -> Optional[str]: 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 sourceObj: The source object containing the math, if known. """ @@ -226,7 +240,8 @@ def interactWithMathMl(mathMl: str, sourceObj: "NVDAObject | None" = None) -> No def getLanguageFromMath(mathMl): """Get the language specified in a math tag. - :return: The language or ``None`` if unspeicifed. + + :return: The language or ``None`` if unspecified. """ m = RE_MATH_LANG.search(mathMl) if m: diff --git a/source/vision/visionHandler.py b/source/vision/visionHandler.py index bbc5ba17cc7..bb248c7534c 100644 --- a/source/vision/visionHandler.py +++ b/source/vision/visionHandler.py @@ -325,6 +325,10 @@ def handleReviewMove(self, context: Context = Context.REVIEW) -> None: self.extensionPoints.post_reviewMove.notify(context=context) def handleMathNavigation(self, rect: RectLTRB | None) -> None: + """Notify providers that the math navigation position has changed. + + :param rect: The current math navigation rectangle, or ``None`` if no rectangle is available. + """ self.extensionPoints.post_mathNavigation.notify(rect=rect) def handleMouseMove(self, obj, x: int, y: int) -> None: diff --git a/source/visionEnhancementProviders/NVDAHighlighter.py b/source/visionEnhancementProviders/NVDAHighlighter.py index 6f8a84f9c75..8eb761c3eb4 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -601,6 +601,10 @@ def handleBrowseModeMove(self, obj: "CursorManager | None" = None) -> None: self.updateContextRect(context=Context.BROWSEMODE) def handleMathNavigation(self, rect: RectLTRB | None) -> None: + """Update the browse mode highlight for math navigation. + + :param rect: The current math navigation rectangle, or ``None`` if no rectangle is available. + """ if rect is None: self.contextToRectMap.pop(Context.BROWSEMODE, None) self.updateContextRect(context=Context.BROWSEMODE) From 2ddf8c8bc128608b4541f45755d3437edb812c6c Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 17:40:43 -0400 Subject: [PATCH 40/44] Create synthetic IDs without overwriting existing ones --- source/mathPres/MathCAT/_navNodeMapping.py | 60 +++++++++++-------- source/mathPres/_mathMlNode.py | 2 +- .../test_mathCatNavNodeMapping.py | 6 +- 3 files changed, 38 insertions(+), 30 deletions(-) diff --git a/source/mathPres/MathCAT/_navNodeMapping.py b/source/mathPres/MathCAT/_navNodeMapping.py index 9b44843ce73..dd8697824df 100644 --- a/source/mathPres/MathCAT/_navNodeMapping.py +++ b/source/mathPres/MathCAT/_navNodeMapping.py @@ -11,9 +11,9 @@ import mathPres from mathPres._mathMlNode import ( + MathMlNodeId, MathMlNodeInfo, MathMlNodePath, - SyntheticMathMlNodeId, ) from logHandler import log @@ -25,7 +25,6 @@ _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" _MATHCAT_ID_ADDED_ATTR = "data-id-added" @@ -33,10 +32,19 @@ def _stripMathMlNamespace(tag: str) -> str: return tag.rsplit("}", 1)[-1] -def _getSyntheticNodeId(nodePath: MathMlNodePath) -> str: +def _getSyntheticNodeIdPrefix(existingNodeIds: set[MathMlNodeId]) -> str: + prefix = _NAV_NODE_ID_PREFIX + suffix = 1 + while any(nodeId.startswith(prefix) for nodeId in existingNodeIds): + prefix = f"{_NAV_NODE_ID_PREFIX}{suffix}-" + suffix += 1 + return prefix + + +def _getSyntheticNodeId(nodePath: MathMlNodePath, prefix: str) -> MathMlNodeId: if not nodePath: - return f"{_NAV_NODE_ID_PREFIX}root" - return f"{_NAV_NODE_ID_PREFIX}{'-'.join(str(index) for index in nodePath)}" + return f"{prefix}root" + return f"{prefix}{'-'.join(str(index) for index in nodePath)}" def _iterMathMlElements( @@ -54,9 +62,9 @@ def _iterMathMlElements( ) -def _addSyntheticIdsToMathMl( +def _addNavigationIdsToMathMl( mathml: str, -) -> tuple[str, dict[SyntheticMathMlNodeId, MathMlNodeInfo]]: +) -> tuple[str, dict[MathMlNodeId, MathMlNodeInfo]]: ElementTree.register_namespace("", _MATHML_NAMESPACE) try: root = ElementTree.fromstring(mathPres.stripExtraneousXml(mathml)) @@ -66,14 +74,20 @@ def _addSyntheticIdsToMathMl( 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] = {} + existingNodeIds: set[MathMlNodeId] = set() + for element in root.iter(): + nodeId = element.get("id") + if nodeId: + existingNodeIds.add(nodeId) + syntheticNodeIdPrefix = _getSyntheticNodeIdPrefix(existingNodeIds) + nodeInfoById: dict[MathMlNodeId, 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") + nodeId = element.get("id") + if not nodeId: + # MathCAT exposes the current NavNode by MathML id, so add an id to this copy only. + nodeId = _getSyntheticNodeId(nodePath, syntheticNodeIdPrefix) + element.set("id", nodeId) + element.set(_NAV_NODE_ID_ADDED_ATTR, "true") nodeInfoById[nodeId] = MathMlNodeInfo( path=nodePath, tag=_stripMathMlNamespace(element.tag), @@ -87,18 +101,14 @@ def removeSyntheticIdsFromMathMl(mathml: str) -> str: root = ElementTree.fromstring(mathPres.stripExtraneousXml(mathml)) except ElementTree.ParseError: return mathml - for element, _nodePath in _iterMathMlElements(root, ()): + for element in root.iter(): if element.get(_MATHCAT_ID_ADDED_ATTR) == "true": element.attrib.pop("id", None) element.attrib.pop(_MATHCAT_ID_ADDED_ATTR, None) continue 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("id", None) element.attrib.pop(_NAV_NODE_ID_ADDED_ATTR, None) return ElementTree.tostring(root, encoding="unicode") @@ -106,8 +116,8 @@ def removeSyntheticIdsFromMathMl(mathml: str) -> str: def prepareMathMlForNavigation( mathml: str, sourceObj: "NVDAObject | None", -) -> tuple[str, dict[SyntheticMathMlNodeId, "RectLTRB"]]: - """Add synthetic ids to MathML and map those ids to IA2 rectangles.""" +) -> tuple[str, dict[MathMlNodeId, "RectLTRB"]]: + """Add missing ids to MathML and map node ids to IA2 rectangles.""" if not sourceObj: return mathml, {} # Avoid importing ia2Web at startup. @@ -115,7 +125,7 @@ def prepareMathMlForNavigation( if not isinstance(sourceObj, Ia2WebMath): return mathml, {} - mathmlWithIds, mathMlNodeInfoById = _addSyntheticIdsToMathMl(mathml) + mathmlWithIds, mathMlNodeInfoById = _addNavigationIdsToMathMl(mathml) if not mathMlNodeInfoById: return mathml, {} try: @@ -123,7 +133,7 @@ def prepareMathMlForNavigation( except RuntimeError: log.debugWarning("Math highlight could not build IA2 rectangle map", exc_info=True) return mathml, {} - nodeRectsById: dict[SyntheticMathMlNodeId, "RectLTRB"] = {} + nodeRectsById: dict[MathMlNodeId, "RectLTRB"] = {} missingPathCount = 0 tagMismatchCount = 0 for nodeId, mathMlNodeInfo in mathMlNodeInfoById.items(): @@ -137,7 +147,7 @@ def prepareMathMlForNavigation( continue nodeRectsById[nodeId] = ia2NodeInfo.rect log.debug( - f"Math highlight added synthetic ids to {len(mathMlNodeInfoById)} MathML nodes; " + f"Math highlight prepared ids for {len(mathMlNodeInfoById)} MathML nodes; " f"mapped {len(nodeRectsById)} ids to IA2 rectangles; " f"missing IA2 paths: {missingPathCount}; tag mismatches: {tagMismatchCount}", ) diff --git a/source/mathPres/_mathMlNode.py b/source/mathPres/_mathMlNode.py index 6f5bff90001..b6c07891225 100644 --- a/source/mathPres/_mathMlNode.py +++ b/source/mathPres/_mathMlNode.py @@ -13,7 +13,7 @@ MathMlNodePath = tuple[int, ...] -SyntheticMathMlNodeId = str +MathMlNodeId = str @dataclass(frozen=True) diff --git a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py index 29b82ffc609..4355777972c 100644 --- a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -41,10 +41,8 @@ def test_removeSyntheticIdsFromMathMl(self): {}, ), ( - 'x', - {"id": "author-id"}, + 'x', + {"id": "author-id", "href": "#author-id"}, ), ( 'x', From b789b9132a9cbe8dab78c19ee7591d020aceee0b Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 21:58:11 -0400 Subject: [PATCH 41/44] Clean up IA2 fallback handling --- source/mathPres/MathCAT/_navNodeMapping.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/mathPres/MathCAT/_navNodeMapping.py b/source/mathPres/MathCAT/_navNodeMapping.py index dd8697824df..819f2fdb8b2 100644 --- a/source/mathPres/MathCAT/_navNodeMapping.py +++ b/source/mathPres/MathCAT/_navNodeMapping.py @@ -131,15 +131,15 @@ def prepareMathMlForNavigation( try: ia2NodeInfoByPath = sourceObj._getMathNodeInfoByPath() except RuntimeError: + # Fall back to normal math interaction if IA2 rectangle mapping fails. log.debugWarning("Math highlight could not build IA2 rectangle map", exc_info=True) return mathml, {} nodeRectsById: dict[MathMlNodeId, "RectLTRB"] = {} missingPathCount = 0 tagMismatchCount = 0 for nodeId, mathMlNodeInfo in mathMlNodeInfoById.items(): - try: - ia2NodeInfo = ia2NodeInfoByPath[mathMlNodeInfo.path] - except KeyError: + ia2NodeInfo = ia2NodeInfoByPath.get(mathMlNodeInfo.path) + if ia2NodeInfo is None: missingPathCount += 1 continue if ia2NodeInfo.tag != mathMlNodeInfo.tag: From f8806a2198a21c89fb12d1289b900595dc8effa0 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 22:33:48 -0400 Subject: [PATCH 42/44] Add more tests --- .../test_mathCatNavNodeMapping.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py index 4355777972c..25758f4e0d7 100644 --- a/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py +++ b/tests/unit/test_mathPres/test_mathCatNavNodeMapping.py @@ -5,11 +5,20 @@ """Unit tests for MathCAT NavNode mapping helpers.""" +import sys import unittest import xml.etree.ElementTree as ElementTree +from types import ModuleType +from typing import TYPE_CHECKING, cast +from unittest.mock import patch +from locationHelper import RectLTRB +from mathPres._mathMlNode import MathMlNodeInfo, MathMlNodeRectInfo from mathPres.MathCAT import _navNodeMapping as navNodeMapping +if TYPE_CHECKING: + from NVDAObjects import NVDAObject + class TestMathCatNavNodeMapping(unittest.TestCase): def test_stripMathMlNamespace(self): @@ -34,6 +43,65 @@ def test_prepareMathMlForNavigation_withoutSourceObj_returnsOriginalMathMl(self) (mathml, {}), ) + def test_addNavigationIdsToMathMl_preservesAuthorIdsAndAvoidsPrefixCollisions(self) -> None: + mathml = ( + '' + '' + 'x' + "" + "" + ) + + result, nodeInfoById = navNodeMapping._addNavigationIdsToMathMl(mathml) + + root = ElementTree.fromstring(result) + mrow = root.find("mrow") + assert mrow is not None + mi = mrow.find("mi") + assert mi is not None + self.assertEqual(root.attrib, {"id": "author-root"}) + self.assertEqual(mrow.attrib, {"id": "nvda-math-node-author"}) + self.assertEqual( + mi.attrib, + { + "href": "#author-root", + "id": "nvda-math-node-1-0-0", + "data-nvda-math-id-added": "true", + }, + ) + self.assertEqual( + nodeInfoById, + { + "author-root": MathMlNodeInfo(path=(), tag="math"), + "nvda-math-node-author": MathMlNodeInfo(path=(0,), tag="mrow"), + "nvda-math-node-1-0-0": MathMlNodeInfo(path=(0, 0), tag="mi"), + }, + ) + + def test_prepareMathMlForNavigation_mapsMatchingIa2Nodes(self) -> None: + rootRect = RectLTRB(left=0, top=0, right=100, bottom=50) + mismatchedRect = RectLTRB(left=10, top=10, right=20, bottom=20) + + class FakeIa2WebMath: + def _getMathNodeInfoByPath(self) -> dict[tuple[int, ...], MathMlNodeRectInfo]: + return { + (): MathMlNodeRectInfo(path=(), tag="math", rect=rootRect), + (0,): MathMlNodeRectInfo(path=(0,), tag="mstyle", rect=mismatchedRect), + } + + sourceObj = cast("NVDAObject", FakeIa2WebMath()) + fakeIa2WebModule = ModuleType("NVDAObjects.IAccessible.ia2Web") + setattr(fakeIa2WebModule, "Math", FakeIa2WebMath) + with patch.dict(sys.modules, {"NVDAObjects.IAccessible.ia2Web": fakeIa2WebModule}): + result, rectsById = navNodeMapping.prepareMathMlForNavigation( + "x", + sourceObj, + ) + + root = ElementTree.fromstring(result) + self.assertEqual(root.get("id"), "nvda-math-node-root") + self.assertEqual(rectsById, {"nvda-math-node-root": rootRect}) + def test_removeSyntheticIdsFromMathMl(self): testCases = [ ( @@ -59,3 +127,18 @@ def test_removeSyntheticIdsFromMathMl(self): def test_removeSyntheticIdsFromMathMl_parseError_returnsOriginalMathMl(self): mathml = "x" self.assertEqual(navNodeMapping.removeSyntheticIdsFromMathMl(mathml), mathml) + + def test_removeSyntheticIdsFromMathMl_removesMathCatIdAndPreservesNamespace(self) -> None: + mathml = ( + '\n' + '' + 'x' + "" + ) + + result = navNodeMapping.removeSyntheticIdsFromMathMl(mathml) + + self.assertEqual( + result, + 'x', + ) From d0f82514eff6f643b62da4069d867fbdbfd696bd Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Mon, 20 Jul 2026 23:55:30 -0400 Subject: [PATCH 43/44] Narrow type annotations to IAccessible when possible --- source/NVDAObjects/IAccessible/ia2Web.py | 29 +++++++++++------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 50725e49ebb..e840615deca 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -336,25 +336,22 @@ class EditorChunk(Ia2Web): class Math(Ia2Web): - def _getMathObjAttributes(self, obj: NVDAObjects.NVDAObject) -> dict[str, str]: - if not isinstance(obj, IAccessible): - return {} - return obj.IA2Attributes - - def _getMathElementChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]: - return tuple(child for child in obj.children if self._getMathObjAttributes(child).get("tag")) + def _getMathElementChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[IAccessible, ...]: + return tuple( + child + for child in obj.children + if isinstance(child, IAccessible) and child.IA2Attributes.get("tag") + ) - def _getMathNodeMapRoot(self) -> NVDAObjects.NVDAObject: - if self._getMathObjAttributes(self).get("tag") == "math": + def _getMathNodeMapRoot(self) -> IAccessible: + if self.IA2Attributes.get("tag") == "math": return self mathChildren = tuple( - child - for child in self._getMathElementChildren(self) - if self._getMathObjAttributes(child).get("tag") == "math" + child for child in self._getMathElementChildren(self) if child.IA2Attributes.get("tag") == "math" ) return mathChildren[0] if len(mathChildren) == 1 else self - def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> "RectLTRB | None": + def _getMathNodeRectFromObj(self, obj: IAccessible) -> "RectLTRB | None": if obj.hasIrrelevantLocation: return None location = obj.location @@ -371,18 +368,18 @@ def _getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"] from mathPres._mathMlNode import MathMlNodeRectInfo nodeInfoByPath: dict["MathMlNodePath", "MathMlNodeRectInfo"] = {} - stack: list[tuple[NVDAObjects.NVDAObject, "MathMlNodePath"]] = [ + stack: list[tuple[IAccessible, "MathMlNodePath"]] = [ (self._getMathNodeMapRoot(), ()), ] visitedCount = 0 while stack: obj, path = stack.pop() visitedCount += 1 - tag = self._getMathObjAttributes(obj).get("tag") + tag = obj.IA2Attributes.get("tag") if tag and (rect := self._getMathNodeRectFromObj(obj)): 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)))) + stack.extend((child, path + (index,)) for index, child in enumerate(children)) log.debug( f"Math highlight built IA2 path map with {len(nodeInfoByPath)} usable rectangles " f"after visiting {visitedCount} MathML element objects", From 1919bda1b9550b1fb70ad54a7528ab4de2448086 Mon Sep 17 00:00:00 2001 From: Ryan McCleary Date: Tue, 21 Jul 2026 01:09:39 -0400 Subject: [PATCH 44/44] Update developer guide --- projectDocs/dev/developerGuide/developerGuide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 5024f63cfc5..e2885676611 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1625,6 +1625,7 @@ Please see the `EventExtensionPoints` class documentation for more information, | `Action` | `post_foregroundChange` | the foreground NVDAObject has changed. | | `Action` | `post_caretMove` | a physical caret has moved. | | `Action` | `post_browseModeMove` | a virtual caret has moved. | +| `Action` | `post_mathNavigation` | the math navigation position has changed. | | `Action` | `post_reviewMove` | the position of the review cursor has changed. | | `Action` | `post_mouseMove` | the mouse has moved. | | `Action` | `post_coreCycle` | the end of each core cycle has been reached. |