fix: bracket a fraction only where the slash would split it

`\frac{n(n+1)}{2}` came out as `(n(n+1))/2` and the quadratic formula
as a wall of parentheses, because the test was "is this operand a
single token" rather than "would the slash change what this means".

Brackets now appear in exactly two cases: a sum, where the slash would
otherwise divide only the last term, and a denominator that is more
than one term, where `1/2a` is read two ways. `n(n+1)/2`, `√π/2` and
`mc²/√(1 − v²/c²)` need nothing, while `1 / (2a)` and
`(−b ± √(b² − 4ac)) / (2a)` keep their brackets. A script is read
through rather than counted, since a sum above the line is unambiguous
by construction.

The slash is spaced only when something beside it is bracketed, which
is where the brackets would otherwise crowd it.
This commit is contained in:
2026-10-07 15:36:27 +08:00
parent a804afd56a
commit d958201813
3 changed files with 100 additions and 5 deletions
+2
View File
@@ -275,6 +275,8 @@ Two facts about the renderer decided the design. A paragraph is a `TextEdit` wit
So this is a linearisation rather than a typesetter. `\frac{a}{b}` becomes `a/b`, `\sqrt{x+1}` becomes `√(x+1)`, `\sum_{i=1}^{n}` becomes `∑ᵢ₌₁ⁿ`, and only what Unicode has no form for falls back to a tag, so `x^{a/b}` becomes `x<sup>a/b</sup>`. `$...$`, `\(...\)`, `$$...$$` or `\[...\]` on a line of its own, and a fence labelled `math`, are all read.
Brackets appear only where the slash would otherwise change what the formula means. `\frac{n(n+1)}{2}` is `n(n+1)/2` and `\frac{\sqrt{\pi}}{2}` is `√π/2`, because one product either side of a slash needs nothing, while `\frac{1}{2a}` is `1 / (2a)` because `1/2a` is read two ways, and a sum is always bracketed so the slash cannot divide only its last term: `(−b ± √(b² − 4ac)) / (2a)`. The space around the slash shows up only when something beside it is bracketed, which is where the brackets would otherwise crowd it.
The one guard is worth knowing about. A lone `$` pair counts as maths only when the inside looks like maths, because `x=$a$b` and `cd $A; echo $B` are not formulas, and turning them into one is the single way this feature can make an answer worse than the dollar signs it replaced. `$5 and $10` is left exactly as written.
### Custom HTML
+27 -2
View File
@@ -24,10 +24,35 @@ describe("renderLatex", () => {
expect(renderLatex("\\int_{0}^{\\infty}")).toBe("∫₀<sup>∞</sup>");
});
it("linearises a fraction and brackets only what needs it", () => {
it("linearises a fraction and brackets only what the slash would split", () => {
expect(renderLatex("\\frac{1}{2}")).toBe("1/2");
expect(renderLatex("\\frac{a}{b}")).toBe("a/b");
expect(renderLatex("\\frac{a+b}{c}")).toBe("(a+b)/c");
expect(renderLatex("\\frac{a+b}{c}")).toBe("(a+b) / c");
});
it("leaves a numerator that is one product unbracketed", () => {
// `n(n+1)/2` is unambiguous. Asking whether the operand was a single token
// instead produced `(n(n+1))/2`, which is a wall of parentheses.
expect(renderLatex("\\frac{n(n+1)}{2}")).toBe("n(n+1)/2");
expect(renderLatex("\\frac{\\sqrt{\\pi}}{2}")).toBe("√π/2");
expect(renderLatex("E = \\frac{mc^2}{\\sqrt{1 - v^2/c^2}}")).toBe("E = mc²/√(1 − v²/c²)");
});
it("brackets a denominator of more than one term, because 1/2a is read two ways", () => {
expect(renderLatex("\\frac{1}{2a}")).toBe("1 / (2a)");
expect(renderLatex("\\frac{\\pi}{2}")).toBe("π/2");
});
it("brackets a sum so the slash cannot divide only its last term", () => {
expect(renderLatex("\\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}")).toBe("(−b ± √(b² − 4ac)) / (2a)");
expect(renderLatex("\\frac{\\sin x}{x}")).toBe("(sin x) / x");
});
it("reads through a script rather than counting it as a split", () => {
// The sum is above the line by construction, so it needs no brackets. A
// script with no Unicode form is what puts a tag in the way of the scan.
expect(renderLatex("\\frac{x^{a/b}}{y}")).toBe("x<sup>a/b</sup>/y");
expect(renderLatex("\\frac{x^{a+b}}{y}")).toBe("xᵃ⁺ᵇ/y");
});
it("renders a root, using the root sign for the degree it has", () => {
+71 -3
View File
@@ -611,18 +611,86 @@ function subscript(rendered: string): string {
return mapScript(rendered, SUBSCRIPTS) ?? `<sub>${rendered}</sub>`;
}
/**
* Characters that split a fraction when a formula is read left to right.
*/
const OPERAND_SPLIT = /[+−±∓]/;
/** Prefixes that apply to what follows them, so they are not an operand of their own. */
const PREFIX_OPERATORS = new Set(["√", "∛", "∜"]);
/**
* Whether a rendered operand needs brackets beside the fraction slash.
*
* The rendered text is what a reader sees, so that is what this looks at. Two
* cases need brackets and nothing else does: a sum, because the slash would
* otherwise divide only its last term, and a denominator that is more than one
* thing, because `1/2a` is read as `1/(2a)` by some readers and `(1/2)a` by
* others.
*
* Asking whether an operand is a single token instead, which is what this used
* to do, brackets almost everything: `n(n+1)/2` came out as `(n(n+1))/2` and the
* quadratic formula as a wall of parentheses.
*/
function needsFractionBrackets(rendered: string, side: "numerator" | "denominator"): boolean {
// A script is skipped rather than read, because `x^{a+b}/y` is already
// unambiguous: the sum is above the line by construction.
const plain = rendered
.replace(/<(?:sup|sub)>[\s\S]*?<\/(?:sup|sub)>/g, "")
.replace(/<[^>]*>/g, "")
.trim();
let depth = 0;
let operands = 0;
for (let index = 0; index < plain.length; index += 1) {
const char = plain[index];
if (char === "(" || char === "[" || char === "{") {
depth += 1;
continue;
}
if (char === ")" || char === "]" || char === "}") {
depth = Math.max(0, depth - 1);
continue;
}
if (depth > 0) continue;
// A space at the top level is function application or a product, and either
// one is clearer bracketed: `(sin x) / x` rather than `sin x/x`.
if (char === " ") return true;
if (OPERAND_SPLIT.test(char)) {
// A leading sign belongs to the term rather than splitting the fraction.
if (index !== 0) return true;
continue;
}
// `√π` is one thing; `2π` is two multiplied together.
if (PREFIX_OPERATORS.has(char)) continue;
operands += 1;
}
return side === "denominator" && operands > 1;
}
/**
* `\frac{a}{b}` as a slash, with brackets only where they change the meaning.
*
* A stacked fraction is the thing this cannot do, and `1/2` is what a reader
* reconstructs from it anyway. `(a+b)/2` needs the brackets; `\alpha/2` does not.
* reconstructs from it anyway. The space around the slash appears only when
* something beside it is bracketed, where the brackets would otherwise crowd it.
*/
function renderFraction(numerator: string, denominator: string, display: boolean): string {
const top = convert(numerator, display);
const bottom = convert(denominator, display);
const bracket = (rendered: string, body: string) => (isAtomic(body) ? rendered : `(${rendered})`);
return `${bracket(top, numerator)}/${bracket(bottom, denominator)}`;
const bracketedTop = needsFractionBrackets(top, "numerator");
const bracketedBottom = needsFractionBrackets(bottom, "denominator");
const slash = bracketedTop || bracketedBottom ? " / " : "/";
return `${bracketedTop ? `(${top})` : top}${slash}${bracketedBottom ? `(${bottom})` : bottom}`;
}
/** `\sqrt{x}` and `\sqrt[3]{x}`, where only a cube and a fourth root have a glyph. */