From d9582018135ad3e53ec740bddb1b0a9c6bebc6bb Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 7 Oct 2026 15:36:27 +0800 Subject: [PATCH] fix: bracket a fraction only where the slash would split it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `\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. --- README.md | 2 + src/__tests__/latex.test.ts | 29 ++++++++++++++- src/lib/latex.ts | 74 +++++++++++++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index da70145..12f801e 100644 --- a/README.md +++ b/README.md @@ -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 `xa/b`. `$...$`, `\(...\)`, `$$...$$` 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 diff --git a/src/__tests__/latex.test.ts b/src/__tests__/latex.test.ts index 3b6802a..71a49c4 100644 --- a/src/__tests__/latex.test.ts +++ b/src/__tests__/latex.test.ts @@ -24,10 +24,35 @@ describe("renderLatex", () => { expect(renderLatex("\\int_{0}^{\\infty}")).toBe("∫₀∞"); }); - 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("xa/b/y"); + expect(renderLatex("\\frac{x^{a+b}}{y}")).toBe("xᵃ⁺ᵇ/y"); }); it("renders a root, using the root sign for the degree it has", () => { diff --git a/src/lib/latex.ts b/src/lib/latex.ts index 0c7d14e..79bbdc4 100644 --- a/src/lib/latex.ts +++ b/src/lib/latex.ts @@ -611,18 +611,86 @@ function subscript(rendered: string): string { return mapScript(rendered, SUBSCRIPTS) ?? `${rendered}`; } +/** + * 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. */ -- 2.54.0