diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 243cbca46..66db754c9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- Fixed LaTeX legacy font switches falling back to raw source, centered `cases` layouts around surrounding equations, and vertically laid out unsupported and nested display scripts ([#8827](https://github.com/earendil-works/pi/issues/8827), [#9564](https://github.com/earendil-works/pi/issues/9564), [#7929](https://github.com/earendil-works/pi/issues/7929)). - Fixed fullscreen clipboard failures hiding actionable backend error messages behind a generic notice, and extended failure notices to five seconds ([#9618](https://github.com/earendil-works/pi/issues/9618)). ## [0.85.1] - 2026-09-05 diff --git a/packages/tui/src/latex.ts b/packages/tui/src/latex.ts index b9c0f9e8c..060fd052d 100644 --- a/packages/tui/src/latex.ts +++ b/packages/tui/src/latex.ts @@ -523,6 +523,7 @@ const SPACING_COMMANDS = new Set([ ]); const NEGATIVE_SPACING_COMMANDS = new Set(["!", "negmedspace", "negthickspace", "negthinspace"]); const NEGATIVE_SPACE = "\u0000"; +const FONT_SWITCH_COMMANDS = new Set(["bf", "cal", "it", "rm", "sf", "sl", "tt"]); const IGNORED_COMMANDS = new Set([ "displaystyle", "limits", @@ -610,10 +611,17 @@ function replaceCharacters(value: string, replacements: Readonly padLayoutLine(line, width)) ?? []), + " ".repeat(width), + ...(lower?.lines.map((line) => padLayoutLine(line, width)) ?? []), + ], + width, + baseline: upper?.lines.length ?? 0, + }); } else { const width = Math.max(0, ...node.lines.map((line) => visibleWidth(line))); layouts.push({ @@ -815,6 +842,7 @@ class LatexParser { private position = 0; private supported = true; private stackFractions = true; + private scriptDepth = 0; constructor(source: string, layoutNodes: LayoutNode[], display: boolean) { this.source = source; @@ -866,7 +894,7 @@ class LatexParser { if (character === "^" || character === "_") { this.position++; result = result.trimEnd(); - const script = formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + const script = this.parseScripts(character); if (result.endsWith(NAMED_OPERATOR_END)) { result = `${result.slice(0, -NAMED_OPERATOR_END.length)}${script}${NAMED_OPERATOR_END}`; } else { @@ -918,6 +946,64 @@ class LatexParser { return result; } + private parseScripts(initialMarker: "^" | "_"): string { + const scripts: { sub?: string; sup?: string } = {}; + const order: Array<"sub" | "sup"> = []; + const parse = (marker: "^" | "_"): void => { + const kind = marker === "_" ? "sub" : "sup"; + this.scriptDepth++; + try { + scripts[kind] = this.parseRequiredArgument(false); + } finally { + this.scriptDepth--; + } + order.push(kind); + }; + + parse(initialMarker); + let nextPosition = this.position; + while (nextPosition < this.source.length && /\s/.test(this.source[nextPosition] ?? "")) { + nextPosition++; + } + const nextMarker = this.source[nextPosition]; + if ((nextMarker === "^" || nextMarker === "_") && nextMarker !== initialMarker) { + this.position = nextPosition + 1; + parse(nextMarker); + } + + const subUnicode = scripts.sub === undefined ? undefined : formatUnicodeScript(scripts.sub, "sub"); + const supUnicode = scripts.sup === undefined ? undefined : formatUnicodeScript(scripts.sup, "sup"); + const canUseLayout = ![scripts.sub, scripts.sup].some( + (value) => + value !== undefined && + (value.includes("/") || + (!value.includes(LAYOUT_MARKER_START) && Array.from(value).length > 1 && !/[A-Z*∗]/.test(value))), + ); + const needsLayout = + this.display && + canUseLayout && + (this.scriptDepth > 0 || + (scripts.sub !== undefined && subUnicode === undefined) || + (scripts.sup !== undefined && supUnicode === undefined)); + if (!needsLayout) { + return order + .map((kind) => + kind === "sub" + ? (subUnicode ?? formatScript(scripts.sub ?? "", kind)) + : (supUnicode ?? formatScript(scripts.sup ?? "", kind)), + ) + .join(""); + } + + const index = + this.layoutNodes.push({ + type: "script", + lower: scripts.sub === undefined ? undefined : normalizeOutput(scripts.sub), + upper: scripts.sup === undefined ? undefined : normalizeOutput(scripts.sup), + }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + private parseWhitespace(): string { while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { this.position++; @@ -961,6 +1047,12 @@ class LatexParser { if (NEGATIVE_SPACING_COMMANDS.has(command)) { return NEGATIVE_SPACE; } + if (FONT_SWITCH_COMMANDS.has(command)) { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { + this.position++; + } + return ""; + } if (IGNORED_COMMANDS.has(command)) { return ""; } @@ -1284,18 +1376,7 @@ class LatexParser { } if (environment === "cases" || environment === "cases*") { - const rows = this.splitEnvironmentRows(body) - .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) - .filter((row) => row.some(Boolean)); - return rows - .map((row, index) => { - const value = (row[0] ?? "").replace(/,\s*$/, ""); - const condition = row[1] ?? ""; - const delimiter = index === 0 ? "⎧" : index === rows.length - 1 ? "⎩" : "⎨"; - const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if "; - return `${delimiter} ${value}${condition ? `${conditionPrefix}${condition}` : ""}`; - }) - .join("\n"); + return this.renderCases(body); } if ( @@ -1309,6 +1390,37 @@ class LatexParser { return body; } + private renderCases(body: string): string { + const rows = this.splitEnvironmentRows(body) + .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) + .filter((row) => row.some(Boolean)); + const valueWidth = Math.max(0, ...rows.map((row) => visibleWidth((row[0] ?? "").replace(/,\s*$/, "")))); + const contents = rows.map((row) => { + const value = (row[0] ?? "").replace(/,\s*$/, ""); + const condition = row[1] ?? ""; + if (!condition) { + return value; + } + const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if "; + return `${value}${PROTECTED_SPACE.repeat(valueWidth - visibleWidth(value))}${conditionPrefix}${condition}`; + }); + if (contents.length <= 1) { + return contents.length === 0 ? "" : `⎧ ${contents[0]}`; + } + + const middle = Math.floor(contents.length / 2); + const visualRows: Array = [...contents]; + if (contents.length % 2 === 0) { + visualRows.splice(middle, 0, undefined); + } + const lines = visualRows.map((content, index) => { + const delimiter = index === 0 ? "⎧" : index === visualRows.length - 1 ? "⎩" : "⎨"; + return content === undefined ? delimiter : `${delimiter} ${content}`; + }); + const index = this.layoutNodes.push({ type: "matrix", lines, baseline: middle }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + private renderMatrix(environment: string, body: string): string { const matrix = this.splitEnvironmentRows(body) .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts index 6df4260cf..95a90a5cc 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/tui/test/latex.test.ts @@ -53,7 +53,7 @@ describe("renderLatex", () => { [String.raw`\ge 2`, "≥ 2"], [String.raw`\ge 3`, "≥ 3"], ["1", "1"], - [String.raw`\mathrm{diag}(-1/2,1,1)`, "diag(-1/2,1,1)"], + [String.raw`\mathrm{diag}(-1/2,1,1),\quad F_{\rm intrinsic}(\lambda)`, "diag(-1/2,1,1), F_intrinsic(λ)"], ["4+3xy", "4+3xy"], ]); }); @@ -230,7 +230,7 @@ c_n \Psi^\ast\Psi, & 0 { @@ -362,8 +367,8 @@ c_n it("uses natural case conditions and aligns matrix columns", () => { assert.strictEqual( - renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`), - "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c otherwise", + renderLatex(String.raw`f(x)=\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`), + " ⎧ a if x < 0\nf(x) = ⎨ b if x = 0\n ⎩ c otherwise", ); assert.strictEqual( renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`), @@ -449,10 +454,10 @@ R\left(\frac{\pi}{4}\right) assert.strictEqual(renderLatex(String.raw`\int\limits_0^1 f(x)\,dx`, { display: true }), "1\n∫ f(x) dx\n0"); }); - it("uses the middle brace for intermediate case rows", () => { + it("centers even case rows around a middle brace", () => { assert.strictEqual( - renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`), - "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0", + renderLatex(String.raw`f(x) = \begin{cases} x^{2} & x \geq 0 \\ -x & x < 0 \end{cases}`), + " ⎧ x² if x ≥ 0\nf(x) = ⎨\n ⎩ -x if x < 0", ); }); @@ -487,9 +492,14 @@ R\left(\frac{\pi}{4}\right) } }); - it("keeps fractions linear in scripts and text-style fractions", () => { - assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}`, { display: true }), "e^(1/2)"); - assert.strictEqual(renderLatex(String.raw`\tfrac{1}{2}`, { display: true }), "1/2"); + it("lays out unsupported and nested scripts while keeping script fractions linear", () => { + assert.strictEqual( + renderLatex(String.raw`\partial_tU_2(t,0)=Aj_*(1-t)^{-A-1}.\qquad x^{n^2}+x_{i_j}`, { + display: true, + }), + " 2\n -A-1 n\n∂ₜU₂(t,0) = Aj (1-t) . x +x\n * i\n j", + ); + assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}+\tfrac{1}{2}`, { display: true }), "e^(1/2)+1/2"); }); it("returns undefined for unsupported commands", () => { diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 6347aa5d7..785c312b5 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -785,7 +785,7 @@ describe("Markdown component", () => { describe("LaTeX math", () => { it("renders inline dollar and parenthesis delimiters", () => { const markdown = new Markdown( - String.raw`A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, and \(s \to \infty\).`, + String.raw`A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, $\rightarrow$, and \(s \to \infty\).`, 0, 0, defaultMarkdownTheme, @@ -793,7 +793,7 @@ describe("Markdown component", () => { const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); - assert.deepStrictEqual(lines, ["A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, and s → ∞."]); + assert.deepStrictEqual(lines, ["A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, →, and s → ∞."]); }); it("renders display dollar delimiters without Markdown escape corruption", () => { @@ -936,7 +936,13 @@ A= it("allows LaTeX rendering to be disabled", () => { const markdown = new Markdown( - String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`, + String.raw`$$ +\widetilde Y_{sf} += +(1-w_{sf})\mu_{sf}^{\mathrm{MAR}} +$$ + +Inline \(A_{sf}\)`, 0, 0, defaultMarkdownTheme, @@ -948,7 +954,15 @@ A= assert.deepStrictEqual( markdown.render(80).map((line) => stripAnsi(line).trimEnd()), - [String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`], + [ + "$$", + String.raw`\widetilde Y_{sf}`, + "=", + String.raw`(1-w_{sf})\mu_{sf}^{\mathrm{MAR}}`, + "$$", + "", + String.raw`Inline \(A_{sf}\)`, + ], ); });