Skip to content

Conversation

sclower
Copy link

@sclower sclower commented Jul 13, 2025

Description

Mathquill's screen reader messages, known internally as "mathspeak," have always been hard-coded to English. We are receiving more requests to make them respect the user's language, and this work shows how we could fulfill these requests.

Some notable changes include:

  • Factors out messages to individual English and Spanish catalogs. Note: the latter needs human verification for accuracy but appears to be reasonable enough to keep for testing purposes.
  • Messages are bundled with the build, so no external files need be distributed.
  • User can set Mathquill's language: e.g. mq.config({ language: 'es' }). At present, only English and Spanish are recognized, but the system is configured to allow for more, including locale-specific variants.
  • New Mathquill.L10n API allows the user to check for language availability, set, and get the active language, as well as provide a callback to fire when the language changes (so that, for example, related DOM can be updated).
  • Dynamic language switching: Default aria label and Mathspeak are recomputed when language changes. Note that user-supplied custom aria labels persist.
  • Per-instance localization: Each MathField can have independent language settings if desired.
  • All common symbols, blocks, and structural descriptions are localizable.
  • Custom fraction, root, and exponent shorthand speech is preserved with some subtle improvements introduced.
  • Tweaked all mixed-case constructs to be separate words, e.g. "StartFraction" is now "Start Fraction" (tests updated accordingly)
  • Common function names with existing screen reader-friendly replacements (e.g. "sine" for "sin") are localizable. TBD if this is worth keeping or if callers should be expected to provide all auto operators and language-specific alternatives themselves.
  • Added documentation and tests for new API methods

Open Questions

  1. The Fluent library adds more size to the generated bundle which also inflates anything embedding Mathquill:
  • Mathquill.js: 439K before, 652K after
  • Mathquill.min.js: 149K before, 210K after

This is fairly significant, and it might be worth exploring a dedicated make target which allows external callers to pass in an existing FluentJS bundle.

  1. Support currently exists to localize some common function names (see above). For stand-alone Mathquill instantiation where the list of auto operator names is not replaced, I think this will work out well. Life could become much more confusing for callers which include custom keywords which also require localization.
  2. Fluent syntax has proven to be powerful and flexible. Is the system overkill for this project?

Note: Claude Code was used for parts of this work, especially in assisting with the refactoring of English messages into the Fluent catalogs and translation into Spanish. I have reviewed and tweaked any new code which was generated along the way.

Mathquill's screen reader messages, known internally as "mathspeak," have always been hard-coded to English. We are receiving more requests to make them respect the user's language, and this work aims to do just that.

Some notable changes include:

- Factors out messages to individual English and Spanish catalogs. Note: the latter needs human verification for accuracy but appears to be reasonable enough to keep for testing purposes.
- Messages are bundled with the build, so no external files need be distributed.
- User can set Mathquill's language: e.g. mq.config({ language: 'es' }). At present, only English and Spanish are recognized, but the system is configured to allow for more, including locale-specific variants.
- Dynamic language switching: Default aria label and Mathspeak are recomputed when language changes. Note that user-supplied custom aria labels persist.
- Per-instance localization: Each MathField can have independent language settings if desired.
- All common symbols, blocks, and structural descriptions are localizable.
- Custom fraction, root, and exponent shorthand speech is preserved with some subtle improvements introduced.
- Tweaked all mixed-case constructs to be separate words, e.g. "StartFraction" is now "Start Fraction" (tests updated accordingly)
@sclower sclower changed the title WIP: Add mathspeak localization to Mathquill POC: Add mathspeak localization to Mathquill Jul 13, 2025
sclower added 5 commits July 13, 2025 13:16
- Added new Mathquill.L10n API and moved the previously global Window functions here
- Removed temporary any typings
- Updated documentation
@sclower sclower changed the title POC: Add mathspeak localization to Mathquill Add mathspeak localization to Mathquill Jul 15, 2025
@sclower sclower requested a review from timstallmann July 21, 2025 12:31
var processor = (optionProcessors as any)[name]; // TODO - validate option processors better
(currentOptions as any)[name] = processor ? processor(value) : value; // TODO - think about typing better

// Handle language changes by updating the global language manager

Choose a reason for hiding this comment

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

unclear to me if this is needed? we're already doing it in MQ.config. I'm just not clear what the codepaths are here

