Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
10cfd8f
Wire up math navigation to visual highlighting
RyanMcCleary Jun 18, 2026
a55dc3a
Implement highlight for web IA2
RyanMcCleary Jun 18, 2026
a0cd4f3
Debug logging
RyanMcCleary Jun 18, 2026
302b494
More debug logging
RyanMcCleary Jun 18, 2026
1b70876
Store copy of mathML, generating IDs where not already present
RyanMcCleary Jun 18, 2026
946697a
Fixed math highlight settings
RyanMcCleary Jun 18, 2026
c971fe1
Change highlight color to orange
RyanMcCleary Jun 18, 2026
ce5ed03
Clean up types and move some classmethods into standalone functions
RyanMcCleary Jun 21, 2026
26b22cf
Refactor nav node mapping
RyanMcCleary Jun 21, 2026
83a159a
Remove lookup by original ID
RyanMcCleary Jun 21, 2026
043b31f
Remove lookup by original ID
RyanMcCleary Jun 21, 2026
9c51e9c
Add more test cases
RyanMcCleary Jun 21, 2026
fc92759
Merge branch 'highlight-math' of github.com:RyanMcCleary/nvda into hi…
RyanMcCleary Jun 21, 2026
7a6c02e
Pre-commit auto-fix
pre-commit-ci[bot] Jun 21, 2026
86b691f
Fold math context into browse mode context
RyanMcCleary Jun 22, 2026
9f94dad
Merge branch 'highlight-math' of github.com:RyanMcCleary/nvda into hi…
RyanMcCleary Jun 22, 2026
2f8d2b4
Pre-commit auto-fix
pre-commit-ci[bot] Jun 22, 2026
f4a1617
Changelog entry and user docs update
RyanMcCleary Jun 22, 2026
2401f41
Merge branch 'highlight-math' of github.com:RyanMcCleary/nvda into hi…
RyanMcCleary Jun 22, 2026
ae267e9
Clean up log messages and docstrings
RyanMcCleary Jun 22, 2026
4871123
Update source/mathPres/MathCAT/MathCAT.py
RyanMcCleary Jun 23, 2026
25d1d66
Update source/mathPres/__init__.py
RyanMcCleary Jun 23, 2026
78684a9
Update source/mathPres/__init__.py
RyanMcCleary Jun 23, 2026
b58914f
Update source/mathPres/__init__.py
RyanMcCleary Jun 23, 2026
5fcd252
Fix copyright header
RyanMcCleary Jun 23, 2026
2f24dd3
Merge branch 'highlight-math' of github.com:RyanMcCleary/nvda into hi…
RyanMcCleary Jun 23, 2026
88022c4
Merge remote-tracking branch 'upstream/master' into highlight-math
RyanMcCleary Jun 25, 2026
3a97a79
Fix vision handler extension point
RyanMcCleary Jun 25, 2026
5ba601d
Move 'import vision' to top level
RyanMcCleary Jun 25, 2026
61f5629
Remove unnecessary try/except, add justification for local import
RyanMcCleary Jun 25, 2026
2bc0545
Cleanup highlight update / clear methods
RyanMcCleary Jun 26, 2026
2543636
Cleanup highlight update / clear methods; refactor supportsInteractio…
RyanMcCleary Jun 26, 2026
f31bc0d
Merge branch 'highlight-math' of github.com:RyanMcCleary/nvda into hi…
RyanMcCleary Jun 27, 2026
19336ad
Merge remote-tracking branch 'upstream/master' into highlight-math
RyanMcCleary Jun 27, 2026
71bacfe
Merge remote-tracking branch 'upstream/master' into highlight-math
RyanMcCleary Jul 1, 2026
d11a100
Use a stack for _iterMathMlElements
RyanMcCleary Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions source/NVDAObjects/IAccessible/ia2Web.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import (
Generator,
Optional,
TYPE_CHECKING,
Tuple,
)
import re
Expand Down Expand Up @@ -35,6 +36,10 @@
import config
import NVDAObjects

if TYPE_CHECKING:
from locationHelper import RectLTRB
from mathPres.mathMlNode import MathMlNodePath, MathMlNodeRectInfo


class IA2WebAnnotationTarget(AnnotationTarget):
def __init__(self, target: IAccessible):
Expand Down Expand Up @@ -331,6 +336,73 @@ class EditorChunk(Ia2Web):


class Math(Ia2Web):
def _getMathObjChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]:
try:
return tuple(obj.children)
except RuntimeError:
log.debugWarning("Error fetching MathML node children", exc_info=True)
return ()

def _getMathObjAttributes(self, obj: NVDAObjects.NVDAObject) -> dict[str, str]:
try:
return obj.IA2Attributes
except AttributeError:
return {}

def _getMathElementChildren(self, obj: NVDAObjects.NVDAObject) -> tuple[NVDAObjects.NVDAObject, ...]:
return tuple(
child for child in self._getMathObjChildren(obj) if self._getMathObjAttributes(child).get("tag")
)

