From e3e934649854f22f473d42ea2ee24363be4438c6 Mon Sep 17 00:00:00 2001 From: "K. Andrew Parker" Date: Thu, 21 May 2026 20:04:44 -0400 Subject: [PATCH] Add \ion, \positiveion, \negativeion commands for chemistry notation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ion charge notation — `\ion[+]{2}` rendering the "2+" of a 2+ cation — is a superscript-only command: an editable charge block followed by a fixed sign. `\positiveion` and `\negativeion` are sign-bound shorthands. These commands existed in a downstream MathQuill fork used for WeBWorK chemistry problems (the chemQuill answer-entry toolbars); this restores them on @openwebwork/mathquill so that work can drop the private fork. Ion extends SupSub, mirroring the existing subscript/superscript classes. latex() serializes to \ion[sign]{charge}; \positiveion / \negativeion canonicalize to the same. Round-trip tests added to test/latex.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/math/commands.ts | 62 +++++++++++++++++++++++++++++++++++ test/latex.test.ts | 14 ++++++++ 2 files changed, 76 insertions(+) diff --git a/src/commands/math/commands.ts b/src/commands/math/commands.ts index 72fbf038..589e6e67 100644 --- a/src/commands/math/commands.ts +++ b/src/commands/math/commands.ts @@ -248,6 +248,68 @@ 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'})`; + } + + 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('-'); + } +}; + class SummationNotation extends UpperLowerLimitCommand { constructor(ch: string, html: string, ariaLabel?: string) { super( diff --git a/test/latex.test.ts b/test/latex.test.ts index 682ffeb2..6d14005c 100644 --- a/test/latex.test.ts +++ b/test/latex.test.ts @@ -67,6 +67,20 @@ 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('inner groups', function () { assertParsesLatex('a{bc}d', 'abcd'); assertParsesLatex('{bc}d', 'bcd');