diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 77775be37af..b8995af8123 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -986,10 +986,10 @@ class GlobalPlugin(globalPluginHandler.GlobalPlugin): ## Packaging Code as NVDA Add-ons {#Addons} -Add-ons make it easy for users to share and install plugins, drivers, speech symbol dictionaries and braille translation tables. +Add-ons make it easy for users to share and install plugins, drivers, speech pronunciation/symbol dictionaries and braille translation tables. They can be packaged in to a single NVDA add-on package, which the user can then install into a copy of NVDA via the Add-on Store found under Tools in the NVDA menu. An add-on package is simply a standard zip archive with the file extension of "`nvda-addon`". -It can contain a manifest file, install/uninstall code and directories containing plugins, drivers, speech symbol dictionaries and braille translation tables. +It can contain a manifest file, install/uninstall code and directories containing plugins, drivers, speech dictionaries, symbol dictionaries and braille translation tables. ### Non-ASCII File Names in Zip Archives {#nonASCIIFileNamesInZip} @@ -1057,8 +1057,8 @@ The lastTestedNVDAVersion field in particular is used to ensure that users can b It allows the add-on author to make an assurance that the add-on will not cause instability, or break the users system. When this is not provided, or is less than the current version of NVDA (ignoring minor point updates e.g. 2018.3.1) then the user will be warned not to install the add-on. -The manifest can also specify information regarding any additional speech symbol dictionaries or braille translation tables provided by the add-on. -Please refer to the [speech symbol dictionaries](#AddonSymbolDictionaries) and [braille translation tables](#BrailleTables) sections. +The manifest can also specify information regarding any additional speech dictionaries, symbol or braille translation tables provided by the add-on. +Please refer to the [speech dictionaries](#AddonSpeechDictionaries), [symbol dictionaries](#AddonSymbolDictionaries) and [braille translation tables](#BrailleTables) sections. #### An Example Manifest File {#manifestExample} @@ -1082,7 +1082,8 @@ The following plugins and drivers can be included in an add-on: * Braille display drivers: Place them in a `brailleDisplayDrivers` directory in the archive. * Global plugins: Place them in a `globalPlugins` directory in the archive. * Synthesizer drivers: Place them in a `synthDrivers` directory in the archive. -* [Speech symbol dictionaries](#AddonSymbolDictionaries): Place them in the directory for one or more [locales](#localizingAddons) with a file name of `symbols-.dic`, e.g. `locale\en\symbols-greek.dic`. +* [Speech dictionaries](#AddonSpeechDictionaries): Place them in the `speechDicts` directory with a file name with the `.dic` extension, e.g. `speechDicts\pronunciation.dic`. +* [Symbol dictionaries](#AddonSymbolDictionaries): Place them in the directory for one or more [locales](#localizingAddons) with a file name of `symbols-.dic`, e.g. `locale\en\symbols-greek.dic`. * [Braille translation tables](#BrailleTables): Place them in a `brailleTables` directory in the archive. ### Optional install / Uninstall code {#installUninstallCode} @@ -1125,18 +1126,18 @@ To allow plugins in your add-on to access gettext message information via calls This function cannot be called in modules that do not belong to an add-on, e.g. in a scratchpad subdirectory. For more information about gettext and NVDA translation in general, please read the [Translating NVDA page](https://github.com/nvaccess/nvda/blob/master/projectDocs/translating/readme.md) -#### Speech symbol dictionaries {#AddonSymbolDictionaries} +#### Symbol dictionaries {#AddonSymbolDictionaries} You can provide custom speech symbol dictionaries in add-ons to improve symbol pronunciation. The process to create custom speech symbol dictionaries is very similar to that of the [translation process of existing symbols](#symbolPronunciation). Note that [complex symbols](#complexSymbols) are not supported. -Custom dictionaries must be placed in a language directory and have a filename in the form `symbols-.dic`, where `` is the name that has to be provided in the add-ons manifest. +Custom symbol dictionaries must be placed in a language directory and have a filename in the form `symbols-.dic`, where `` is the name that has to be provided in the add-ons manifest. All locales implicitly inherit the symbol information for English, though any of this information can be overridden for specific locales. -When adding a dictionary not marked as mandatory, some information must be provided such as its display name, since it should be shown in the speech category of the settings dialog. -A dictionary can also be marked mandatory, in which case it is always enabled with the add-on. -When an add-on ships with dictionaries, this information is included in its manifest in the optional `symbolDictionaries` section. +When adding a symbol dictionary not marked as mandatory, some information must be provided such as its display name, since it should be shown in the speech category of the settings dialog. +A symbol dictionary can also be marked mandatory, in which case it is always enabled with the add-on. +When an add-on ships with symbol dictionaries, this information is included in its manifest in the optional `symbolDictionaries` section. For example: ```ini @@ -1150,14 +1151,14 @@ displayName = Biblical Hebrew mandatory = true ``` -In the above example, `greek` is a dictionary that is optional and will be listed in the speech category of NVDA's settings dialog under the "Extra dictionaries for character and symbol processing" setting. +In the above example, `greek` is a symbol dictionary that is optional and will be listed in the speech category of NVDA's settings dialog under the "Extra dictionaries for character and symbol processing" setting. Its file will be stored as `locale\en\symbols-greek.dic`, whereas French translations of the symbols are stored in `locale\fr\symbols-greek.dic`. When using NVDA in French, symbols that aren't defined in the French dictionary inherit the symbol information for English. Also in the example, the `hebrew` dictionary is marked mandatory and will therefore always be enabled as long as the add-on is active. Its file will be stored as `locale\en\symbols-hebrew.dic`, whereas French translations of the symbols are stored in `locale\fr\symbols-hebrew.dic`. -Note that for the display name of the dictionary to be translated, an entry should be added to a [locale manifest](#localeManifest). +Note that for the display name of the symbol dictionary to be translated, an entry should be added to a [locale manifest](#localeManifest). For example, add the following to `locale\fr\manifest.ini`: ```ini @@ -1166,6 +1167,103 @@ For example, add the following to `locale\fr\manifest.ini`: displayName = Hébreu Biblique ``` +### Speech dictionaries {#AddonSpeechDictionaries} + +You can provide custom speech dictionaries in add-ons to improve pronunciation of words that are usually pronounced incorrectly by speech synthesizers. +Custom dictionaries must be placed in the `speechDicts` directory and have a filename with the `.dic` extension. +For example, when your dictionary is named `.dic`, `` is the name that has to be provided in the add-ons manifest. + +When adding a speech dictionary not marked as mandatory, some information must be provided such as its display name, since it should be shown in the speech category of the settings dialog. +A speech dictionary can also be marked mandatory, in which case it is always enabled with the add-on. +When an add-on ships with speech dictionaries, this information is included in its manifest in the optional `speechDictionaries` section. +For example: + +```ini +[speechDictionaries] +[[pronunciation]] +displayName = Dodgy Dictionary +mandatory = false +``` + +In the above example, `pronunciation` is a dictionary that is optional and will be listed in the speech category of NVDA's settings dialog under the "Speech Dictionaries" setting. +Its file will be stored as `speechDicts\pronunciation.dic`. +When you mark the dictionary as mandatory, it will be always enabled as long as the add-on is active. + +Note that for the display name of the dictionary to be translated, an entry should be added to a [locale manifest](#localeManifest). +For example, add the following to `locale\fr\manifest.ini`: + +```ini +[speechDictionaries] +[[pronunciation]] +displayName = Dictionnaire douteux +``` + +Unlike symbol dictionaries, speech dictionaries are currently locale-agnostic. +Therefore, an active dictionary is always active, regardless of the current language. + +#### Creating speech dictionaries {#AddonCreatingSpeechDictionaries} + +A speech dictionary file contains dictionary rules, one per line. +Each dictionary rule consists of four tab-separated fields on a single line: + +``` + +``` + +The fields are: + +1. `pattern`: The text pattern to match. + Hash characters (`#`) must be escaped as `\#`. +2. `replacement`: The text to replace the matched pattern with. + Hash characters (`#`) must be escaped as `\#`. +3. `caseSensitive`: A numeric flag indicating case sensitivity: + * `0`: Case insensitive matching + * `1`: Case sensitive matching +4. `type`: A number indicating the type of pattern matching to use: + * `0`: Anywhere - Pattern can match anywhere in the text (literal string) + * `1`: Regular expression - Pattern is treated as a Python regular expression + * `2`: Whole word - Pattern must match a complete word with word boundaries on both sides + * `3`: Part of word - Pattern must be preceded or followed by a word character (letter, digit, underscore) + * `4`: Start of word - Pattern must have a word boundary at the start and a word character at the end + * `5`: End of word - Pattern must have a word character at the start and a word boundary at the end + * `6`: Unix shell-style wildcards - Pattern uses Unix shell wildcards (`*`, `?`, `[]`, etc.) + +Comments can be added before entries to provide descriptions. +A comment is preceded by a `#` (hash sign) and applies to the next entry line that appears after it. + +##### Examples + +``` +# Expand NVDA acronym +NVDA NonVisual Desktop Access 1 2 +``` + +This means that the word "NVDA" (case sensitive, whole word) should be spoken as "NonVisual Desktop Access". + +``` +# Convert percentages to spoken format +(\d+)% \1 percent 0 1 +``` + +This uses a regular expression to match numbers followed by a percent sign and replaces them with the number followed by the word "percent". +For example, "50%" becomes "50 percent". + +``` +# Change "LOL" to full phrase +LOL laughing out loud 0 2 +``` + +This means that the word "LOL" (case insensitive, whole word) should be spoken as "laughing out loud". + +``` +# Match any .txt file using wildcards +*.txt text file 0 6 +``` + +This uses Unix shell-style wildcards to match any string ending in ".txt" and replaces it with "text file". + +For more information on speech dictionaries, see the NVDA user guide section on speech. + ### Add-on Documentation {#AddonDoc} Documentation for an add-on should be placed in the `doc` directory in the archive. diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 68ea058fb32..71aa3d9ffd3 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -1090,6 +1090,13 @@ class AddonManifest(ConfigObj): displayName = string() mandatory = boolean(default=false) +# Speech Dictionaries +[speechDictionaries] + # The key is the speech dictionary file name (not the full path) + [[__many__]] + displayName = string() + mandatory = boolean(default=false) + # NOTE: apiVersion: # EG: 2019.1.0 or 0.0.0 # Must have 3 integers separated by dots. @@ -1133,6 +1140,10 @@ def __init__(self, input: IO[bytes], translatedInput: IO[bytes] | None = None): value = dictConfig.get("displayName") if value: self["symbolDictionaries"][fileName]["displayName"] = value + for fileName, dictConfig in self._translatedConfig.get("speechDictionaries", {}).items(): + value = dictConfig.get("displayName") + if value: + self["speechDictionaries"][fileName]["displayName"] = value @property def errors(self): diff --git a/source/config/configSpec.py b/source/config/configSpec.py index de3412c5b19..1476912593b 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -39,6 +39,7 @@ unicodeNormalization = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled") reportNormalizedForCharacterNavigation = boolean(default=true) symbolDictionaries = string_list(default=list("cldr")) + speechDictionaries = string_list(default=list("default", "voice")) beepSpeechModePitch = integer(default=10000,min=50,max=11025) autoLanguageSwitching = boolean(default=true) autoDialectSwitching = boolean(default=false) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index f1187e50523..1e7c3f6fb43 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -47,6 +47,7 @@ import queueHandler import requests import speech +import speechDictHandler import systemUtils import vision import vision.providerBase @@ -1768,6 +1769,8 @@ def makeSettings(self, settingsSizer): self._appendDelayedCharacterDescriptions(settingsSizerHelper) + self._appendSpeechDictionariesList(settingsSizerHelper) + minPitchChange = int( config.conf.getConfigValidation( ("speech", self.driver.name, "capPitchChange"), @@ -1837,7 +1840,7 @@ def _appendSymbolDictionariesList(self, settingsSizerHelper: guiHelper.BoxSizerH d for d in characterProcessing.listAvailableSymbolDictionaryDefinitions() if d.userVisible ] self.symbolDictionariesList: nvdaControls.CustomCheckListBox = settingsSizerHelper.addLabeledControl( - # Translators: Label of the list where user can enable or disable symbol dictionaires. + # Translators: Label of the list where user can enable or disable symbol dictionaries. _("E&xtra dictionaries for character and symbol processing:"), nvdaControls.CustomCheckListBox, choices=[d.displayName for d in self._availableSymbolDictionaries], @@ -1848,6 +1851,22 @@ def _appendSymbolDictionariesList(self, settingsSizerHelper: guiHelper.BoxSizerH ] self.symbolDictionariesList.Select(0) + def _appendSpeechDictionariesList(self, settingsSizerHelper: guiHelper.BoxSizerHelper) -> None: + self._availableSpeechDictionaries = [ + d for d in speechDictHandler.listAvailableSpeechDictDefinitions(forDisplay=True) if d.userVisible + ] + self.speechDictionariesList: nvdaControls.CustomCheckListBox = settingsSizerHelper.addLabeledControl( + # Translators: Label of the list where user can enable or disable speech dictionaries. + _("Sp&eech dictionaries:"), + nvdaControls.CustomCheckListBox, + choices=[d.displayName for d in self._availableSpeechDictionaries], + ) + self.bindHelpEvent("SpeechDictionaries", self.speechDictionariesList) + self.speechDictionariesList.CheckedItems = [ + i for i, d in enumerate(self._availableSpeechDictionaries) if d.enabled + ] + self.speechDictionariesList.Select(0) + def _appendSpeechModesList(self, settingsSizerHelper: guiHelper.BoxSizerHelper) -> None: self._allSpeechModes = list(speech.SpeechMode) self.speechModesList: nvdaControls.CustomCheckListBox = settingsSizerHelper.addLabeledControl( @@ -1905,6 +1924,11 @@ def onSave(self): if set(currentSymbolDictionaries) != set(newSymbolDictionaries): # Either included or excluded symbol dictionaries, so clear the cache. characterProcessing.clearSpeechSymbols() + config.conf["speech"]["speechDictionaries"] = [ + d.name + for i, d in enumerate(self._availableSpeechDictionaries) + if i in self.speechDictionariesList.CheckedItems + ] delayedDescriptions = self.delayedCharacterDescriptionsCheckBox.IsChecked() config.conf["speech"]["delayedCharacterDescriptions"] = delayedDescriptions config.conf["speech"][self.driver.name]["capPitchChange"] = self.capPitchChangeEdit.Value diff --git a/source/gui/speechDict.py b/source/gui/speechDict.py index 343cb9f299a..76700a7df92 100644 --- a/source/gui/speechDict.py +++ b/source/gui/speechDict.py @@ -325,30 +325,29 @@ def onRemoveAll(self, evt): class DefaultDictionaryDialog(DictionaryDialog): def __init__(self, parent): + definition = speechDictHandler.definitions._getDictionaryDefinition(DictionaryType.DEFAULT) super().__init__( parent, - # Translators: Title for default speech dictionary dialog. - title=_("Default dictionary"), - speechDict=speechDictHandler.dictionaries[DictionaryType.DEFAULT], + title=definition.displayName, + speechDict=definition.dictionary, ) class VoiceDictionaryDialog(DictionaryDialog): def __init__(self, parent): + definition = speechDictHandler.definitions._getDictionaryDefinition(DictionaryType.VOICE) super().__init__( parent, - # Translators: Title for voice dictionary for the current voice such as current eSpeak variant. - title=_("Voice dictionary (%s)") % speechDictHandler.dictionaries[DictionaryType.VOICE].fileName, - speechDict=speechDictHandler.dictionaries[DictionaryType.VOICE], + title=definition.displayName, + speechDict=definition.dictionary, ) class TemporaryDictionaryDialog(DictionaryDialog): def __init__(self, parent): + definition = speechDictHandler.definitions._getDictionaryDefinition(DictionaryType.TEMP) super().__init__( parent, - # Translators: Title for temporary speech dictionary dialog (the voice dictionary that is active as long - # as NvDA is running). - title=_("Temporary dictionary"), - speechDict=speechDictHandler.dictionaries[DictionaryType.TEMP], + title=definition.displayName, + speechDict=definition.dictionary, ) diff --git a/source/speechDictHandler/__init__.py b/source/speechDictHandler/__init__.py index 330fa0c5744..6b8b75143be 100644 --- a/source/speechDictHandler/__init__.py +++ b/source/speechDictHandler/__init__.py @@ -3,17 +3,16 @@ # 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 -import os import typing - import globalVars from logHandler import log -from NVDAState import WritePaths -from utils._deprecate import MovedSymbol, handleDeprecations +from utils._deprecate import MovedSymbol, RemovedSymbol, handleDeprecations -from . import dictFormatUpgrade -from .types import DictionaryType -from .types import SpeechDict as _SpeechDict +from . import definitions +from .types import ( + DictionaryType, + VoiceSpeechDictDefinition, +) if typing.TYPE_CHECKING: import synthDriverHandler @@ -25,46 +24,51 @@ MovedSymbol("ENTRY_TYPE_REGEXP", "speechDictHandler.types", "EntryType", "REGEXP"), MovedSymbol("SpeechDict", "speechDictHandler.types"), MovedSymbol("SpeechDictEntry", "speechDictHandler.types"), + RemovedSymbol( + "dictionaries", + lambda: { + d.source: d.dictionary for d in definitions._speechDictDefinitions if d.source in DictionaryType + }, + callValue=True, + ), + RemovedSymbol("dictTypes", tuple(t.value for t in DictionaryType)), ) -dictionaries: dict[DictionaryType | str, _SpeechDict] = {} -dictTypes = ( - DictionaryType.TEMP.value, - DictionaryType.VOICE.value, - DictionaryType.DEFAULT.value, - DictionaryType.BUILTIN.value, -) -"""Types ordered by their priority E.G. voice specific speech dictionary is processed before the default.""" - def processText(text: str) -> str: - """Processes the given text through all speech dictionaries.""" + """Processes the given text through all speech dictionaries. + :param text: The text to process. + :returns: The processed text. + """ if not globalVars.speechDictionaryProcessing: return text - for type in dictTypes: - text = dictionaries[type].sub(text) + for definition in definitions._speechDictDefinitions: + if not definition.enabled: + continue + text = definition.sub(text) return text def initialize() -> None: - for type in dictTypes: - dictionaries[type] = _SpeechDict() - dictionaries[DictionaryType.DEFAULT].load(WritePaths.speechDictDefaultFile) - dictionaries[DictionaryType.BUILTIN].load(os.path.join(globalVars.appDir, "builtin.dic")) + definitions._addSpeechDictionaries() + + +def terminate() -> None: + definitions._speechDictDefinitions.clear() def loadVoiceDict(synth: "synthDriverHandler.SynthDriver") -> None: """Loads appropriate dictionary for the given synthesizer. It handles case when the synthesizer doesn't support voice setting. """ - try: - dictFormatUpgrade.doAnyUpgrades(synth) - except: # noqa: E722 - log.exception("error trying to upgrade dictionaries") - if synth.isSupported("voice"): - voice = synth.availableVoices[synth.voice].displayName - baseName = dictFormatUpgrade.createVoiceDictFileName(synth.name, voice) - else: - baseName = f"{synth.name}.dic" - fileName = os.path.join(WritePaths.voiceDictsDir, synth.name, baseName) - dictionaries[DictionaryType.VOICE].load(fileName) + definition = next( + (d for d in definitions._speechDictDefinitions if isinstance(d, VoiceSpeechDictDefinition)), + None, + ) + if definition is None: + log.error( + "No VoiceSpeechDictDefinition found in _speechDictDefinitions. " + "Speech dictionaries may not have been initialized.", + ) + raise RuntimeError("No voice speech dictionary definition is available to load.") + definition.load(synth) diff --git a/source/speechDictHandler/definitions.py b/source/speechDictHandler/definitions.py new file mode 100644 index 00000000000..48e3e6111de --- /dev/null +++ b/source/speechDictHandler/definitions.py @@ -0,0 +1,118 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter +# 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 + +import os.path +from locale import strxfrm + +import globalVars +from logHandler import log +from NVDAState import WritePaths + +from .types import DictionaryType, SpeechDictDefinition, VoiceSpeechDictDefinition + +_speechDictDefinitions: list[SpeechDictDefinition] = [] +""" +A list of available speech dictionary definitions. +These definitions are used to load speech dictionaries. +The list is filled with definitions from core and add-ons using _addSpeechDictionaries. +With listAvailableSpeechDictDefinitions, there is a public interface to retrieve definitions. +""" + + +def listAvailableSpeechDictDefinitions(forDisplay: bool = False) -> list[SpeechDictDefinition]: + """Get available speech dictionary definitions. + Note that this function returns both mandatory and optional speech dictionaries, and does not filter based on whether the dictionary is currently enabled. + :param forDisplay: If True, the returned list is sorted for display order. + Such a list is sorted alphabetically by display name, with built-in dictionaries listed first. + """ + defs = list(_speechDictDefinitions) + if not forDisplay: + return defs + return sorted( + defs, + key=lambda dct: (dct.source not in DictionaryType, strxfrm(dct.displayName or dct.name)), + ) + + +def _addSpeechDictionaries(): + """ + Adds speech dictionary definitions to the global _speechDictDefinitions list. + + This function is responsible for initializing the available speech dictionaries. + It adds definitions for the built-in speech dictionaries, as well as any speech dictionaries in add-ons. + + The built-in dictionaries include: + - "builtin": Built-in speech dictionary that assists in breaking up words that contain capital letters. + - "default": Default speech dictionary in the user profile. + - "voice": Voice-specific speech dictionary that adapts to the currently active voice. + + For each installed add-on, the function checks the add-on's manifest for any defined speech dictionaries, + and adds those to the _speechDictDefinitions list as well. + """ + _speechDictDefinitions.extend( + ( + SpeechDictDefinition( + name=DictionaryType.TEMP.value, + source=DictionaryType.TEMP, + mandatory=True, + # Translators: Title for the temporary speech dictionary (the dictionary that is active as long + # as NVDA is running). + displayName=_("Temporary dictionary"), + ), + VoiceSpeechDictDefinition(), + SpeechDictDefinition( + name=DictionaryType.DEFAULT.value, + source=DictionaryType.DEFAULT, + path=WritePaths.speechDictDefaultFile, + # Translators: Name of the default speech dictionary. + displayName=_("Default Dictionary"), + ), + SpeechDictDefinition( + name=DictionaryType.BUILTIN.value, + source=DictionaryType.BUILTIN, + path=os.path.join(globalVars.appDir, "builtin.dic"), + mandatory=True, + ), + ), + ) + # Add add-on speech dictionaries + import addonHandler + + for addon in addonHandler.getRunningAddons(): + speechDicts = addon.manifest.get("speechDictionaries") + if not speechDicts: + continue + log.debug( + f"Found {len(speechDicts)} speech dictionary entries in manifest for add-on {addon.name!r}", + ) + directory = os.path.join(addon.path, "speechDicts") + for name, dictConfig in speechDicts.items(): + try: + definition = SpeechDictDefinition( + name=name, + path=os.path.join(directory, f"{name}.dic"), + source=addon.name, + displayName=dictConfig["displayName"], + mandatory=dictConfig["mandatory"], + ) + except Exception: + log.exception( + f"Error while applying custom speech dictionaries config from addon {addon.name!r}", + ) + continue + else: + _speechDictDefinitions.append(definition) + + +def _getDictionaryDefinition(source: DictionaryType | str) -> SpeechDictDefinition: + """Get the speech dictionary definition for a given source. + :param source: The source of the speech dictionary, which can be a DictionaryType or a string (e.g., add-on name). + :return: The corresponding SpeechDictDefinition. + :raises KeyError: If no definition is found for the given source. + """ + for definition in _speechDictDefinitions: + if definition.source == source: + return definition + raise KeyError(f"No speech dictionary definition found for source {source!r}") diff --git a/source/speechDictHandler/types.py b/source/speechDictHandler/types.py index 57410dc1c00..af7b001bdc5 100644 --- a/source/speechDictHandler/types.py +++ b/source/speechDictHandler/types.py @@ -10,12 +10,21 @@ import re from dataclasses import dataclass, field from functools import cached_property -from typing import Self +from typing import ( + TYPE_CHECKING as _TYPE_CHECKING, + Self, +) +import config from logHandler import log -from NVDAState import shouldWriteToDisk +from NVDAState import WritePaths, shouldWriteToDisk from utils.displayString import DisplayStringIntEnum, DisplayStringStrEnum +from . import dictFormatUpgrade + +if _TYPE_CHECKING: + import synthDriverHandler + class EntryType(DisplayStringIntEnum): """Types of speech dictionary entries:""" @@ -140,13 +149,16 @@ class SpeechDict(list[SpeechDictEntry]): def __repr__(self) -> str: return f"{self.__class__.__name__} ({len(self)} entries, fileName={self.fileName})" - def load(self, fileName: str) -> None: + def load(self, fileName: str, raiseOnError: bool = False) -> None: self.fileName = fileName comment = "" self.clear() - log.debug("Loading speech dictionary '%s'...", fileName) + log.debug("Loading speech dictionary %r...", fileName) if not os.path.isfile(fileName): - log.debug("file '%s' not found.", fileName) + msg = f"file {fileName!r} not found." + if raiseOnError: + raise FileNotFoundError(msg) + log.debug(msg) return with open(fileName, encoding="utf_8_sig", errors="replace") as file: for line in file: @@ -172,11 +184,17 @@ def load(self, fileName: str) -> None: type=EntryType(int(temp[3])), ) self.append(dictionaryEntry) - except Exception: - log.exception('Dictionary ("%s") entry invalid for "%s"', fileName, line) + except Exception as e: + msg = f"Dictionary {fileName!r} entry invalid for {line!r}" + if raiseOnError: + raise ValueError(msg) from e + log.exception(msg) comment = "" else: - log.warning("can't parse line '%s'", line) + msg = f"can't parse line {line!r}" + if raiseOnError: + raise ValueError(msg) + log.warning(msg) log.debug("%d loaded records.", len(self)) def save(self, fileName: str | None = None): @@ -210,3 +228,90 @@ def sub(self, text: str) -> str: for index in reversed(invalidEntries): del self[index] return text + + +@dataclass(frozen=True, kw_only=True) +class SpeechDictDefinition: + """An abstract class for a speech dictionary definition.""" + + name: str + """The name of the dictionary.""" + + path: str | None = None + """The path to the dictionary.""" + + source: DictionaryType | str + """The source of the dictionary.""" + + displayName: str | None = None + """The translatable name of the dictionary. + When not provided, the dictionary can not be visible to the end user. + """ + + mandatory: bool = False + """Whether this dictionary is mandatory. + Mandatory dictionaries are always enabled.""" + + dictionary: SpeechDict = field(init=False, repr=False, compare=False, default_factory=SpeechDict) + + def __post_init__(self): + if not self.displayName and not self.mandatory: + raise ValueError("A non-mandatory dictionary without a display name is unsupported") + if self.path: + self.dictionary.load(self.path, raiseOnError=self.source not in DictionaryType) + + @property + def readOnly(self) -> bool: + """Whether this dictionary is read-only.""" + return self.source not in DictionaryType + + @property + def userVisible(self) -> bool: + """Whether this dictionary is visible to end users (i.e. in the GUI). + Mandatory dictionaries are hidden. + """ + return not self.mandatory and bool(self.displayName) + + @property + def enabled(self) -> bool: + return self.mandatory or self.name in config.conf["speech"]["speechDictionaries"] + + def sub(self, text: str) -> str: + """Applies the dictionary to the given text. + :param text: The text to apply the dictionary to. + :return: The text after applying the dictionary. + """ + return self.dictionary.sub(text) + + +@dataclass(frozen=True, kw_only=True) +class VoiceSpeechDictDefinition(SpeechDictDefinition): + source: DictionaryType = field(init=False, default=DictionaryType.VOICE) + name: str = field(init=False, default=DictionaryType.VOICE.value) + + @property + def displayName(self) -> str: + """The display name for the voice dictionary.""" + # Translators: Title for voice dictionary for the current voice such as current eSpeak variant. + return _("Voice dictionary (%s)") % (os.path.basename(self.path) if self.path else None) + + @displayName.setter + def displayName(self, value: str) -> None: + # Ignore any attempts to set displayName, as it is computed. + pass + + def load(self, synth: "synthDriverHandler.SynthDriver"): + """Loads appropriate dictionary for the given synthesizer. + It handles the case when the synthesizer doesn't support the voice setting. + """ + try: + dictFormatUpgrade.doAnyUpgrades(synth) + except Exception: + log.exception("error trying to upgrade dictionaries") + if synth.isSupported("voice"): + voice = synth.availableVoices[synth.voice].displayName + baseName = dictFormatUpgrade.createVoiceDictFileName(synth.name, voice) + else: + baseName = f"{synth.name}.dic" + object.__setattr__(self, "path", os.path.join(WritePaths.voiceDictsDir, synth.name, baseName)) + self.__post_init__() diff --git a/source/utils/_deprecate.py b/source/utils/_deprecate.py index 3a7c7a55b4f..7691b5089ab 100644 --- a/source/utils/_deprecate.py +++ b/source/utils/_deprecate.py @@ -105,19 +105,30 @@ def value(self): class RemovedSymbol(DeprecatedSymbol): """A symbol which has been removed from the public API.""" - def __init__(self, name: str, value: Any, *, message: str = "No public replacement is planned."): + def __init__( + self, + name: str, + value: Any, + *, + callValue: bool = False, + message: str = "No public replacement is planned.", + ): """Initialiser. :param name: Old name of the symbol. :param value: Old value of the symbol. + :param callValue: Whether to treat the value as a callable that should be called to get the actual value. :param message: _description_, defaults to "No public replacement is planned." """ super().__init__(name) self._value = value + self._callValue = callValue self._extraMessage = message @property def value(self) -> Any: + if self._callValue: + return self._value() return self._value def getLogMessage(self, moduleName: str) -> str: diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index a2e6af18414..bbbc1f77b86 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -15,6 +15,10 @@ * A new command, assigned to `NVDA+x`, has been introduced to repeat the last information spoken by NVDA; pressing it twice shows it in a browseable message. (#625, @CyrilleB79) * Added an unassigned command to toggle keyboard layout. (#19211, @CyrilleB79) * Added an unassigned Quick Navigation Command for jumping to next/previous slider in browse mode. (#17005, @hdzrvcc0X74) +* Added support for custom speech dictionaries. (#19558, #17468, @LeonarddeR) + * Dictionaries can be provided in the `speechDicts` folder in an add-on package. + * Dictionary metadata can be added to an optional `speechDictionaries` section in the add-on manifest. + * Please consult the [Custom speech dictionaries section in the developer guide](https://www.nvaccess.org/files/nvda/documentation/developerGuide.html#AddonSpeechDictionaries) for more details. * New types have been added for Speech Dictionary entries, such as part of word and start of word. Consult the speech dictionaries section in the User Guide for more details. (#19506, @LeonarddeR)