def _getMathNodeMapRoot(self) -> NVDAObjects.NVDAObject:
if self._getMathObjAttributes(self).get("tag") == "math":
return self
mathChildren = tuple(
child
for child in self._getMathElementChildren(self)
if self._getMathObjAttributes(child).get("tag") == "math"
)
return mathChildren[0] if len(mathChildren) == 1 else self

def _getMathNodeRectFromObj(self, obj: NVDAObjects.NVDAObject) -> "RectLTRB | None":
try:
if obj.hasIrrelevantLocation:
return None
location = obj.location
except Exception:
log.debugWarning("Error fetching MathML node location", exc_info=True)
return None
if not location or not location.width or not location.height:
return None
return location.toLTRB()

def getMathNodeInfoByPath(self) -> dict["MathMlNodePath", "MathMlNodeRectInfo"]:
"""Map MathML element paths to tag names and screen rectangles for this IA2 math subtree.

Paths are tuples where each entry indicates an index of a child node to be traversed from the root.
"""
from mathPres.mathMlNode import MathMlNodeRectInfo

nodeInfoByPath: dict["MathMlNodePath", "MathMlNodeRectInfo"] = {}
stack: list[tuple[NVDAObjects.NVDAObject, "MathMlNodePath"]] = [
(self._getMathNodeMapRoot(), ()),
]
visitedCount = 0
while stack:
obj, path = stack.pop()
visitedCount += 1
tag = self._getMathObjAttributes(obj).get("tag")
if rect := self._getMathNodeRectFromObj(obj):
if tag:
nodeInfoByPath[path] = MathMlNodeRectInfo(path=path, tag=tag, rect=rect)
children = self._getMathElementChildren(obj)
stack.extend((child, path + (index,)) for index, child in reversed(tuple(enumerate(children))))
log.debug(
f"Math highlight built IA2 path map with {len(nodeInfoByPath)} usable rectangles "
f"after visiting {visitedCount} MathML element objects",
)
return nodeInfoByPath

def _get_mathMl(self):
# Chromium browsers now expose a 'math' IAccessible2 attribute,
# which contains all the raw MathML.
Expand Down
2 changes: 1 addition & 1 deletion source/browseMode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions source/globalCommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4814,20 +4814,28 @@ def script_toggleConfigProfileTriggers(self, gesture):
def script_interactWithMath(self, gesture):
import mathPres

mathMl = mathPres.getMathMlFromTextInfo(api.getReviewPosition())
reviewPosition = api.getReviewPosition()
mathMl = mathPres.getMathMlFromTextInfo(reviewPosition)
sourceObj = None
if not mathMl:
obj = api.getNavigatorObject()
if obj.role == controlTypes.Role.MATH:
try:
mathMl = obj.mathMl
sourceObj = obj
except (NotImplementedError, LookupError):
mathMl = None
else:
try:
sourceObj = reviewPosition.NVDAObjectAtStart
except (NotImplementedError, LookupError):
pass
if not mathMl:
# Translators: Reported when the user attempts math interaction
# with something that isn't math.
ui.message(_("Not math"))
return
mathPres.interactWithMathMl(mathMl)
mathPres.interactWithMathMl(mathMl, sourceObj=sourceObj)

@script(
# Translators: Describes a command.
Expand Down
82 changes: 74 additions & 8 deletions source/mathPres/MathCAT/MathCAT.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
windll,
)
from os import path
from typing import Type
from typing import TYPE_CHECKING, Type

import braille
import config
import gui
import libmathcat_py as libmathcat
import speech
import ui
import vision
import winKernel
import winUser
from api import getClipData
Expand All @@ -44,9 +45,21 @@
import mathPres
from .localization import getLanguageToUse
from .navCommands import NAV_COMMANDS
from .navNodeMapping import (
NAV_NODE_ID_PREFIX,
prepareMathMlForNavigation,
removeSyntheticIdsFromMathMl,
)
from .preferences import applyUserPreferences
from .speech import convertSSMLTextForNVDA

if TYPE_CHECKING:
from locationHelper import RectLTRB
from NVDAObjects import NVDAObject

# Translators: The name of the category of MathCAT navigation commands in the Input Gestures dialog.
SCRCAT_MATHCAT_NAV = _("Math navigation")


class MathCATError(Exception):
"""MathCAT failure, including Rust panics from PyO3."""
Expand Down Expand Up @@ -81,13 +94,19 @@ def __init__(
self,
provider: mathPres.MathPresentationProvider | None = None,
mathMl: str | None = None,
sourceObj: "NVDAObject | None" = None,
):
"""Initialize the MathCATInteraction object.

:param provider: Optional presentation provider.
:param mathMl: Optional initial MathML string.
:param sourceObj: Optional source object containing the math.
"""
super(MathCATInteraction, self).__init__(provider=provider, mathMl=mathMl)
super().__init__(provider=provider, mathMl=mathMl, sourceObj=sourceObj)
self.mathMlForNavigation, self._mathNodeRectsById = prepareMathMlForNavigation(
mathMl or "<math></math>",
sourceObj,
)
if mathMl is None:
self.initMathML = "<math></math>"
else:
Expand All @@ -99,10 +118,47 @@ def reportFocus(self) -> None:
try:
text: str = libmathcat.DoNavigateCommand("ZoomIn")
speech.speak(convertSSMLTextForNVDA(text))
self._updateMathHighlight()
except Exception:
log.exception()
# Translators: this message reports an error in starting navigation of math.
ui.message(pgettext("math", "Error in starting navigation of math."))
self._clearMathHighlight()

