fix(tui): improve LaTeX compatibility and layouts

closes #8827
This commit is contained in:
Armin Ronacher 2026-09-19 23:28:04 +02:00
parent 40c256cccb
commit fa0e1f48ac
4 changed files with 169 additions and 32 deletions

View file

@ -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

View file

@ -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<Record<string,
return result;
}
function normalizeScriptValue(value: string): string {
return value.trim().replace(/\s*([=+-])\s*/g, "$1");
}
function formatUnicodeScript(value: string, kind: "sub" | "sup"): string | undefined {
return replaceCharacters(normalizeScriptValue(value), kind === "sub" ? SUBSCRIPTS : SUPERSCRIPTS);
}
function formatScript(value: string, kind: "sub" | "sup"): string {
value = value.trim();
const replacements = kind === "sub" ? SUBSCRIPTS : SUPERSCRIPTS;
const unicode = replaceCharacters(value.replace(/\s*([=+-])\s*/g, "$1"), replacements);
value = normalizeScriptValue(value);
const unicode = formatUnicodeScript(value, kind);
if (unicode !== undefined) {
return unicode;
}
@ -669,13 +677,19 @@ interface OperatorNode {
upper?: string;
}
interface ScriptNode {
type: "script";
lower?: string;
upper?: string;
}
interface MatrixNode {
type: "matrix";
lines: string[];
baseline: number;
}
type LayoutNode = FractionNode | OperatorNode | MatrixNode;
type LayoutNode = FractionNode | OperatorNode | ScriptNode | MatrixNode;
interface Layout {
lines: string[];
@ -778,6 +792,19 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout {
width: contentWidth + 1,
baseline: node.upper === undefined ? 0 : 1,
});
} else if (node.type === "script") {
const upper = node.upper === undefined ? undefined : renderLayout(node.upper, nodes);
const lower = node.lower === undefined ? undefined : renderLayout(node.lower, nodes);
const width = Math.max(upper?.width ?? 0, lower?.width ?? 0);
layouts.push({
lines: [
...(upper?.lines.map((line) => 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<string | undefined> = [...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()))

View file

@ -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<x<L,\\
0, & \text{otherwise}.
\end{cases}`,
"Ψ(x,t) = ∑ₙ₌₁^∞ cₙ √(2/L) sin((nπ x)/L)_(spatial eigenmode) exp(-(iℏ n²π²)/(2mL²)t), |Ψ(x,t)|² = ⎧ Ψ^∗Ψ if 0 < x < L,\n⎩ 0 otherwise.",
`${" ".repeat(97)}⎧ Ψ^∗Ψ if 0 < x < L,\nΨ(x,t) = ∑ₙ₌₁^∞ cₙ √(2/L) sin((nπ x)/L)_(spatial eigenmode) exp(-(iℏ n²π²)/(2mL²)t), |Ψ(x,t)|² = ⎨\n${" ".repeat(97)}⎩ 0 otherwise.`,
],
[String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, "x = (-b±√(b²-4ac))/(2a)"],
[String.raw`\int_0^\infty e^{-x^2}\,dx=\frac{\sqrt{\pi}}{2}`, "∫₀^∞ e^(-x²) dx = (√π)/2"],
@ -346,7 +346,12 @@ c_n
renderLatex(String.raw`\acute{x}+\grave{y}+\widehat{xyz}+\overrightarrow{AB}`),
"x́+ỳ+widehat(xyz)+overrightarrow(AB)",
);
assert.strictEqual(renderLatex(String.raw`\textnormal{hello}+\mbox{world}+\boldsymbol{x}`), "hello+world+x");
assert.strictEqual(
renderLatex(
String.raw`\textnormal{hello}+\mbox{world}+\boldsymbol{x}+{\rm roman}+{\bf bold}+{\it italic}+{\sf sans}+{\tt mono}+{\cal calligraphic}+{\sl slanted}`,
),
"hello+world+x+roman+bold+italic+sans+mono+calligraphic+slanted",
);
});
it("renders additional display environments", () => {
@ -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", () => {

View file

@ -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}\)`,
],
);
});