mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
parent
40c256cccb
commit
fa0e1f48ac
4 changed files with 169 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}\)`,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue