From d58689b79b062aee29b1fc7c1bd6a9819490a837 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 13:14:23 -0400 Subject: [PATCH 1/9] feat(tui): render LaTeX math in the terminal --- bun.lock | 33 ++ packages/cli/script/build.ts | 10 +- packages/cli/script/math-graphics.ts | 47 ++ packages/cli/test/math-graphics.test.ts | 51 +++ packages/tui/package.json | 1 + .../tui/src/feature-plugins/system/latex.ts | 246 +++++++++++ .../system/storybook/index.tsx | 3 +- .../system/storybook/latex.tsx | 148 +++++++ packages/tui/src/plugin/builtins.ts | 2 + packages/tui/test/latex.test.ts | 407 ++++++++++++++++++ 10 files changed, 946 insertions(+), 2 deletions(-) create mode 100644 packages/cli/script/math-graphics.ts create mode 100644 packages/cli/test/math-graphics.test.ts create mode 100644 packages/tui/src/feature-plugins/system/latex.ts create mode 100644 packages/tui/src/feature-plugins/system/storybook/latex.tsx create mode 100644 packages/tui/test/latex.test.ts diff --git a/bun.lock b/bun.lock index 44d1ed98ab9e..07429c6717f6 100644 --- a/bun.lock +++ b/bun.lock @@ -892,6 +892,7 @@ "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", + "opentui-math": "0.1.0", "opentui-spinner": "catalog:", "remeda": "catalog:", "solid-js": "catalog:", @@ -1994,6 +1995,8 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@mathjax/mathjax-newcm-font": ["@mathjax/mathjax-newcm-font@4.1.3", "", {}, "sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], @@ -2628,6 +2631,32 @@ "@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="], + "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], @@ -4582,6 +4611,8 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mathjax": ["mathjax@4.1.3", "", { "dependencies": { "@mathjax/mathjax-newcm-font": "^4.1.3" } }, "sha512-BN/8Pkgn7G1pIDYJqd9md+JHsE/jydSYbyOZnSdSA0WziuVO8mRxdYiWFumkVVly/8U+hm9DpIIoWuvySverzw=="], + "md-to-react-email": ["md-to-react-email@5.0.0", "", { "dependencies": { "marked": "7.0.4" }, "peerDependencies": { "react": "18.x" } }, "sha512-GdBrBUbAAJHypnuyofYGfVos8oUslxHx69hs3CW9P0L8mS1sT6GnJuMBTlz/Fw+2widiwdavcu9UwyLF/BzZ4w=="], "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], @@ -4864,6 +4895,8 @@ "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], + "opentui-math": ["opentui-math@0.1.0", "", { "dependencies": { "@resvg/resvg-js": "2.6.2", "mathjax": "4.1.3" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/react": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-SfGKOogZHXY1YGCSiV9+fFDveiGnm6zrb3ts0LVeb57V6Hzc1vz1xl806L1UVYqC/iKjaDWM8sSrWopduTXapw=="], + "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="], diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index cb482b4f5932..c5b4bbfcc34d 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -10,6 +10,7 @@ import pkg from "../package.json" import { buildAppArchive } from "./app-assets" import { verifyArtifact, verifySimulationGraph } from "./verify-artifact" import { resolveOpencodePty } from "./opencode-pty" +import { mathGraphicsPlugin } from "./math-graphics" const dir = path.resolve(import.meta.dirname, "..") const binary = "opencode2" @@ -124,7 +125,14 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh const result = await Bun.build({ entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", - plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin], + plugins: [ + appAssetsPlugin, + solidPlugin, + parcelWatcherPlugin, + opencodePtyPlugin, + simulationGraphPlugin, + mathGraphicsPlugin(item), + ], external: ["node-gyp"], format: "esm", minify: true, diff --git a/packages/cli/script/math-graphics.ts b/packages/cli/script/math-graphics.ts new file mode 100644 index 000000000000..7416e87fbfd2 --- /dev/null +++ b/packages/cli/script/math-graphics.ts @@ -0,0 +1,47 @@ +import type { BunPlugin } from "bun" +import path from "node:path" + +export function mathGraphicsPlugin(target: { os: string; arch: string; abi?: "musl" }): BunPlugin { + return { + name: "opencode-math-graphics", + setup(build) { + build.onLoad({ filter: /mathjax[/\\]node-main-setup\.cjs$/ }, (args) => { + const directory = path.dirname(args.path) + const font = path.dirname(Bun.resolveSync("@mathjax/mathjax-newcm-font/svg.js", args.path)) + // MathJax's eval-created importer is invisible to Bun. Keep lazy loading, + // but expose TeX components and SVG font shards as literal imports. + const imports = [ + ...["input/tex.js", "output/svg.js", ...new Bun.Glob("input/tex/extensions/*.js").scanSync(directory)] + .sort() + .map( + (file) => + `${JSON.stringify(`/opencode-mathjax/${file.replaceAll("\\", "/")}`)}: () => import(${JSON.stringify(path.join(directory, file))})`, + ), + ...["svg.js", ...new Bun.Glob("svg/dynamic/*.js").scanSync(font)] + .sort() + .map( + (file) => + `${JSON.stringify(`@mathjax/mathjax-newcm-font/${file.replaceAll("\\", "/")}`)}: () => import(${JSON.stringify(path.join(font, file))})`, + ), + ] + return { + loader: "js", + contents: `global.require = require; +global.MathJax ??= {}; +global.MathJax.__dirname = "/opencode-mathjax"; +const components = {${imports.join(",\n")}}; +global.MathJax.loader = { ...global.MathJax.loader, require(file) { + const load = components[file.replace(/^file:\\/\\//, "")]; + if (!load) throw new Error("MathJax component not bundled: " + file); + return load(); +}};`, + } + }) + build.onLoad({ filter: /@resvg[/\\]resvg-js[/\\]js-binding\.js$/ }, (args) => { + const suffix = target.os === "linux" ? `-${target.abi ?? "gnu"}` : target.os === "win32" ? "-msvc" : "" + const binding = Bun.resolveSync(`@resvg/resvg-js-${target.os}-${target.arch}${suffix}`, args.path) + return { loader: "js", contents: `module.exports = require(${JSON.stringify(binding)})` } + }) + }, + } +} diff --git a/packages/cli/test/math-graphics.test.ts b/packages/cli/test/math-graphics.test.ts new file mode 100644 index 000000000000..bae4dd7e79b7 --- /dev/null +++ b/packages/cli/test/math-graphics.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { mathGraphicsPlugin } from "../script/math-graphics" + +test("compiled math graphics loads TeX components, SVG fonts, and the native rasterizer", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-math-graphics-")) + await using _cleanup = { [Symbol.asyncDispose]: () => rm(directory, { recursive: true, force: true }) } + const entry = path.join(directory, "entry.ts") + const executable = path.join(directory, process.platform === "win32" ? "math.exe" : "math") + const cwd = path.join(directory, "clean") + await mkdir(cwd) + await Bun.write( + entry, + `const { renderLatexToPng } = await import(${JSON.stringify(Bun.resolveSync("opentui-math/graphics", path.resolve(import.meta.dirname, "../../tui")))}) +const results = []; +for (const source of [String.raw\`\\frac{1}{2} + \\sqrt{x}\`, String.raw\`\\cancel{x} + \\mathscr{A}\`]) { + const image = await renderLatexToPng(source); + if (image.svg.includes('data-mml-node="merror"')) throw new Error("MathJax rendered an error"); + results.push({ bytes: image.png.length, signature: image.png.subarray(0, 8).toString("hex"), width: image.width, height: image.height }); +} +console.log(JSON.stringify(results));`, + ) + const result = await Bun.build({ + entrypoints: [entry], + plugins: [mathGraphicsPlugin({ os: process.platform, arch: process.arch })], + minify: true, + format: "esm", + splitting: true, + compile: { outfile: executable, autoloadBunfig: false, autoloadDotenv: false }, + }) + expect(result.logs).toEqual([]) + expect(result.success).toBe(true) + const child = Bun.spawn([executable], { cwd, stdout: "pipe", stderr: "pipe" }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + expect(stderr).toBe("") + expect(exitCode).toBe(0) + const images = JSON.parse(stdout) + expect(images).toHaveLength(2) + for (const image of images) { + expect(image.signature).toBe("89504e470d0a1a0a") + expect(image.bytes).toBeGreaterThan(8) + expect(image.width).toBeGreaterThan(0) + expect(image.height).toBeGreaterThan(0) + } +}, 30_000) diff --git a/packages/tui/package.json b/packages/tui/package.json index 140b4ad8c4e1..a0dd8f2133cf 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -93,6 +93,7 @@ "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", + "opentui-math": "0.1.0", "opentui-spinner": "catalog:", "remeda": "catalog:", "solid-js": "catalog:", diff --git a/packages/tui/src/feature-plugins/system/latex.ts b/packages/tui/src/feature-plugins/system/latex.ts new file mode 100644 index 000000000000..de6eb3a13a38 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/latex.ts @@ -0,0 +1,246 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import { + CodeRenderable, + ImageRenderable, + RenderableEvents, + ScrollBoxRenderable, + StyledText, + TextRenderable, + createTextAttributes, + parseColor, + resolveImageRenderProtocol, + rgbToHex, + type ColorInput, + type MarkdownCodeBlockRenderer, + type RenderContext, +} from "@opentui/core" +import { LatexParseError, renderLatex, type MathLayout } from "opentui-math" +import { stringWidth } from "../../util/string-width" + +export type LatexOptions = { + text: ColorInput + subdued: ColorInput + background: ColorInput + mode?: "auto" | "cells" +} + +type LatexImage = { + source: string + color: string + background: string + view: ImageRenderable +} + +type LatexFrame = { + source: string + layout: MathLayout + image?: LatexImage +} + +class LatexViewport extends ScrollBoxRenderable { + preview?: ImageRenderable + + override destroyRecursively() { + const preview = this.preview + // Let the next Markdown block adopt the loaded image without decoding or flashing a fallback. + if (preview?.parent === this.content) this.remove(preview) + super.destroyRecursively() + if (preview) + queueMicrotask(() => { + if (!preview.parent) preview.destroy() + }) + } +} + +export default Plugin.define({ + id: "opencode.latex", + setup(context) { + const render = createLatexCodeBlockRenderer(context.renderer, () => ({ + text: context.theme.text.default, + subdued: context.theme.text.subdued, + background: context.theme.background.default, + mode: context.options.mode === "cells" ? "cells" : "auto", + })) + context.markdown.registerCodeBlockRenderer("latex", render) + context.markdown.registerCodeBlockRenderer("math", render) + }, +}) + +export function createLatexCodeBlockRenderer( + context: RenderContext, + options: () => LatexOptions, +): MarkdownCodeBlockRenderer { + const lastGood = new Map() + return (token, render) => { + const fallback = render.defaultRender() + const key = fallback?.id + const previous = key ? lastGood.get(key) : undefined + const retained = previous && token.text.startsWith(previous.source) ? previous : undefined + const fence = /^ {0,3}(`{3,}|~{3,})/.exec(token.raw)?.[1] + const streaming = + fallback instanceof CodeRenderable && + fallback.streaming && + fence && + !new RegExp(`\\n {0,3}${fence[0]}{${fence.length},}\\s*$`).test(token.raw) + const layout = layoutLatex(token.text) + const frame: LatexFrame | undefined = layout + ? { source: token.text, layout, image: retained?.image } + : streaming && retained + ? { ...retained } + : undefined + if (!frame) return fallback ?? undefined + const palette = options() + const text = parseColor(palette.text) + const subdued = parseColor(palette.subdued) + const formula = new TextRenderable(context, { + content: new StyledText( + frame.layout.cells.flatMap((row, index) => [ + ...Array.from(row).flatMap((cell, column) => { + // Wide glyphs already occupy the following cell; do not emit another space for it. + if (column > 0 && stringWidth(row[column - 1]?.char ?? "") > 1) return [] + return [ + { + __isChunk: true as const, + text: cell?.char ?? " ", + fg: /^[()[\]{}|\u239b-\u23ad\u2500-\u257f]$/u.test(cell?.char ?? "") ? subdued : text, + attributes: createTextAttributes({ + bold: cell?.style?.bold || /^[=<>\u2260\u2261\u2264\u2265\u2248]$/u.test(cell?.char ?? ""), + italic: cell?.style?.italic, + dim: cell?.style?.dim, + }), + }, + ] + }), + ...(index < frame.layout.height - 1 ? [{ __isChunk: true as const, text: "\n", fg: text }] : []), + ]), + ), + width: "100%", + minWidth: frame.layout.width, + height: frame.layout.height, + wrapMode: "none", + selectable: false, + flexShrink: 0, + }) + const viewport = new LatexViewport(context, { + width: "100%", + height: frame.layout.height, + flexShrink: 0, + marginTop: 1, + scrollX: true, + scrollY: false, + onMouseScroll(event) { + if (event.modifiers.shift || event.scroll?.direction === "left" || event.scroll?.direction === "right") { + event.stopPropagation() + } + }, + }) + // The setters opt out of automatic scrollbar visibility; constructor options do not. + viewport.horizontalScrollBar.visible = false + viewport.verticalScrollBar.visible = false + viewport.add(formula) + if (key) { + lastGood.set(key, frame) + viewport.once(RenderableEvents.DESTROYED, () => { + // Markdown destroys the old block before constructing its replacement in the same stack. + queueMicrotask(() => { + if (lastGood.get(key) === frame) lastGood.delete(key) + }) + }) + } + + if (palette.mode === "cells") return viewport + const color = rgbToHex(text) + const background = rgbToHex(parseColor(palette.background)) + const image = frame.image + if ( + image?.color === color && + image.background === background && + !image.view.isDestroyed && + image.view.effectiveProtocol !== "blocks" + ) { + viewport.add(image.view) + viewport.preview = image.view + formula.visible = false + viewport.height = image.view.height + if (image.source === frame.source) return viewport + } + viewport.renderBefore = () => { + if (viewport.screenY >= context.height || viewport.screenY + viewport.height <= 0) return + if (resolveImageRenderProtocol("auto", context.capabilities, Boolean(context.resolution)) === "blocks") return + viewport.renderBefore = undefined + // Markdown replaces custom blocks while streaming. Wait briefly so superseded prefixes do not rasterize. + const timer = setTimeout(() => { + void renderImage(frame, color, background, viewport, formula).catch(() => { + if (viewport.isDestroyed) return + viewport + .getChildren() + .filter((child) => child instanceof ImageRenderable) + .forEach((child) => child.destroy()) + frame.image = undefined + viewport.preview = undefined + formula.visible = true + viewport.height = frame.layout.height + }) + }, 75) + viewport.once(RenderableEvents.DESTROYED, () => clearTimeout(timer)) + } + return viewport + } +} + +function layoutLatex(source: string) { + try { + return renderLatex(source, { strict: true, displayMode: true }) + } catch (error) { + // Preserve the exact source for incomplete math, unsupported commands, and oversized input. + if (error instanceof LatexParseError || error instanceof RangeError) return undefined + throw error + } +} + +async function renderImage( + frame: LatexFrame, + color: string, + background: string, + viewport: LatexViewport, + formula: TextRenderable, +) { + const { renderLatexToPng } = await import("opentui-math/graphics") + if (viewport.isDestroyed) return + // OpenTUI 0.5.8 clears graphics cells to the terminal default background. Composite against + // the theme until it preserves cell backgrounds, otherwise transparent pixels reveal a rectangle. + const raster = await renderLatexToPng(frame.source, { + foregroundColor: color, + backgroundColor: background, + fontSize: 24, + pixelRatio: 2, + }) + if (viewport.isDestroyed) return + + // A 2x raster and logical 16px cell height keep math proportional to text when the terminal font is zoomed. + const height = Math.max(1, Math.ceil(raster.height / 32)) + const image = new ImageRenderable(viewport.ctx, { + source: raster.png, + height, + flexShrink: 0, + visible: false, + }) + image.width = Math.max(1, Math.ceil((raster.width / raster.height) * height * image.cellAspectRatio)) + image.minWidth = image.width + viewport.add(image) + await image.loadPromise + if (viewport.isDestroyed) return + if (image.loadError) { + image.destroy() + throw image.loadError + } + viewport + .getChildren() + .filter((child) => child instanceof ImageRenderable && child !== image) + .forEach((child) => child.destroy()) + frame.image = { source: frame.source, color, background, view: image } + viewport.preview = image + formula.visible = false + image.visible = true + viewport.height = height +} diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx index c57e7f6f6e0c..c14bc45c2380 100644 --- a/packages/tui/src/feature-plugins/system/storybook/index.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx @@ -3,6 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid" import { createSignal, For, type JSX } from "solid-js" import { StoryFooter } from "./footer" import { mermanLayoutsStory } from "./merman-layouts" +import { latexStory } from "./latex" import { sessionTabsStory } from "./session-tabs" import { sessionLocationMissingStory } from "./session-location-missing" @@ -16,7 +17,7 @@ export type Story = { render: (context: Plugin.Context) => JSX.Element } -const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory] +const stories: Story[] = [mermanLayoutsStory, latexStory, sessionTabsStory, sessionLocationMissingStory] function Commands(props: { context: Plugin.Context }) { props.context.keymap.layer(() => ({ diff --git a/packages/tui/src/feature-plugins/system/storybook/latex.tsx b/packages/tui/src/feature-plugins/system/storybook/latex.tsx new file mode 100644 index 000000000000..5de780a1c38d --- /dev/null +++ b/packages/tui/src/feature-plugins/system/storybook/latex.tsx @@ -0,0 +1,148 @@ +import type { Plugin } from "@opencode-ai/plugin/tui" +import { createMarkdownCodeBlockRenderer } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { createEffect, createMemo, createSignal, For, onCleanup } from "solid-js" +import { useTheme, useThemes } from "../../../context/theme" +import { createLatexCodeBlockRenderer } from "../latex" +import type { Story } from "./index" +import { StoryFooter } from "./footer" + +const fixtures = [ + { + title: "Quadratic formula", + source: String.raw`x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}`, + }, + { + title: "Matrix", + source: String.raw`A = \begin{pmatrix}a & b \\ c & d\end{pmatrix}`, + }, + { + title: "Piecewise function", + source: String.raw`|x| = \begin{cases}x & x \ge 0 \\ -x & x < 0\end{cases}`, + }, + { + title: "Aligned equations", + source: String.raw`\begin{aligned}(a+b)^2 &= a^2+2ab+b^2 \\ (a-b)^2 &= a^2-2ab+b^2\end{aligned}`, + }, + { + title: "Limit", + source: String.raw`\lim_{n\to\infty}\left(1+\frac{1}{n}\right)^n=e`, + }, + { + title: "Wide expression (scroll horizontally)", + source: String.raw`\frac{a_1+b_1}{c_1+d_1}+\frac{a_2+b_2}{c_2+d_2}+\frac{a_3+b_3}{c_3+d_3}+\frac{a_4+b_4}{c_4+d_4}+\frac{a_5+b_5}{c_5+d_5}=\text{Result}`, + }, + { + title: "Unsupported command stays as source", + source: String.raw`\unsupported{x}`, + }, +] + +function LatexStory(props: { context: Plugin.Context }) { + const dimensions = useTerminalDimensions() + const theme = useTheme() + const themes = useThemes() + const [selected, setSelected] = createSignal(0) + const [length, setLength] = createSignal() + const previews = (["auto", "cells"] as const).map((mode) => ({ + title: mode === "auto" ? "Automatic rendering" : "Unicode rendering", + render: createMarkdownCodeBlockRenderer({ + latex: createLatexCodeBlockRenderer(props.context.renderer, () => ({ + text: theme.text.default, + subdued: theme.text.subdued, + background: theme.background.default, + mode, + })), + }), + })) + const fixture = createMemo(() => fixtures[selected()]) + const source = createMemo(() => fixture().source.slice(0, length())) + const streaming = createMemo(() => source().length < fixture().source.length) + const markdown = createMemo(() => `\`\`\`latex\n${source()}${streaming() ? "" : "\n```"}`) + + createEffect(() => { + const current = length() + if (current === undefined || current >= fixture().source.length) return + const timer = setTimeout(() => setLength(current + 4), 80) + onCleanup(() => clearTimeout(timer)) + }) + + props.context.keymap.layer(() => ({ + commands: [ + { + bind: "escape", + title: "Back to storybook", + run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }), + }, + { + bind: "n", + title: "Next formula", + run: () => { + setSelected((current) => (current + 1) % fixtures.length) + setLength(undefined) + }, + }, + { bind: "s", title: "Replay streaming", run: () => setLength(0) }, + { bind: "f", title: "Finish streaming", run: () => setLength(undefined) }, + { + bind: "r", + title: "Reset fixture", + run: () => { + setSelected(0) + setLength(undefined) + }, + }, + ], + })) + + return ( + + + + {fixture().title} + + {source()} + {fixture().source.slice(source().length)} + + + {(preview) => ( + <> + {preview.title} + + + )} + + + + + + ) +} + +export const latexStory: Story = { + id: "latex", + title: "LaTeX math", + render: (context) => , +} diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 3555a390df23..02021390d849 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -7,6 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer" import Notifications from "../feature-plugins/system/notifications" import Plugins from "../feature-plugins/system/plugins" import Storybook from "../feature-plugins/system/storybook" +import Latex from "../feature-plugins/system/latex" import Merman from "@opencode-ai/merman/plugin" export const builtins = [ @@ -18,6 +19,7 @@ export const builtins = [ Notifications, Plugins, Merman, + Latex, // The storybook is a development tool; keep its route and palette commands out of // normal launches and register it only for OPENCODE_STORY runs. ...(process.env.OPENCODE_STORY ? [Storybook] : []), diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts new file mode 100644 index 000000000000..133863449c79 --- /dev/null +++ b/packages/tui/test/latex.test.ts @@ -0,0 +1,407 @@ +import { afterEach, expect, test } from "bun:test" +import { Writable } from "node:stream" +import { DEFAULT_THEME, resolveThemeDocument } from "@opencode-ai/theme/tui" +import { + CodeRenderable, + ImageRenderable, + MarkdownRenderable, + RGBA, + ScrollBoxRenderable, + SyntaxStyle, + TextAttributes, + TextRenderable, + createMarkdownCodeBlockRenderer, + rgbToHex, +} from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { renderLatex } from "opentui-math" +import { createLatexCodeBlockRenderer } from "../src/feature-plugins/system/latex" + +const renderers: Awaited>["renderer"][] = [] +const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }) + +afterEach(() => { + renderers.splice(0).forEach((renderer) => renderer.destroy()) +}) + +async function setup(content: string, width = 80, graphics?: "kitty" | "sixel") { + const writes: Buffer[] = [] + // OpenTUI accepts an injected Writable, but its public type requires a full TTY stream. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const stdout = new Writable({ + write(chunk: Buffer, _encoding, callback) { + writes.push(Buffer.from(chunk)) + callback() + }, + }) as NodeJS.WriteStream + const output = await createTestRenderer({ + width, + height: 24, + remote: true, + useThread: false, + stdout, + bufferedOutput: "stdout", + }) + renderers.push(output.renderer) + if (graphics) { + await output.renderer.setupTerminal() + output.renderer.stdin.emit("data", Buffer.from(`\x1b[4;432;${width * 9}t`)) + output.renderer.stdin.emit("data", Buffer.from(graphics === "kitty" ? "\x1b_Gi=31337;OK\x1b\\" : "\x1b[?62;4c")) + } + writes.length = 0 + const palette = { text: "#abcdef", subdued: "#667788", background: "#1a1b26" } + const render = createLatexCodeBlockRenderer(output.renderer, () => palette) + const markdown = new MarkdownRenderable(output.renderer, { + content, + syntaxStyle, + streaming: true, + internalBlockMode: "top-level", + renderNode: createMarkdownCodeBlockRenderer({ latex: render, math: render }), + }) + output.renderer.root.add(markdown) + await output.renderOnce() + return { ...output, markdown, palette, writes } +} + +test.each(["latex", "math", "tex", "LATEX title=example"])("renders a %s fence", async (language) => { + const output = await setup(`\`\`\`${language}\n\\frac{1}{2}\n\`\`\``) + const formula = output.markdown.getChildren()[0]?.getChildren()[0] + expect(formula).toBeInstanceOf(TextRenderable) + if (!(formula instanceof TextRenderable)) throw new Error("Expected a formula") + expect(formula.height).toBe(3) + expect(formula.chunks.find((chunk) => chunk.text === "1")?.fg?.equals(RGBA.fromHex("#abcdef"))).toBe(true) + expect(output.captureCharFrame()).toContain("1") + expect(output.captureCharFrame()).toContain("2") + expect(output.captureCharFrame()).not.toContain("\\frac") +}) + +test.each([ + String.raw`\frac{1}{`, + String.raw`\unsupported{x}`, + String.raw`\documentclass{article} +\begin{document} +Hello +\end{document}`, +])("preserves invalid or unsupported math as source: %s", async (source) => { + const output = await setup(`\`\`\`latex\n${source}\n\`\`\``) + const block = output.markdown.getChildren()[0] + expect(block).toBeInstanceOf(CodeRenderable) + if (!(block instanceof CodeRenderable)) throw new Error("Expected source fallback") + expect(block.content).toBe(source) +}) + +test("renders the next valid formula after an incomplete streaming prefix", async () => { + const output = await setup("```latex\n\\frac{1}{") + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) + + output.markdown.content += "2}" + await output.renderOnce() + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) + expect(output.captureCharFrame()).not.toContain("\\frac") + + output.markdown.content += "\n```" + output.markdown.streaming = false + await output.renderOnce() + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) +}) + +test("retains the last valid Unicode formula while the next fraction is incomplete", async () => { + const output = await setup("```latex\n\\frac{a_1+b_1}{c_1+d_1}") + const previous = output.captureCharFrame() + output.markdown.content += "+\\frac{a_" + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable) + expect(output.captureCharFrame()).toBe(previous) + + output.markdown.content += "2+b_2}{c_2+d_2}" + await output.renderOnce() + expect(output.captureCharFrame()).not.toBe(previous) + expect(output.captureCharFrame()).not.toContain("\\frac") +}) + +test.each(["close", "stop"])("discards an incomplete preview when the stream ends: %s", async (end) => { + const output = await setup("```latex\nx^2") + output.markdown.content += " + \\frac{1}{" + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable) + + if (end === "close") output.markdown.content += "\n```" + if (end === "stop") output.markdown.streaming = false + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) +}) + +test("does not reuse another fence's preview or keep a removed fence's preview", async () => { + const output = await setup("```latex\nx^2\n```\n\n```latex\n\\frac{1}{") + expect(output.markdown.getChildren()[1]).toBeInstanceOf(CodeRenderable) + + output.markdown.content = "" + await output.renderOnce() + output.markdown.content = "```latex\nx^2 + \\frac{1}{" + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) +}) + +test("does not leave a stale formula when a stream ends with invalid math", async () => { + const output = await setup("```latex\nx^2") + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) + + output.markdown.content += " + \\unsupported{x}\n```" + output.markdown.streaming = false + await output.renderOnce() + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) +}) + +test("keeps a matrix and surrounding Markdown intact in a narrow terminal", async () => { + const output = await setup("Before\n\n```math\n\\begin{pmatrix}a & b \\\\ c & d\\end{pmatrix}\n```\n\nAfter", 32) + await output.renderOnce() + const frame = output.captureCharFrame() + expect(frame).toContain("Before") + expect(frame).toContain("a b") + expect(frame).toContain("c d") + expect(frame).toContain("After") + expect(frame).not.toContain("pmatrix") +}) + +test("leaves ordinary code fences alone", async () => { + const output = await setup("```typescript\nconst x = 2\n```") + expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) +}) + +test("allows wide formulas to scroll horizontally without wrapping", async () => { + const output = await setup( + "```latex\n\\text{Start a very long formula with enough content to overflow Finish}\n```", + 24, + ) + const viewport = output.markdown.getChildren()[0] + expect(viewport).toBeInstanceOf(ScrollBoxRenderable) + if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected a horizontal viewport") + expect(output.captureCharFrame()).toContain("Start") + expect(output.captureCharFrame()).not.toContain("Finish") + expect(viewport.height).toBe(1) + + await output.mockMouse.scroll(2, 1, "right") + await output.renderOnce() + expect(viewport.scrollLeft).toBeGreaterThan(0) + + viewport.scrollLeft = viewport.scrollWidth + await output.renderOnce() + expect(output.captureCharFrame()).toContain("Finish") + expect(output.captureCharFrame()).not.toContain("Start") +}) + +test("subdues structure and emphasizes relations using the theme", async () => { + const output = await setup("```latex\nx=\\frac{1}{2}\n```") + const formula = output.markdown.getChildren()[0]?.getChildren()[0] + if (!(formula instanceof TextRenderable)) throw new Error("Expected Unicode math") + expect( + formula.chunks.find((chunk) => chunk.text === "\u2500")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), + ).toBe(true) + expect(formula.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true) + expect(formula.chunks.find((chunk) => chunk.text === "=")?.attributes).toBe(TextAttributes.BOLD) + + output.palette.text = "#123456" + output.palette.subdued = "#789abc" + output.markdown.refreshStyles() + await output.renderOnce() + const updated = output.markdown.getChildren()[0]?.getChildren()[0] + if (!(updated instanceof TextRenderable)) throw new Error("Expected Unicode math") + expect(updated.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true) + expect( + updated.chunks.find((chunk) => chunk.text === "\u2500")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), + ).toBe(true) +}) + +async function renderedImage(output: Awaited>, previous?: ImageRenderable) { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const image = output.markdown + .getChildren()[0] + ?.getChildren() + .find( + (child) => + child instanceof ImageRenderable && child.visible && child.image && child.source !== previous?.source, + ) + if (image instanceof ImageRenderable && image.image && image.visible) { + await output.renderOnce() + return image + } + await Bun.sleep(10) + await output.renderOnce() + } + throw new Error("Timed out waiting for native math image") +} + +test.each(["kitty", "sixel"] as const)("uses real %s image output when supported", async (protocol) => { + const output = await setup("```latex\nx=\\frac{1}{2}\n```", 80, protocol) + expect(output.renderer.resolution).toEqual({ width: 720, height: 432 }) + const image = await renderedImage(output) + expect(image.effectiveProtocol).toBe(protocol) + expect(image.loadError).toBeNull() + await output.renderOnce() + const bytes = Buffer.concat(output.writes).toString() + expect(bytes).toMatch(protocol === "kitty" ? /\x1b_G[^;]*;[A-Za-z0-9+/=]{20,}/ : /\x1bP[^q]*q/) + expect(output.markdown.getChildren()[0]?.getChildren()[0].visible).toBe(false) +}) + +test("retains the last image across incomplete chunks and until its replacement is ready", async () => { + const output = await setup("```latex\n\\frac{a_1+b_1}{c_1+d_1}", 80, "kitty") + const first = await renderedImage(output) + const previousFrame = output.captureCharFrame() + output.writes.length = 0 + output.markdown.content += "+\\frac{a_" + await output.renderOnce() + expect(output.captureCharFrame()).toBe(previousFrame) + expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable) + const retained = await renderedImage(output) + expect(retained === first).toBe(true) + expect(retained.source).toBe(first.source) + expect(Buffer.concat(output.writes).toString()).not.toMatch(/\x1b_G[^;]*;[A-Za-z0-9+/=]{20,}/) + + output.markdown.content += "2+b_2}{c_2+d_2}" + await output.renderOnce() + const pending = await renderedImage(output) + expect(pending.source).toBe(first.source) + const replacement = await renderedImage(output, first) + expect(replacement.source).not.toBe(first.source) + expect(replacement.width).toBeGreaterThan(first.width) + expect(first.isDestroyed).toBe(true) +}) + +test("releases a retained image when the Markdown view is destroyed", async () => { + const output = await setup("```latex\nx^2", 80, "kitty") + const image = await renderedImage(output) + output.renderer.destroy() + await Promise.resolve() + expect(image.isDestroyed).toBe(true) +}) + +test("regenerates image pixels when the theme changes without editing the formula", async () => { + const output = await setup("```latex\nx=\\frac{1}{2}\n```", 80, "kitty") + const dark = resolveThemeDocument(DEFAULT_THEME, "dark") + const light = resolveThemeDocument(DEFAULT_THEME, "light") + output.palette.text = rgbToHex(dark.text.default) + output.palette.background = rgbToHex(dark.background.default) + output.markdown.refreshStyles() + await output.renderOnce() + const first = await renderedImage(output) + const pixels = first.image!.raw().data + const darkColor = dark.text.default.toInts() + expect( + pixels.some( + (alpha, index) => + index % 4 === 3 && + alpha > 200 && + pixels[index - 3] === darkColor[0] && + pixels[index - 2] === darkColor[1] && + pixels[index - 1] === darkColor[2], + ), + ).toBe(true) + + output.palette.text = rgbToHex(light.text.default) + output.palette.background = rgbToHex(light.background.default) + output.markdown.refreshStyles() + await output.renderOnce() + const second = await renderedImage(output) + expect(first.isDestroyed).toBe(true) + expect(second).not.toBe(first) + const updated = second.image!.raw().data + const lightColor = light.text.default.toInts() + expect( + updated.some( + (alpha, index) => + index % 4 === 3 && + alpha > 200 && + updated[index - 3] === lightColor[0] && + updated[index - 2] === lightColor[1] && + updated[index - 1] === lightColor[2], + ), + ).toBe(true) +}) + +test("does not add a late image after the math block is removed", async () => { + const output = await setup("```latex\nx=\\frac{1}{2}\n```", 80, "kitty") + output.markdown.content = "" + await Bun.sleep(150) + await output.renderOnce() + expect(output.markdown.getChildren()).toHaveLength(0) + expect(Buffer.concat(output.writes).toString()).not.toMatch(/\x1b_G[^;]*;[A-Za-z0-9+/=]{20,}/) +}) + +test("matches the surrounding background despite Kitty clearing cells to terminal defaults", async () => { + const output = await setup("```latex\n\\frac{1}{2}\n```", 80, "kitty") + const first = await renderedImage(output) + expect(Array.from(first.image!.raw().data.subarray(0, 4))).toEqual(RGBA.fromHex(output.palette.background).toInts()) + + output.palette.background = "#e5e7eb" + output.markdown.refreshStyles() + await output.renderOnce() + const second = await renderedImage(output) + expect(Array.from(second.image!.raw().data.subarray(0, 4))).toEqual(RGBA.fromHex(output.palette.background).toInts()) +}) + +test("upgrades to images when terminal capabilities arrive after the first render", async () => { + const output = await setup("```latex\nx=\\frac{1}{2}\n```") + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) + await output.renderer.setupTerminal() + output.renderer.stdin.emit("data", Buffer.from("\x1b_Gi=31337;OK\x1b\\")) + await output.renderOnce() + expect((await renderedImage(output)).effectiveProtocol).toBe("kitty") +}) + +test("waits until an offscreen formula is visible before rasterizing it", async () => { + const output = await setup("", 80, "kitty") + output.renderer.root.remove(output.markdown) + const viewport = new ScrollBoxRenderable(output.renderer, { width: 80, height: 24 }) + viewport.add(new TextRenderable(output.renderer, { content: "Earlier messages", height: 400, flexShrink: 0 })) + viewport.add(output.markdown) + output.renderer.root.add(viewport) + output.markdown.content = "```latex\nx^2\n```" + await output.renderOnce() + expect(output.markdown.screenY).toBeGreaterThan(24) + await Bun.sleep(150) + expect( + output.markdown + .getChildren()[0] + ?.getChildren() + .some((child) => child instanceof ImageRenderable), + ).toBe(false) + + viewport.scrollTop = viewport.scrollHeight + await output.renderOnce() + expect(output.markdown.screenY).toBeLessThan(24) + expect((await renderedImage(output)).effectiveProtocol).toBe("kitty") +}) + +test("can force styled Unicode even on an image-capable terminal", async () => { + const output = await setup("```latex\nx=\\frac{1}{2}\n```", 80, "kitty") + output.markdown.renderNode = createMarkdownCodeBlockRenderer({ + latex: createLatexCodeBlockRenderer(output.renderer, () => ({ ...output.palette, mode: "cells" })), + }) + await Bun.sleep(150) + await output.renderOnce() + expect(output.markdown.getChildren()[0]?.getChildren()).toHaveLength(1) + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) + expect(Buffer.concat(output.writes).toString()).not.toMatch(/\x1b_G[^;]*;[A-Za-z0-9+/=]{20,}/) +}) + +test.each([String.raw`\text{${"\u4e2d\u6587"}}=x`, String.raw`\frac{\text{${"\u4e2d\u6587"}}}{abcd}=x`])( + "preserves wide-character alignment: %s", + async (source) => { + const layout = renderLatex(source) + const output = await setup(`\`\`\`latex\n${source}\n\`\`\``, layout.width) + const viewport = output.markdown.getChildren()[0] + if (!(viewport instanceof ScrollBoxRenderable)) throw new Error("Expected math viewport") + const formula = viewport.getChildren()[0] + if (!(formula instanceof TextRenderable)) throw new Error("Expected Unicode math") + expect( + formula.chunks + .map((chunk) => chunk.text) + .join("") + .split("\n") + .map((line) => line.trimEnd()) + .join("\n"), + ).toBe(layout.toString()) + expect(viewport.scrollWidth).toBe(layout.width) + }, +) From 90fcfae24704ec2543685a007734c878eda30879 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 13:33:57 -0400 Subject: [PATCH 2/9] fix(tui): reparse final markdown updates --- bun.lock | 3 ++- package.json | 3 ++- packages/tui/test/latex.test.ts | 9 +++++++++ patches/@opentui%2Fcore@0.5.8.patch | 26 ++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 patches/@opentui%2Fcore@0.5.8.patch diff --git a/bun.lock b/bun.lock index 07429c6717f6..fd7d40b0d026 100644 --- a/bun.lock +++ b/bun.lock @@ -1057,6 +1057,7 @@ "tree-sitter-bash", ], "patchedDependencies": { + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", @@ -1068,7 +1069,7 @@ "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", - "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", + "@opentui/core@0.5.8": "patches/@opentui%2Fcore@0.5.8.patch", }, "overrides": { "@effect/platform-node-shared": "catalog:", diff --git a/package.json b/package.json index d2f9b1b4ebab..4b205d846076 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", - "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch" + "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch", + "@opentui/core@0.5.8": "patches/@opentui%2Fcore@0.5.8.patch" } } diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts index 133863449c79..70080dd1fa7d 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/tui/test/latex.test.ts @@ -105,6 +105,15 @@ test("renders the next valid formula after an incomplete streaming prefix", asyn expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) }) +test("renders the final formula when completion is applied before the last text update", async () => { + const output = await setup("```latex\n\\frac{1}{") + output.markdown.streaming = false + output.markdown.content += "2}\n```" + await output.renderOnce() + expect(output.markdown.getChildren().filter((child) => child instanceof ScrollBoxRenderable).length).toBe(1) + expect(output.captureCharFrame()).not.toContain("\\frac") +}) + test("retains the last valid Unicode formula while the next fraction is incomplete", async () => { const output = await setup("```latex\n\\frac{a_1+b_1}{c_1+d_1}") const previous = output.captureCharFrame() diff --git a/patches/@opentui%2Fcore@0.5.8.patch b/patches/@opentui%2Fcore@0.5.8.patch new file mode 100644 index 000000000000..79264bd5be1e --- /dev/null +++ b/patches/@opentui%2Fcore@0.5.8.patch @@ -0,0 +1,26 @@ +diff --git a/index.bun.js b/index.bun.js +index 853e369bf4a3945b3df5a8528f8700c35b24253d..db0115a2c15fd2fb1ede136fdf3d87b9143abcc8 100644 +--- a/index.bun.js ++++ b/index.bun.js +@@ -11803,6 +11803,8 @@ class MarkdownRenderable extends Renderable { + return; + if (this._content !== value) { + this._content = value; ++ if (!this._streaming) ++ this._parseState = null; + this.updateBlocks(); + this.requestRender(); + } +diff --git a/index.node.js b/index.node.js +index 30f831a584c5b4ed5282aef739e9b6735cc3e1e0..613f5735d860939c3829fdfc498a43c578d957a8 100644 +--- a/index.node.js ++++ b/index.node.js +@@ -11802,6 +11802,8 @@ class MarkdownRenderable extends Renderable { + return; + if (this._content !== value) { + this._content = value; ++ if (!this._streaming) ++ this._parseState = null; + this.updateBlocks(); + this.requestRender(); + } From 17b3454c2e0c91a02d5623f62a149f7015359d9b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 14:03:22 -0400 Subject: [PATCH 3/9] refactor(tui): keep LaTeX rendering Unicode-only --- packages/cli/script/build.ts | 10 +- packages/cli/script/math-graphics.ts | 47 ---- packages/cli/test/math-graphics.test.ts | 51 ----- .../tui/src/feature-plugins/system/latex.ts | 118 +---------- .../system/storybook/latex.tsx | 49 ++--- packages/tui/test/latex.test.ts | 200 +----------------- 6 files changed, 25 insertions(+), 450 deletions(-) delete mode 100644 packages/cli/script/math-graphics.ts delete mode 100644 packages/cli/test/math-graphics.test.ts diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index c5b4bbfcc34d..cb482b4f5932 100755 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -10,7 +10,6 @@ import pkg from "../package.json" import { buildAppArchive } from "./app-assets" import { verifyArtifact, verifySimulationGraph } from "./verify-artifact" import { resolveOpencodePty } from "./opencode-pty" -import { mathGraphicsPlugin } from "./math-graphics" const dir = path.resolve(import.meta.dirname, "..") const binary = "opencode2" @@ -125,14 +124,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh const result = await Bun.build({ entrypoints: ["./src/index.ts"], tsconfig: "./tsconfig.json", - plugins: [ - appAssetsPlugin, - solidPlugin, - parcelWatcherPlugin, - opencodePtyPlugin, - simulationGraphPlugin, - mathGraphicsPlugin(item), - ], + plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin], external: ["node-gyp"], format: "esm", minify: true, diff --git a/packages/cli/script/math-graphics.ts b/packages/cli/script/math-graphics.ts deleted file mode 100644 index 7416e87fbfd2..000000000000 --- a/packages/cli/script/math-graphics.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { BunPlugin } from "bun" -import path from "node:path" - -export function mathGraphicsPlugin(target: { os: string; arch: string; abi?: "musl" }): BunPlugin { - return { - name: "opencode-math-graphics", - setup(build) { - build.onLoad({ filter: /mathjax[/\\]node-main-setup\.cjs$/ }, (args) => { - const directory = path.dirname(args.path) - const font = path.dirname(Bun.resolveSync("@mathjax/mathjax-newcm-font/svg.js", args.path)) - // MathJax's eval-created importer is invisible to Bun. Keep lazy loading, - // but expose TeX components and SVG font shards as literal imports. - const imports = [ - ...["input/tex.js", "output/svg.js", ...new Bun.Glob("input/tex/extensions/*.js").scanSync(directory)] - .sort() - .map( - (file) => - `${JSON.stringify(`/opencode-mathjax/${file.replaceAll("\\", "/")}`)}: () => import(${JSON.stringify(path.join(directory, file))})`, - ), - ...["svg.js", ...new Bun.Glob("svg/dynamic/*.js").scanSync(font)] - .sort() - .map( - (file) => - `${JSON.stringify(`@mathjax/mathjax-newcm-font/${file.replaceAll("\\", "/")}`)}: () => import(${JSON.stringify(path.join(font, file))})`, - ), - ] - return { - loader: "js", - contents: `global.require = require; -global.MathJax ??= {}; -global.MathJax.__dirname = "/opencode-mathjax"; -const components = {${imports.join(",\n")}}; -global.MathJax.loader = { ...global.MathJax.loader, require(file) { - const load = components[file.replace(/^file:\\/\\//, "")]; - if (!load) throw new Error("MathJax component not bundled: " + file); - return load(); -}};`, - } - }) - build.onLoad({ filter: /@resvg[/\\]resvg-js[/\\]js-binding\.js$/ }, (args) => { - const suffix = target.os === "linux" ? `-${target.abi ?? "gnu"}` : target.os === "win32" ? "-msvc" : "" - const binding = Bun.resolveSync(`@resvg/resvg-js-${target.os}-${target.arch}${suffix}`, args.path) - return { loader: "js", contents: `module.exports = require(${JSON.stringify(binding)})` } - }) - }, - } -} diff --git a/packages/cli/test/math-graphics.test.ts b/packages/cli/test/math-graphics.test.ts deleted file mode 100644 index bae4dd7e79b7..000000000000 --- a/packages/cli/test/math-graphics.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { expect, test } from "bun:test" -import { mkdir, mkdtemp, rm } from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { mathGraphicsPlugin } from "../script/math-graphics" - -test("compiled math graphics loads TeX components, SVG fonts, and the native rasterizer", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-math-graphics-")) - await using _cleanup = { [Symbol.asyncDispose]: () => rm(directory, { recursive: true, force: true }) } - const entry = path.join(directory, "entry.ts") - const executable = path.join(directory, process.platform === "win32" ? "math.exe" : "math") - const cwd = path.join(directory, "clean") - await mkdir(cwd) - await Bun.write( - entry, - `const { renderLatexToPng } = await import(${JSON.stringify(Bun.resolveSync("opentui-math/graphics", path.resolve(import.meta.dirname, "../../tui")))}) -const results = []; -for (const source of [String.raw\`\\frac{1}{2} + \\sqrt{x}\`, String.raw\`\\cancel{x} + \\mathscr{A}\`]) { - const image = await renderLatexToPng(source); - if (image.svg.includes('data-mml-node="merror"')) throw new Error("MathJax rendered an error"); - results.push({ bytes: image.png.length, signature: image.png.subarray(0, 8).toString("hex"), width: image.width, height: image.height }); -} -console.log(JSON.stringify(results));`, - ) - const result = await Bun.build({ - entrypoints: [entry], - plugins: [mathGraphicsPlugin({ os: process.platform, arch: process.arch })], - minify: true, - format: "esm", - splitting: true, - compile: { outfile: executable, autoloadBunfig: false, autoloadDotenv: false }, - }) - expect(result.logs).toEqual([]) - expect(result.success).toBe(true) - const child = Bun.spawn([executable], { cwd, stdout: "pipe", stderr: "pipe" }) - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]) - expect(stderr).toBe("") - expect(exitCode).toBe(0) - const images = JSON.parse(stdout) - expect(images).toHaveLength(2) - for (const image of images) { - expect(image.signature).toBe("89504e470d0a1a0a") - expect(image.bytes).toBeGreaterThan(8) - expect(image.width).toBeGreaterThan(0) - expect(image.height).toBeGreaterThan(0) - } -}, 30_000) diff --git a/packages/tui/src/feature-plugins/system/latex.ts b/packages/tui/src/feature-plugins/system/latex.ts index de6eb3a13a38..52c0a5d9a30f 100644 --- a/packages/tui/src/feature-plugins/system/latex.ts +++ b/packages/tui/src/feature-plugins/system/latex.ts @@ -1,15 +1,12 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { CodeRenderable, - ImageRenderable, RenderableEvents, ScrollBoxRenderable, StyledText, TextRenderable, createTextAttributes, parseColor, - resolveImageRenderProtocol, - rgbToHex, type ColorInput, type MarkdownCodeBlockRenderer, type RenderContext, @@ -20,36 +17,11 @@ import { stringWidth } from "../../util/string-width" export type LatexOptions = { text: ColorInput subdued: ColorInput - background: ColorInput - mode?: "auto" | "cells" -} - -type LatexImage = { - source: string - color: string - background: string - view: ImageRenderable } type LatexFrame = { source: string layout: MathLayout - image?: LatexImage -} - -class LatexViewport extends ScrollBoxRenderable { - preview?: ImageRenderable - - override destroyRecursively() { - const preview = this.preview - // Let the next Markdown block adopt the loaded image without decoding or flashing a fallback. - if (preview?.parent === this.content) this.remove(preview) - super.destroyRecursively() - if (preview) - queueMicrotask(() => { - if (!preview.parent) preview.destroy() - }) - } } export default Plugin.define({ @@ -58,8 +30,6 @@ export default Plugin.define({ const render = createLatexCodeBlockRenderer(context.renderer, () => ({ text: context.theme.text.default, subdued: context.theme.text.subdued, - background: context.theme.background.default, - mode: context.options.mode === "cells" ? "cells" : "auto", })) context.markdown.registerCodeBlockRenderer("latex", render) context.markdown.registerCodeBlockRenderer("math", render) @@ -84,7 +54,7 @@ export function createLatexCodeBlockRenderer( !new RegExp(`\\n {0,3}${fence[0]}{${fence.length},}\\s*$`).test(token.raw) const layout = layoutLatex(token.text) const frame: LatexFrame | undefined = layout - ? { source: token.text, layout, image: retained?.image } + ? { source: token.text, layout } : streaming && retained ? { ...retained } : undefined @@ -121,7 +91,7 @@ export function createLatexCodeBlockRenderer( selectable: false, flexShrink: 0, }) - const viewport = new LatexViewport(context, { + const viewport = new ScrollBoxRenderable(context, { width: "100%", height: frame.layout.height, flexShrink: 0, @@ -147,43 +117,6 @@ export function createLatexCodeBlockRenderer( }) }) } - - if (palette.mode === "cells") return viewport - const color = rgbToHex(text) - const background = rgbToHex(parseColor(palette.background)) - const image = frame.image - if ( - image?.color === color && - image.background === background && - !image.view.isDestroyed && - image.view.effectiveProtocol !== "blocks" - ) { - viewport.add(image.view) - viewport.preview = image.view - formula.visible = false - viewport.height = image.view.height - if (image.source === frame.source) return viewport - } - viewport.renderBefore = () => { - if (viewport.screenY >= context.height || viewport.screenY + viewport.height <= 0) return - if (resolveImageRenderProtocol("auto", context.capabilities, Boolean(context.resolution)) === "blocks") return - viewport.renderBefore = undefined - // Markdown replaces custom blocks while streaming. Wait briefly so superseded prefixes do not rasterize. - const timer = setTimeout(() => { - void renderImage(frame, color, background, viewport, formula).catch(() => { - if (viewport.isDestroyed) return - viewport - .getChildren() - .filter((child) => child instanceof ImageRenderable) - .forEach((child) => child.destroy()) - frame.image = undefined - viewport.preview = undefined - formula.visible = true - viewport.height = frame.layout.height - }) - }, 75) - viewport.once(RenderableEvents.DESTROYED, () => clearTimeout(timer)) - } return viewport } } @@ -197,50 +130,3 @@ function layoutLatex(source: string) { throw error } } - -async function renderImage( - frame: LatexFrame, - color: string, - background: string, - viewport: LatexViewport, - formula: TextRenderable, -) { - const { renderLatexToPng } = await import("opentui-math/graphics") - if (viewport.isDestroyed) return - // OpenTUI 0.5.8 clears graphics cells to the terminal default background. Composite against - // the theme until it preserves cell backgrounds, otherwise transparent pixels reveal a rectangle. - const raster = await renderLatexToPng(frame.source, { - foregroundColor: color, - backgroundColor: background, - fontSize: 24, - pixelRatio: 2, - }) - if (viewport.isDestroyed) return - - // A 2x raster and logical 16px cell height keep math proportional to text when the terminal font is zoomed. - const height = Math.max(1, Math.ceil(raster.height / 32)) - const image = new ImageRenderable(viewport.ctx, { - source: raster.png, - height, - flexShrink: 0, - visible: false, - }) - image.width = Math.max(1, Math.ceil((raster.width / raster.height) * height * image.cellAspectRatio)) - image.minWidth = image.width - viewport.add(image) - await image.loadPromise - if (viewport.isDestroyed) return - if (image.loadError) { - image.destroy() - throw image.loadError - } - viewport - .getChildren() - .filter((child) => child instanceof ImageRenderable && child !== image) - .forEach((child) => child.destroy()) - frame.image = { source: frame.source, color, background, view: image } - viewport.preview = image - formula.visible = false - image.visible = true - viewport.height = height -} diff --git a/packages/tui/src/feature-plugins/system/storybook/latex.tsx b/packages/tui/src/feature-plugins/system/storybook/latex.tsx index 5de780a1c38d..82cef3bfdfd7 100644 --- a/packages/tui/src/feature-plugins/system/storybook/latex.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/latex.tsx @@ -1,7 +1,7 @@ import type { Plugin } from "@opencode-ai/plugin/tui" import { createMarkdownCodeBlockRenderer } from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" -import { createEffect, createMemo, createSignal, For, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { useTheme, useThemes } from "../../../context/theme" import { createLatexCodeBlockRenderer } from "../latex" import type { Story } from "./index" @@ -44,17 +44,12 @@ function LatexStory(props: { context: Plugin.Context }) { const themes = useThemes() const [selected, setSelected] = createSignal(0) const [length, setLength] = createSignal() - const previews = (["auto", "cells"] as const).map((mode) => ({ - title: mode === "auto" ? "Automatic rendering" : "Unicode rendering", - render: createMarkdownCodeBlockRenderer({ - latex: createLatexCodeBlockRenderer(props.context.renderer, () => ({ - text: theme.text.default, - subdued: theme.text.subdued, - background: theme.background.default, - mode, - })), - }), - })) + const render = createMarkdownCodeBlockRenderer({ + latex: createLatexCodeBlockRenderer(props.context.renderer, () => ({ + text: theme.text.default, + subdued: theme.text.subdued, + })), + }) const fixture = createMemo(() => fixtures[selected()]) const source = createMemo(() => fixture().source.slice(0, length())) const streaming = createMemo(() => source().length < fixture().source.length) @@ -104,24 +99,18 @@ function LatexStory(props: { context: Plugin.Context }) { {source()} {fixture().source.slice(source().length)} - - {(preview) => ( - <> - {preview.title} - - - )} - + Unicode rendering + { renderers.splice(0).forEach((renderer) => renderer.destroy()) }) -async function setup(content: string, width = 80, graphics?: "kitty" | "sixel") { - const writes: Buffer[] = [] - // OpenTUI accepts an injected Writable, but its public type requires a full TTY stream. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const stdout = new Writable({ - write(chunk: Buffer, _encoding, callback) { - writes.push(Buffer.from(chunk)) - callback() - }, - }) as NodeJS.WriteStream +async function setup(content: string, width = 80) { const output = await createTestRenderer({ width, height: 24, remote: true, useThread: false, - stdout, - bufferedOutput: "stdout", }) renderers.push(output.renderer) - if (graphics) { - await output.renderer.setupTerminal() - output.renderer.stdin.emit("data", Buffer.from(`\x1b[4;432;${width * 9}t`)) - output.renderer.stdin.emit("data", Buffer.from(graphics === "kitty" ? "\x1b_Gi=31337;OK\x1b\\" : "\x1b[?62;4c")) - } - writes.length = 0 - const palette = { text: "#abcdef", subdued: "#667788", background: "#1a1b26" } + const palette = { text: "#abcdef", subdued: "#667788" } const render = createLatexCodeBlockRenderer(output.renderer, () => palette) const markdown = new MarkdownRenderable(output.renderer, { content, @@ -60,7 +39,7 @@ async function setup(content: string, width = 80, graphics?: "kitty" | "sixel") }) output.renderer.root.add(markdown) await output.renderOnce() - return { ...output, markdown, palette, writes } + return { ...output, markdown, palette } } test.each(["latex", "math", "tex", "LATEX title=example"])("renders a %s fence", async (language) => { @@ -221,179 +200,6 @@ test("subdues structure and emphasizes relations using the theme", async () => { ).toBe(true) }) -async function renderedImage(output: Awaited>, previous?: ImageRenderable) { - const deadline = Date.now() + 5_000 - while (Date.now() < deadline) { - const image = output.markdown - .getChildren()[0] - ?.getChildren() - .find( - (child) => - child instanceof ImageRenderable && child.visible && child.image && child.source !== previous?.source, - ) - if (image instanceof ImageRenderable && image.image && image.visible) { - await output.renderOnce() - return image - } - await Bun.sleep(10) - await output.renderOnce() - } - throw new Error("Timed out waiting for native math image") -} - -test.each(["kitty", "sixel"] as const)("uses real %s image output when supported", async (protocol) => { - const output = await setup("```latex\nx=\\frac{1}{2}\n```", 80, protocol) - expect(output.renderer.resolution).toEqual({ width: 720, height: 432 }) - const image = await renderedImage(output) - expect(image.effectiveProtocol).toBe(protocol) - expect(image.loadError).toBeNull() - await output.renderOnce() - const bytes = Buffer.concat(output.writes).toString() - expect(bytes).toMatch(protocol === "kitty" ? /\x1b_G[^;]*;[A-Za-z0-9+/=]{20,}/ : /\x1bP[^q]*q/) - expect(output.markdown.getChildren()[0]?.getChildren()[0].visible).toBe(false) -}) - -test("retains the last image across incomplete chunks and until its replacement is ready", async () => { - const output = await setup("```latex\n\\frac{a_1+b_1}{c_1+d_1}", 80, "kitty") - const first = await renderedImage(output) - const previousFrame = output.captureCharFrame() - output.writes.length = 0 - output.markdown.content += "+\\frac{a_" - await output.renderOnce() - expect(output.captureCharFrame()).toBe(previousFrame) - expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable) - const retained = await renderedImage(output) - expect(retained === first).toBe(true) - expect(retained.source).toBe(first.source) - expect(Buffer.concat(output.writes).toString()).not.toMatch(/\x1b_G[^;]*;[A-Za-z0-9+/=]{20,}/) - - output.markdown.content += "2+b_2}{c_2+d_2}" - await output.renderOnce() - const pending = await renderedImage(output) - expect(pending.source).toBe(first.source) - const replacement = await renderedImage(output, first) - expect(replacement.source).not.toBe(first.source) - expect(replacement.width).toBeGreaterThan(first.width) - expect(first.isDestroyed).toBe(true) -}) - -test("releases a retained image when the Markdown view is destroyed", async () => { - const output = await setup("```latex\nx^2", 80, "kitty") - const image = await renderedImage(output) - output.renderer.destroy() - await Promise.resolve() - expect(image.isDestroyed).toBe(true) -}) - -test("regenerates image pixels when the theme changes without editing the formula", async () => { - const output = await setup("```latex\nx=\\frac{1}{2}\n```", 80, "kitty") - const dark = resolveThemeDocument(DEFAULT_THEME, "dark") - const light = resolveThemeDocument(DEFAULT_THEME, "light") - output.palette.text = rgbToHex(dark.text.default) - output.palette.background = rgbToHex(dark.background.default) - output.markdown.refreshStyles() - await output.renderOnce() - const first = await renderedImage(output) - const pixels = first.image!.raw().data - const darkColor = dark.text.default.toInts() - expect( - pixels.some( - (alpha, index) => - index % 4 === 3 && - alpha > 200 && - pixels[index - 3] === darkColor[0] && - pixels[index - 2] === darkColor[1] && - pixels[index - 1] === darkColor[2], - ), - ).toBe(true) - - output.palette.text = rgbToHex(light.text.default) - output.palette.background = rgbToHex(light.background.default) - output.markdown.refreshStyles() - await output.renderOnce() - const second = await renderedImage(output) - expect(first.isDestroyed).toBe(true) - expect(second).not.toBe(first) - const updated = second.image!.raw().data - const lightColor = light.text.default.toInts() - expect( - updated.some( - (alpha, index) => - index % 4 === 3 && - alpha > 200 && - updated[index - 3] === lightColor[0] && - updated[index - 2] === lightColor[1] && - updated[index - 1] === lightColor[2], - ), - ).toBe(true) -}) - -test("does not add a late image after the math block is removed", async () => { - const output = await setup("```latex\nx=\\frac{1}{2}\n```", 80, "kitty") - output.markdown.content = "" - await Bun.sleep(150) - await output.renderOnce() - expect(output.markdown.getChildren()).toHaveLength(0) - expect(Buffer.concat(output.writes).toString()).not.toMatch(/\x1b_G[^;]*;[A-Za-z0-9+/=]{20,}/) -}) - -test("matches the surrounding background despite Kitty clearing cells to terminal defaults", async () => { - const output = await setup("```latex\n\\frac{1}{2}\n```", 80, "kitty") - const first = await renderedImage(output) - expect(Array.from(first.image!.raw().data.subarray(0, 4))).toEqual(RGBA.fromHex(output.palette.background).toInts()) - - output.palette.background = "#e5e7eb" - output.markdown.refreshStyles() - await output.renderOnce() - const second = await renderedImage(output) - expect(Array.from(second.image!.raw().data.subarray(0, 4))).toEqual(RGBA.fromHex(output.palette.background).toInts()) -}) - -test("upgrades to images when terminal capabilities arrive after the first render", async () => { - const output = await setup("```latex\nx=\\frac{1}{2}\n```") - expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) - await output.renderer.setupTerminal() - output.renderer.stdin.emit("data", Buffer.from("\x1b_Gi=31337;OK\x1b\\")) - await output.renderOnce() - expect((await renderedImage(output)).effectiveProtocol).toBe("kitty") -}) - -test("waits until an offscreen formula is visible before rasterizing it", async () => { - const output = await setup("", 80, "kitty") - output.renderer.root.remove(output.markdown) - const viewport = new ScrollBoxRenderable(output.renderer, { width: 80, height: 24 }) - viewport.add(new TextRenderable(output.renderer, { content: "Earlier messages", height: 400, flexShrink: 0 })) - viewport.add(output.markdown) - output.renderer.root.add(viewport) - output.markdown.content = "```latex\nx^2\n```" - await output.renderOnce() - expect(output.markdown.screenY).toBeGreaterThan(24) - await Bun.sleep(150) - expect( - output.markdown - .getChildren()[0] - ?.getChildren() - .some((child) => child instanceof ImageRenderable), - ).toBe(false) - - viewport.scrollTop = viewport.scrollHeight - await output.renderOnce() - expect(output.markdown.screenY).toBeLessThan(24) - expect((await renderedImage(output)).effectiveProtocol).toBe("kitty") -}) - -test("can force styled Unicode even on an image-capable terminal", async () => { - const output = await setup("```latex\nx=\\frac{1}{2}\n```", 80, "kitty") - output.markdown.renderNode = createMarkdownCodeBlockRenderer({ - latex: createLatexCodeBlockRenderer(output.renderer, () => ({ ...output.palette, mode: "cells" })), - }) - await Bun.sleep(150) - await output.renderOnce() - expect(output.markdown.getChildren()[0]?.getChildren()).toHaveLength(1) - expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) - expect(Buffer.concat(output.writes).toString()).not.toMatch(/\x1b_G[^;]*;[A-Za-z0-9+/=]{20,}/) -}) - test.each([String.raw`\text{${"\u4e2d\u6587"}}=x`, String.raw`\frac{\text{${"\u4e2d\u6587"}}}{abcd}=x`])( "preserves wide-character alignment: %s", async (source) => { From 8b0623d1746ef68fbd9a0ceefed7735812580ba0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 14:12:05 -0400 Subject: [PATCH 4/9] refactor(tui): inline the Unicode math engine Keep the Unicode parser, symbols, layout, and input limits in the TUI source tree. Remove the math package and its graphics dependencies, and retain parser and layout regression coverage. --- bun.lock | 33 - packages/tui/package.json | 1 - .../tui/src/feature-plugins/system/latex.ts | 3 +- .../src/feature-plugins/system/latex/LICENSE | 21 + .../feature-plugins/system/latex/layout.ts | 668 ++++++++++++++++++ .../feature-plugins/system/latex/limits.ts | 40 ++ .../feature-plugins/system/latex/parser.ts | 581 +++++++++++++++ .../feature-plugins/system/latex/render.ts | 11 + .../feature-plugins/system/latex/symbols.ts | 307 ++++++++ .../src/feature-plugins/system/latex/types.ts | 90 +++ packages/tui/test/latex.test.ts | 2 +- packages/tui/test/latex/parser.test.ts | 104 +++ packages/tui/test/latex/render.test.ts | 68 ++ 13 files changed, 1893 insertions(+), 36 deletions(-) create mode 100644 packages/tui/src/feature-plugins/system/latex/LICENSE create mode 100644 packages/tui/src/feature-plugins/system/latex/layout.ts create mode 100644 packages/tui/src/feature-plugins/system/latex/limits.ts create mode 100644 packages/tui/src/feature-plugins/system/latex/parser.ts create mode 100644 packages/tui/src/feature-plugins/system/latex/render.ts create mode 100644 packages/tui/src/feature-plugins/system/latex/symbols.ts create mode 100644 packages/tui/src/feature-plugins/system/latex/types.ts create mode 100644 packages/tui/test/latex/parser.test.ts create mode 100644 packages/tui/test/latex/render.test.ts diff --git a/bun.lock b/bun.lock index fd7d40b0d026..b15bec5e6b9d 100644 --- a/bun.lock +++ b/bun.lock @@ -892,7 +892,6 @@ "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", - "opentui-math": "0.1.0", "opentui-spinner": "catalog:", "remeda": "catalog:", "solid-js": "catalog:", @@ -1996,8 +1995,6 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], - "@mathjax/mathjax-newcm-font": ["@mathjax/mathjax-newcm-font@4.1.3", "", {}, "sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew=="], - "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], @@ -2632,32 +2629,6 @@ "@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="], - "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], - - "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], - - "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="], - - "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="], - - "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="], - - "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="], - - "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="], - - "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="], - - "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="], - - "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="], - - "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="], - - "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="], - - "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="], @@ -4612,8 +4583,6 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mathjax": ["mathjax@4.1.3", "", { "dependencies": { "@mathjax/mathjax-newcm-font": "^4.1.3" } }, "sha512-BN/8Pkgn7G1pIDYJqd9md+JHsE/jydSYbyOZnSdSA0WziuVO8mRxdYiWFumkVVly/8U+hm9DpIIoWuvySverzw=="], - "md-to-react-email": ["md-to-react-email@5.0.0", "", { "dependencies": { "marked": "7.0.4" }, "peerDependencies": { "react": "18.x" } }, "sha512-GdBrBUbAAJHypnuyofYGfVos8oUslxHx69hs3CW9P0L8mS1sT6GnJuMBTlz/Fw+2widiwdavcu9UwyLF/BzZ4w=="], "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], @@ -4896,8 +4865,6 @@ "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], - "opentui-math": ["opentui-math@0.1.0", "", { "dependencies": { "@resvg/resvg-js": "2.6.2", "mathjax": "4.1.3" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/react": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-SfGKOogZHXY1YGCSiV9+fFDveiGnm6zrb3ts0LVeb57V6Hzc1vz1xl806L1UVYqC/iKjaDWM8sSrWopduTXapw=="], - "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], "own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="], diff --git a/packages/tui/package.json b/packages/tui/package.json index a0dd8f2133cf..140b4ad8c4e1 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -93,7 +93,6 @@ "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", - "opentui-math": "0.1.0", "opentui-spinner": "catalog:", "remeda": "catalog:", "solid-js": "catalog:", diff --git a/packages/tui/src/feature-plugins/system/latex.ts b/packages/tui/src/feature-plugins/system/latex.ts index 52c0a5d9a30f..529525479a43 100644 --- a/packages/tui/src/feature-plugins/system/latex.ts +++ b/packages/tui/src/feature-plugins/system/latex.ts @@ -11,7 +11,8 @@ import { type MarkdownCodeBlockRenderer, type RenderContext, } from "@opentui/core" -import { LatexParseError, renderLatex, type MathLayout } from "opentui-math" +import { renderLatex } from "./latex/render" +import { LatexParseError, type MathLayout } from "./latex/types" import { stringWidth } from "../../util/string-width" export type LatexOptions = { diff --git a/packages/tui/src/feature-plugins/system/latex/LICENSE b/packages/tui/src/feature-plugins/system/latex/LICENSE new file mode 100644 index 000000000000..a57b6831979c --- /dev/null +++ b/packages/tui/src/feature-plugins/system/latex/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 opentui-math contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/tui/src/feature-plugins/system/latex/layout.ts b/packages/tui/src/feature-plugins/system/latex/layout.ts new file mode 100644 index 000000000000..54f14e3e0729 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/latex/layout.ts @@ -0,0 +1,668 @@ +import type { MathCell, MathLayout, MathNode, MathStyle, MathVariant, RenderLatexOptions, SymbolRole } from "./types" + +interface Box { + width: number + height: number + baseline: number + cells: Array> +} + +interface LayoutContext { + displayMode: boolean + compactScripts: boolean + style?: MathStyle + variant?: MathVariant +} + +const superscripts: Readonly> = { + "0": "⁰", + "1": "¹", + "2": "²", + "3": "³", + "4": "⁴", + "5": "⁵", + "6": "⁶", + "7": "⁷", + "8": "⁸", + "9": "⁹", + "+": "⁺", + "-": "⁻", + "=": "⁼", + "(": "⁽", + ")": "⁾", + n: "ⁿ", + i: "ⁱ", +} + +const subscripts: Readonly> = { + "0": "₀", + "1": "₁", + "2": "₂", + "3": "₃", + "4": "₄", + "5": "₅", + "6": "₆", + "7": "₇", + "8": "₈", + "9": "₉", + "+": "₊", + "-": "₋", + "=": "₌", + "(": "₍", + ")": "₎", + a: "ₐ", + e: "ₑ", + h: "ₕ", + i: "ᵢ", + j: "ⱼ", + k: "ₖ", + l: "ₗ", + m: "ₘ", + n: "ₙ", + o: "ₒ", + p: "ₚ", + r: "ᵣ", + s: "ₛ", + t: "ₜ", + u: "ᵤ", + v: "ᵥ", + x: "ₓ", +} + +export function layoutMath(node: MathNode, options: RenderLatexOptions = {}): MathLayout { + const context: LayoutContext = { + displayMode: options.displayMode ?? true, + compactScripts: options.compactScripts ?? true, + ...(options.color ? { style: { color: options.color } } : {}), + } + return asPublicLayout(layoutNode(node, context)) +} + +function layoutNode(node: MathNode, context: LayoutContext): Box { + switch (node.type) { + case "row": + return layoutRow(node.body, context) + case "symbol": + return textBox(applyVariant(node.value, context.variant), context.style) + case "text": + return textBox(applyVariant(node.value, context.variant), context.style) + case "space": + return blank(node.width, 1, 0) + case "fraction": + return layoutFraction(node.numerator, node.denominator, node.bar, context) + case "root": + return layoutRoot(node.body, node.index, context) + case "scripts": + return layoutScripts(node, context) + case "delimited": + return layoutDelimited(node.left, node.body, node.right, context) + case "matrix": + return layoutMatrix(node.rows, node.environment, context) + case "accent": + return layoutAccent(node.accent, node.body, context) + case "variant": + return layoutNode(node.body, withVariant(context, node.variant)) + case "operator": + return textBox(applyVariant(node.value, context.variant), context.style) + case "overunder": + return layoutOverUnder(node.base, node.over, node.under, context) + case "color": + return layoutNode(node.body, { ...context, style: mergeStyle(context.style, { color: node.color }) }) + } + throw new Error("Unsupported math node") +} + +function layoutRow(nodes: MathNode[], context: LayoutContext): Box { + if (nodes.length === 0) return blank(0, 1, 0) + + const boxes: Box[] = [] + let previousRole: SymbolRole | undefined + + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + const rawRole = nodeRole(node) + const role = normalizeBinaryRole(rawRole, previousRole, nextSignificantRole(nodes, index + 1)) + if (needsMathSpace(previousRole, role, boxes.length)) boxes.push(blank(1, 1, 0)) + boxes.push(layoutNode(node, context)) + if (node.type !== "space") previousRole = role ?? "ordinary" + } + + return hpack(boxes) +} + +function layoutFraction(numeratorNode: MathNode, denominatorNode: MathNode, bar: boolean, context: LayoutContext): Box { + const numerator = layoutNode(numeratorNode, context) + const denominator = layoutNode(denominatorNode, context) + const width = Math.max(numerator.width, denominator.width) + 2 + // Barless fractions (binomials) still reserve an axis row so surrounding + // atoms and their stretching parentheses align between the two entries. + const gap = 1 + const height = numerator.height + denominator.height + gap + // TeX places a fraction's math axis on its rule (or the equivalent empty + // axis row for a binomial). Align neighbors there, not on the denominator. + const baseline = numerator.height + const result = blank(width, height, baseline) + + overlay(result, numerator, Math.floor((width - numerator.width) / 2), 0) + if (bar) drawHorizontal(result, numerator.height, 0, width, "─", context.style) + overlay(result, denominator, Math.floor((width - denominator.width) / 2), numerator.height + gap) + return result +} + +function layoutRoot(bodyNode: MathNode, indexNode: MathNode | undefined, context: LayoutContext): Box { + const body = layoutNode(bodyNode, context) + const index = indexNode ? layoutNode(indexNode, context) : undefined + const indexWidth = index ? Math.max(0, index.width - 1) : 0 + const bodyX = indexWidth + 2 + const width = bodyX + body.width + const height = body.height + 1 + const baseline = body.baseline + 1 + const result = blank(width, height, baseline) + + setCell(result, bodyX - 1, 0, "╭", context.style) + drawHorizontal(result, 0, bodyX, body.width, "─", context.style) + setCell(result, bodyX - 2, baseline, "√", context.style) + overlay(result, body, bodyX, 1) + if (index) overlay(result, index, 0, 0) + return result +} + +function layoutScripts(node: Extract, context: LayoutContext): Box { + const simpleBase = layoutNode(node.base, context) + const superscriptText = node.superscript ? simpleNodeText(node.superscript) : undefined + const subscriptText = node.subscript ? simpleNodeText(node.subscript) : undefined + const canCompactBase = !(node.base.type === "operator" && node.base.limits && context.displayMode) + + if (context.compactScripts && canCompactBase && superscriptText !== undefined && subscriptText !== undefined) { + const compactSuperscript = mapScript(superscriptText, superscripts) + const compactSubscript = mapScript(subscriptText, subscripts) + if (compactSuperscript !== undefined && compactSubscript !== undefined) { + return hpack([simpleBase, textBox(compactSuperscript, context.style), textBox(compactSubscript, context.style)]) + } + } + + if ( + context.compactScripts && + canCompactBase && + !(node.superscript && node.subscript) && + superscriptText !== undefined + ) { + const compact = mapScript(superscriptText, superscripts) + if (compact !== undefined) return hpack([simpleBase, textBox(compact, context.style)]) + } + if ( + context.compactScripts && + canCompactBase && + !(node.superscript && node.subscript) && + subscriptText !== undefined + ) { + const compact = mapScript(subscriptText, subscripts) + if (compact !== undefined) return hpack([simpleBase, textBox(compact, context.style)]) + } + + if (node.base.type === "operator" && node.base.limits && context.displayMode) { + return layoutOverUnder(node.base, node.superscript, node.subscript, context) + } + + const superscript = node.superscript ? layoutNode(node.superscript, context) : undefined + const subscript = node.subscript ? layoutNode(node.subscript, context) : undefined + const scriptWidth = Math.max(superscript?.width ?? 0, subscript?.width ?? 0) + const topHeight = superscript?.height ?? 0 + const bottomHeight = subscript?.height ?? 0 + const width = simpleBase.width + scriptWidth + const height = topHeight + simpleBase.height + bottomHeight + const baseline = topHeight + simpleBase.baseline + const result = blank(width, height, baseline) + + overlay(result, simpleBase, 0, topHeight) + if (superscript) overlay(result, superscript, simpleBase.width, 0) + if (subscript) overlay(result, subscript, simpleBase.width, topHeight + simpleBase.height) + return result +} + +function layoutOverUnder( + baseNode: MathNode, + overNode: MathNode | undefined, + underNode: MathNode | undefined, + context: LayoutContext, +): Box { + const base = layoutNode(baseNode, context) + const over = overNode ? layoutNode(overNode, context) : undefined + const under = underNode ? layoutNode(underNode, context) : undefined + const width = Math.max(base.width, over?.width ?? 0, under?.width ?? 0) + const overHeight = over?.height ?? 0 + const height = overHeight + base.height + (under?.height ?? 0) + const baseline = overHeight + base.baseline + const result = blank(width, height, baseline) + + if (over) overlay(result, over, Math.floor((width - over.width) / 2), 0) + overlay(result, base, Math.floor((width - base.width) / 2), overHeight) + if (under) overlay(result, under, Math.floor((width - under.width) / 2), overHeight + base.height) + return result +} + +function layoutDelimited(left: string, bodyNode: MathNode, right: string, context: LayoutContext): Box { + const body = layoutNode(bodyNode, context) + const leftBox = delimiterBox(left, body.height, body.baseline, true, context.style) + const rightBox = delimiterBox(right, body.height, body.baseline, false, context.style) + return hpack([leftBox, body, rightBox]) +} + +function layoutMatrix( + rows: MathNode[][], + environment: Extract["environment"], + context: LayoutContext, +): Box { + const cellRows = rows.map((row) => row.map((cell) => layoutNode(cell, context))) + const columnCount = Math.max(0, ...cellRows.map((row) => row.length)) + const columnWidths = Array.from({ length: columnCount }, (_, column) => + Math.max(0, ...cellRows.map((row) => row[column]?.width ?? 0)), + ) + const rowAscents = cellRows.map((row) => Math.max(0, ...row.map((cell) => cell.baseline))) + const rowDescents = cellRows.map((row) => Math.max(0, ...row.map((cell) => cell.height - cell.baseline - 1))) + const rowHeights = rowAscents.map((ascent, index) => ascent + 1 + rowDescents[index]) + const columnGap = environment === "cases" ? 2 : environment === "aligned" || environment === "align" ? 2 : 1 + const width = columnWidths.reduce((sum, value) => sum + value, 0) + Math.max(0, columnCount - 1) * columnGap + const height = Math.max(1, rowHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, rows.length - 1)) + const result = blank(width, height, Math.floor(height / 2)) + let y = 0 + + for (let rowIndex = 0; rowIndex < cellRows.length; rowIndex++) { + let x = 0 + const cells = cellRows[rowIndex] + for (let column = 0; column < columnCount; column++) { + const cell = cells[column] + const columnWidth = columnWidths[column] + if (cell) { + const centered = environment !== "aligned" && environment !== "align" && environment !== "cases" + const cellX = + x + (centered ? Math.floor((columnWidth - cell.width) / 2) : column % 2 === 0 ? columnWidth - cell.width : 0) + const cellY = y + rowAscents[rowIndex] - cell.baseline + overlay(result, cell, cellX, cellY) + } + x += columnWidth + columnGap + } + y += rowHeights[rowIndex] + 1 + } + + const delimiters = matrixDelimiters(environment) + return delimiters + ? hpack([ + delimiterBox(delimiters[0], height, result.baseline, true, context.style), + result, + delimiterBox(delimiters[1], height, result.baseline, false, context.style), + ]) + : result +} + +function layoutAccent( + accent: Extract["accent"], + bodyNode: MathNode, + context: LayoutContext, +): Box { + const body = layoutNode(bodyNode, context) + if (accent === "underline") { + const result = blank(body.width, body.height + 1, body.baseline) + overlay(result, body, 0, 0) + drawHorizontal(result, body.height, 0, body.width, "─", context.style) + return result + } + + const result = blank(body.width, body.height + 1, body.baseline + 1) + overlay(result, body, 0, 1) + const mark = + accent === "hat" || accent === "widehat" + ? body.width === 1 + ? "^" + : "⌢" + : accent === "bar" || accent === "overline" + ? "─" + : accent === "vec" + ? "→" + : accent === "tilde" + ? "~" + : accent === "dot" + ? "·" + : "¨" + + if (accent === "bar" || accent === "overline") drawHorizontal(result, 0, 0, body.width, mark, context.style) + else setCell(result, Math.max(0, Math.floor((body.width - cellWidth(mark)) / 2)), 0, mark, context.style) + return result +} + +function delimiterBox( + delimiter: string, + height: number, + baseline: number, + left: boolean, + style: MathStyle | undefined, +): Box { + if (!delimiter) return blank(0, height, baseline) + if (height <= 1) return textBox(delimiter, style) + const glyphs = delimiterGlyphs(delimiter) + const width = Math.max(...glyphs.map(cellWidth)) + const result = blank(width, height, baseline) + for (let y = 0; y < height; y++) { + const glyph = y === 0 ? glyphs[0] : y === height - 1 ? glyphs[2] : glyphs[1] + setCell(result, 0, y, glyph, style) + } + if ((delimiter === "{" || delimiter === "}") && height >= 3) { + setCell(result, 0, Math.floor(height / 2), left ? "⎨" : "⎬", style) + } + return result +} + +function delimiterGlyphs(delimiter: string): [string, string, string] { + switch (delimiter) { + case "(": + return ["⎛", "⎜", "⎝"] + case ")": + return ["⎞", "⎟", "⎠"] + case "[": + return ["⎡", "⎢", "⎣"] + case "]": + return ["⎤", "⎥", "⎦"] + case "{": + return ["⎧", "⎪", "⎩"] + case "}": + return ["⎫", "⎪", "⎭"] + case "⌊": + return ["│", "│", "⌊"] + case "⌋": + return ["│", "│", "⌋"] + case "⌈": + return ["⌈", "│", "│"] + case "⌉": + return ["⌉", "│", "│"] + case "⟨": + return ["/", "│", "\\"] + case "⟩": + return ["\\", "│", "/"] + default: + return [delimiter, delimiter, delimiter] + } +} + +function matrixDelimiters(environment: string): [string, string] | undefined { + switch (environment) { + case "pmatrix": + return ["(", ")"] + case "bmatrix": + return ["[", "]"] + case "Bmatrix": + return ["{", "}"] + case "vmatrix": + return ["│", "│"] + case "Vmatrix": + return ["║", "║"] + case "cases": + return ["{", ""] + default: + return undefined + } +} + +function hpack(boxes: Box[]): Box { + if (boxes.length === 0) return blank(0, 1, 0) + const ascent = Math.max(...boxes.map((box) => box.baseline)) + const descent = Math.max(...boxes.map((box) => box.height - box.baseline - 1)) + const width = boxes.reduce((sum, box) => sum + box.width, 0) + const result = blank(width, ascent + descent + 1, ascent) + let x = 0 + for (const box of boxes) { + overlay(result, box, x, ascent - box.baseline) + x += box.width + } + return result +} + +function textBox(text: string, style?: MathStyle): Box { + const graphemes = segment(text) + const width = graphemes.reduce((sum, grapheme) => sum + cellWidth(grapheme), 0) + const result = blank(width, 1, 0) + let x = 0 + for (const grapheme of graphemes) { + setCell(result, x, 0, grapheme, style) + x += cellWidth(grapheme) + } + return result +} + +function blank(width: number, height: number, baseline: number): Box { + return { + width: Math.max(0, width), + height: Math.max(1, height), + baseline: Math.max(0, baseline), + cells: Array.from({ length: Math.max(1, height) }, () => Array(Math.max(0, width))), + } +} + +function overlay(target: Box, source: Box, x: number, y: number): void { + for (let sourceY = 0; sourceY < source.height; sourceY++) { + for (let sourceX = 0; sourceX < source.width; sourceX++) { + const cell = source.cells[sourceY]?.[sourceX] + if (cell) target.cells[y + sourceY][x + sourceX] = cell + } + } +} + +function drawHorizontal( + box: Box, + y: number, + x: number, + width: number, + char: string, + style: MathStyle | undefined, +): void { + for (let offset = 0; offset < width; offset++) setCell(box, x + offset, y, char, style) +} + +function setCell(box: Box, x: number, y: number, char: string, style?: MathStyle): void { + if (x < 0 || y < 0 || x >= box.width || y >= box.height) return + box.cells[y][x] = style ? { char, style } : { char } +} + +function nodeRole(node: MathNode): SymbolRole | undefined { + if (node.type === "symbol") return node.role + if (node.type === "operator") return "operator" + // Tall constructs need a terminal-cell side bearing. Treating them like + // operators gives their fraction bars/radical hooks breathing room without + // adding padding inside the construct itself. + if (node.type === "fraction" || node.type === "root" || node.type === "matrix") return "operator" + if (node.type === "scripts") return nodeRole(node.base) + return undefined +} + +function needsMathSpace(previous: SymbolRole | undefined, current: SymbolRole | undefined, count: number): boolean { + if (count === 0) return false + if (previous === "punctuation" || previous === "opening" || current === "punctuation" || current === "closing") { + return false + } + return ( + previous === "binary" || + previous === "relation" || + previous === "operator" || + current === "binary" || + current === "relation" || + current === "operator" + ) +} + +function normalizeBinaryRole( + role: SymbolRole | undefined, + previous: SymbolRole | undefined, + next: SymbolRole | undefined, +): SymbolRole | undefined { + if (role !== "binary") return role + if ( + previous === undefined || + previous === "binary" || + previous === "relation" || + previous === "operator" || + previous === "punctuation" || + previous === "opening" || + next === undefined || + next === "binary" || + next === "relation" || + next === "punctuation" || + next === "closing" + ) { + return "ordinary" + } + return role +} + +function nextSignificantRole(nodes: MathNode[], start: number): SymbolRole | undefined { + for (let index = start; index < nodes.length; index++) { + const node = nodes[index] + if (node.type === "space") continue + return nodeRole(node) ?? "ordinary" + } + return undefined +} + +function simpleNodeText(node: MathNode): string | undefined { + if (node.type === "symbol" || node.type === "text" || node.type === "operator") return node.value + if (node.type === "row") { + const values = node.body.map(simpleNodeText) + return values.every((value) => value !== undefined) ? values.join("") : undefined + } + return undefined +} + +function mapScript(value: string, table: Readonly>): string | undefined { + let result = "" + for (const char of value) { + const mapped = table[char] + if (!mapped) return undefined + result += mapped + } + return result +} + +function withVariant(context: LayoutContext, variant: MathVariant): LayoutContext { + const style = + variant === "bold" + ? { bold: true } + : variant === "italic" + ? { italic: true } + : variant === "sans" + ? {} + : variant === "monospace" + ? {} + : {} + return { ...context, variant, style: mergeStyle(context.style, style) } +} + +function applyVariant(value: string, variant: MathVariant | undefined): string { + if (!variant || variant === "normal" || variant === "bold" || variant === "italic") return value + + const exceptions: Partial>>> = { + "double-struck": { + C: "ℂ", + H: "ℍ", + N: "ℕ", + P: "ℙ", + Q: "ℚ", + R: "ℝ", + Z: "ℤ", + }, + script: { + B: "ℬ", + E: "ℰ", + F: "ℱ", + H: "ℋ", + I: "ℐ", + L: "ℒ", + M: "ℳ", + R: "ℛ", + e: "ℯ", + g: "ℊ", + o: "ℴ", + }, + fraktur: { + C: "ℭ", + H: "ℌ", + I: "ℑ", + R: "ℜ", + Z: "ℨ", + }, + } + + const ranges: Partial> = { + "double-struck": [0x1d538, 0x1d552, 0x1d7d8], + script: [0x1d49c, 0x1d4b6], + fraktur: [0x1d504, 0x1d51e], + sans: [0x1d5a0, 0x1d5ba, 0x1d7e2], + monospace: [0x1d670, 0x1d68a, 0x1d7f6], + } + const range = ranges[variant] + if (!range) return value + + return Array.from(value) + .map((char) => { + const exception = exceptions[variant]?.[char] + if (exception) return exception + const code = char.codePointAt(0)! + if (code >= 65 && code <= 90) return String.fromCodePoint(range[0] + code - 65) + if (code >= 97 && code <= 122) return String.fromCodePoint(range[1] + code - 97) + if (range[2] !== undefined && code >= 48 && code <= 57) return String.fromCodePoint(range[2] + code - 48) + return char + }) + .join("") +} + +function mergeStyle(base: MathStyle | undefined, overlayStyle: MathStyle): MathStyle { + return { ...base, ...overlayStyle } +} + +function segment(value: string): string[] { + if (typeof Intl.Segmenter === "function") { + return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value)].map((item) => item.segment) + } + return Array.from(value) +} + +function cellWidth(value: string): number { + if (value.length === 0) return 0 + if (/^(?:[\u0000-\u001f\u007f-\u009f]|[\u0300-\u036f]|[\ufe00-\ufe0f])$/u.test(value)) return 0 + const code = value.codePointAt(0) ?? 0 + if ( + code >= 0x1100 && + (code <= 0x115f || + code === 0x2329 || + code === 0x232a || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe10 && code <= 0xfe6f) || + (code >= 0xff00 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) || + (code >= 0x1f300 && code <= 0x1faff)) + ) { + return 2 + } + return 1 +} + +function asPublicLayout(box: Box): MathLayout { + return { + width: box.width, + height: box.height, + baseline: box.baseline, + cells: box.cells, + toString() { + return box.cells + .map((row) => { + let output = "" + for (let x = 0; x < box.width; x++) { + const cell = row[x] + output += cell?.char ?? " " + if (cell && cellWidth(cell.char) > 1) x += cellWidth(cell.char) - 1 + } + return output.trimEnd() + }) + .join("\n") + }, + } +} diff --git a/packages/tui/src/feature-plugins/system/latex/limits.ts b/packages/tui/src/feature-plugins/system/latex/limits.ts new file mode 100644 index 000000000000..14319cbd98da --- /dev/null +++ b/packages/tui/src/feature-plugins/system/latex/limits.ts @@ -0,0 +1,40 @@ +import { LatexParseError } from "./types" + +export const DEFAULT_MAX_SOURCE_LENGTH = 100_000 +export const DEFAULT_MAX_NESTING_DEPTH = 256 + +export function resolvePositiveInteger(value: number | undefined, fallback: number, optionName: string): number { + if (value === undefined) return fallback + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError(`${optionName} must be a positive safe integer`) + } + return value +} + +export function assertSourceLength(source: string, maximum: number, label = "LaTeX source"): void { + if (source.length > maximum) { + throw new LatexParseError(`${label} exceeds the ${maximum}-character limit`, maximum) + } +} + +export function assertNestingDepth(source: string, maximum: number): void { + let depth = 0 + let slashRun = 0 + for (let index = 0; index < source.length; index++) { + const char = source[index] + if (char === "\\") { + slashRun++ + continue + } + const escaped = slashRun % 2 === 1 + slashRun = 0 + if (char === "{" && !escaped) { + depth++ + if (depth > maximum) { + throw new LatexParseError(`LaTeX nesting exceeds the ${maximum}-level limit`, index) + } + } else if (char === "}" && !escaped) { + depth = Math.max(0, depth - 1) + } + } +} diff --git a/packages/tui/src/feature-plugins/system/latex/parser.ts b/packages/tui/src/feature-plugins/system/latex/parser.ts new file mode 100644 index 000000000000..eba51939f07d --- /dev/null +++ b/packages/tui/src/feature-plugins/system/latex/parser.ts @@ -0,0 +1,581 @@ +import { + LatexParseError, + type AccentKind, + type MathNode, + type MathVariant, + type MatrixEnvironment, + type ParseOptions, +} from "./types" +import { + assertNestingDepth, + assertSourceLength, + DEFAULT_MAX_NESTING_DEPTH, + DEFAULT_MAX_SOURCE_LENGTH, + resolvePositiveInteger, +} from "./limits" +import { delimiterTable, largeOperators, namedOperators, spacingCommands, symbolTable } from "./symbols" + +const matrixEnvironments: MatrixEnvironment[] = [ + "matrix", + "pmatrix", + "bmatrix", + "Bmatrix", + "vmatrix", + "Vmatrix", + "cases", + "aligned", + "align", + "gathered", + "gather", + "smallmatrix", + "array", +] + +const accents: Readonly> = { + hat: "hat", + widehat: "widehat", + bar: "bar", + overline: "overline", + underline: "underline", + vec: "vec", + tilde: "tilde", + widetilde: "tilde", + dot: "dot", + ddot: "ddot", +} + +const variants: Readonly> = { + mathrm: "normal", + textrm: "normal", + mathnormal: "normal", + mathbf: "bold", + boldsymbol: "bold", + bm: "bold", + mathit: "italic", + mathsf: "sans", + mathtt: "monospace", + mathbb: "double-struck", + mathcal: "script", + mathscr: "script", + mathfrak: "fraktur", +} + +export function parseLatex(source: string, options: ParseOptions = {}): MathNode { + const expanded = expandLatexMacros(source, options) + const maxDepth = resolvePositiveInteger(options.maxDepth, DEFAULT_MAX_NESTING_DEPTH, "maxDepth") + return new Parser(expanded, options.strict ?? false, maxDepth).parse() +} + +export function expandLatexMacros(source: string, options: ParseOptions = {}): string { + const maxSourceLength = resolvePositiveInteger(options.maxSourceLength, DEFAULT_MAX_SOURCE_LENGTH, "maxSourceLength") + const maxExpandedLength = resolvePositiveInteger(options.maxExpandedLength, maxSourceLength, "maxExpandedLength") + const maxExpand = resolvePositiveInteger(options.maxExpand, 100, "maxExpand") + const maxDepth = resolvePositiveInteger(options.maxDepth, DEFAULT_MAX_NESTING_DEPTH, "maxDepth") + assertSourceLength(source, maxSourceLength) + assertNestingDepth(source, maxDepth) + const expanded = expandMacros(source, options.macros, maxExpand, maxExpandedLength) + assertNestingDepth(expanded, maxDepth) + return expanded +} + +function expandMacros( + source: string, + macros: ParseOptions["macros"], + maxExpand: number, + maxExpandedLength: number, +): string { + assertSourceLength(source, maxExpandedLength, "Expanded LaTeX source") + if (!macros || Object.keys(macros).length === 0) return source + + let result = source + for (let pass = 0; pass < maxExpand; pass++) { + let changed = false + let cursor = 0 + let outputLength = 0 + const output: string[] = [] + const commands = /\\[A-Za-z@]+|\\./g + + for (const match of result.matchAll(commands)) { + const command = match[0] + const index = match.index + const replacement = macros[command] ?? macros[command.slice(1)] + if (replacement === undefined) continue + if (typeof replacement !== "string") { + throw new TypeError(`Macro ${command} must expand to a string`) + } + + appendWithinLimit(output, result.slice(cursor, index), outputLength, maxExpandedLength) + outputLength += index - cursor + appendWithinLimit(output, replacement, outputLength, maxExpandedLength) + outputLength += replacement.length + cursor = index + command.length + changed = true + } + + if (!changed) return result + appendWithinLimit(output, result.slice(cursor), outputLength, maxExpandedLength) + result = output.join("") + } + + throw new LatexParseError(`Macro expansion exceeded ${maxExpand} passes`, 0) +} + +function appendWithinLimit(output: string[], value: string, currentLength: number, maximum: number): void { + if (currentLength + value.length > maximum) { + throw new LatexParseError(`Expanded LaTeX source exceeds the ${maximum}-character limit`, maximum) + } + output.push(value) +} + +class Parser { + private position = 0 + private depth = 0 + + constructor( + private readonly source: string, + private readonly strict: boolean, + private readonly maxDepth: number, + ) {} + + public parse(): MathNode { + const body = this.parseRow() + this.skipMathWhitespace() + if (!this.done()) this.fail(`Unexpected "${this.peek()}"`) + return row(body) + } + + private parseRow(stop?: () => boolean): MathNode[] { + const body: MathNode[] = [] + + while (!this.done()) { + this.skipMathWhitespace() + if (this.done() || stop?.()) break + + const current = this.peek() + if (current === "}") break + + if (current === "^" || current === "_") { + this.position++ + const script = this.parseArgument() + const previous = body.pop() ?? { type: "row", body: [] } + const existing = previous.type === "scripts" ? previous : { type: "scripts" as const, base: previous } + if (current === "^") existing.superscript = script + else existing.subscript = script + body.push(existing) + continue + } + + if (current === "\\" && this.applyLimitsModifier(body)) continue + body.push(this.parseAtom()) + } + + return body + } + + private parseAtom(): MathNode { + this.depth++ + if (this.depth > this.maxDepth) { + this.depth-- + this.fail(`LaTeX nesting exceeds the ${this.maxDepth}-level limit`) + } + try { + return this.parseAtomInner() + } finally { + this.depth-- + } + } + + private parseAtomInner(): MathNode { + const current = this.peek() + if (current === "{") return this.parseGroup() + if (current === "\\") return this.parseCommand() + if (current === "~") { + this.position++ + return { type: "space", width: 1 } + } + + this.position++ + return { type: "symbol", value: current, role: inferRole(current) } + } + + private parseCommand(): MathNode { + const start = this.position + const command = this.readCommand() + + if (command === "\\") return { type: "row", body: [] } + if (command === "begin") return this.parseEnvironment() + if (command === "frac" || command === "dfrac" || command === "tfrac" || command === "cfrac") { + return { + type: "fraction", + numerator: this.parseArgument(), + denominator: this.parseArgument(), + bar: true, + } + } + if (command === "binom" || command === "dbinom" || command === "tbinom") { + const fraction: MathNode = { + type: "fraction", + numerator: this.parseArgument(), + denominator: this.parseArgument(), + bar: false, + } + return { type: "delimited", left: "(", body: fraction, right: ")" } + } + if (command === "sqrt") { + const index = this.peekOptionalArgument() + const result: MathNode = { type: "root", body: this.parseArgument() } + if (index) result.index = index + return result + } + if (command === "left") return this.parseLeftRight() + if (command === "middle") return { type: "symbol", value: this.readDelimiter() } + if (command === "right") { + this.position = start + this.fail("Unexpected \\right") + } + if (command in accents) { + return { type: "accent", accent: accents[command], body: this.parseArgument() } + } + if (command in variants) { + return { type: "variant", variant: variants[command], body: this.parseArgument() } + } + if (command === "text" || command === "mbox") return { type: "text", value: this.readTextGroup() } + if (command === "operatorname") { + const limits = this.peek() === "*" + if (limits) this.position++ + return { type: "operator", value: this.readTextGroup(), limits } + } + if (command === "overset" || command === "stackrel") { + const over = this.parseArgument() + const base = this.parseArgument() + return { type: "overunder", base, over } + } + if (command === "underset") { + const under = this.parseArgument() + const base = this.parseArgument() + return { type: "overunder", base, under } + } + if (command === "overbrace") { + return { type: "overunder", base: this.parseArgument(), over: { type: "symbol", value: "⏞" } } + } + if (command === "underbrace") { + return { type: "overunder", base: this.parseArgument(), under: { type: "symbol", value: "⏟" } } + } + if (command === "textcolor") { + const color = this.readRawGroup() + return { type: "color", color, body: this.parseArgument() } + } + if (command === "color") { + const color = this.readRawGroup() + return { type: "color", color, body: row(this.parseRow()) } + } + if (command === "not") { + const target = this.parseAtom() + if (target.type === "symbol") return { ...target, value: negateSymbol(target.value) } + return { type: "row", body: [{ type: "symbol", value: "¬" }, target] } + } + if (command === "pmod") { + return { + type: "row", + body: [ + { type: "space", width: 1 }, + { type: "text", value: "(mod " }, + this.parseArgument(), + { type: "text", value: ")" }, + ], + } + } + if (command === "mod" || command === "bmod") return { type: "operator", value: "mod", limits: false } + if ( + command === "limits" || + command === "nolimits" || + command === "displaylines" || + command === "displaystyle" || + command === "textstyle" || + command === "scriptstyle" || + command === "scriptscriptstyle" + ) { + return { type: "row", body: [] } + } + if (/^(?:big|Big|bigg|Bigg)[lrm]?$/.test(command)) { + return { type: "symbol", value: this.readDelimiter() } + } + if (command in spacingCommands) return { type: "space", width: spacingCommands[command] } + if (command in symbolTable) { + const symbol = symbolTable[command] + return { type: "symbol", value: symbol.value, ...(symbol.role ? { role: symbol.role } : {}) } + } + if (command in largeOperators) { + return { type: "operator", value: largeOperators[command], limits: !command.includes("int") } + } + if (namedOperators.has(command)) { + return { + type: "operator", + value: command, + limits: command.startsWith("lim") || command === "min" || command === "max", + } + } + if (command === "backslash") return { type: "symbol", value: "\\" } + if (command in delimiterTable) return { type: "symbol", value: delimiterTable[command] } + if (command === "{" || command === "}") return { type: "symbol", value: command } + if (command === "%" || command === "#" || command === "$" || command === "&" || command === "_") { + return { type: "symbol", value: command } + } + + if (this.strict) this.fail(`Unsupported command \\${command}`, start) + return { type: "text", value: `\\${command}` } + } + + private parseEnvironment(): MathNode { + const rawEnvironment = this.readRawGroup() + const unstarredEnvironment = rawEnvironment.endsWith("*") ? rawEnvironment.slice(0, -1) : rawEnvironment + const environment = matrixEnvironments.find((name) => name === unstarredEnvironment) + if (!environment) { + if (this.strict) this.fail(`Unsupported environment ${unstarredEnvironment}`) + const content = this.readUntilEnd(rawEnvironment) + return { type: "text", value: content } + } + if (environment === "array" && this.peekAfterWhitespace() === "{") this.readRawGroup() + + const rows: MathNode[][] = [] + let cells: MathNode[] = [] + + while (!this.done()) { + this.skipMathWhitespace() + if (this.isEndEnvironment(rawEnvironment)) { + this.consumeEndEnvironment(rawEnvironment) + if (cells.length > 0 || rows.length === 0) rows.push(cells) + return { type: "matrix", rows, environment } + } + + const cellStart = this.position + const cell = row( + this.parseRow( + () => + this.peek() === "&" || + this.source.startsWith("\\\\", this.position) || + this.isEndEnvironment(rawEnvironment), + ), + ) + if (this.position === cellStart) { + this.fail(`Unexpected "${this.peek()}" in ${rawEnvironment}`) + } + cells.push(cell) + this.skipMathWhitespace() + + if (this.peek() === "&") { + this.position++ + continue + } + if (this.source.startsWith("\\\\", this.position)) { + this.position += 2 + this.consumeOptionalBracket() + rows.push(cells) + cells = [] + continue + } + } + + return this.fail(`Missing \\end{${rawEnvironment}}`) + } + + private parseLeftRight(): MathNode { + const left = this.readDelimiter() + const body = row(this.parseRow(() => this.source.startsWith("\\right", this.position))) + if (!this.source.startsWith("\\right", this.position)) this.fail("Missing \\right") + this.readCommand() + const right = this.readDelimiter() + return { type: "delimited", left, body, right } + } + + private parseArgument(): MathNode { + this.skipMathWhitespace() + if (this.peek() === "{") return this.parseGroup() + if (this.done()) this.fail("Expected an argument") + return this.parseAtom() + } + + private parseGroup(): MathNode { + this.expect("{") + const body = row(this.parseRow()) + this.expect("}") + return body + } + + private peekOptionalArgument(): MathNode | undefined { + this.skipMathWhitespace() + if (this.peek() !== "[") return undefined + this.position++ + const body = row(this.parseRow(() => this.peek() === "]")) + this.expect("]") + return body + } + + private consumeOptionalBracket(): void { + this.skipMathWhitespace() + if (this.peek() !== "[") return + let depth = 0 + while (!this.done()) { + const char = this.source[this.position++] + if (char === "[") depth++ + if (char === "]" && --depth === 0) return + } + } + + private readDelimiter(): string { + this.skipMathWhitespace() + let token: string + if (this.peek() === "\\") token = this.readCommand() + else token = this.source[this.position++] ?? "" + return delimiterTable[token] ?? delimiterTable[`\\${token}`] ?? token + } + + private readCommand(): string { + this.expect("\\") + if (this.done()) return "\\" + const next = this.peek() + if (!/[A-Za-z@]/.test(next)) { + this.position++ + return next + } + + const start = this.position + while (!this.done() && /[A-Za-z@]/.test(this.peek())) this.position++ + const command = this.source.slice(start, this.position) + if (this.peek() === " ") this.position++ + return command + } + + private readRawGroup(): string { + this.skipMathWhitespace() + this.expect("{") + const start = this.position + let depth = 1 + while (!this.done()) { + const char = this.source[this.position++] + const escaped = (char === "{" || char === "}") && this.isEscaped(this.position - 1) + if (char === "{" && !escaped) depth++ + if (char === "}" && !escaped && --depth === 0) return this.source.slice(start, this.position - 1) + } + return this.fail("Unterminated group", start) + } + + private applyLimitsModifier(body: MathNode[]): boolean { + const match = /^\\(limits|nolimits)(?![A-Za-z@])/.exec(this.source.slice(this.position)) + if (!match) return false + this.position += match[0].length + + const target = body.at(-1) + const operator = + target?.type === "operator" + ? target + : target?.type === "scripts" && target.base.type === "operator" + ? target.base + : undefined + if (operator) operator.limits = match[1] === "limits" + return true + } + + private isEscaped(index: number): boolean { + let slashCount = 0 + for (let cursor = index - 1; cursor >= 0 && this.source[cursor] === "\\"; cursor--) slashCount++ + return slashCount % 2 === 1 + } + + private readTextGroup(): string { + return this.readRawGroup() + .replace(/\\([{}%#$&_])/g, "$1") + .replace(/~/g, " ") + .replace(/\\textbackslash\b/g, "\\") + } + + private isEndEnvironment(environment: string): boolean { + return this.source.startsWith(`\\end{${environment}}`, this.position) + } + + private consumeEndEnvironment(environment: string): void { + this.position += `\\end{${environment}}`.length + } + + private readUntilEnd(environment: string): string { + const marker = `\\end{${environment}}` + const end = this.source.indexOf(marker, this.position) + if (end < 0) this.fail(`Missing ${marker}`) + const content = this.source.slice(this.position, end) + this.position = end + marker.length + return content + } + + private skipMathWhitespace(): void { + while (!this.done()) { + if (/\s/.test(this.peek())) { + this.position++ + continue + } + if (this.peek() === "%") { + while (!this.done() && this.peek() !== "\n") this.position++ + continue + } + break + } + } + + private peekAfterWhitespace(): string { + let offset = this.position + while (offset < this.source.length && /\s/.test(this.source[offset])) offset++ + return this.source[offset] ?? "" + } + + private expect(value: string): void { + if (!this.source.startsWith(value, this.position)) this.fail(`Expected "${value}"`) + this.position += value.length + } + + private peek(): string { + return this.source[this.position] ?? "" + } + + private done(): boolean { + return this.position >= this.source.length + } + + private fail(message: string, position = this.position): never { + throw new LatexParseError(message, position) + } +} + +function row(body: MathNode[]): MathNode { + if (body.length === 1) return body[0] + return { type: "row", body } +} + +function inferRole(value: string): "binary" | "relation" | "punctuation" | "opening" | "closing" | "ordinary" { + if ("+-*/×÷±∓".includes(value)) return "binary" + if ("=<>≤≥≠≈∈∉⊂⊃".includes(value)) return "relation" + if (",;:".includes(value)) return "punctuation" + if ("([{".includes(value)) return "opening" + if (")]}".includes(value)) return "closing" + return "ordinary" +} + +function negateSymbol(value: string): string { + const negated: Record = { + "=": "≠", + "∈": "∉", + "∋": "∌", + "≡": "≢", + "≈": "≉", + "∼": "≁", + "<": "≮", + ">": "≯", + "≤": "≰", + "≥": "≱", + "⊂": "⊄", + "⊃": "⊅", + "⊆": "⊈", + "⊇": "⊉", + "∣": "∤", + "∥": "∦", + } + return negated[value] ?? `${value}̸` +} diff --git a/packages/tui/src/feature-plugins/system/latex/render.ts b/packages/tui/src/feature-plugins/system/latex/render.ts new file mode 100644 index 000000000000..4ebc8aa59b82 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/latex/render.ts @@ -0,0 +1,11 @@ +import { layoutMath } from "./layout" +import { parseLatex } from "./parser" +import type { MathLayout, RenderLatexOptions } from "./types" + +export function renderLatex(source: string, options: RenderLatexOptions = {}): MathLayout { + return layoutMath(parseLatex(source, options), options) +} + +export function renderLatexToString(source: string, options: RenderLatexOptions = {}): string { + return renderLatex(source, options).toString() +} diff --git a/packages/tui/src/feature-plugins/system/latex/symbols.ts b/packages/tui/src/feature-plugins/system/latex/symbols.ts new file mode 100644 index 000000000000..83db4341105e --- /dev/null +++ b/packages/tui/src/feature-plugins/system/latex/symbols.ts @@ -0,0 +1,307 @@ +import type { SymbolRole } from "./types" + +export interface SymbolDefinition { + value: string + role?: SymbolRole +} + +const ordinary: Record = { + alpha: "α", + beta: "β", + gamma: "γ", + delta: "δ", + epsilon: "ε", + varepsilon: "ϵ", + zeta: "ζ", + eta: "η", + theta: "θ", + vartheta: "ϑ", + iota: "ι", + kappa: "κ", + lambda: "λ", + mu: "μ", + nu: "ν", + xi: "ξ", + omicron: "ο", + pi: "π", + varpi: "ϖ", + rho: "ρ", + varrho: "ϱ", + sigma: "σ", + varsigma: "ς", + tau: "τ", + upsilon: "υ", + phi: "ϕ", + varphi: "φ", + chi: "χ", + psi: "ψ", + omega: "ω", + Gamma: "Γ", + Delta: "Δ", + Theta: "Θ", + Lambda: "Λ", + Xi: "Ξ", + Pi: "Π", + Sigma: "Σ", + Upsilon: "Υ", + Phi: "Φ", + Psi: "Ψ", + Omega: "Ω", + infty: "∞", + ell: "ℓ", + hbar: "ℏ", + imath: "ı", + jmath: "ȷ", + Re: "ℜ", + Im: "ℑ", + aleph: "ℵ", + beth: "ℶ", + gimel: "ℷ", + daleth: "ℸ", + partial: "∂", + nabla: "∇", + angle: "∠", + measuredangle: "∡", + triangle: "△", + square: "□", + lozenge: "◊", + top: "⊤", + bot: "⊥", + emptyset: "∅", + varnothing: "∅", + forall: "∀", + exists: "∃", + nexists: "∄", + neg: "¬", + lnot: "¬", + prime: "′", + backprime: "‵", + clubsuit: "♣", + diamondsuit: "♢", + heartsuit: "♡", + spadesuit: "♠", + checkmark: "✓", +} + +const binary: Record = { + pm: "±", + mp: "∓", + times: "×", + div: "÷", + cdot: "·", + ast: "∗", + star: "⋆", + circ: "∘", + bullet: "∙", + oplus: "⊕", + ominus: "⊖", + otimes: "⊗", + oslash: "⊘", + odot: "⊙", + cap: "∩", + cup: "∪", + uplus: "⊎", + sqcap: "⊓", + sqcup: "⊔", + vee: "∨", + lor: "∨", + wedge: "∧", + land: "∧", + setminus: "∖", + wr: "≀", + diamond: "⋄", + bigtriangleup: "△", + bigtriangledown: "▽", + triangleleft: "◁", + triangleright: "▷", +} + +const relation: Record = { + equals: "=", + neq: "≠", + ne: "≠", + equiv: "≡", + approx: "≈", + sim: "∼", + simeq: "≃", + cong: "≅", + asymp: "≍", + propto: "∝", + lt: "<", + gt: ">", + le: "≤", + leq: "≤", + ge: "≥", + geq: "≥", + ll: "≪", + gg: "≫", + prec: "≺", + succ: "≻", + preceq: "⪯", + succeq: "⪰", + subset: "⊂", + supset: "⊃", + subseteq: "⊆", + supseteq: "⊇", + sqsubset: "⊏", + sqsupset: "⊐", + sqsubseteq: "⊑", + sqsupseteq: "⊒", + in: "∈", + ni: "∋", + notin: "∉", + owns: "∋", + vdash: "⊢", + dashv: "⊣", + models: "⊨", + mid: "∣", + parallel: "∥", + perp: "⊥", + smile: "⌣", + frown: "⌢", +} + +const arrows: Record = { + leftarrow: "←", + gets: "←", + rightarrow: "→", + to: "→", + leftrightarrow: "↔", + Leftarrow: "⇐", + Rightarrow: "⇒", + Leftrightarrow: "⇔", + mapsto: "↦", + hookleftarrow: "↩", + hookrightarrow: "↪", + leftharpoonup: "↼", + leftharpoondown: "↽", + rightharpoonup: "⇀", + rightharpoondown: "⇁", + rightleftharpoons: "⇌", + longleftarrow: "⟵", + longrightarrow: "⟶", + longleftrightarrow: "⟷", + Longleftarrow: "⟸", + Longrightarrow: "⟹", + Longleftrightarrow: "⟺", + longmapsto: "⟼", + uparrow: "↑", + downarrow: "↓", + updownarrow: "↕", + Uparrow: "⇑", + Downarrow: "⇓", + Updownarrow: "⇕", + nearrow: "↗", + searrow: "↘", + swarrow: "↙", + nwarrow: "↖", +} + +const punctuation: Record = { + cdots: "⋯", + ldots: "…", + dots: "…", + vdots: "⋮", + ddots: "⋱", + colon: ":", +} + +export const symbolTable: Readonly> = { + ...Object.fromEntries(Object.entries(ordinary).map(([name, value]) => [name, { value, role: "ordinary" as const }])), + ...Object.fromEntries(Object.entries(binary).map(([name, value]) => [name, { value, role: "binary" as const }])), + ...Object.fromEntries(Object.entries(relation).map(([name, value]) => [name, { value, role: "relation" as const }])), + ...Object.fromEntries(Object.entries(arrows).map(([name, value]) => [name, { value, role: "relation" as const }])), + ...Object.fromEntries( + Object.entries(punctuation).map(([name, value]) => [name, { value, role: "punctuation" as const }]), + ), +} + +export const largeOperators: Readonly> = { + sum: "∑", + prod: "∏", + coprod: "∐", + int: "∫", + iint: "∬", + iiint: "∭", + oint: "∮", + bigcap: "⋂", + bigcup: "⋃", + bigvee: "⋁", + bigwedge: "⋀", + bigoplus: "⨁", + bigotimes: "⨂", + bigodot: "⨀", +} + +export const namedOperators = new Set([ + "arccos", + "arcsin", + "arctan", + "arg", + "cos", + "cosh", + "cot", + "coth", + "csc", + "deg", + "det", + "dim", + "exp", + "gcd", + "hom", + "inf", + "ker", + "lg", + "lim", + "liminf", + "limsup", + "ln", + "log", + "max", + "min", + "mod", + "Pr", + "sec", + "sin", + "sinh", + "sup", + "tan", + "tanh", +]) + +export const delimiterTable: Readonly> = { + "(": "(", + ")": ")", + "[": "[", + "]": "]", + "\\{": "{", + "\\}": "}", + "{": "{", + "}": "}", + "|": "│", + "\\|": "║", + vert: "│", + Vert: "║", + lvert: "│", + rvert: "│", + lVert: "║", + rVert: "║", + langle: "⟨", + rangle: "⟩", + lfloor: "⌊", + rfloor: "⌋", + lceil: "⌈", + rceil: "⌉", + ".": "", +} + +export const spacingCommands: Readonly> = { + ",": 0, + ":": 1, + ";": 1, + "!": 0, + quad: 2, + qquad: 4, + enspace: 1, + thinspace: 0, +} diff --git a/packages/tui/src/feature-plugins/system/latex/types.ts b/packages/tui/src/feature-plugins/system/latex/types.ts new file mode 100644 index 000000000000..2a30c05ff5fd --- /dev/null +++ b/packages/tui/src/feature-plugins/system/latex/types.ts @@ -0,0 +1,90 @@ +export type MathVariant = "normal" | "bold" | "italic" | "sans" | "monospace" | "double-struck" | "script" | "fraktur" + +export type MathNode = + | { type: "row"; body: MathNode[] } + | { type: "symbol"; value: string; role?: SymbolRole } + | { type: "text"; value: string } + | { type: "space"; width: number } + | { type: "fraction"; numerator: MathNode; denominator: MathNode; bar: boolean } + | { type: "root"; body: MathNode; index?: MathNode } + | { type: "scripts"; base: MathNode; superscript?: MathNode; subscript?: MathNode } + | { type: "delimited"; left: string; body: MathNode; right: string } + | { type: "matrix"; rows: MathNode[][]; environment: MatrixEnvironment } + | { type: "accent"; accent: AccentKind; body: MathNode } + | { type: "variant"; variant: MathVariant; body: MathNode } + | { type: "operator"; value: string; limits: boolean } + | { type: "overunder"; base: MathNode; over?: MathNode; under?: MathNode } + | { type: "color"; color: string; body: MathNode } + +export type SymbolRole = "ordinary" | "binary" | "relation" | "operator" | "punctuation" | "opening" | "closing" + +export type MatrixEnvironment = + | "matrix" + | "pmatrix" + | "bmatrix" + | "Bmatrix" + | "vmatrix" + | "Vmatrix" + | "cases" + | "aligned" + | "align" + | "gathered" + | "gather" + | "smallmatrix" + | "array" + +export type AccentKind = "hat" | "widehat" | "bar" | "overline" | "underline" | "vec" | "tilde" | "dot" | "ddot" + +export interface ParseOptions { + macros?: Readonly> + maxExpand?: number + /** + * Maximum accepted input length. This guards interactive and AI-generated + * formulas against accidentally exhausting the terminal process. + */ + maxSourceLength?: number + /** + * Maximum length after user-macro expansion. Defaults to + * `maxSourceLength`. + */ + maxExpandedLength?: number + /** Maximum structural nesting depth. */ + maxDepth?: number + strict?: boolean +} + +export class LatexParseError extends Error { + public readonly position: number + + constructor(message: string, position: number) { + super(`${message} at offset ${position}`) + this.name = "LatexParseError" + this.position = position + } +} + +export interface MathStyle { + color?: string + bold?: boolean + italic?: boolean + dim?: boolean +} + +export interface MathCell { + char: string + style?: MathStyle +} + +export interface MathLayout { + readonly width: number + readonly height: number + readonly baseline: number + readonly cells: ReadonlyArray> + toString(): string +} + +export interface RenderLatexOptions extends ParseOptions { + displayMode?: boolean + compactScripts?: boolean + color?: string +} diff --git a/packages/tui/test/latex.test.ts b/packages/tui/test/latex.test.ts index 10f346cb395b..7aa44fd424aa 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/tui/test/latex.test.ts @@ -10,7 +10,7 @@ import { createMarkdownCodeBlockRenderer, } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" -import { renderLatex } from "opentui-math" +import { renderLatex } from "../src/feature-plugins/system/latex/render" import { createLatexCodeBlockRenderer } from "../src/feature-plugins/system/latex" const renderers: Awaited>["renderer"][] = [] diff --git a/packages/tui/test/latex/parser.test.ts b/packages/tui/test/latex/parser.test.ts new file mode 100644 index 000000000000..84fcfb4cb111 --- /dev/null +++ b/packages/tui/test/latex/parser.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test" +import { parseLatex } from "../../src/feature-plugins/system/latex/parser" +import { LatexParseError } from "../../src/feature-plugins/system/latex/types" + +describe("parseLatex", () => { + test("parses fractions and scripts structurally", () => { + expect(parseLatex(String.raw`\frac{x^2+1}{y_0}`)).toMatchObject({ + type: "fraction", + bar: true, + numerator: { type: "row" }, + denominator: { type: "scripts" }, + }) + }) + + test("parses matrix environments into rows and cells", () => { + expect(parseLatex(String.raw`\begin{pmatrix}a & b \\ c & d\end{pmatrix}`)).toMatchObject({ + type: "matrix", + environment: "pmatrix", + rows: [ + [ + { type: "symbol", value: "a" }, + { type: "symbol", value: "b" }, + ], + [ + { type: "symbol", value: "c" }, + { type: "symbol", value: "d" }, + ], + ], + }) + }) + + test("accepts array column specs and starred alignment environments", () => { + expect(parseLatex(String.raw`\begin{array}{cc}a & b \\ c & d\end{array}`)).toMatchObject({ + type: "matrix", + environment: "array", + rows: [ + [{}, {}], + [{}, {}], + ], + }) + expect(parseLatex(String.raw`\begin{align*}a &= b \\ c &= d\end{align*}`)).toMatchObject({ + type: "matrix", + environment: "align", + }) + }) + + test("expands user macros", () => { + expect(parseLatex(String.raw`\R \to \R`, { macros: { "\\R": String.raw`\mathbb{R}` } })).toMatchObject({ + type: "row", + }) + }) + + test("reports useful strict-mode errors", () => { + expect(() => parseLatex(String.raw`\definitelyUnknown{x}`, { strict: true })).toThrow(LatexParseError) + }) + + test("keeps escaped braces inside raw text groups", () => { + expect(parseLatex(String.raw`\text{left \{ only}`)).toMatchObject({ + type: "text", + value: "left { only", + }) + expect(parseLatex(String.raw`\text{right \} only}`)).toMatchObject({ + type: "text", + value: "right } only", + }) + }) + + test("supports starred named operators and limits modifiers", () => { + expect(parseLatex(String.raw`\operatorname*{arg\,max}_{x}`)).toMatchObject({ + type: "scripts", + base: { type: "operator", value: String.raw`arg\,max`, limits: true }, + }) + expect(parseLatex(String.raw`\int\limits_0^1`)).toMatchObject({ + type: "scripts", + base: { type: "operator", value: "∫", limits: true }, + }) + expect(parseLatex(String.raw`\sum\nolimits_{i=1}`)).toMatchObject({ + type: "scripts", + base: { type: "operator", value: "∑", limits: false }, + }) + }) + + test("bounds source and recursive macro expansion", () => { + expect(() => parseLatex("12345", { maxSourceLength: 4 })).toThrow(/4-character limit/) + expect(() => + parseLatex(String.raw`\a`, { + macros: { a: String.raw`\a\a` }, + maxExpandedLength: 64, + }), + ).toThrow(/64-character limit/) + expect(() => parseLatex("x", { maxSourceLength: 0 })).toThrow(RangeError) + }) + + test("fails quickly when malformed environments cannot advance", () => { + expect(() => parseLatex(String.raw`\begin{matrix}]`)).toThrow(/Missing \\end{matrix}/) + expect(() => parseLatex(String.raw`\begin{matrix}x}`)).toThrow(/Unexpected "}" in matrix/) + }) + + test("bounds structural nesting with a parse error instead of overflowing the stack", () => { + const source = "{".repeat(80) + "x" + "}".repeat(80) + expect(() => parseLatex(source, { maxDepth: 64 })).toThrow(/64-level limit/) + expect(() => parseLatex(String.raw`\frac`.repeat(80) + "x", { maxDepth: 64 })).toThrow(/64-level limit/) + }) +}) diff --git a/packages/tui/test/latex/render.test.ts b/packages/tui/test/latex/render.test.ts new file mode 100644 index 000000000000..3ea18b9732bb --- /dev/null +++ b/packages/tui/test/latex/render.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import { renderLatex, renderLatexToString } from "../../src/feature-plugins/system/latex/render" + +describe("renderLatexToString", () => { + test("renders a fraction with a centered rule", () => { + expect(renderLatexToString(String.raw`\frac{x+1}{y-1}`)).toBe([" x + 1", "───────", " y - 1"].join("\n")) + }) + + test("uses compact unicode scripts where possible", () => { + expect(renderLatexToString(String.raw`E = mc^2`)).toBe("E = mc²") + expect(renderLatexToString(String.raw`a_n`)).toBe("aₙ") + expect(renderLatexToString(String.raw`x_i^2`)).toBe("x²ᵢ") + }) + + test("centers binomials around an empty math-axis row", () => { + expect(renderLatexToString(String.raw`P = \binom{n}{k}`)).toBe([" ⎛ n ⎞", "P = ⎜ ⎟", " ⎝ k ⎠"].join("\n")) + }) + + test("renders roots with a vinculum", () => { + expect(renderLatexToString(String.raw`\sqrt{x^2+y^2}`)).toBe([" ╭───────", "√ x² + y²"].join("\n")) + }) + + test("renders matrices with stretching delimiters", () => { + expect(renderLatexToString(String.raw`\begin{pmatrix}a & b \\ c & d\end{pmatrix}`)).toBe( + ["⎛a b⎞", "⎜ ⎟", "⎝c d⎠"].join("\n"), + ) + }) + + test("places display operator limits above and below", () => { + expect(renderLatexToString(String.raw`\sum_{i=1}^{n} i^2`)).toBe([" n", " ∑ i²", "i = 1"].join("\n")) + }) + + test("returns intrinsic geometry and baseline", () => { + const layout = renderLatex(String.raw`\frac{1}{2}`) + expect(layout.width).toBe(3) + expect(layout.height).toBe(3) + expect(layout.baseline).toBe(1) + }) + + test("renders blackboard, calligraphic, and fraktur alphabets", () => { + expect(renderLatexToString(String.raw`\mathbb{R} \to \mathcal{C} \times \mathfrak{g}`)).toBe("ℝ → 𝒞 × 𝔤") + }) + + test("renders nested fractions without flattening their structure", () => { + const result = renderLatexToString(String.raw`\frac{1}{1+\frac{1}{x}}`) + expect(result.split("\n")).toHaveLength(5) + expect(result.match(/─/g)?.length).toBeGreaterThanOrEqual(10) + }) + + test("renders common textbook structures", () => { + const result = renderLatexToString(String.raw`\left[\frac{-b \pm \sqrt{b^2-4ac}}{2a}\right]`) + expect(result).toContain("±") + expect(result).toContain("√") + expect(result).toContain("─") + expect(result).toContain("⎡") + expect(result).toContain("⎦") + }) + + test("places fallback combining negation after the base symbol", () => { + const result = renderLatexToString(String.raw`\not\rightarrow`) + expect(Array.from(result)).toEqual(["→", "̸"]) + }) + + test("treats square brackets as ordinary interval delimiters", () => { + expect(renderLatexToString(String.raw`x\in[0,1]`)).toBe("x ∈ [0,1]") + expect(renderLatexToString(String.raw`[-1,1]`)).toBe("[-1,1]") + }) +}) From 6600074b68cbfe25f523fbab014598587bdd96db Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 14:21:53 -0400 Subject: [PATCH 5/9] refactor(latex): match the merman package boundary Move the engine, Markdown adapter, plugin, and tests into a private workspace package. Remove the standalone license file and dependency patch; apply final Markdown content before completion at the TUI call site. --- bun.lock | 20 +++++++++- package.json | 3 +- packages/latex/package.json | 25 ++++++++++++ .../system/latex => latex/src}/layout.ts | 0 .../system/latex => latex/src}/limits.ts | 0 .../src/markdown.test.ts} | 8 ++-- .../system/latex.ts => latex/src/markdown.ts} | 19 ++------- .../test/latex => latex/src}/parser.test.ts | 4 +- .../system/latex => latex/src}/parser.ts | 0 packages/latex/src/plugin.ts | 14 +++++++ .../test/latex => latex/src}/render.test.ts | 2 +- .../system/latex => latex/src}/render.ts | 0 .../system/latex => latex/src}/symbols.ts | 0 .../system/latex => latex/src}/types.ts | 0 packages/latex/tsconfig.json | 8 ++++ packages/tui/package.json | 1 + .../src/feature-plugins/system/latex/LICENSE | 21 ---------- .../system/storybook/latex.tsx | 2 +- packages/tui/src/plugin/builtins.ts | 2 +- packages/tui/src/routes/session/index.tsx | 3 +- packages/tui/test/markdown-streaming.test.tsx | 39 +++++++++++++++++++ patches/@opentui%2Fcore@0.5.8.patch | 26 ------------- turbo.json | 4 ++ 23 files changed, 124 insertions(+), 77 deletions(-) create mode 100644 packages/latex/package.json rename packages/{tui/src/feature-plugins/system/latex => latex/src}/layout.ts (100%) rename packages/{tui/src/feature-plugins/system/latex => latex/src}/limits.ts (100%) rename packages/{tui/test/latex.test.ts => latex/src/markdown.test.ts} (97%) rename packages/{tui/src/feature-plugins/system/latex.ts => latex/src/markdown.ts} (87%) rename packages/{tui/test/latex => latex/src}/parser.test.ts (95%) rename packages/{tui/src/feature-plugins/system/latex => latex/src}/parser.ts (100%) create mode 100644 packages/latex/src/plugin.ts rename packages/{tui/test/latex => latex/src}/render.test.ts (96%) rename packages/{tui/src/feature-plugins/system/latex => latex/src}/render.ts (100%) rename packages/{tui/src/feature-plugins/system/latex => latex/src}/symbols.ts (100%) rename packages/{tui/src/feature-plugins/system/latex => latex/src}/types.ts (100%) create mode 100644 packages/latex/tsconfig.json delete mode 100644 packages/tui/src/feature-plugins/system/latex/LICENSE create mode 100644 packages/tui/test/markdown-streaming.test.tsx delete mode 100644 patches/@opentui%2Fcore@0.5.8.patch diff --git a/bun.lock b/bun.lock index b15bec5e6b9d..565c695873a0 100644 --- a/bun.lock +++ b/bun.lock @@ -553,6 +553,20 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/latex": { + "name": "@opencode-ai/latex", + "version": "0.0.0", + "dependencies": { + "@opencode-ai/plugin": "workspace:*", + "@opentui/core": "catalog:", + "string-width": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/merman": { "name": "@opencode-ai/merman", "version": "0.0.0", @@ -877,6 +891,7 @@ "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/latex": "workspace:*", "@opencode-ai/merman": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -1056,7 +1071,6 @@ "tree-sitter-bash", ], "patchedDependencies": { - "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", @@ -1068,7 +1082,7 @@ "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", - "@opentui/core@0.5.8": "patches/@opentui%2Fcore@0.5.8.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", }, "overrides": { "@effect/platform-node-shared": "catalog:", @@ -2149,6 +2163,8 @@ "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], + "@opencode-ai/latex": ["@opencode-ai/latex@workspace:packages/latex"], + "@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], diff --git a/package.json b/package.json index 4b205d846076..d2f9b1b4ebab 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,6 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch", - "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch", - "@opentui/core@0.5.8": "patches/@opentui%2Fcore@0.5.8.patch" + "@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch" } } diff --git a/packages/latex/package.json b/packages/latex/package.json new file mode 100644 index 000000000000..f21ea473bba9 --- /dev/null +++ b/packages/latex/package.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/latex", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./markdown": "./src/markdown.ts", + "./plugin": "./src/plugin.ts" + }, + "scripts": { + "test": "bun test --timeout 30000 --only-failures", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@opencode-ai/plugin": "workspace:*", + "@opentui/core": "catalog:", + "string-width": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/tui/src/feature-plugins/system/latex/layout.ts b/packages/latex/src/layout.ts similarity index 100% rename from packages/tui/src/feature-plugins/system/latex/layout.ts rename to packages/latex/src/layout.ts diff --git a/packages/tui/src/feature-plugins/system/latex/limits.ts b/packages/latex/src/limits.ts similarity index 100% rename from packages/tui/src/feature-plugins/system/latex/limits.ts rename to packages/latex/src/limits.ts diff --git a/packages/tui/test/latex.test.ts b/packages/latex/src/markdown.test.ts similarity index 97% rename from packages/tui/test/latex.test.ts rename to packages/latex/src/markdown.test.ts index 7aa44fd424aa..a8796eeaf514 100644 --- a/packages/tui/test/latex.test.ts +++ b/packages/latex/src/markdown.test.ts @@ -10,8 +10,8 @@ import { createMarkdownCodeBlockRenderer, } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" -import { renderLatex } from "../src/feature-plugins/system/latex/render" -import { createLatexCodeBlockRenderer } from "../src/feature-plugins/system/latex" +import { renderLatex } from "./render" +import { createLatexCodeBlockRenderer } from "./markdown" const renderers: Awaited>["renderer"][] = [] const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }) @@ -84,10 +84,10 @@ test("renders the next valid formula after an incomplete streaming prefix", asyn expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) }) -test("renders the final formula when completion is applied before the last text update", async () => { +test("renders the final formula when the last text update is applied before completion", async () => { const output = await setup("```latex\n\\frac{1}{") - output.markdown.streaming = false output.markdown.content += "2}\n```" + output.markdown.streaming = false await output.renderOnce() expect(output.markdown.getChildren().filter((child) => child instanceof ScrollBoxRenderable).length).toBe(1) expect(output.captureCharFrame()).not.toContain("\\frac") diff --git a/packages/tui/src/feature-plugins/system/latex.ts b/packages/latex/src/markdown.ts similarity index 87% rename from packages/tui/src/feature-plugins/system/latex.ts rename to packages/latex/src/markdown.ts index 529525479a43..03ebe7db6da6 100644 --- a/packages/tui/src/feature-plugins/system/latex.ts +++ b/packages/latex/src/markdown.ts @@ -1,4 +1,3 @@ -import { Plugin } from "@opencode-ai/plugin/tui" import { CodeRenderable, RenderableEvents, @@ -11,9 +10,9 @@ import { type MarkdownCodeBlockRenderer, type RenderContext, } from "@opentui/core" -import { renderLatex } from "./latex/render" -import { LatexParseError, type MathLayout } from "./latex/types" -import { stringWidth } from "../../util/string-width" +import stringWidth from "string-width" +import { renderLatex } from "./render" +import { LatexParseError, type MathLayout } from "./types" export type LatexOptions = { text: ColorInput @@ -25,18 +24,6 @@ type LatexFrame = { layout: MathLayout } -export default Plugin.define({ - id: "opencode.latex", - setup(context) { - const render = createLatexCodeBlockRenderer(context.renderer, () => ({ - text: context.theme.text.default, - subdued: context.theme.text.subdued, - })) - context.markdown.registerCodeBlockRenderer("latex", render) - context.markdown.registerCodeBlockRenderer("math", render) - }, -}) - export function createLatexCodeBlockRenderer( context: RenderContext, options: () => LatexOptions, diff --git a/packages/tui/test/latex/parser.test.ts b/packages/latex/src/parser.test.ts similarity index 95% rename from packages/tui/test/latex/parser.test.ts rename to packages/latex/src/parser.test.ts index 84fcfb4cb111..200dc32323c1 100644 --- a/packages/tui/test/latex/parser.test.ts +++ b/packages/latex/src/parser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { parseLatex } from "../../src/feature-plugins/system/latex/parser" -import { LatexParseError } from "../../src/feature-plugins/system/latex/types" +import { parseLatex } from "./parser" +import { LatexParseError } from "./types" describe("parseLatex", () => { test("parses fractions and scripts structurally", () => { diff --git a/packages/tui/src/feature-plugins/system/latex/parser.ts b/packages/latex/src/parser.ts similarity index 100% rename from packages/tui/src/feature-plugins/system/latex/parser.ts rename to packages/latex/src/parser.ts diff --git a/packages/latex/src/plugin.ts b/packages/latex/src/plugin.ts new file mode 100644 index 000000000000..39a648c04be0 --- /dev/null +++ b/packages/latex/src/plugin.ts @@ -0,0 +1,14 @@ +import { Plugin } from "@opencode-ai/plugin/tui" +import { createLatexCodeBlockRenderer } from "./markdown" + +export default Plugin.define({ + id: "opencode.latex", + setup(context) { + const render = createLatexCodeBlockRenderer(context.renderer, () => ({ + text: context.theme.text.default, + subdued: context.theme.text.subdued, + })) + context.markdown.registerCodeBlockRenderer("latex", render) + context.markdown.registerCodeBlockRenderer("math", render) + }, +}) diff --git a/packages/tui/test/latex/render.test.ts b/packages/latex/src/render.test.ts similarity index 96% rename from packages/tui/test/latex/render.test.ts rename to packages/latex/src/render.test.ts index 3ea18b9732bb..b6e0b05a8627 100644 --- a/packages/tui/test/latex/render.test.ts +++ b/packages/latex/src/render.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { renderLatex, renderLatexToString } from "../../src/feature-plugins/system/latex/render" +import { renderLatex, renderLatexToString } from "./render" describe("renderLatexToString", () => { test("renders a fraction with a centered rule", () => { diff --git a/packages/tui/src/feature-plugins/system/latex/render.ts b/packages/latex/src/render.ts similarity index 100% rename from packages/tui/src/feature-plugins/system/latex/render.ts rename to packages/latex/src/render.ts diff --git a/packages/tui/src/feature-plugins/system/latex/symbols.ts b/packages/latex/src/symbols.ts similarity index 100% rename from packages/tui/src/feature-plugins/system/latex/symbols.ts rename to packages/latex/src/symbols.ts diff --git a/packages/tui/src/feature-plugins/system/latex/types.ts b/packages/latex/src/types.ts similarity index 100% rename from packages/tui/src/feature-plugins/system/latex/types.ts rename to packages/latex/src/types.ts diff --git a/packages/latex/tsconfig.json b/packages/latex/tsconfig.json new file mode 100644 index 000000000000..00ef12546856 --- /dev/null +++ b/packages/latex/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json index 140b4ad8c4e1..c6fe116d07ea 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -78,6 +78,7 @@ "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/latex": "workspace:*", "@opencode-ai/merman": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", diff --git a/packages/tui/src/feature-plugins/system/latex/LICENSE b/packages/tui/src/feature-plugins/system/latex/LICENSE deleted file mode 100644 index a57b6831979c..000000000000 --- a/packages/tui/src/feature-plugins/system/latex/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 opentui-math contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/tui/src/feature-plugins/system/storybook/latex.tsx b/packages/tui/src/feature-plugins/system/storybook/latex.tsx index 82cef3bfdfd7..988055e93090 100644 --- a/packages/tui/src/feature-plugins/system/storybook/latex.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/latex.tsx @@ -3,7 +3,7 @@ import { createMarkdownCodeBlockRenderer } from "@opentui/core" import { useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { useTheme, useThemes } from "../../../context/theme" -import { createLatexCodeBlockRenderer } from "../latex" +import { createLatexCodeBlockRenderer } from "@opencode-ai/latex/markdown" import type { Story } from "./index" import { StoryFooter } from "./footer" diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index 02021390d849..fabfaff68f3b 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -7,7 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer" import Notifications from "../feature-plugins/system/notifications" import Plugins from "../feature-plugins/system/plugins" import Storybook from "../feature-plugins/system/storybook" -import Latex from "../feature-plugins/system/latex" +import Latex from "@opencode-ai/latex/plugin" import Merman from "@opencode-ai/merman/plugin" export const builtins = [ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6c7a1310f9ec..3f0d0de7aff8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2543,11 +2543,12 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText; mes return ( + {/* Apply content before streaming so completion does not freeze the previous Markdown tokens. */} >["renderer"][] = [] +const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }) + +afterEach(() => { + renderers.splice(0).forEach((renderer) => renderer.destroy()) +}) + +test.each(["completion-first", "content-first"])("applies final fence text in a Solid batch: %s", async (order) => { + const [content, setContent] = createSignal("```text\ninitial") + const [streaming, setStreaming] = createSignal(true) + const output = await testRender( + () => ( + + ), + { width: 80, height: 12, remote: true, useThread: false }, + ) + renderers.push(output.renderer) + await output.renderOnce() + + batch(() => { + if (order === "completion-first") setStreaming(false) + setContent("```text\ninitial final\n```") + if (order === "content-first") setStreaming(false) + }) + await output.renderOnce() + + const markdown = output.renderer.root.getChildren()[0] + expect(markdown).toBeInstanceOf(MarkdownRenderable) + const block = markdown?.getChildren()[0] + expect(block).toBeInstanceOf(CodeRenderable) + if (!(block instanceof CodeRenderable)) throw new Error("Expected a code fence") + expect(block.content).toBe("initial final") + expect(markdown?.getChildren()).toHaveLength(1) +}) diff --git a/patches/@opentui%2Fcore@0.5.8.patch b/patches/@opentui%2Fcore@0.5.8.patch deleted file mode 100644 index 79264bd5be1e..000000000000 --- a/patches/@opentui%2Fcore@0.5.8.patch +++ /dev/null @@ -1,26 +0,0 @@ -diff --git a/index.bun.js b/index.bun.js -index 853e369bf4a3945b3df5a8528f8700c35b24253d..db0115a2c15fd2fb1ede136fdf3d87b9143abcc8 100644 ---- a/index.bun.js -+++ b/index.bun.js -@@ -11803,6 +11803,8 @@ class MarkdownRenderable extends Renderable { - return; - if (this._content !== value) { - this._content = value; -+ if (!this._streaming) -+ this._parseState = null; - this.updateBlocks(); - this.requestRender(); - } -diff --git a/index.node.js b/index.node.js -index 30f831a584c5b4ed5282aef739e9b6735cc3e1e0..613f5735d860939c3829fdfc498a43c578d957a8 100644 ---- a/index.node.js -+++ b/index.node.js -@@ -11802,6 +11802,8 @@ class MarkdownRenderable extends Renderable { - return; - if (this._content !== value) { - this._content = value; -+ if (!this._streaming) -+ this._parseState = null; - this.updateBlocks(); - this.requestRender(); - } diff --git a/turbo.json b/turbo.json index 89ae3dc0daf0..a79aa3a83f82 100644 --- a/turbo.json +++ b/turbo.json @@ -49,6 +49,10 @@ "dependsOn": ["^build"], "outputs": [] }, + "@opencode-ai/latex#test": { + "dependsOn": ["^build"], + "outputs": [] + }, "@opencode-ai/app#test": { "dependsOn": ["^build"], "outputs": [] From e70fbcef60cab78b9f138eb21d50703c2fb22627 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 14:30:13 -0400 Subject: [PATCH 6/9] chore(tui): remove the LaTeX playground story --- .../system/storybook/index.tsx | 3 +- .../system/storybook/latex.tsx | 137 ------------------ 2 files changed, 1 insertion(+), 139 deletions(-) delete mode 100644 packages/tui/src/feature-plugins/system/storybook/latex.tsx diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx index c14bc45c2380..c57e7f6f6e0c 100644 --- a/packages/tui/src/feature-plugins/system/storybook/index.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx @@ -3,7 +3,6 @@ import { useTerminalDimensions } from "@opentui/solid" import { createSignal, For, type JSX } from "solid-js" import { StoryFooter } from "./footer" import { mermanLayoutsStory } from "./merman-layouts" -import { latexStory } from "./latex" import { sessionTabsStory } from "./session-tabs" import { sessionLocationMissingStory } from "./session-location-missing" @@ -17,7 +16,7 @@ export type Story = { render: (context: Plugin.Context) => JSX.Element } -const stories: Story[] = [mermanLayoutsStory, latexStory, sessionTabsStory, sessionLocationMissingStory] +const stories: Story[] = [mermanLayoutsStory, sessionTabsStory, sessionLocationMissingStory] function Commands(props: { context: Plugin.Context }) { props.context.keymap.layer(() => ({ diff --git a/packages/tui/src/feature-plugins/system/storybook/latex.tsx b/packages/tui/src/feature-plugins/system/storybook/latex.tsx deleted file mode 100644 index 988055e93090..000000000000 --- a/packages/tui/src/feature-plugins/system/storybook/latex.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import type { Plugin } from "@opencode-ai/plugin/tui" -import { createMarkdownCodeBlockRenderer } from "@opentui/core" -import { useTerminalDimensions } from "@opentui/solid" -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import { useTheme, useThemes } from "../../../context/theme" -import { createLatexCodeBlockRenderer } from "@opencode-ai/latex/markdown" -import type { Story } from "./index" -import { StoryFooter } from "./footer" - -const fixtures = [ - { - title: "Quadratic formula", - source: String.raw`x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}`, - }, - { - title: "Matrix", - source: String.raw`A = \begin{pmatrix}a & b \\ c & d\end{pmatrix}`, - }, - { - title: "Piecewise function", - source: String.raw`|x| = \begin{cases}x & x \ge 0 \\ -x & x < 0\end{cases}`, - }, - { - title: "Aligned equations", - source: String.raw`\begin{aligned}(a+b)^2 &= a^2+2ab+b^2 \\ (a-b)^2 &= a^2-2ab+b^2\end{aligned}`, - }, - { - title: "Limit", - source: String.raw`\lim_{n\to\infty}\left(1+\frac{1}{n}\right)^n=e`, - }, - { - title: "Wide expression (scroll horizontally)", - source: String.raw`\frac{a_1+b_1}{c_1+d_1}+\frac{a_2+b_2}{c_2+d_2}+\frac{a_3+b_3}{c_3+d_3}+\frac{a_4+b_4}{c_4+d_4}+\frac{a_5+b_5}{c_5+d_5}=\text{Result}`, - }, - { - title: "Unsupported command stays as source", - source: String.raw`\unsupported{x}`, - }, -] - -function LatexStory(props: { context: Plugin.Context }) { - const dimensions = useTerminalDimensions() - const theme = useTheme() - const themes = useThemes() - const [selected, setSelected] = createSignal(0) - const [length, setLength] = createSignal() - const render = createMarkdownCodeBlockRenderer({ - latex: createLatexCodeBlockRenderer(props.context.renderer, () => ({ - text: theme.text.default, - subdued: theme.text.subdued, - })), - }) - const fixture = createMemo(() => fixtures[selected()]) - const source = createMemo(() => fixture().source.slice(0, length())) - const streaming = createMemo(() => source().length < fixture().source.length) - const markdown = createMemo(() => `\`\`\`latex\n${source()}${streaming() ? "" : "\n```"}`) - - createEffect(() => { - const current = length() - if (current === undefined || current >= fixture().source.length) return - const timer = setTimeout(() => setLength(current + 4), 80) - onCleanup(() => clearTimeout(timer)) - }) - - props.context.keymap.layer(() => ({ - commands: [ - { - bind: "escape", - title: "Back to storybook", - run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }), - }, - { - bind: "n", - title: "Next formula", - run: () => { - setSelected((current) => (current + 1) % fixtures.length) - setLength(undefined) - }, - }, - { bind: "s", title: "Replay streaming", run: () => setLength(0) }, - { bind: "f", title: "Finish streaming", run: () => setLength(undefined) }, - { - bind: "r", - title: "Reset fixture", - run: () => { - setSelected(0) - setLength(undefined) - }, - }, - ], - })) - - return ( - - - - {fixture().title} - - {source()} - {fixture().source.slice(source().length)} - - Unicode rendering - - - - - - ) -} - -export const latexStory: Story = { - id: "latex", - title: "LaTeX math", - render: (context) => , -} From 860b9c4208edeb9caf37b4a9b45378dcf6529bea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 14:33:03 -0400 Subject: [PATCH 7/9] fix(latex): use one color for radical strokes --- packages/latex/src/markdown.test.ts | 14 +++++++++++++- packages/latex/src/markdown.ts | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/latex/src/markdown.test.ts b/packages/latex/src/markdown.test.ts index a8796eeaf514..1892f6dc7344 100644 --- a/packages/latex/src/markdown.test.ts +++ b/packages/latex/src/markdown.test.ts @@ -179,12 +179,18 @@ test("allows wide formulas to scroll horizontally without wrapping", async () => }) test("subdues structure and emphasizes relations using the theme", async () => { - const output = await setup("```latex\nx=\\frac{1}{2}\n```") + const output = await setup("```latex\nx=\\sqrt{\\frac{1}{2}}\n```") const formula = output.markdown.getChildren()[0]?.getChildren()[0] if (!(formula instanceof TextRenderable)) throw new Error("Expected Unicode math") expect( formula.chunks.find((chunk) => chunk.text === "\u2500")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), ).toBe(true) + expect( + formula.chunks.find((chunk) => chunk.text === "\u221a")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), + ).toBe(true) + expect( + formula.chunks.find((chunk) => chunk.text === "\u256d")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), + ).toBe(true) expect(formula.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true) expect(formula.chunks.find((chunk) => chunk.text === "=")?.attributes).toBe(TextAttributes.BOLD) @@ -198,6 +204,12 @@ test("subdues structure and emphasizes relations using the theme", async () => { expect( updated.chunks.find((chunk) => chunk.text === "\u2500")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), ).toBe(true) + expect( + updated.chunks.find((chunk) => chunk.text === "\u221a")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), + ).toBe(true) + expect( + updated.chunks.find((chunk) => chunk.text === "\u256d")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), + ).toBe(true) }) test.each([String.raw`\text{${"\u4e2d\u6587"}}=x`, String.raw`\frac{\text{${"\u4e2d\u6587"}}}{abcd}=x`])( diff --git a/packages/latex/src/markdown.ts b/packages/latex/src/markdown.ts index 03ebe7db6da6..f5ecceed1a13 100644 --- a/packages/latex/src/markdown.ts +++ b/packages/latex/src/markdown.ts @@ -60,7 +60,7 @@ export function createLatexCodeBlockRenderer( { __isChunk: true as const, text: cell?.char ?? " ", - fg: /^[()[\]{}|\u239b-\u23ad\u2500-\u257f]$/u.test(cell?.char ?? "") ? subdued : text, + fg: /^[()[\]{}|\u221a\u239b-\u23ad\u2500-\u257f]$/u.test(cell?.char ?? "") ? subdued : text, attributes: createTextAttributes({ bold: cell?.style?.bold || /^[=<>\u2260\u2261\u2264\u2265\u2248]$/u.test(cell?.char ?? ""), italic: cell?.style?.italic, From 6a6d80e4da45cd0dcc38bbc66174cc4ea7bad067 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 14:33:23 -0400 Subject: [PATCH 8/9] refactor(latex): simplify parser and layout internals --- packages/latex/src/layout.ts | 90 +++++++++---------------------- packages/latex/src/parser.test.ts | 1 + packages/latex/src/parser.ts | 6 +-- packages/latex/src/render.test.ts | 27 ++++++++-- 4 files changed, 51 insertions(+), 73 deletions(-) diff --git a/packages/latex/src/layout.ts b/packages/latex/src/layout.ts index 54f14e3e0729..88a7327de351 100644 --- a/packages/latex/src/layout.ts +++ b/packages/latex/src/layout.ts @@ -14,6 +14,8 @@ interface LayoutContext { variant?: MathVariant } +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }) + const superscripts: Readonly> = { "0": "⁰", "1": "¹", @@ -83,8 +85,8 @@ function layoutNode(node: MathNode, context: LayoutContext): Box { case "row": return layoutRow(node.body, context) case "symbol": - return textBox(applyVariant(node.value, context.variant), context.style) case "text": + case "operator": return textBox(applyVariant(node.value, context.variant), context.style) case "space": return blank(node.width, 1, 0) @@ -102,12 +104,10 @@ function layoutNode(node: MathNode, context: LayoutContext): Box { return layoutAccent(node.accent, node.body, context) case "variant": return layoutNode(node.body, withVariant(context, node.variant)) - case "operator": - return textBox(applyVariant(node.value, context.variant), context.style) case "overunder": return layoutOverUnder(node.base, node.over, node.under, context) case "color": - return layoutNode(node.body, { ...context, style: mergeStyle(context.style, { color: node.color }) }) + return layoutNode(node.body, { ...context, style: { ...context.style, color: node.color } }) } throw new Error("Unsupported math node") } @@ -168,55 +168,32 @@ function layoutRoot(bodyNode: MathNode, indexNode: MathNode | undefined, context } function layoutScripts(node: Extract, context: LayoutContext): Box { - const simpleBase = layoutNode(node.base, context) - const superscriptText = node.superscript ? simpleNodeText(node.superscript) : undefined - const subscriptText = node.subscript ? simpleNodeText(node.subscript) : undefined - const canCompactBase = !(node.base.type === "operator" && node.base.limits && context.displayMode) - - if (context.compactScripts && canCompactBase && superscriptText !== undefined && subscriptText !== undefined) { - const compactSuperscript = mapScript(superscriptText, superscripts) - const compactSubscript = mapScript(subscriptText, subscripts) - if (compactSuperscript !== undefined && compactSubscript !== undefined) { - return hpack([simpleBase, textBox(compactSuperscript, context.style), textBox(compactSubscript, context.style)]) - } - } - - if ( - context.compactScripts && - canCompactBase && - !(node.superscript && node.subscript) && - superscriptText !== undefined - ) { - const compact = mapScript(superscriptText, superscripts) - if (compact !== undefined) return hpack([simpleBase, textBox(compact, context.style)]) - } - if ( - context.compactScripts && - canCompactBase && - !(node.superscript && node.subscript) && - subscriptText !== undefined - ) { - const compact = mapScript(subscriptText, subscripts) - if (compact !== undefined) return hpack([simpleBase, textBox(compact, context.style)]) - } - if (node.base.type === "operator" && node.base.limits && context.displayMode) { return layoutOverUnder(node.base, node.superscript, node.subscript, context) } + const base = layoutNode(node.base, context) + if (context.compactScripts) { + const superscript = mapScript(node.superscript ? simpleNodeText(node.superscript) : "", superscripts) + const subscript = mapScript(node.subscript ? simpleNodeText(node.subscript) : "", subscripts) + if (superscript !== undefined && subscript !== undefined) { + return hpack([base, textBox(superscript + subscript, context.style)]) + } + } + const superscript = node.superscript ? layoutNode(node.superscript, context) : undefined const subscript = node.subscript ? layoutNode(node.subscript, context) : undefined const scriptWidth = Math.max(superscript?.width ?? 0, subscript?.width ?? 0) const topHeight = superscript?.height ?? 0 const bottomHeight = subscript?.height ?? 0 - const width = simpleBase.width + scriptWidth - const height = topHeight + simpleBase.height + bottomHeight - const baseline = topHeight + simpleBase.baseline + const width = base.width + scriptWidth + const height = topHeight + base.height + bottomHeight + const baseline = topHeight + base.baseline const result = blank(width, height, baseline) - overlay(result, simpleBase, 0, topHeight) - if (superscript) overlay(result, superscript, simpleBase.width, 0) - if (subscript) overlay(result, subscript, simpleBase.width, topHeight + simpleBase.height) + overlay(result, base, 0, topHeight) + if (superscript) overlay(result, superscript, base.width, 0) + if (subscript) overlay(result, subscript, base.width, topHeight + base.height) return result } @@ -417,7 +394,7 @@ function hpack(boxes: Box[]): Box { } function textBox(text: string, style?: MathStyle): Box { - const graphemes = segment(text) + const graphemes = Array.from(graphemeSegmenter.segment(text), (item) => item.segment) const width = graphemes.reduce((sum, grapheme) => sum + cellWidth(grapheme), 0) const result = blank(width, 1, 0) let x = 0 @@ -530,7 +507,8 @@ function simpleNodeText(node: MathNode): string | undefined { return undefined } -function mapScript(value: string, table: Readonly>): string | undefined { +function mapScript(value: string | undefined, table: Readonly>): string | undefined { + if (value === undefined) return undefined let result = "" for (const char of value) { const mapped = table[char] @@ -541,17 +519,8 @@ function mapScript(value: string, table: Readonly>): stri } function withVariant(context: LayoutContext, variant: MathVariant): LayoutContext { - const style = - variant === "bold" - ? { bold: true } - : variant === "italic" - ? { italic: true } - : variant === "sans" - ? {} - : variant === "monospace" - ? {} - : {} - return { ...context, variant, style: mergeStyle(context.style, style) } + const style = variant === "bold" ? { bold: true } : variant === "italic" ? { italic: true } : {} + return { ...context, variant, style: { ...context.style, ...style } } } function applyVariant(value: string, variant: MathVariant | undefined): string { @@ -612,17 +581,6 @@ function applyVariant(value: string, variant: MathVariant | undefined): string { .join("") } -function mergeStyle(base: MathStyle | undefined, overlayStyle: MathStyle): MathStyle { - return { ...base, ...overlayStyle } -} - -function segment(value: string): string[] { - if (typeof Intl.Segmenter === "function") { - return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value)].map((item) => item.segment) - } - return Array.from(value) -} - function cellWidth(value: string): number { if (value.length === 0) return 0 if (/^(?:[\u0000-\u001f\u007f-\u009f]|[\u0300-\u036f]|[\ufe00-\ufe0f])$/u.test(value)) return 0 diff --git a/packages/latex/src/parser.test.ts b/packages/latex/src/parser.test.ts index 200dc32323c1..e6c86f1950ab 100644 --- a/packages/latex/src/parser.test.ts +++ b/packages/latex/src/parser.test.ts @@ -88,6 +88,7 @@ describe("parseLatex", () => { maxExpandedLength: 64, }), ).toThrow(/64-character limit/) + expect(() => parseLatex(String.raw`\a`, { macros: { a: "{{x}}" }, maxDepth: 1 })).toThrow(/1-level limit/) expect(() => parseLatex("x", { maxSourceLength: 0 })).toThrow(RangeError) }) diff --git a/packages/latex/src/parser.ts b/packages/latex/src/parser.ts index eba51939f07d..5ed2901545e2 100644 --- a/packages/latex/src/parser.ts +++ b/packages/latex/src/parser.ts @@ -74,7 +74,7 @@ export function expandLatexMacros(source: string, options: ParseOptions = {}): s assertSourceLength(source, maxSourceLength) assertNestingDepth(source, maxDepth) const expanded = expandMacros(source, options.macros, maxExpand, maxExpandedLength) - assertNestingDepth(expanded, maxDepth) + if (expanded !== source) assertNestingDepth(expanded, maxDepth) return expanded } @@ -222,7 +222,7 @@ class Parser { return { type: "delimited", left: "(", body: fraction, right: ")" } } if (command === "sqrt") { - const index = this.peekOptionalArgument() + const index = this.parseOptionalArgument() const result: MathNode = { type: "root", body: this.parseArgument() } if (index) result.index = index return result @@ -402,7 +402,7 @@ class Parser { return body } - private peekOptionalArgument(): MathNode | undefined { + private parseOptionalArgument(): MathNode | undefined { this.skipMathWhitespace() if (this.peek() !== "[") return undefined this.position++ diff --git a/packages/latex/src/render.test.ts b/packages/latex/src/render.test.ts index b6e0b05a8627..6061c588b4f6 100644 --- a/packages/latex/src/render.test.ts +++ b/packages/latex/src/render.test.ts @@ -6,10 +6,24 @@ describe("renderLatexToString", () => { expect(renderLatexToString(String.raw`\frac{x+1}{y-1}`)).toBe([" x + 1", "───────", " y - 1"].join("\n")) }) - test("uses compact unicode scripts where possible", () => { - expect(renderLatexToString(String.raw`E = mc^2`)).toBe("E = mc²") - expect(renderLatexToString(String.raw`a_n`)).toBe("aₙ") - expect(renderLatexToString(String.raw`x_i^2`)).toBe("x²ᵢ") + test.each([ + [String.raw`E = mc^2`, "E = mc²"], + [String.raw`a_n`, "aₙ"], + [String.raw`x_i^2`, "x²ᵢ"], + [String.raw`x^{}`, "x"], + [String.raw`x_{}`, "x"], + [String.raw`x^{}_{}`, "x"], + [String.raw`x^m_1`, " m\nx\n 1"], + [String.raw`x^2_q`, " 2\nx\n q"], + [String.raw`x^{\frac{1}{2}}_1`, " 1\n ───\n 2\nx\n 1"], + ])("compacts scripts only when every script is supported: %s", (source, expected) => { + expect(renderLatexToString(source)).toBe(expected) + }) + + test("respects script and display mode options", () => { + expect(renderLatexToString(String.raw`x_i^2`, { compactScripts: false })).toBe(" 2\nx\n i") + expect(renderLatexToString(String.raw`\sum_1^n`, { displayMode: false })).toBe("∑ⁿ₁") + expect(renderLatexToString(String.raw`\sum_1^n`, { compactScripts: false })).toBe("n\n∑\n1") }) test("centers binomials around an empty math-axis row", () => { @@ -41,6 +55,11 @@ describe("renderLatexToString", () => { expect(renderLatexToString(String.raw`\mathbb{R} \to \mathcal{C} \times \mathfrak{g}`)).toBe("ℝ → 𝒞 × 𝔤") }) + test("preserves inherited styles through nested variants and colors", () => { + const layout = renderLatex(String.raw`\mathbf{\mathsf{\textcolor{red}{\mathit{x}}}}`) + expect(layout.cells[0][0]).toEqual({ char: "x", style: { bold: true, italic: true, color: "red" } }) + }) + test("renders nested fractions without flattening their structure", () => { const result = renderLatexToString(String.raw`\frac{1}{1+\frac{1}{x}}`) expect(result.split("\n")).toHaveLength(5) From 9dec8f03fd24185c3a20ddc3d81477a86162a9ae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 15:39:15 -0400 Subject: [PATCH 9/9] fix(latex): preserve notation and connected geometry Preserve norm delimiters, command boundaries, empty cells, array column formats, and continued-fraction options. Size indexed radicals safely, join their strokes, and place scripts and brace annotations around the full expression. --- packages/latex/src/layout.test.ts | 87 ++++++++++++ packages/latex/src/layout.ts | 117 +++++++++++----- packages/latex/src/markdown.test.ts | 47 ++++--- packages/latex/src/parser-render.test.ts | 23 +++ packages/latex/src/parser.test.ts | 170 ++++++++++++++++++++++- packages/latex/src/parser.ts | 99 +++++++------ packages/latex/src/render.test.ts | 4 +- packages/latex/src/root.test.ts | 69 +++++++++ packages/latex/src/symbols.ts | 2 + packages/latex/src/types.ts | 11 +- 10 files changed, 530 insertions(+), 99 deletions(-) create mode 100644 packages/latex/src/layout.test.ts create mode 100644 packages/latex/src/parser-render.test.ts create mode 100644 packages/latex/src/root.test.ts diff --git a/packages/latex/src/layout.test.ts b/packages/latex/src/layout.test.ts new file mode 100644 index 000000000000..19dec3592019 --- /dev/null +++ b/packages/latex/src/layout.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { layoutMath } from "./layout" +import { renderLatexToString } from "./render" + +const text = (value: string) => ({ type: "text" as const, value }) + +describe("structured math layout", () => { + test.each([ + String.raw`\sqrt{x}`, + String.raw`\begin{pmatrix}a&b\\c&d\end{pmatrix}`, + String.raw`\underbrace{abcd}`, + String.raw`\overbrace{abcd}`, + String.raw`\sum`, + ])("empty scripts do not change geometry: %s", (source) => { + for (const scripts of ["^{}", "_{}", "^{}_{}"]) { + expect(renderLatexToString(source + scripts)).toBe(renderLatexToString(source)) + } + }) + + test("centers annotations over even-width brace junctions", () => { + expect(renderLatexToString(String.raw`\overbrace{abcd}^{n}`)).toBe([" n", "╭┴─╮", "abcd"].join("\n")) + expect(renderLatexToString(String.raw`\underbrace{abcd}_{n}`)).toBe(["abcd", "╰┬─╯", " n"].join("\n")) + }) + + test("raises powers above tall matrix delimiters", () => { + expect(renderLatexToString(String.raw`\begin{pmatrix}a&b\\c&d\end{pmatrix}^2`)).toBe( + [" 2", "⎛a b⎞", "⎜ ⎟", "⎝c d⎠"].join("\n"), + ) + }) + + test("keeps piecewise values left-aligned", () => { + expect(renderLatexToString(String.raw`\begin{cases}x & x>0\\x^2+1 & x\le0\end{cases}`)).toBe( + ["⎧x x > 0", "⎨", "⎩x² + 1 x ≤ 0"].join("\n"), + ) + }) + + test("honors array column alignment and continuous separators", () => { + expect( + layoutMath({ + type: "matrix", + environment: "array", + columns: "l|r", + rows: [ + [text("a"), text("wide")], + [text("long"), text("b")], + ], + }).toString(), + ).toBe(["a │ wide", " │", "long │ b"].join("\n")) + }) + + test("preserves edge rules and double array separators", () => { + expect( + layoutMath({ + type: "matrix", + environment: "array", + columns: "|l||r|", + rows: [ + [text("a"), text("b")], + [text("long"), text("c")], + ], + }).toString(), + ).toBe(["│ a ││ b │", "│ ││ │", "│ long ││ c │"].join("\n")) + }) + + test.each(["left", "right"] as const)("aligns continued-fraction numerators to the %s", (numeratorAlign) => { + const layout = layoutMath({ + type: "fraction", + numerator: text("1"), + denominator: text("12345"), + bar: true, + numeratorAlign, + }) + expect(layout.toString()).toBe([numeratorAlign === "left" ? " 1" : " 1", "───────", " 12345"].join("\n")) + }) + + test.each(["over", "under"] as const)("stretches %s braces and places annotations outside them", (position) => { + const layout = layoutMath({ + type: "scripts", + base: { type: "brace", body: text("a + b + c"), position }, + ...(position === "over" ? { superscript: text("n") } : { subscript: text("n") }), + }) + expect(layout.toString()).toBe( + (position === "over" ? [" n", "╭───┴───╮", "a + b + c"] : ["a + b + c", "╰───┬───╯", " n"]).join("\n"), + ) + expect(layout.baseline).toBe(position === "over" ? 2 : 0) + }) +}) diff --git a/packages/latex/src/layout.ts b/packages/latex/src/layout.ts index 88a7327de351..fed6fa03106b 100644 --- a/packages/latex/src/layout.ts +++ b/packages/latex/src/layout.ts @@ -91,7 +91,7 @@ function layoutNode(node: MathNode, context: LayoutContext): Box { case "space": return blank(node.width, 1, 0) case "fraction": - return layoutFraction(node.numerator, node.denominator, node.bar, context) + return layoutFraction(node, context) case "root": return layoutRoot(node.body, node.index, context) case "scripts": @@ -99,7 +99,9 @@ function layoutNode(node: MathNode, context: LayoutContext): Box { case "delimited": return layoutDelimited(node.left, node.body, node.right, context) case "matrix": - return layoutMatrix(node.rows, node.environment, context) + return layoutMatrix(node, context) + case "brace": + return layoutBrace(node, context) case "accent": return layoutAccent(node.accent, node.body, context) case "variant": @@ -130,9 +132,9 @@ function layoutRow(nodes: MathNode[], context: LayoutContext): Box { return hpack(boxes) } -function layoutFraction(numeratorNode: MathNode, denominatorNode: MathNode, bar: boolean, context: LayoutContext): Box { - const numerator = layoutNode(numeratorNode, context) - const denominator = layoutNode(denominatorNode, context) +function layoutFraction(node: Extract, context: LayoutContext): Box { + const numerator = layoutNode(node.numerator, context) + const denominator = layoutNode(node.denominator, context) const width = Math.max(numerator.width, denominator.width) + 2 // Barless fractions (binomials) still reserve an axis row so surrounding // atoms and their stretching parentheses align between the two entries. @@ -143,8 +145,14 @@ function layoutFraction(numeratorNode: MathNode, denominatorNode: MathNode, bar: const baseline = numerator.height const result = blank(width, height, baseline) - overlay(result, numerator, Math.floor((width - numerator.width) / 2), 0) - if (bar) drawHorizontal(result, numerator.height, 0, width, "─", context.style) + const numeratorX = + node.numeratorAlign === "left" + ? 1 + : node.numeratorAlign === "right" + ? width - numerator.width - 1 + : Math.floor((width - numerator.width) / 2) + overlay(result, numerator, numeratorX, 0) + if (node.bar) drawHorizontal(result, numerator.height, 0, width, "─", context.style) overlay(result, denominator, Math.floor((width - denominator.width) / 2), numerator.height + gap) return result } @@ -153,36 +161,42 @@ function layoutRoot(bodyNode: MathNode, indexNode: MathNode | undefined, context const body = layoutNode(bodyNode, context) const index = indexNode ? layoutNode(indexNode, context) : undefined const indexWidth = index ? Math.max(0, index.width - 1) : 0 + // The index ends beside the overbar, never inside the hook or radicand. + const top = Math.max(0, (index?.height ?? 1) - 1) const bodyX = indexWidth + 2 const width = bodyX + body.width - const height = body.height + 1 - const baseline = body.baseline + 1 + const height = top + body.height + 1 + const baseline = top + body.baseline + 1 const result = blank(width, height, baseline) - setCell(result, bodyX - 1, 0, "╭", context.style) - drawHorizontal(result, 0, bodyX, body.width, "─", context.style) - setCell(result, bodyX - 2, baseline, "√", context.style) - overlay(result, body, bodyX, 1) + setCell(result, bodyX - 1, top, "╭", context.style) + drawHorizontal(result, top, bodyX, body.width, "─", context.style) + for (let y = top + 1; y < height - 1; y++) setCell(result, bodyX - 1, y, "│", context.style) + setCell(result, bodyX - 2, height - 1, "╰", context.style) + setCell(result, bodyX - 1, height - 1, "╯", context.style) + overlay(result, body, bodyX, top + 1) if (index) overlay(result, index, 0, 0) return result } function layoutScripts(node: Extract, context: LayoutContext): Box { - if (node.base.type === "operator" && node.base.limits && context.displayMode) { - return layoutOverUnder(node.base, node.superscript, node.subscript, context) + const superscriptNode = node.superscript && simpleNodeText(node.superscript) !== "" ? node.superscript : undefined + const subscriptNode = node.subscript && simpleNodeText(node.subscript) !== "" ? node.subscript : undefined + if (node.base.type === "brace" || (node.base.type === "operator" && node.base.limits && context.displayMode)) { + return layoutOverUnder(node.base, superscriptNode, subscriptNode, context) } const base = layoutNode(node.base, context) - if (context.compactScripts) { - const superscript = mapScript(node.superscript ? simpleNodeText(node.superscript) : "", superscripts) - const subscript = mapScript(node.subscript ? simpleNodeText(node.subscript) : "", subscripts) + if (context.compactScripts && base.height === 1) { + const superscript = mapScript(superscriptNode ? simpleNodeText(superscriptNode) : "", superscripts) + const subscript = mapScript(subscriptNode ? simpleNodeText(subscriptNode) : "", subscripts) if (superscript !== undefined && subscript !== undefined) { return hpack([base, textBox(superscript + subscript, context.style)]) } } - const superscript = node.superscript ? layoutNode(node.superscript, context) : undefined - const subscript = node.subscript ? layoutNode(node.subscript, context) : undefined + const superscript = superscriptNode ? layoutNode(superscriptNode, context) : undefined + const subscript = subscriptNode ? layoutNode(subscriptNode, context) : undefined const scriptWidth = Math.max(superscript?.width ?? 0, subscript?.width ?? 0) const topHeight = superscript?.height ?? 0 const bottomHeight = subscript?.height ?? 0 @@ -225,44 +239,63 @@ function layoutDelimited(left: string, bodyNode: MathNode, right: string, contex return hpack([leftBox, body, rightBox]) } -function layoutMatrix( - rows: MathNode[][], - environment: Extract["environment"], - context: LayoutContext, -): Box { - const cellRows = rows.map((row) => row.map((cell) => layoutNode(cell, context))) - const columnCount = Math.max(0, ...cellRows.map((row) => row.length)) +function layoutMatrix(node: Extract, context: LayoutContext): Box { + const cellRows = node.rows.map((row) => row.map((cell) => layoutNode(cell, context))) + const columns = node.columns?.match(/[lcr]/g) + const rules = node.columns?.split(/[lcr]/).map((rule) => rule.length) ?? [] + const columnCount = Math.max(columns?.length ?? 0, ...cellRows.map((row) => row.length)) const columnWidths = Array.from({ length: columnCount }, (_, column) => Math.max(0, ...cellRows.map((row) => row[column]?.width ?? 0)), ) const rowAscents = cellRows.map((row) => Math.max(0, ...row.map((cell) => cell.baseline))) const rowDescents = cellRows.map((row) => Math.max(0, ...row.map((cell) => cell.height - cell.baseline - 1))) const rowHeights = rowAscents.map((ascent, index) => ascent + 1 + rowDescents[index]) - const columnGap = environment === "cases" ? 2 : environment === "aligned" || environment === "align" ? 2 : 1 - const width = columnWidths.reduce((sum, value) => sum + value, 0) + Math.max(0, columnCount - 1) * columnGap - const height = Math.max(1, rowHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, rows.length - 1)) + const aligned = node.environment === "aligned" || node.environment === "align" + const columnGap = node.environment === "cases" || aligned ? 2 : 1 + const gaps = Array.from({ length: columnCount + 1 }, (_, boundary) => { + const edge = boundary === 0 || boundary === columnCount + return rules[boundary] ? rules[boundary] + (edge ? 1 : 2) : edge ? 0 : columnGap + }) + const width = columnWidths.reduce((sum, value) => sum + value, 0) + gaps.reduce((sum, value) => sum + value, 0) + const height = Math.max(1, rowHeights.reduce((sum, value) => sum + value, 0) + Math.max(0, node.rows.length - 1)) const result = blank(width, height, Math.floor(height / 2)) let y = 0 for (let rowIndex = 0; rowIndex < cellRows.length; rowIndex++) { - let x = 0 + let x = gaps[0] const cells = cellRows[rowIndex] for (let column = 0; column < columnCount; column++) { const cell = cells[column] const columnWidth = columnWidths[column] if (cell) { - const centered = environment !== "aligned" && environment !== "align" && environment !== "cases" + const alignment = + columns?.[column] ?? (node.environment === "cases" ? "l" : aligned ? (column % 2 === 0 ? "r" : "l") : "c") const cellX = - x + (centered ? Math.floor((columnWidth - cell.width) / 2) : column % 2 === 0 ? columnWidth - cell.width : 0) + x + + (alignment === "l" + ? 0 + : alignment === "r" + ? columnWidth - cell.width + : Math.floor((columnWidth - cell.width) / 2)) const cellY = y + rowAscents[rowIndex] - cell.baseline overlay(result, cell, cellX, cellY) } - x += columnWidth + columnGap + x += columnWidth + gaps[column + 1] } y += rowHeights[rowIndex] + 1 } - const delimiters = matrixDelimiters(environment) + let boundaryX = 0 + for (let boundary = 0; boundary <= columnCount; boundary++) { + for (let rule = 0; rule < (rules[boundary] ?? 0); rule++) { + for (let row = 0; row < height; row++) { + setCell(result, boundaryX + (boundary === 0 ? 0 : 1) + rule, row, "│", context.style) + } + } + boundaryX += gaps[boundary] + (columnWidths[boundary] ?? 0) + } + + const delimiters = matrixDelimiters(node.environment) return delimiters ? hpack([ delimiterBox(delimiters[0], height, result.baseline, true, context.style), @@ -272,6 +305,20 @@ function layoutMatrix( : result } +function layoutBrace(node: Extract, context: LayoutContext): Box { + const body = layoutNode(node.body, context) + const over = node.position === "over" + const width = Math.max(3, body.width) + const result = blank(width, body.height + 1, body.baseline + (over ? 1 : 0)) + const y = over ? 0 : body.height + overlay(result, body, Math.floor((width - body.width) / 2), over ? 1 : 0) + drawHorizontal(result, y, 0, width, "─", context.style) + setCell(result, 0, y, over ? "╭" : "╰", context.style) + setCell(result, width - 1, y, over ? "╮" : "╯", context.style) + setCell(result, Math.floor((width - 1) / 2), y, over ? "┴" : "┬", context.style) + return result +} + function layoutAccent( accent: Extract["accent"], bodyNode: MathNode, diff --git a/packages/latex/src/markdown.test.ts b/packages/latex/src/markdown.test.ts index 1892f6dc7344..c6cf4a2a95b9 100644 --- a/packages/latex/src/markdown.test.ts +++ b/packages/latex/src/markdown.test.ts @@ -57,6 +57,9 @@ test.each(["latex", "math", "tex", "LATEX title=example"])("renders a %s fence", test.each([ String.raw`\frac{1}{`, String.raw`\unsupported{x}`, + String.raw`\cfrac[x]{1}{2}`, + String.raw`\left\unknown x\right)`, + String.raw`\begin{array}{p{2cm}}x\end{array}`, String.raw`\documentclass{article} \begin{document} Hello @@ -69,6 +72,22 @@ Hello expect(block.content).toBe(source) }) +test.each([ + String.raw`\sqrt[\frac{1}{2}]{x}`, + String.raw`\left\|v\right\|`, + String.raw`\left(A\rightarrow B\right)`, + String.raw`\begin{aligned}a&=b+c\\&=d\end{aligned}`, + String.raw`\displaylines{x=1\\y=2}`, + String.raw`\cfrac[l]{1}{12345}`, + String.raw`\underbrace{a+b+c}_{n}`, + String.raw`\begin{array}{l|r}a&wide\\long&b\end{array}`, +])("renders structured math through the Markdown adapter: %s", async (source) => { + const output = await setup(`\`\`\`latex\n${source}\n\`\`\``) + expect(output.markdown.getChildren()[0]).toBeInstanceOf(ScrollBoxRenderable) + expect(output.markdown.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(TextRenderable) + expect(output.captureCharFrame()).not.toContain("\\") +}) + test("renders the next valid formula after an incomplete streaming prefix", async () => { const output = await setup("```latex\n\\frac{1}{") expect(output.markdown.getChildren()[0]).toBeInstanceOf(CodeRenderable) @@ -182,15 +201,11 @@ test("subdues structure and emphasizes relations using the theme", async () => { const output = await setup("```latex\nx=\\sqrt{\\frac{1}{2}}\n```") const formula = output.markdown.getChildren()[0]?.getChildren()[0] if (!(formula instanceof TextRenderable)) throw new Error("Expected Unicode math") - expect( - formula.chunks.find((chunk) => chunk.text === "\u2500")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), - ).toBe(true) - expect( - formula.chunks.find((chunk) => chunk.text === "\u221a")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), - ).toBe(true) - expect( - formula.chunks.find((chunk) => chunk.text === "\u256d")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), - ).toBe(true) + for (const mark of ["\u2500", "\u2502", "\u256d", "\u256f", "\u2570"]) { + expect(formula.chunks.find((chunk) => chunk.text === mark)?.fg?.equals(RGBA.fromHex(output.palette.subdued))).toBe( + true, + ) + } expect(formula.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true) expect(formula.chunks.find((chunk) => chunk.text === "=")?.attributes).toBe(TextAttributes.BOLD) @@ -201,15 +216,11 @@ test("subdues structure and emphasizes relations using the theme", async () => { const updated = output.markdown.getChildren()[0]?.getChildren()[0] if (!(updated instanceof TextRenderable)) throw new Error("Expected Unicode math") expect(updated.chunks.find((chunk) => chunk.text === "x")?.fg?.equals(RGBA.fromHex(output.palette.text))).toBe(true) - expect( - updated.chunks.find((chunk) => chunk.text === "\u2500")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), - ).toBe(true) - expect( - updated.chunks.find((chunk) => chunk.text === "\u221a")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), - ).toBe(true) - expect( - updated.chunks.find((chunk) => chunk.text === "\u256d")?.fg?.equals(RGBA.fromHex(output.palette.subdued)), - ).toBe(true) + for (const mark of ["\u2500", "\u2502", "\u256d", "\u256f", "\u2570"]) { + expect(updated.chunks.find((chunk) => chunk.text === mark)?.fg?.equals(RGBA.fromHex(output.palette.subdued))).toBe( + true, + ) + } }) test.each([String.raw`\text{${"\u4e2d\u6587"}}=x`, String.raw`\frac{\text{${"\u4e2d\u6587"}}}{abcd}=x`])( diff --git a/packages/latex/src/parser-render.test.ts b/packages/latex/src/parser-render.test.ts new file mode 100644 index 000000000000..5eb4c0c5f4af --- /dev/null +++ b/packages/latex/src/parser-render.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { renderLatexToString } from "./render" + +describe("parser rendering regressions", () => { + test.each([ + [String.raw`\|v\|`, "║v║"], + [String.raw`\left\|v\right\|`, "║v║"], + [String.raw`\left|v\right|`, "│v│"], + [String.raw`\left(A\rightarrow B\right)`, "(A → B)"], + [String.raw`\left\lbrace x\right\rbrace`, "{x}"], + [String.raw`\operatorname{arg\,max} x`, "arg max x"], + [String.raw`\textrm{if }x`, "if x"], + [String.raw`\displaylines{x=1\\y=2}`, "x = 1\n\ny = 2"], + ])("renders supported syntax without leaking or losing tokens: %s", (source, expected) => { + expect(renderLatexToString(source, { strict: true })).toBe(expected) + }) + + test("renders empty aligned cells like explicitly empty groups", () => { + expect(renderLatexToString(String.raw`\begin{aligned}&=x\\&=y\end{aligned}`, { strict: true })).toBe( + renderLatexToString(String.raw`\begin{aligned}{}&=x\\{}&=y\end{aligned}`, { strict: true }), + ) + }) +}) diff --git a/packages/latex/src/parser.test.ts b/packages/latex/src/parser.test.ts index e6c86f1950ab..7c59be817f8d 100644 --- a/packages/latex/src/parser.test.ts +++ b/packages/latex/src/parser.test.ts @@ -33,6 +33,7 @@ describe("parseLatex", () => { expect(parseLatex(String.raw`\begin{array}{cc}a & b \\ c & d\end{array}`)).toMatchObject({ type: "matrix", environment: "array", + columns: "cc", rows: [ [{}, {}], [{}, {}], @@ -44,6 +45,159 @@ describe("parseLatex", () => { }) }) + test("preserves double norm delimiters without changing single bars", () => { + expect(parseLatex(String.raw`\|v\|`, { strict: true })).toMatchObject({ + type: "row", + body: [{ value: "║" }, { value: "v" }, { value: "║" }], + }) + expect(parseLatex(String.raw`\left\|v\right\|`, { strict: true })).toMatchObject({ + type: "delimited", + left: "║", + right: "║", + }) + expect(parseLatex(String.raw`\left|v\right|`, { strict: true })).toMatchObject({ + type: "delimited", + left: "│", + right: "│", + }) + }) + + test("matches the whole right command and keeps nested delimiters", () => { + expect(parseLatex(String.raw`\left(A\rightarrow B\right)`, { strict: true })).toMatchObject({ + type: "delimited", + left: "(", + body: { type: "row", body: [{ value: "A" }, { value: "→" }, { value: "B" }] }, + right: ")", + }) + expect(parseLatex(String.raw`\left(\left[A\right]\rightharpoonup B\right)`)).toMatchObject({ + type: "delimited", + body: { type: "row", body: [{ type: "delimited" }, { value: "⇀" }, { value: "B" }] }, + }) + expect(() => parseLatex(String.raw`\left(A\rightarrow B`, { strict: true })).toThrow(/Missing \\right/) + expect(() => parseLatex(String.raw`\left(A\rightward B\right)`, { strict: true })).toThrow( + /Unsupported command \\rightward/, + ) + }) + + test("accepts empty leading, interior, and trailing environment cells", () => { + expect(parseLatex(String.raw`\begin{aligned}&=x\\&=y\end{aligned}`, { strict: true })).toMatchObject({ + type: "matrix", + environment: "aligned", + rows: [ + [ + { type: "row", body: [] }, + { type: "row", body: [{ value: "=" }, { value: "x" }] }, + ], + [ + { type: "row", body: [] }, + { type: "row", body: [{ value: "=" }, { value: "y" }] }, + ], + ], + }) + expect(parseLatex(String.raw`\begin{matrix}a&&\\&b&\end{matrix}`, { strict: true })).toMatchObject({ + type: "matrix", + rows: [ + [{ value: "a" }, { type: "row", body: [] }, { type: "row", body: [] }], + [{ type: "row", body: [] }, { value: "b" }, { type: "row", body: [] }], + ], + }) + expect(parseLatex(String.raw`\begin{matrix}a\\\end{matrix}`)).toMatchObject({ rows: [[{ value: "a" }]] }) + expect(parseLatex(String.raw`\begin{matrix}\\\end{matrix}`)).toMatchObject({ rows: [[{ type: "row", body: [] }]] }) + }) + + test("parses displaylines as separate gathered rows", () => { + expect(parseLatex(String.raw`\displaylines{x=1\\y=2}`, { strict: true })).toMatchObject({ + type: "matrix", + environment: "gathered", + rows: [ + [{ type: "row", body: [{ value: "x" }, { value: "=" }, { value: "1" }] }], + [{ type: "row", body: [{ value: "y" }, { value: "=" }, { value: "2" }] }], + ], + }) + expect(parseLatex(String.raw`\displaylines{\frac{1}{2}\\{y}}+z`)).toMatchObject({ + type: "row", + body: [{ type: "matrix", rows: [[{ type: "fraction" }], [{ value: "y" }]] }, { value: "+" }, { value: "z" }], + }) + expect(() => parseLatex(String.raw`\displaylines[l]{x\\y}`, { strict: true })).toThrow(LatexParseError) + expect(() => parseLatex(String.raw`\displaylines{x\\y`, { strict: true })).toThrow(LatexParseError) + }) + + test.each([ + ["", undefined], + ["[]", undefined], + ["[l]", "left"], + ["[r]", "right"], + ] as const)("parses continued fraction alignment %s before its arguments", (option, numeratorAlign) => { + expect(parseLatex(String.raw`\cfrac${option}{1}{23}`, { strict: true })).toEqual({ + type: "fraction", + numerator: { type: "symbol", value: "1", role: "ordinary" }, + denominator: { + type: "row", + body: [ + { type: "symbol", value: "2", role: "ordinary" }, + { type: "symbol", value: "3", role: "ordinary" }, + ], + }, + bar: true, + ...(numeratorAlign ? { numeratorAlign } : {}), + }) + }) + + test.each(["[c]", "[lr]", "[left]", "[l"])("rejects unsupported continued fraction alignment %s", (option) => { + expect(() => parseLatex(String.raw`\cfrac${option}{1}{2}`, { strict: true })).toThrow(LatexParseError) + }) + + test("retains normalized array columns including edge and double rules", () => { + expect(parseLatex(String.raw`\begin{array}{ | l || c r | }a&b&c\end{array}`, { strict: true })).toMatchObject({ + type: "matrix", + environment: "array", + columns: "|l||cr|", + }) + }) + + test.each(["", "||", "p{2cm}", "*{2}{c}", "c@{}c", "lXr"])("rejects unsupported array columns %s", (columns) => { + expect(() => parseLatex(String.raw`\begin{array}{${columns}}a\end{array}`, { strict: true })).toThrow( + LatexParseError, + ) + }) + + test("requires an array column specification", () => { + expect(() => parseLatex(String.raw`\begin{array}a&b\end{array}`, { strict: true })).toThrow(LatexParseError) + }) + + test("emits structural braces while keeping annotations as scripts", () => { + expect(parseLatex(String.raw`\overbrace{a+b}^{n}`, { strict: true })).toMatchObject({ + type: "scripts", + base: { type: "brace", position: "over", body: { type: "row" } }, + superscript: { value: "n" }, + }) + expect(parseLatex(String.raw`\underbrace{x}_{k}`, { strict: true })).toMatchObject({ + type: "scripts", + base: { type: "brace", position: "under", body: { value: "x" } }, + subscript: { value: "k" }, + }) + }) + + test("recognizes named braces and rejects unsupported delimiter commands in strict mode", () => { + expect(parseLatex(String.raw`\left\lbrace x\right\rbrace`, { strict: true })).toMatchObject({ + type: "delimited", + left: "{", + right: "}", + }) + expect(parseLatex(String.raw`\lbrace x\rbrace`, { strict: true })).toMatchObject({ + type: "row", + body: [{ value: "{" }, { value: "x" }, { value: "}" }], + }) + for (const source of [ + String.raw`\left\unknown x\right)`, + String.raw`\left(x\right\unknown`, + String.raw`\big\unknown`, + String.raw`\left(x\middle\unknown y\right)`, + ]) { + expect(() => parseLatex(source, { strict: true })).toThrow(/Unsupported delimiter \\unknown/) + } + }) + test("expands user macros", () => { expect(parseLatex(String.raw`\R \to \R`, { macros: { "\\R": String.raw`\mathbb{R}` } })).toMatchObject({ type: "row", @@ -68,7 +222,7 @@ describe("parseLatex", () => { test("supports starred named operators and limits modifiers", () => { expect(parseLatex(String.raw`\operatorname*{arg\,max}_{x}`)).toMatchObject({ type: "scripts", - base: { type: "operator", value: String.raw`arg\,max`, limits: true }, + base: { type: "operator", value: "arg max", limits: true }, }) expect(parseLatex(String.raw`\int\limits_0^1`)).toMatchObject({ type: "scripts", @@ -80,6 +234,19 @@ describe("parseLatex", () => { }) }) + test("interprets operator spacing and preserves roman text whitespace", () => { + expect(parseLatex(String.raw`\operatorname{arg\,max}`, { strict: true })).toEqual({ + type: "operator", + value: "arg max", + limits: false, + }) + expect(parseLatex(String.raw`\textrm{ if }`, { strict: true })).toEqual({ + type: "variant", + variant: "normal", + body: { type: "text", value: " if " }, + }) + }) + test("bounds source and recursive macro expansion", () => { expect(() => parseLatex("12345", { maxSourceLength: 4 })).toThrow(/4-character limit/) expect(() => @@ -95,6 +262,7 @@ describe("parseLatex", () => { test("fails quickly when malformed environments cannot advance", () => { expect(() => parseLatex(String.raw`\begin{matrix}]`)).toThrow(/Missing \\end{matrix}/) expect(() => parseLatex(String.raw`\begin{matrix}x}`)).toThrow(/Unexpected "}" in matrix/) + expect(() => parseLatex(String.raw`\begin{matrix}&}`)).toThrow(/Unexpected "}" in matrix/) }) test("bounds structural nesting with a parse error instead of overflowing the stack", () => { diff --git a/packages/latex/src/parser.ts b/packages/latex/src/parser.ts index 5ed2901545e2..2bb2f08c246a 100644 --- a/packages/latex/src/parser.ts +++ b/packages/latex/src/parser.ts @@ -205,11 +205,17 @@ class Parser { if (command === "\\") return { type: "row", body: [] } if (command === "begin") return this.parseEnvironment() if (command === "frac" || command === "dfrac" || command === "tfrac" || command === "cfrac") { + this.skipMathWhitespace() + const alignment = + command === "cfrac" && this.peek() === "[" ? /^\[([lr]?)\]/.exec(this.source.slice(this.position)) : undefined + if (alignment === null) this.fail("Unsupported \\cfrac alignment; expected [l], [r], or []") + if (alignment) this.position += alignment[0].length return { type: "fraction", numerator: this.parseArgument(), denominator: this.parseArgument(), bar: true, + ...(alignment?.[1] ? { numeratorAlign: alignment[1] === "l" ? "left" : "right" } : {}), } } if (command === "binom" || command === "dbinom" || command === "tbinom") { @@ -237,7 +243,11 @@ class Parser { return { type: "accent", accent: accents[command], body: this.parseArgument() } } if (command in variants) { - return { type: "variant", variant: variants[command], body: this.parseArgument() } + return { + type: "variant", + variant: variants[command], + body: command === "textrm" ? { type: "text", value: this.readTextGroup() } : this.parseArgument(), + } } if (command === "text" || command === "mbox") return { type: "text", value: this.readTextGroup() } if (command === "operatorname") { @@ -255,11 +265,8 @@ class Parser { const base = this.parseArgument() return { type: "overunder", base, under } } - if (command === "overbrace") { - return { type: "overunder", base: this.parseArgument(), over: { type: "symbol", value: "⏞" } } - } - if (command === "underbrace") { - return { type: "overunder", base: this.parseArgument(), under: { type: "symbol", value: "⏟" } } + if (command === "overbrace" || command === "underbrace") { + return { type: "brace", body: this.parseArgument(), position: command === "overbrace" ? "over" : "under" } } if (command === "textcolor") { const color = this.readRawGroup() @@ -286,10 +293,14 @@ class Parser { } } if (command === "mod" || command === "bmod") return { type: "operator", value: "mod", limits: false } + if (command === "displaylines") { + this.skipMathWhitespace() + this.expect("{") + return this.parseMatrix("gathered", "}") + } if ( command === "limits" || command === "nolimits" || - command === "displaylines" || command === "displaystyle" || command === "textstyle" || command === "scriptstyle" || @@ -316,7 +327,8 @@ class Parser { } } if (command === "backslash") return { type: "symbol", value: "\\" } - if (command in delimiterTable) return { type: "symbol", value: delimiterTable[command] } + const delimiter = delimiterTable[`\\${command}`] ?? delimiterTable[command] + if (delimiter !== undefined) return { type: "symbol", value: delimiter } if (command === "{" || command === "}") return { type: "symbol", value: command } if (command === "%" || command === "#" || command === "$" || command === "&" || command === "_") { return { type: "symbol", value: command } @@ -335,18 +347,20 @@ class Parser { const content = this.readUntilEnd(rawEnvironment) return { type: "text", value: content } } - if (environment === "array" && this.peekAfterWhitespace() === "{") this.readRawGroup() + const columns = environment === "array" ? this.readRawGroup().replace(/\s/g, "") : undefined + if (columns !== undefined && (!/^[lcr|]+$/.test(columns) || !/[lcr]/.test(columns))) { + this.fail("Unsupported array columns; expected l, c, r, and |") + } + return this.parseMatrix(environment, `\\end{${rawEnvironment}}`, columns) + } + private parseMatrix(environment: MatrixEnvironment, end: string, columns?: string): MathNode { const rows: MathNode[][] = [] let cells: MathNode[] = [] while (!this.done()) { this.skipMathWhitespace() - if (this.isEndEnvironment(rawEnvironment)) { - this.consumeEndEnvironment(rawEnvironment) - if (cells.length > 0 || rows.length === 0) rows.push(cells) - return { type: "matrix", rows, environment } - } + if (this.source.startsWith(end, this.position) && cells.length === 0) break const cellStart = this.position const cell = row( @@ -354,12 +368,9 @@ class Parser { () => this.peek() === "&" || this.source.startsWith("\\\\", this.position) || - this.isEndEnvironment(rawEnvironment), + this.source.startsWith(end, this.position), ), ) - if (this.position === cellStart) { - this.fail(`Unexpected "${this.peek()}" in ${rawEnvironment}`) - } cells.push(cell) this.skipMathWhitespace() @@ -374,15 +385,23 @@ class Parser { cells = [] continue } + if (this.source.startsWith(end, this.position)) break + // Empty cells are valid only when a cell, row, or closing delimiter advances the parser. + if (this.position === cellStart) this.fail(`Unexpected "${this.peek()}" in ${environment}`) } - return this.fail(`Missing \\end{${rawEnvironment}}`) + if (!this.source.startsWith(end, this.position)) this.fail(`Missing ${end}`) + this.expect(end) + if (cells.length > 0 || rows.length === 0) rows.push(cells) + return { type: "matrix", rows, environment, ...(columns !== undefined ? { columns } : {}) } } private parseLeftRight(): MathNode { const left = this.readDelimiter() - const body = row(this.parseRow(() => this.source.startsWith("\\right", this.position))) - if (!this.source.startsWith("\\right", this.position)) this.fail("Missing \\right") + const atRight = () => + this.source.startsWith("\\right", this.position) && !/[A-Za-z@]/.test(this.source[this.position + 6] ?? "") + const body = row(this.parseRow(atRight)) + if (!atRight()) this.fail("Missing \\right") this.readCommand() const right = this.readDelimiter() return { type: "delimited", left, body, right } @@ -424,10 +443,17 @@ class Parser { private readDelimiter(): string { this.skipMathWhitespace() - let token: string - if (this.peek() === "\\") token = this.readCommand() - else token = this.source[this.position++] ?? "" - return delimiterTable[token] ?? delimiterTable[`\\${token}`] ?? token + if (this.done()) this.fail("Expected a delimiter") + const start = this.position + if (this.peek() === "\\") { + const command = this.readCommand() + const delimiter = delimiterTable[`\\${command}`] ?? delimiterTable[command] + if (delimiter !== undefined) return delimiter + if (this.strict) this.fail(`Unsupported delimiter \\${command}`, start) + return `\\${command}` + } + const token = this.source[this.position++] + return delimiterTable[token] ?? token } private readCommand(): string { @@ -484,17 +510,14 @@ class Parser { private readTextGroup(): string { return this.readRawGroup() - .replace(/\\([{}%#$&_])/g, "$1") + .replace(/\\([A-Za-z@]+|.)/g, (match, command: string) => { + if ("{}%#$&_ ".includes(command)) return command + if (command === "textbackslash") return "\\" + if (command === "!") return "" + if (command in spacingCommands) return " ".repeat(Math.max(1, spacingCommands[command])) + return match + }) .replace(/~/g, " ") - .replace(/\\textbackslash\b/g, "\\") - } - - private isEndEnvironment(environment: string): boolean { - return this.source.startsWith(`\\end{${environment}}`, this.position) - } - - private consumeEndEnvironment(environment: string): void { - this.position += `\\end{${environment}}`.length } private readUntilEnd(environment: string): string { @@ -520,12 +543,6 @@ class Parser { } } - private peekAfterWhitespace(): string { - let offset = this.position - while (offset < this.source.length && /\s/.test(this.source[offset])) offset++ - return this.source[offset] ?? "" - } - private expect(value: string): void { if (!this.source.startsWith(value, this.position)) this.fail(`Expected "${value}"`) this.position += value.length diff --git a/packages/latex/src/render.test.ts b/packages/latex/src/render.test.ts index 6061c588b4f6..b6a280339f4c 100644 --- a/packages/latex/src/render.test.ts +++ b/packages/latex/src/render.test.ts @@ -31,7 +31,7 @@ describe("renderLatexToString", () => { }) test("renders roots with a vinculum", () => { - expect(renderLatexToString(String.raw`\sqrt{x^2+y^2}`)).toBe([" ╭───────", "√ x² + y²"].join("\n")) + expect(renderLatexToString(String.raw`\sqrt{x^2+y^2}`)).toBe([" ╭───────", "╰╯x² + y²"].join("\n")) }) test("renders matrices with stretching delimiters", () => { @@ -69,7 +69,7 @@ describe("renderLatexToString", () => { test("renders common textbook structures", () => { const result = renderLatexToString(String.raw`\left[\frac{-b \pm \sqrt{b^2-4ac}}{2a}\right]`) expect(result).toContain("±") - expect(result).toContain("√") + expect(result).toContain("╰╯") expect(result).toContain("─") expect(result).toContain("⎡") expect(result).toContain("⎦") diff --git a/packages/latex/src/root.test.ts b/packages/latex/src/root.test.ts new file mode 100644 index 000000000000..d9b3482862d9 --- /dev/null +++ b/packages/latex/src/root.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import { renderLatex } from "./render" + +describe("root geometry", () => { + test.each([ + ["x", String.raw`\frac{1}{2}`], + ["x", String.raw`\sqrt{n}`], + ["x", "123456789"], + ["x", String.raw`\frac{123456789}{\frac{n}{m}}`], + [String.raw`\frac{a}{b}`, "3"], + [String.raw`\sqrt{\frac{a}{b}}`, String.raw`\sqrt{\frac{n}{m}}`], + [String.raw`\text{界}`, String.raw`\text{次}`], + ["", String.raw`\frac{1}{2}`], + ])("preserves body %s and index %s", (bodySource, indexSource) => { + const body = renderLatex(bodySource, { color: "red" }) + const index = renderLatex(indexSource, { color: "blue" }) + const root = renderLatex(String.raw`\sqrt[\textcolor{blue}{${indexSource}}]{\textcolor{red}{${bodySource}}}`) + const bodyX = root.width - body.width + const bodyY = root.height - body.height + + expect(root.cells).toHaveLength(root.height) + expect(root.baseline).toBe(bodyY + body.baseline) + expect(bodyX).toBeGreaterThan(index.width) + expect(bodyY).toBeGreaterThanOrEqual(index.height) + for (const row of root.cells) expect(row).toHaveLength(root.width) + for (const [y, row] of body.cells.entries()) { + for (const [x, cell] of row.entries()) expect(root.cells[bodyY + y][bodyX + x]).toEqual(cell) + } + for (const [y, row] of index.cells.entries()) { + for (const [x, cell] of row.entries()) expect(root.cells[y][x]).toEqual(cell) + } + expect(root.cells.flat().filter((cell) => cell?.style?.color === "red")).toHaveLength( + body.cells.flat().filter(Boolean).length, + ) + expect(root.cells.flat().filter((cell) => cell?.style?.color === "blue")).toHaveLength( + index.cells.flat().filter(Boolean).length, + ) + }) + + test("connects each nested overbar to a full-height stem", () => { + const root = renderLatex(String.raw`\sqrt{\sqrt{\sqrt{x}}}`) + expect(root.toString()).toBe([" ╭─────", " │ ╭───", " │ │ ╭─", "╰╯╰╯╰╯x"].join("\n")) + expect(root.height).toBe(4) + expect(root.baseline).toBe(3) + for (const depth of [0, 1, 2]) { + for (let y = depth; y < root.height; y++) expect(root.cells[y][depth * 2 + 1]).toBeDefined() + } + expect(root.cells.flat().filter((cell) => cell?.char === "x")).toHaveLength(1) + }) + + test("extends a fraction root below the math axis without moving its baseline", () => { + const root = renderLatex(String.raw`\sqrt{\frac{a}{b}}`) + expect(root.toString()).toBe([" ╭───", " │ a", " │───", "╰╯ b"].join("\n")) + expect(root.height).toBe(4) + expect(root.baseline).toBe(2) + for (let y = 0; y < root.height; y++) expect(root.cells[y][1]).toBeDefined() + expect(root.cells[root.baseline].map((cell) => cell?.char ?? " ").join("")).toContain("───") + expect(root.cells[root.height - 1].some((cell) => cell?.char === "b")).toBe(true) + }) + + test.each([ + [String.raw`\sqrt{x}`, [" ╭─", "╰╯x"]], + [String.raw`\sqrt[3]{x}`, ["3╭─", "╰╯x"]], + [String.raw`\sqrt[\frac{1}{2}]{x}`, [" 1", "───", " 2 ╭─", " ╰╯x"]], + [String.raw`\sqrt[\sqrt{n}]{x}`, [" ╭─", "╰╯n╭─", " ╰╯x"]], + ])("uses the same connected construction for %s", (source, expected) => { + expect(renderLatex(source).toString()).toBe(expected.join("\n")) + }) +}) diff --git a/packages/latex/src/symbols.ts b/packages/latex/src/symbols.ts index 83db4341105e..ab54910a448e 100644 --- a/packages/latex/src/symbols.ts +++ b/packages/latex/src/symbols.ts @@ -286,6 +286,8 @@ export const delimiterTable: Readonly> = { rvert: "│", lVert: "║", rVert: "║", + lbrace: "{", + rbrace: "}", langle: "⟨", rangle: "⟩", lfloor: "⌊", diff --git a/packages/latex/src/types.ts b/packages/latex/src/types.ts index 2a30c05ff5fd..009838c1554a 100644 --- a/packages/latex/src/types.ts +++ b/packages/latex/src/types.ts @@ -5,11 +5,18 @@ export type MathNode = | { type: "symbol"; value: string; role?: SymbolRole } | { type: "text"; value: string } | { type: "space"; width: number } - | { type: "fraction"; numerator: MathNode; denominator: MathNode; bar: boolean } + | { + type: "fraction" + numerator: MathNode + denominator: MathNode + bar: boolean + numeratorAlign?: "left" | "right" + } | { type: "root"; body: MathNode; index?: MathNode } | { type: "scripts"; base: MathNode; superscript?: MathNode; subscript?: MathNode } | { type: "delimited"; left: string; body: MathNode; right: string } - | { type: "matrix"; rows: MathNode[][]; environment: MatrixEnvironment } + | { type: "matrix"; rows: MathNode[][]; environment: MatrixEnvironment; columns?: string } + | { type: "brace"; body: MathNode; position: "over" | "under" } | { type: "accent"; accent: AccentKind; body: MathNode } | { type: "variant"; variant: MathVariant; body: MathNode } | { type: "operator"; value: string; limits: boolean }