diff --git a/public/input-test.js b/public/input-test.js
index 5386a5f8..cf2a72a8 100644
--- a/public/input-test.js
+++ b/public/input-test.js
@@ -53,6 +53,15 @@
'exponent'
);
+ // Demonstrate toolbar buttons for the chemistry/scientific-notation commands
+ // added in this branch (\sci, \positiveion, \negativeion). \ion takes
+ // [sign]{charge}, so the sign-bound shorthands are the natural one-click buttons.
+ mathField.options.addToolbarButtons([
+ { id: 'sci', latex: '\\sci', tooltip: 'scientific notation', icon: 'E' },
+ { id: 'positiveion', latex: '\\positiveion', tooltip: 'positive ion', icon: '\\text{ }^{\\text{ }+}' },
+ { id: 'negativeion', latex: '\\negativeion', tooltip: 'negative ion', icon: '\\text{ }^{\\text{ }-}' }
+ ]);
+
const optionsContainer = document.querySelector('.options-container');
for (const option in cfg) {
diff --git a/public/unit-test.html b/public/unit-test.html
index e6f47337..6fc76ff7 100644
--- a/public/unit-test.html
+++ b/public/unit-test.html
@@ -29,166 +29,170 @@
Unit Tests
while (mock.firstChild) mock.firstChild.remove();
});
- if (post_xunit_to) {
- let xunit = '';
- Mocha.process.stdout.write = (line) => (xunit += line);
- const runner = mocha.run();
-
- // the following is based on
- // https://github.com/saucelabs-sample-scripts/JavaScript/blob/
- // 4946c5cf0ab7325dce5562881dba7c28e30989e5/reporting_mocha.js
- const failedTests = [];
- runner.on('fail', (test, err) => {
- const flattenTitles = (test) => {
- const titles = [];
- while (test.parent.title) {
- titles.push(test.parent.title);
- test = test.parent;
- }
- return titles.reverse();
- };
+ // Run after window load so the test bundle (mathquill.test.js, included at
+ // the end of the body) has registered its suites before mocha.run().
+ window.addEventListener('load', () => {
+ if (post_xunit_to) {
+ let xunit = '';
+ Mocha.process.stdout.write = (line) => (xunit += line);
+ const runner = mocha.run();
+
+ // the following is based on
+ // https://github.com/saucelabs-sample-scripts/JavaScript/blob/
+ // 4946c5cf0ab7325dce5562881dba7c28e30989e5/reporting_mocha.js
+ const failedTests = [];
+ runner.on('fail', (test, err) => {
+ const flattenTitles = (test) => {
+ const titles = [];
+ while (test.parent.title) {
+ titles.push(test.parent.title);
+ test = test.parent;
+ }
+ return titles.reverse();
+ };
- failedTests.push({
- name: test.title,
- result: false,
- message: err.message,
- stack: err.stack,
- titles: flattenTitles(test)
+ failedTests.push({
+ name: test.title,
+ result: false,
+ message: err.message,
+ stack: err.stack,
+ titles: flattenTitles(test)
+ });
});
- });
- runner.on('end', () => {
- fetch(post_xunit_to, { method: 'post', body: xunit }).then(() => {
- window.mochaResults = runner.stats;
- window.mochaResults.reports = failedTests;
- });
- });
- } else {
- const json = location.search.indexOf('json') >= 0;
- const listTests = location.search.indexOf('listTests') >= 0;
- const suiteMap = {};
- const runner = mocha.run();
-
- runner.on('suite', (suite) => {
- const title = xmlEscape(suite.fullTitle());
- suiteMap[title] = {
- assertions: []
- };
- if (listTests) {
- suiteMap[title].assertions.push({
- elapsedTime: 0,
- timestamp: 0,
- result: true,
- message: 'okay'
- });
- }
- });
-
- runner.on('pass', (test) => {
- if (!listTests) {
- const title = getTestSuiteTitle(test);
- const elapsedTime = test.duration / 1000;
- const timestamp = Date.now();
- suiteMap[title].assertions.push({
- elapsedTime: elapsedTime,
- timestamp: timestamp,
- result: true,
- message: xmlEscape(test.title)
+ runner.on('end', () => {
+ fetch(post_xunit_to, { method: 'post', body: xunit }).then(() => {
+ window.mochaResults = runner.stats;
+ window.mochaResults.reports = failedTests;
});
- }
- });
-
- runner.on('fail', (test, err) => {
- if (!listTests) {
- const title = getTestSuiteTitle(test);
- const elapsedTime = test.duration / 1000;
- const timestamp = Date.now();
- suiteMap[title].assertions.push({
- elapsedTime: elapsedTime,
- timestamp: timestamp,
- result: false,
- message: xmlEscape(err.message),
- stacktrace: xmlEscape(err.stack),
- expected: true,
- actual: false
- });
- }
- });
-
- runner.on('end', () => {
- const moduleResults = [];
- for (const suiteTitle in suiteMap) {
- if (suiteMap.hasOwnProperty(suiteTitle)) {
- const suiteResults = suiteMap[suiteTitle];
- let duration = 0;
- for (const assertion of suiteResults.assertions) duration += assertion.elapsedTime;
- moduleResults.push({
- name: suiteTitle,
- assertions: suiteResults.assertions,
- time: duration
+ });
+ } else {
+ const json = location.search.indexOf('json') >= 0;
+ const listTests = location.search.indexOf('listTests') >= 0;
+ const suiteMap = {};
+ const runner = mocha.run();
+
+ runner.on('suite', (suite) => {
+ const title = xmlEscape(suite.fullTitle());
+ suiteMap[title] = {
+ assertions: []
+ };
+ if (listTests) {
+ suiteMap[title].assertions.push({
+ elapsedTime: 0,
+ timestamp: 0,
+ result: true,
+ message: 'okay'
+ });
+ }
+ });
+
+ runner.on('pass', (test) => {
+ if (!listTests) {
+ const title = getTestSuiteTitle(test);
+ const elapsedTime = test.duration / 1000;
+ const timestamp = Date.now();
+ suiteMap[title].assertions.push({
+ elapsedTime: elapsedTime,
+ timestamp: timestamp,
+ result: true,
+ message: xmlEscape(test.title)
});
}
- }
- const testResults = {
- modules: { mathquill: moduleResults },
- passes: runner.stats.passes,
- failures: runner.stats.failures,
- skips: 0
+ });
+
+ runner.on('fail', (test, err) => {
+ if (!listTests) {
+ const title = getTestSuiteTitle(test);
+ const elapsedTime = test.duration / 1000;
+ const timestamp = Date.now();
+ suiteMap[title].assertions.push({
+ elapsedTime: elapsedTime,
+ timestamp: timestamp,
+ result: false,
+ message: xmlEscape(err.message),
+ stacktrace: xmlEscape(err.stack),
+ expected: true,
+ actual: false
+ });
+ }
+ });
+
+ runner.on('end', () => {
+ const moduleResults = [];
+ for (const suiteTitle in suiteMap) {
+ if (suiteMap.hasOwnProperty(suiteTitle)) {
+ const suiteResults = suiteMap[suiteTitle];
+ let duration = 0;
+ for (const assertion of suiteResults.assertions) duration += assertion.elapsedTime;
+ moduleResults.push({
+ name: suiteTitle,
+ assertions: suiteResults.assertions,
+ time: duration
+ });
+ }
+ }
+ const testResults = {
+ modules: { mathquill: moduleResults },
+ passes: runner.stats.passes,
+ failures: runner.stats.failures,
+ skips: 0
+ };
+ if (json) window.testResultsString = JSON.stringify(testResults, null, 2);
+ else window.testResultsString = outputXML(testResults);
+ });
+
+ const getTestSuiteTitle = (test) => xmlEscape(test.parent.fullTitle());
+
+ // must escape a few symbols in xml attributes:
+ // http://stackoverflow.com/questions/866706/
+ // which-characters-are-invalid-unless-encoded-in-an-xml-attribute
+ const xmlEscape = (string) => {
+ if (typeof string !== 'string') return '';
+ string = string || '';
+ string = string.replace(/&/g, '&');
+ string = string.replace(/"/g, '"');
+ string = string.replace(/ xmlEscape(test.parent.fullTitle());
-
- // must escape a few symbols in xml attributes:
- // http://stackoverflow.com/questions/866706/
- // which-characters-are-invalid-unless-encoded-in-an-xml-attribute
- const xmlEscape = (string) => {
- if (typeof string !== 'string') return '';
- string = string || '';
- string = string.replace(/&/g, '&');
- string = string.replace(/"/g, '"');
- string = string.replace(/ {
- const xml = [];
- xml.push('');
- xml.push('');
-
- for (const moduleName in results.modules) {
- const module = results.modules[moduleName];
- for (const test of module) {
- xml.push(``);
-
- for (const assertion of test.assertions) {
- const assertionMessage = assertion.message || 'no-assertion-message';
- const assertionTime = assertion.elapsedTime;
-
- xml.push(``);
-
- if (assertion.result === false) {
- xml.push(``);
- xml.push(`Expected: ${assertion.expected}\n`);
- xml.push(`Actual: ${assertion.actual}\n`);
- xml.push(`Stacktrace: ${assertion.stacktrace}`);
- xml.push('');
- } else if (assertion.result === undefined) {
- xml.push('');
+
+ const outputXML = (results) => {
+ const xml = [];
+ xml.push('');
+ xml.push('');
+
+ for (const moduleName in results.modules) {
+ const module = results.modules[moduleName];
+ for (const test of module) {
+ xml.push(``);
+
+ for (const assertion of test.assertions) {
+ const assertionMessage = assertion.message || 'no-assertion-message';
+ const assertionTime = assertion.elapsedTime;
+
+ xml.push(``);
+
+ if (assertion.result === false) {
+ xml.push(``);
+ xml.push(`Expected: ${assertion.expected}\n`);
+ xml.push(`Actual: ${assertion.actual}\n`);
+ xml.push(`Stacktrace: ${assertion.stacktrace}`);
+ xml.push('');
+ } else if (assertion.result === undefined) {
+ xml.push('');
+ }
+
+ xml.push('');
}
- xml.push('');
+ xml.push('');
}
-
- xml.push('');
}
- }
- return xml.join('\n');
- };
- }
+ return xml.join('\n');
+ };
+ }
+ });
diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts
index 72fbf038..9f2a8b4b 100644
--- a/src/commands/math/commands.ts
+++ b/src/commands/math/commands.ts
@@ -248,6 +248,139 @@ LatexCmds.superscript =
}
};
+// Ion notation: a superscript-only command carrying an editable charge
+// block followed by a fixed sign — `\ion[+]{2}` renders as the "2+" of a
+// 2+ cation. `\positiveion` / `\negativeion` are sign-bound shorthands.
+// Restored for @openwebwork/mathquill; the chemistry answer-entry toolbars
+// (chemQuillChemistry.pl) drive these commands.
+class Ion extends SupSub {
+ sign: '+' | '-';
+
+ constructor(sign?: string) {
+ super();
+ this.sign = sign === '-' ? '-' : '+';
+ this.supsub = 'sup';
+ this.htmlTemplate =
+ '' +
+ '' +
+ '&0' +
+ `${this.sign}` +
+ '';
+ }
+
+ latex() {
+ return `\\ion[${this.sign}]{${this.sup?.latex() || '1'}}`;
+ }
+
+ text() {
+ return `^(${this.sign}${this.sup?.text() || '1'})`;
+ }
+
+ mathspeak() {
+ const charge = (this.sup ? getCtrlSeqsFromBlock(this.sup) : '') || '1';
+ return `charge ${charge} ${this.sign === '-' ? 'negative' : 'positive'}`;
+ }
+
+ parser() {
+ return latexMathParser.optBlock
+ .then((optBlock: MathBlock) => {
+ return latexMathParser.block.map((block: MathBlock) => {
+ const ion = new Ion(optBlock.text() === '-' ? '-' : '+');
+ ion.blocks = [block];
+ block.adopt(ion);
+ return ion;
+ });
+ })
+ .or(super.parser());
+ }
+
+ finalizeTree() {
+ this.upInto = this.sup = this.ends.right;
+ if (this.sup) this.sup.downOutOf = insLeftOfMeUnlessAtEnd;
+ super.finalizeTree();
+ }
+}
+
+LatexCmds.ion = Ion;
+
+LatexCmds.positiveion = class extends Ion {
+ constructor() {
+ super('+');
+ }
+};
+
+LatexCmds.negativeion = class extends Ion {
+ constructor() {
+ super('-');
+ }
+};
+
+// Scientific notation: a superscript-only command rendering a fixed `×10`
+// followed by an editable exponent block — `\sci{8}` renders as the "×10⁸"
+// of 3.5×10⁸. Restored for @openwebwork/mathquill; the scientific-notation
+// answer-entry toolbar (chemQuillMath.pl) drives this command. text()
+// serializes the explicit operations (`*10^8`), not E-notation, so the
+// multiplication and power that scientific notation denotes survive into
+// the answer string.
+class ScientificNotation extends SupSub {
+ constructor() {
+ super();
+ this.supsub = 'sup';
+ this.htmlTemplate =
+ '×' +
+ '10' +
+ '' +
+ '&0' +
+ '';
+ }
+
+ latex() {
+ return `\\sci{${this.sup?.latex() || '0'}}`;
+ }
+
+ text() {
+ return `*10^${this.sup?.text() || '0'}`;
+ }
+
+ mathspeak() {
+ const raw = (this.sup ? getCtrlSeqsFromBlock(this.sup) : '') || '0';
+ const intMatch = /^([+-]?)(\d+)$/.exec(raw);
+ if (intMatch) {
+ const sign = intMatch[1] === '-' ? 'negative ' : '';
+ const digits = intMatch[2];
+ let suffix = 'th';
+ if (!/^(?:11|12|13)$/.test(digits.slice(-2))) {
+ const last = digits.slice(-1);
+ if (last === '1') suffix = 'st';
+ else if (last === '2') suffix = 'nd';
+ else if (last === '3') suffix = 'rd';
+ }
+ return `times ten to the ${sign}${digits}${suffix} power`;
+ }
+ // Non-integer exponent — speak the block contents generically.
+ return `times ten to the ${this.sup?.mathspeak().trim() || '0'} power`;
+ }
+
+ parser() {
+ return latexMathParser.block
+ .map((block: MathBlock) => {
+ const sci = new ScientificNotation();
+ sci.blocks = [block];
+ block.adopt(sci);
+ return sci;
+ })
+ .or(super.parser());
+ }
+
+ finalizeTree() {
+ this.upInto = this.sup = this.ends.right;
+ if (this.sup) this.sup.downOutOf = insLeftOfMeUnlessAtEnd;
+ super.finalizeTree();
+ }
+}
+
+LatexCmds.sci = ScientificNotation;
+
class SummationNotation extends UpperLowerLimitCommand {
constructor(ch: string, html: string, ariaLabel?: string) {
super(
diff --git a/src/css/toolbar.scss b/src/css/toolbar.scss
index 3e73c406..ae1625b5 100644
--- a/src/css/toolbar.scss
+++ b/src/css/toolbar.scss
@@ -63,10 +63,24 @@
transform: translateY(0);
}
+ // Schematic icons built from \text{ } placeholders keep the supsub tiny.
+ // But an icon whose super/subscript carries a real glyph (e.g. the ion
+ // charge \text{ }^{+}) must let that glyph show rather than be clipped to
+ // the placeholder size — so size the supsub to its content.
.mq-supsub {
- height: 6px;
- width: 6px;
+ min-width: 6px;
margin-left: 2px;
}
+
+ // Real (non-placeholder) glyphs inside an icon's super/subscript — the
+ // ion +/- charge, etc. Render them small enough to sit in the button but
+ // large enough to read.
+ .mq-sup,
+ .mq-sub {
+ & > .mq-binary-operator,
+ & > .mq-unary-operator {
+ font-size: 0.7em;
+ }
+ }
}
}
diff --git a/test/aria.test.ts b/test/aria.test.ts
index 3ae1e191..e7ba1cc2 100644
--- a/test/aria.test.ts
+++ b/test/aria.test.ts
@@ -304,4 +304,31 @@ suite('aria', function () {
staticMath.latex('2+2');
assert.equal(staticMath.__controller.mathspeakSpan?.textContent, 'Static Label: 2 plus 2');
});
+
+ test('mathspeak for ion and scientific notation commands', function () {
+ const staticSpan = document.createElement('span');
+ document.getElementById('mock')?.append(staticSpan);
+ // Pin supSubsRequireOperand off so the commands' mathspeak is tested in
+ // isolation: with it on, a standalone (operand-less) SupSub-derived
+ // command unwraps to just its block content, which is a separate concern.
+ const staticMath = MQ.StaticMath(staticSpan, { supSubsRequireOperand: false });
+ const speak = (latex: string) => {
+ staticMath.latex(latex);
+ return staticMath.__controller.mathspeakSpan?.textContent;
+ };
+
+ // Ion charges speak as "charge positive|negative" rather than the
+ // inherited SupSub "Superscript, ..., Baseline".
+ assert.equal(speak('\\ion[+]{2}'), 'charge 2 positive');
+ assert.equal(speak('\\ion[-]{3}'), 'charge 3 negative');
+ assert.equal(speak('\\positiveion{1}'), 'charge 1 positive');
+ assert.equal(speak('\\negativeion{2}'), 'charge 2 negative');
+
+ // Scientific notation speaks the full "times ten to the power".
+ assert.equal(speak('\\sci{8}'), 'times ten to the 8th power');
+ assert.equal(speak('\\sci{-3}'), 'times ten to the negative 3rd power');
+ assert.equal(speak('\\sci{23}'), 'times ten to the 23rd power');
+ assert.equal(speak('\\sci{11}'), 'times ten to the 11th power');
+ assert.equal(speak('\\sci{1}'), 'times ten to the 1st power');
+ });
});
diff --git a/test/latex.test.ts b/test/latex.test.ts
index 682ffeb2..af6eb06b 100644
--- a/test/latex.test.ts
+++ b/test/latex.test.ts
@@ -67,6 +67,30 @@ suite('latex', function () {
assertParsesLatex('x ^2', 'x^2');
});
+ test('ion charges', function () {
+ // \ion[sign]{charge} — superscript-only with a fixed sign after the block.
+ assertParsesLatex('\\ion[+]{2}');
+ assertParsesLatex('\\ion[-]{3}');
+ // Missing optional sign defaults to '+'.
+ assertParsesLatex('\\ion{2}', '\\ion[+]{2}');
+ // Empty charge block defaults to 1 (e.g. Na+).
+ assertParsesLatex('\\ion[+]{}', '\\ion[+]{1}');
+ // \positiveion / \negativeion are sign-bound shorthands; both
+ // canonicalize to \ion[sign]{...}.
+ assertParsesLatex('\\positiveion{2}', '\\ion[+]{2}');
+ assertParsesLatex('\\negativeion{3}', '\\ion[-]{3}');
+ });
+
+ test('scientific notation', function () {
+ // \sci{exp} — fixed ×10 followed by a superscript-only exponent block.
+ assertParsesLatex('\\sci{8}');
+ assertParsesLatex('\\sci{-3}');
+ // Multi-character exponent block.
+ assertParsesLatex('\\sci{23}');
+ // Empty exponent block defaults to 0.
+ assertParsesLatex('\\sci{}', '\\sci{0}');
+ });
+
test('inner groups', function () {
assertParsesLatex('a{bc}d', 'abcd');
assertParsesLatex('{bc}d', 'bcd');