diff --git a/src/api.md b/src/api.md index fd4972613..02c94801e 100644 --- a/src/api.md +++ b/src/api.md @@ -803,6 +803,28 @@ manually. + + +##### MathfieldElement.compactSerialization + +```ts +get static compactSerialization(): boolean +set static compactSerialization(value: boolean): void +``` + +When `true` (the default), single-digit arguments are serialized in a +compact form, without braces: `\frac12`, `\sqrt2`, `x^2`. + +When `false`, arguments are always serialized with explicit braces: +`\frac{1}{2}`, `\sqrt{2}`, `x^{2}`. + +The compact form is valid LaTeX, but can be hard to read when a digit +follows: `\frac123` is one half followed by 3, not 1/23. + +**Default**: `true` + + + #### Styles diff --git a/src/atoms/surd.ts b/src/atoms/surd.ts index b9e852924..b3e5419f5 100644 --- a/src/atoms/surd.ts +++ b/src/atoms/surd.ts @@ -8,6 +8,7 @@ import { Context } from '../core/context'; import { makeCustomSizedDelim } from '../core/delimiters'; import { latexCommand } from '../core/tokenizer'; +import { _MathEnvironment } from '../core/math-environment'; import { getDefinition } from '../latex-commands/definitions-utils'; import type { CreateAtomOptions, AtomJson, ToLatexOptions } from 'core/types'; @@ -55,7 +56,11 @@ export class SurdAtom extends Atom { if (this.above && !this.hasEmptyBranch('above')) return latexCommand(`${command}[${this.aboveToLatex(options)}]`, body); - if (/^[0-9]$/.test(body)) return `${command}${body}`; + // Special case serialization when the radicand is a single digit: + // serialize `\sqrt{2}` as `\sqrt2`, unless compact serialization has + // been turned off (some people find the compact form confusing). + if (_MathEnvironment.compactSerialization && /^[0-9]$/.test(body)) + return `${command}${body}`; return latexCommand(command, body); } diff --git a/src/core/atom-class.ts b/src/core/atom-class.ts index aa68b4be9..cb59acc84 100644 --- a/src/core/atom-class.ts +++ b/src/core/atom-class.ts @@ -5,6 +5,7 @@ import { boxType, Box } from './box'; import { makeLimitsStack, VBox } from './v-box'; import { joinLatex, latexCommand } from './tokenizer'; import { Mode } from './modes-utils'; +import { _MathEnvironment } from './math-environment'; import { getDefinition } from '../latex-commands/definitions-utils'; import { Context } from './context'; @@ -391,7 +392,8 @@ export class Atom { else if (sub.length === 1) { // Using the short form without braces is a stylistic choice // In general, LaTeX recommends the use of braces - if (/^[0-9]$/.test(sub)) result += `_${sub}`; + if (_MathEnvironment.compactSerialization && /^[0-9]$/.test(sub)) + result += `_${sub}`; else result += `_{${sub}}`; } else result += `_{${sub}}`; } @@ -404,7 +406,8 @@ export class Atom { else if (sup === '\u2033') result += '^\\doubleprime '; // Using the short form without braces is a stylistic choice // In general, LaTeX recommends the use of braces - else if (/^[0-9]$/.test(sup)) result += `^${sup}`; + else if (_MathEnvironment.compactSerialization && /^[0-9]$/.test(sup)) + result += `^${sup}`; else result += `^{${sup}}`; } else result += `^{${sup}}`; } diff --git a/src/core/math-environment.ts b/src/core/math-environment.ts index 35197a5b6..dc049faf9 100644 --- a/src/core/math-environment.ts +++ b/src/core/math-environment.ts @@ -3,6 +3,8 @@ */ export const _MathEnvironment: { fractionNavigationOrder: 'denominator-numerator' | 'numerator-denominator'; + compactSerialization: boolean; } = { fractionNavigationOrder: 'numerator-denominator', + compactSerialization: true, }; diff --git a/src/latex-commands/functions.ts b/src/latex-commands/functions.ts index 3b6ff7d0b..746997102 100644 --- a/src/latex-commands/functions.ts +++ b/src/latex-commands/functions.ts @@ -1,4 +1,5 @@ import { joinLatex, latexCommand } from '../core/tokenizer'; +import { _MathEnvironment } from '../core/math-environment'; import { Atom } from '../core/atom-class'; import { ExtensibleSymbolAtom } from '../atoms/extensible-symbol'; @@ -199,11 +200,15 @@ defineFunction( serialize: (atom, options) => { const numer = atom.aboveToLatex(options); const denom = atom.belowToLatex(options); - // Special case serialization when numer and denom are digits - if (/^[0-9]$/.test(numer) && /^[0-9]$/.test(denom)) - // We used to serialize as `\frac{3}{4}` as \frac34, but - // some people got confused by this, so we now serialize it as `\frac{3}{4}`. - // See a discussion on this topic here: https://tex.stackexchange.com/questions/82329/how-bad-for-tex-is-omitting-braces-even-if-the-result-is-the-same + // Special case serialization when numer and denom are digits: + // serialize `\frac{3}{4}` as `\frac34`, unless compact serialization + // has been turned off (some people find the compact form confusing). + // See a discussion on this topic here: https://tex.stackexchange.com/questions/82329/how-bad-for-tex-is-omitting-braces-even-if-the-result-is-the-same + if ( + _MathEnvironment.compactSerialization && + /^[0-9]$/.test(numer) && + /^[0-9]$/.test(denom) + ) return `${atom.command}${numer}${denom}`; return latexCommand(atom.command, numer, denom); diff --git a/src/public/mathfield-element.ts b/src/public/mathfield-element.ts index cc6c78ef7..2f1aa2572 100644 --- a/src/public/mathfield-element.ts +++ b/src/public/mathfield-element.ts @@ -1082,6 +1082,26 @@ export class MathfieldElement extends HTMLElement implements Mathfield { reparseAllMathfields(); } + /** + * When `true` (the default), single-digit arguments are serialized in a + * compact form, without braces: `\frac12`, `\sqrt2`, `x^2`. + * + * When `false`, arguments are always serialized with explicit braces: + * `\frac{1}{2}`, `\sqrt{2}`, `x^{2}`. + * + * The compact form is valid LaTeX, but can be hard to read when a digit + * follows: `\frac123` is one half followed by 3, not 1/23. + * + * **Default**: `true` + * @category Customization + */ + static get compactSerialization(): boolean { + return _MathEnvironment.compactSerialization; + } + static set compactSerialization(value: boolean) { + _MathEnvironment.compactSerialization = Boolean(value); + } + /** * A custom compute engine instance. If none is provided, a default one is * used. If `null` is specified, no compute engine is used. diff --git a/test/compact-serialization.test.ts b/test/compact-serialization.test.ts new file mode 100644 index 000000000..9fd884dd5 --- /dev/null +++ b/test/compact-serialization.test.ts @@ -0,0 +1,63 @@ +import '../src/public/mathlive-ssr'; +import { parseLatex } from '../src/core/parser'; +import { Atom } from '../src/core/atom-class'; +import { _MathEnvironment } from '../src/core/math-environment'; + +function roundtrip(latex: string): string { + const atoms = parseLatex(latex, { parseMode: 'math' }); + // Discard verbatim LaTeX so the serializer logic is exercised + for (const atom of atoms) atom.verbatimLatex = undefined; + return Atom.serialize(atoms, { defaultMode: 'math' }); +} + +describe('compact serialization', () => { + afterEach(() => { + _MathEnvironment.compactSerialization = true; + }); + + test('compact (default)', () => { + expect(roundtrip('\\frac{1}{2}')).toBe('\\frac12'); + expect(roundtrip('\\binom{1}{2}')).toBe('\\binom12'); + expect(roundtrip('\\sqrt{2}')).toBe('\\sqrt2'); + expect(roundtrip('x^{2}')).toBe('x^2'); + expect(roundtrip('x_{1}')).toBe('x_1'); + }); + + test('explicit braces when compactSerialization is false', () => { + _MathEnvironment.compactSerialization = false; + expect(roundtrip('\\frac{1}{2}')).toBe('\\frac{1}{2}'); + expect(roundtrip('\\frac{12}{2}')).toBe('\\frac{12}{2}'); + expect(roundtrip('\\binom{1}{2}')).toBe('\\binom{1}{2}'); + expect(roundtrip('\\sqrt{2}')).toBe('\\sqrt{2}'); + expect(roundtrip('\\sqrt{12}')).toBe('\\sqrt{12}'); + expect(roundtrip('\\sqrt[3]{8}')).toBe('\\sqrt[3]{8}'); + expect(roundtrip('x^{2}')).toBe('x^{2}'); + expect(roundtrip('x_{1}')).toBe('x_{1}'); + expect(roundtrip('x^{12}')).toBe('x^{12}'); + }); + + test('non-digit superscripts are unaffected', () => { + expect(roundtrip("x'")).toBe('x^{\\prime}'); + expect(roundtrip('x^{n}')).toBe('x^{n}'); + _MathEnvironment.compactSerialization = false; + expect(roundtrip("x'")).toBe('x^{\\prime}'); + expect(roundtrip('x^{n}')).toBe('x^{n}'); + }); + + test('digits are not glued to a following digit', () => { + // These round-trip correctly either way, but the compact form is + // hard to read: `\frac123` is a half followed by 3, not 1/23 + _MathEnvironment.compactSerialization = false; + expect(roundtrip('\\frac{1}{2}3')).toBe('\\frac{1}{2}3'); + expect(roundtrip('x^{2}3')).toBe('x^{2}3'); + expect(roundtrip('x_{1}2')).toBe('x_{1}2'); + }); + + test('`\\sqrt12` is the root of 1, followed by 2', () => { + // `\sqrt` takes a single mandatory argument, so `\sqrt12` is + // `\sqrt{1}2`, not `\sqrt{12}` + expect(roundtrip('\\sqrt12')).toBe('\\sqrt12'); + _MathEnvironment.compactSerialization = false; + expect(roundtrip('\\sqrt12')).toBe('\\sqrt{1}2'); + }); +});