Skip to content

Commit 38449fd

Browse files
committed
Split out most functionality of __init__.py into MathCAT.py so that the (renamed) function ConvertSSMLTextForNVDA can be imported/called (avoid circular import)
Hooked up OnClickPreviewVoiceButton
1 parent 12dc620 commit 38449fd

File tree

3 files changed

+309
-293
lines changed

3 files changed

+309
-293
lines changed
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
# MathCAT add-on: generates speech, braille, and allows exploration of expressions written in MathML
2+
# The goal of this add-on is to replicate/improve upon the functionality of MathPlayer which has been discontinued.
3+
# Author: Neil Soiffer
4+
# Copyright: this file is copyright GPL2
5+
# The code additionally makes use of the MathCAT library (written in Rust) which is covered by the MIT license
6+
# and also (obviously) requires external speech engines and braille drivers.
7+
# The plugin also requires the use of a small python dll: python3.dll
8+
# python3.dll has "Copyright © 2001-2022 Python Software Foundation; All Rights Reserved"
9+
10+
11+
# Note: this code is a lot of cut/paste from other code and very likely could be substantially improved/cleaned.
12+
import braille # we generate braille
13+
import globalVars
14+
from keyboardHandler import KeyboardInputGesture # navigation key strokes
15+
from logHandler import log # logging
16+
import mathPres # math plugin stuff
17+
from os import path # set rule dir path
18+
import re # regexp patter match
19+
import speech # speech commands
20+
import ui # copy message
21+
from scriptHandler import script # copy MathML via ctrl-c
22+
from synthDriverHandler import getSynth # speech engine param setting
23+
import winUser # clipboard manipultation
24+
from ctypes import windll # register clipboard formats
25+
26+
from . import libmathcat
27+
28+
# speech/SSML processing borrowed from NVDA's mathPres/mathPlayer.py
29+
from speech.commands import (
30+
PitchCommand,
31+
VolumeCommand,
32+
RateCommand,
33+
LangChangeCommand,
34+
BreakCommand,
35+
CharacterModeCommand,
36+
PhonemeCommand,
37+
)
38+
39+
RE_MP_SPEECH = re.compile(
40+
# Break.
41+
r"<break time='(?P<break>\d+)ms'/> ?"
42+
# Pronunciation of characters.
43+
r"|<say-as interpret-as='characters'>(?P<char>[^<]+)</say-as> ?"
44+
# Specific pronunciation.
45+
r"|<phoneme alphabet='ipa' ph='(?P<ipa>[^']+)'> (?P<phonemeText>[^ <]+)</phoneme> ?"
46+
# Prosody.
47+
r"|<prosody(?: pitch='(?P<pitch>\d+)%')?(?: volume='(?P<volume>\d+)%')?(?: rate='(?P<rate>\d+)%')?> ?"
48+
r"|(?P<prosodyReset></prosody>) ?"
49+
# Other tags, which we don't care about.
50+
r"|<[^>]+> ?"
51+
# Commas indicating pauses in navigation messages.
52+
r"| ?(?P<comma>,) ?"
53+
# Actual content.
54+
r"|(?P<content>[^<,]+)")
55+
56+
PROSODY_COMMANDS = {
57+
"pitch": PitchCommand,
58+
"volume": VolumeCommand,
59+
"rate": RateCommand,
60+
}
61+
62+
def ConvertSSMLTextForNVDA(text, language=""):
63+
# MathCAT's default rate is 180 wpm.
64+
# Assume that 0% is 80 wpm and 100% is 450 wpm and scale accordingly.
65+
synth = getSynth()
66+
wpm = synth._percentToParam(synth.rate, 80, 450)
67+
breakMulti = 180.0 / wpm
68+
out = []
69+
if language:
70+
out.append(LangChangeCommand(language))
71+
resetProsody = set()
72+
for m in RE_MP_SPEECH.finditer(text):
73+
if m.lastgroup == "break":
74+
out.append(BreakCommand(time=int(m.group("break")) * breakMulti))
75+
elif m.lastgroup == "char":
76+
out.extend((CharacterModeCommand(True), m.group("char"), CharacterModeCommand(False)))
77+
elif m.lastgroup == "comma":
78+
out.append(BreakCommand(time=100))
79+
elif m.lastgroup in PROSODY_COMMANDS:
80+
command = PROSODY_COMMANDS[m.lastgroup]
81+
out.append(command(multiplier=int(m.group(m.lastgroup)) / 100.0))
82+
resetProsody.add(command)
83+
elif m.lastgroup == "prosodyReset":
84+
for command in resetProsody:
85+
out.append(command(multiplier=1))
86+
resetProsody.clear()
87+
elif m.lastgroup == "phonemeText":
88+
out.append(PhonemeCommand(m.group("ipa"), text=m.group("phonemeText")))
89+
elif m.lastgroup == "content":
90+
out.append(m.group(0))
91+
if language:
92+
out.append(LangChangeCommand(None))
93+
return out
94+
95+
class MathCATInteraction(mathPres.MathInteractionNVDAObject):
96+
# Put MathML on the clipboard using the two formats below (defined by MathML spec)
97+
# We use both formats because some apps may only use one or the other
98+
# Note: filed https://github.com/nvaccess/nvda/issues/13240 to make this usable outside of MathCAT
99+
CF_MathML = windll.user32.RegisterClipboardFormatW("MathML")
100+
CF_MathML_Presentation = windll.user32.RegisterClipboardFormatW("MathML Presentation")
101+
# log.info("2**** MathCAT registering data formats: CF_MathML %x, CF_MathML_Presentation %x" % (CF_MathML, CF_MathML_Presentation))
102+
103+
def __init__(self, provider=None, mathMl=None):
104+
super(MathCATInteraction, self).__init__(provider=provider, mathMl=mathMl)
105+
provider._setSpeechLanguage(mathMl)
106+
try:
107+
libmathcat.SetMathML(mathMl)
108+
except Exception as e:
109+
speech.speakMessage(_("Illegal MathML found: see NVDA error log for details"))
110+
log.error(e)
111+
112+
def reportFocus(self):
113+
super(MathCATInteraction, self).reportFocus()
114+
try:
115+
speech.speak(ConvertSSMLTextForNVDA(libmathcat.GetSpokenText(),
116+
self.provider._language))
117+
except Exception as e:
118+
log.error(e)
119+
speech.speakMessage(_("Error in speaking math: see NVDA error log for details"))
120+
121+
122+
def getBrailleRegions(self, review=False):
123+
# log.info("***MathCAT start getBrailleRegions")
124+
yield braille.NVDAObjectRegion(self, appendText=" ")
125+
region = braille.Region()
126+
region.focusToHardLeft = True
127+
# libmathcat.SetBrailleWidth(braille.handler.displaySize)
128+
try:
129+
region.rawText = libmathcat.GetBraille("")
130+
except Exception as e:
131+
log.error(e)
132+
133+
# log.info("***MathCAT end getBrailleRegions ***")
134+
yield region
135+
136+
def getScript(self, gesture):
137+
# Pass most keys to MathCAT. Pretty ugly.
138+
if isinstance(gesture, KeyboardInputGesture) and "NVDA" not in gesture.modifierNames and (
139+
gesture.mainKeyName in {
140+
"leftArrow", "rightArrow", "upArrow", "downArrow",
141+
"home", "end",
142+
"space", "backspace", "enter",
143+
}
144+
# or len(gesture.mainKeyName) == 1
145+
):
146+
return self.script_navigate
147+
return super().getScript(gesture)
148+
149+
def script_navigate(self, gesture):
150+
# log.info("***MathCAT script_navigate")
151+
modNames = gesture.modifierNames
152+
try:
153+
text = libmathcat.DoNavigateKeyPress(gesture.vkCode,
154+
"shift" in modNames, "control" in modNames, "alt" in modNames, False)
155+
speech.speak(ConvertSSMLTextForNVDA(text, self.provider._language))
156+
157+
# update the braille to reflect the nav position (might be excess code, but it works)
158+
nav_node = libmathcat.GetNavigationMathMLId()
159+
region = braille.Region()
160+
region.rawText = libmathcat.GetBraille(nav_node[0])
161+
region.focusToHardLeft = True
162+
region.update()
163+
braille.handler.buffer.regions.append(region)
164+
braille.handler.buffer.focus(region)
165+
braille.handler.buffer.update()
166+
braille.handler.update()
167+
except Exception as e:
168+
log.error(e)
169+
170+
_startsWithMath = re.compile("\\s*?<math")
171+
@script(
172+
gesture="kb:control+c",
173+
)
174+
def script_rawdataToClip(self, gesture):
175+
try:
176+
mathml = libmathcat.GetNavigationMathML()[0]
177+
if not re.match(self._startsWithMath, mathml):
178+
mathml = "<math>" + mathml + "</math>" # copy will fix up namespacing
179+
self._copyToClipAsMathML(mathml)
180+
ui.message(_("copy"))
181+
except Exception as e:
182+
log.error(e)
183+
184+
# not a perfect match sequence, but should capture normal MathML
185+
_mathTagHasNameSpace = re.compile("<math .*?xmlns.+?>")
186+
def _wrapMathMLForClipBoard(self, text: str) -> str:
187+
# cleanup the MathML a little
188+
mathml_with_ns = text.replace(" data-changed='added'", "").replace(" data-id-added='true'", "")
189+
if not re.match(self._mathTagHasNameSpace, text):
190+
mathml_with_ns = mathml_with_ns.replace('math', 'math xmlns="http://www.w3.org/1998/Math/MathML"', 1)
191+
return '<?xml version="1.0"?>' + mathml_with_ns
192+
193+
from typing import Any, Optional
194+
def _copyToClipAsMathML(self, text: str, notify: Optional[bool] = False) -> bool:
195+
"""Copies the given text to the windows clipboard.
196+
@returns: True if it succeeds, False otherwise.
197+
@param text: the text which will be copied to the clipboard
198+
@param notify: whether to emit a confirmation message
199+
"""
200+
# copied from api.py and modified to use CF_MathML_Presentation
201+
if not isinstance(text, str) or len(text) == 0:
202+
return False
203+
from api import getClipData
204+
try:
205+
with winUser.openClipboard(mainFrame.Handle):
206+
winUser.emptyClipboard()
207+
self._setClipboardData(self.CF_MathML, self._wrapMathMLForClipBoard(text))
208+
self._setClipboardData(self.CF_MathML_Presentation, self._wrapMathMLForClipBoard(text))
209+
self._setClipboardData(winUser.CF_UNICODETEXT, text)
210+
got = getClipData()
211+
except OSError:
212+
if notify:
213+
ui.reportTextCopiedToClipboard() # No argument reports a failure.
214+
return False
215+
if got == text:
216+
if notify:
217+
ui.reportTextCopiedToClipboard(text)
218+
return True
219+
if notify:
220+
ui.reportTextCopiedToClipboard() # No argument reports a failure.
221+
return False
222+
223+
def _setClipboardData(self, format,data):
224+
# Need to support MathML Presentation, so this copied from winUser.py and the first two lines are commented out
225+
# For now only unicode is a supported format
226+
# if format!=CF_UNICODETEXT:
227+
# raise ValueError("Unsupported format")
228+
from textUtils import WCHAR_ENCODING
229+
from ctypes import c_wchar
230+
import winKernel
231+
text = data
232+
bufLen = len(text.encode(WCHAR_ENCODING, errors="surrogatepass")) + 2
233+
# Allocate global memory
234+
h=winKernel.HGLOBAL.alloc(winKernel.GMEM_MOVEABLE, bufLen)
235+
# Acquire a lock to the global memory receiving a local memory address
236+
with h.lock() as addr:
237+
# Write the text into the allocated memory
238+
buf=(c_wchar*bufLen).from_address(addr)
239+
buf.value=text
240+
# Set the clipboard data with the global memory
241+
if not windll.user32.SetClipboardData(format,h):
242+
raise ctypes.WinError()
243+
# NULL the global memory handle so that it is not freed at the end of scope as the clipboard now has it.
244+
h.forget()
245+
246+
class MathCAT(mathPres.MathPresentationProvider):
247+
def __init__(self):
248+
# super(MathCAT, self).__init__(*args, **kwargs)
249+
250+
try:
251+
# IMPORTANT -- SetRulesDir must be the first call to libmathcat
252+
rules_dir = path.join( path.dirname(path.abspath(__file__)), "Rules")
253+
log.info("MathCAT Rules dir: %s" % rules_dir)
254+
libmathcat.SetRulesDir(rules_dir)
255+
libmathcat.SetPreference("TTS", "SSML")
256+
257+
except Exception as e:
258+
log.error(e)
259+
260+
# store mathcontent for navigation and copy
261+
mathcontent = None
262+
263+
def getSpeechForMathMl(self, mathml):
264+
self._setSpeechLanguage(mathml)
265+
try:
266+
libmathcat.SetMathML(mathml)
267+
mathcontent = mathml
268+
except Exception as e:
269+
log.error(e)
270+
speech.speakMessage(_("Illegal MathML found: see NVDA error log for details"))
271+
try:
272+
return ConvertSSMLTextForNVDA(libmathcat.GetSpokenText())
273+
except Exception as e:
274+
log.error(e)
275+
speech.speakMessage(_("Error in speaking math: see NVDA error log for details"))
276+
277+
278+
def getBrailleForMathMl(self, mathml):
279+
# log.info("***MathCAT getBrailleForMathMl")
280+
try:
281+
libmathcat.SetMathML(mathml)
282+
except Exception as e:
283+
log.error(e)
284+
speech.speakMessage(_("Illegal MathML found: see NVDA error log for details"))
285+
try:
286+
return libmathcat.GetBraille("")
287+
except Exception as e:
288+
log.error(e)
289+
speech.speakMessage(_("Error in brailling math: see NVDA error log for details"))
290+
291+
292+
def interactWithMathMl(self, mathMl):
293+
MathCATInteraction(provider=self, mathMl=mathMl).setFocus()
294+
295+
def _setSpeechLanguage(self, mathMl):
296+
lang = mathPres.getLanguageFromMath(mathMl)
297+
if not lang:
298+
lang = speech.getCurrentLanguage()
299+
try:
300+
libmathcat.SetPreference("Language", lang.replace("_", "-"))
301+
self._language = lang
302+
except Exception as e:
303+
log.error(e)

NVDA-addon/addon/globalPlugins/MathCAT/MathCATPreferences.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,11 @@ def write_user_preferences():
175175
yaml.dump(user_preferences, stream=f, allow_unicode=True)
176176

177177
def OnClickPreviewVoiceButton(self,event):
178-
#insert code to preview the voice at the selected rate and delete following line
179-
pass
178+
from .MathCAT import ConvertSSMLTextForNVDA
179+
from speech import speak
180+
rate = self.m_sliderRelativeSpeed.GetValue()
181+
text = "<prosody rate='XXX%'>the square root of x squared plus y squared</prosody>".replace("XXX", str(rate), 1)
182+
speak( ConvertSSMLTextForNVDA(text) )
180183

181184
def OnClickOK(self,event):
182185
UserInterface.get_ui_values(self)

0 commit comments

Comments
 (0)