def _getHighlightRect(self) -> "RectLTRB | None":
"""Get the navigation rectangle for a supported web math source object."""
sourceObj = self.sourceObj
if not sourceObj:
return None
# Only import Math from ia2Web when it's actually needed (i.e., when a source object is present).
from NVDAObjects.IAccessible.ia2Web import Math as Ia2WebMath

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can these imports be moved out?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other imports should definitely be moved out, but it seems to me like it would be a good idea to keep this import local. I've added a comment with my reasoning for that. I'm still happy to move it out if you think that's best though.


if not isinstance(sourceObj, Ia2WebMath):
return None
try:
nodeId = libmathcat.GetNavigationMathMLId()[0]
except Exception:
log.debugWarning("Error getting MathCAT navigation node id", exc_info=True)
return None
if nodeId in self._mathNodeRectsById:
return self._mathNodeRectsById[nodeId]
if nodeId.startswith(NAV_NODE_ID_PREFIX):
log.debug(
f"Math highlight found synthetic MathML id {nodeId!r}, but it has no mapped IA2 rectangle",
)
log.debug(f"Math highlight falling back to source rectangle for node id {nodeId!r}")
if sourceObj.hasIrrelevantLocation:
return None
location = sourceObj.location
return location.toLTRB() if location else None

def _updateMathHighlight(self) -> None:
if vision.handler:
vision.handler.handleMathNavigation(self._getHighlightRect())

def _clearMathHighlight(self) -> None:
if vision.handler:
vision.handler.handleMathNavigation(None)

def getBrailleRegions(
self,
Expand Down Expand Up @@ -130,10 +186,12 @@ def _doNavigateCommand(self, commandName: str) -> None:
try:
text = libmathcat.DoNavigateCommand(commandName)
speech.speak(convertSSMLTextForNVDA(text))
self._updateMathHighlight()
except Exception:
log.exception()
# Translators: this message alerts users to an error in navigating math.
ui.message(pgettext("math", "Error in navigating math"))
self._clearMathHighlight()

self._updateBraille()

Expand Down Expand Up @@ -210,7 +268,7 @@ def script_rawdataToClip(self, gesture: KeyboardInputGesture) -> None:
mathml = self.initMathML
if copyAs == "speech":
# save the old MathML, set the navigation MathML as MathMl, get the speech, then reset the MathML
savedMathML: str = self.initMathML
savedMathML: str = self.mathMlForNavigation
savedTTS: str = libmathcat.GetPreference("TTS")
if savedMathML == "": # shouldn't happen
raise Exception("Internal error -- MathML not set for copy")
Expand All @@ -233,12 +291,11 @@ def script_rawdataToClip(self, gesture: KeyboardInputGesture) -> None:

# not a perfect match sequence, but should capture normal MathML
_mathTagHasNameSpace: re.Pattern = re.compile("<math .*?xmlns.+?>")
_hasAddedId: re.Pattern = re.compile(" id='[^'].+' data-id-added='true'")
_hasDataAttr: re.Pattern = re.compile(" data-[^=]+='[^']*'")
_hasDataAttr: re.Pattern = re.compile(r""" data-[^=]+=(['"]).*?\1""")

def _wrapMathMLForClipBoard(self, text: str) -> str:
"""Cleanup the MathML a little."""
text = re.sub(self._hasAddedId, "", text)
text = removeSyntheticIdsFromMathMl(text)
mathMLWithNS: str = re.sub(self._hasDataAttr, "", text)
if not re.match(self._mathTagHasNameSpace, mathMLWithNS):
mathMLWithNS = mathMLWithNS.replace(
Expand Down Expand Up @@ -411,11 +468,20 @@ def getBrailleForMathMl(self, mathml: str) -> str:
ui.message(pgettext("math", "Error in brailling math."))
return ""

def interactWithMathMl(self, mathml: str) -> None:
def interactWithMathMl(self, mathml: str, sourceObj: "NVDAObject | None" = None) -> None:
"""Interact with a MathML string, creating a MathCATInteraction object.

:param mathml: The MathML representing the math to interact with.
:param sourceObj: Optional source object containing the math.
"""
interaction = MathCATInteraction(provider=self, mathMl=mathml)
interaction = MathCATInteraction(provider=self, mathMl=mathml, sourceObj=sourceObj)
try:
libmathcat.SetMathML(interaction.mathMlForNavigation)
except Exception:
log.exception(f"MathML is {interaction.mathMlForNavigation}")
# Translators: this message reports illegal MathML.
ui.message(pgettext("math", "Invalid MathML found."))
libmathcat.SetMathML("<math></math>")
return
interaction.setFocus()
interaction._updateBraille()
Loading
Loading