this.revert = function () {
ctrlr.removeMouseEventListener();
// Clean up language change callback
if (ctrlr.unregisterLanguageChange) {

Choose a reason for hiding this comment

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

why is this named unregisterLanguageChange?

type NodeRef = MQNode | 0;

// Handle timeout types for both browser and Node.js environments
type TimeoutId = number | NodeJS.Timeout;
Copy link

@timstallmann timstallmann Jul 21, 2025

Choose a reason for hiding this comment

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

is this type addition necessary for the localization stuff?

plus = más
positive = positivo
minus = menos
negative = negativo

Choose a reason for hiding this comment

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

pretty sure this is also menos in practice. I kind of think we shouldn't be deploying translations that claude generated, especially for math where there's a lot of language patterns which are math-specific. would it be possible to send the english ftl file off to localizers, or just get someone with professional translation experience to review the es translations?

Choose a reason for hiding this comment

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

@@ -0,0 +1,182 @@
#!/usr/bin/env node

Choose a reason for hiding this comment

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

thinking about how we already have a version of this in knox, which is also in typescript. I dunno if it makes sense to try to standardize, mainly just thinking out loud

sclower added 2 commits July 22, 2025 16:49
This also ensures that nodes whose content doesn't update with language changes (specifically binomials, text, and style blocks) are updated by the controller.
- More mathspeak template optimization
- Added more localizable messages for block types and brackets
- Now using menos instead of negativo to describe negative numbers in Spanish
- Removed duplicate strings where possible
const animate = (function () {
// IIFE exists just to hang on to configured rafShim and cancelShim
// functions
// functions. Both return/accept number tokens for cross-environment compatibility.

Choose a reason for hiding this comment

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

can we revert this file to main? I'm having trouble seeing how this change is necessary? but maybe it is?

if (this.controller) autoOps = this.controller.options.autoOperatorNames;
const outerThis = this; // Capture outer 'this' for use in callback
return (
this.foldChildren<string[]>([], function (speechArray, cmd) {

Choose a reason for hiding this comment

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

if this were an arrow function we wouldn't need the outerThis abstraction, would be clearer.

);
LatexCmds.lbrack = bindVanillaSymbol('[', 'left bracket');
LatexCmds.rbrack = bindVanillaSymbol(']', 'right bracket');
class LeftBrace extends VanillaSymbol {

Choose a reason for hiding this comment

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

is the idea that not all of the symbols in here get mathspeak localizations yet? e.g. \\simeq?


mathspeak() {
const localization = getControllerLocalization(this);
this.mathspeakTemplate = [
Copy link

@timstallmann timstallmann Jul 23, 2025

Choose a reason for hiding this comment

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

could this use createMathspeakTemplate?


mathspeak(opts?: MathspeakOptions) {
const localization = getControllerLocalization(this);
this.mathspeakTemplate = [
Copy link

@timstallmann timstallmann Jul 23, 2025

Choose a reason for hiding this comment

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

could this use createMathspeakTemplate?

'StartNestedFraction,',
'NestedOver',
', EndNestedFraction'
localization.formatMessage('start-nested-fraction') + ',',
Copy link

@timstallmann timstallmann Jul 23, 2025

Choose a reason for hiding this comment

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

could this use createMathspeakTemplate?

];
} else {
this.mathspeakTemplate = ['StartFraction,', 'Over', ', EndFraction'];
this.mathspeakTemplate = [
Copy link

@timstallmann timstallmann Jul 23, 2025

Choose a reason for hiding this comment

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

could this use createMathspeakTemplate?

localization.formatMessage('end-cube-root')
);
} else {
return (

Choose a reason for hiding this comment

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

should this also use a mathspeak template or do we want to be directly concatenating the string for some reason?

readonly cursor: Cursor;
editable: boolean | undefined;
_ariaAlertTimeout: number;
_ariaAlertTimeout: TimeoutId;

Choose a reason for hiding this comment

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

i think this change is not relevant to the PR

textareaSpan: HTMLElement | undefined;
mathspeakSpan: HTMLElement | undefined;
mathspeakId: string | undefined;
unregisterLanguageChange: (() => void) | undefined;

Choose a reason for hiding this comment

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

still confused about naming here -- what does unregister mean in this context?

private _domFrag = domFrag();
selection: MQSelection | undefined;
intervalId: number;
intervalId: TimeoutId;

Choose a reason for hiding this comment

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

revert this to main

__disableGroupingTimeout: number;
textareaSelectionTimeout: number;
__disableGroupingTimeout: TimeoutId;
textareaSelectionTimeout: TimeoutId;

Choose a reason for hiding this comment

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

can revert this file also

/** Poller that fires once every tick. */
class EveryTick<Args extends unknown[] = []> {
private timeoutId: number;
private timeoutId: TimeoutId;

Choose a reason for hiding this comment

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

revert this file

@sclower
Copy link
Author

sclower commented Jul 23, 2025

I feel this is near the finish line, but after some internal discussion I'm going to close this PR for now as we might have a better long-term way to address this need.

@sclower sclower closed this Jul 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants