diff --git a/package-lock.json b/package-lock.json
index 6517d78..77ecd1f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"html-to-text": "^10.0.0",
"i18next": "^25.10.10",
"json5": "^2.2.3",
+ "katex": "^0.16.47",
"lucide-react": "^1.27.0",
"onnxruntime-web": "^1.23.2",
"pdfjs-dist": "^6.2.108",
@@ -29,7 +30,9 @@
"react-dom": "^19.2.8",
"react-i18next": "^16.6.6",
"react-markdown": "^10.1.0",
+ "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
+ "remark-math": "^6.0.0",
"sherpa-onnx": "1.13.4",
"undici": "^7.29.0",
"yaml": "^2.9.0",
@@ -3547,6 +3550,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
+ "license": "MIT"
+ },
"node_modules/@types/keyv": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
@@ -7308,6 +7317,125 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-from-dom": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
+ "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
+ "license": "ISC",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hastscript": "^9.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
+ "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.1.0",
+ "hast-util-from-parse5": "^8.0.0",
+ "parse5": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html-isomorphic": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
+ "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-from-dom": "^5.0.0",
+ "hast-util-from-html": "^2.0.0",
+ "unist-util-remove-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/hast-util-from-html/node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
+ "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "devlop": "^1.0.0",
+ "hastscript": "^9.0.0",
+ "property-information": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-location": "^5.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
+ "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -7335,6 +7463,22 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
+ "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unist-util-find-after": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -7348,6 +7492,23 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hastscript": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
+ "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -8038,6 +8199,31 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/katex": {
+ "version": "0.16.47",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
+ "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/katex/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -8371,6 +8557,25 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdast-util-math": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
+ "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.1.0",
+ "unist-util-remove-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -8722,6 +8927,25 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/micromark-extension-math": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
+ "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/katex": "^0.16.0",
+ "devlop": "^1.0.0",
+ "katex": "^0.16.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -10763,6 +10987,25 @@
"node": ">=8"
}
},
+ "node_modules/rehype-katex": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
+ "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/katex": "^0.16.0",
+ "hast-util-from-html-isomorphic": "^2.0.0",
+ "hast-util-to-text": "^4.0.0",
+ "katex": "^0.16.0",
+ "unist-util-visit-parents": "^6.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
@@ -10781,6 +11024,22 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark-math": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
+ "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-math": "^3.0.0",
+ "micromark-extension-math": "^3.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -11951,6 +12210,20 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/unist-util-find-after": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
+ "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
@@ -11977,6 +12250,20 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/unist-util-remove-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
+ "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@@ -12163,6 +12450,20 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/vfile-location": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
+ "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -12848,6 +13149,16 @@
"node": ">=18"
}
},
+ "node_modules/web-namespaces": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
+ "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/webcrypto-core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
diff --git a/package.json b/package.json
index a5ec626..1bf1944 100644
--- a/package.json
+++ b/package.json
@@ -116,6 +116,10 @@
{
"from": "node_modules/onnxruntime-web/LICENSE",
"to": "licenses/onnxruntime-web-MIT.txt"
+ },
+ {
+ "from": "node_modules/katex/LICENSE",
+ "to": "licenses/katex-MIT.txt"
}
],
"win": {
@@ -165,6 +169,7 @@
"html-to-text": "^10.0.0",
"i18next": "^25.10.10",
"json5": "^2.2.3",
+ "katex": "^0.16.47",
"lucide-react": "^1.27.0",
"onnxruntime-web": "^1.23.2",
"pdfjs-dist": "^6.2.108",
@@ -175,7 +180,9 @@
"react-dom": "^19.2.8",
"react-i18next": "^16.6.6",
"react-markdown": "^10.1.0",
+ "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
+ "remark-math": "^6.0.0",
"sherpa-onnx": "1.13.4",
"undici": "^7.29.0",
"yaml": "^2.9.0",
diff --git a/src/renderer/src/MarkdownRenderer.test.tsx b/src/renderer/src/MarkdownRenderer.test.tsx
index ce17fee..d261dc0 100644
--- a/src/renderer/src/MarkdownRenderer.test.tsx
+++ b/src/renderer/src/MarkdownRenderer.test.tsx
@@ -73,6 +73,98 @@ const ready = true
expect(container.querySelector('pre')).not.toBeInTheDocument()
})
+ it('renders inline and display LaTeX formulas with KaTeX', () => {
+ const { container } = render(
+ {`质能方程为 $E = mc^2$。
+
+$$
+\\int_0^1 x^2\\,dx = \\frac{1}{3}
+$$`}
+ )
+
+ expect(container.querySelectorAll('.katex')).toHaveLength(2)
+ expect(container.querySelector('.katex-display')).toBeInTheDocument()
+ expect(
+ container.querySelector(
+ 'annotation[encoding="application/x-tex"]'
+ )
+ ).toHaveTextContent('E = mc^2')
+ expect(
+ [...container.querySelectorAll(
+ 'annotation[encoding="application/x-tex"]'
+ )].at(-1)
+ ).toHaveTextContent('\\int_0^1 x^2\\,dx = \\frac{1}{3}')
+ })
+
+ it('supports common model-style LaTeX delimiters outside code', () => {
+ const { container } = render(
+ {`行内公式 \\(a^2 + b^2 = c^2\\)。
+
+\\[
+\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}
+\\]
+
+\\[ \\sqrt{x^2} = |x| \\]
+
+\`\\(not rendered\\)\`
+
+\`\`\`tex
+\\[
+also not rendered
+\\]
+\`\`\``}
+ )
+
+ expect(container.querySelectorAll('.katex')).toHaveLength(3)
+ expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
+ expect(screen.getByText('\\(not rendered\\)')).toBeInTheDocument()
+ expect(screen.getByText(/also not rendered/u)).toBeInTheDocument()
+ expect(container.querySelectorAll('code .katex')).toHaveLength(0)
+ })
+
+ it('promotes formulas that occupy a whole line to centered display math', () => {
+ const { container } = render(
+ {`段落内仍是 $E = mc^2$ 行内公式。
+
+$S(A) = \\frac{\\operatorname{Area}(\\gamma_A)}{4G_N}$
+
+\\(S \\leq \\frac{A}{4G_N}\\)`}
+ )
+
+ expect(container.querySelectorAll('.katex')).toHaveLength(3)
+ expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
+ expect(
+ container.querySelector(
+ 'p:first-child .katex-display'
+ )
+ ).not.toBeInTheDocument()
+ for (const display of container.querySelectorAll('.katex-display')) {
+ expect(display.querySelector(':scope > .katex')).toBeInTheDocument()
+ }
+ })
+
+ it('shows invalid formulas without crashing or rendering raw HTML', () => {
+ const { container } = render(
+ {`$\\notARealCommand{value}$
+
+$\\href{javascript:alert(1)}{unsafe}$`}
+ )
+
+ expect(
+ container.querySelector(
+ 'annotation[encoding="application/x-tex"]'
+ )
+ ).toHaveTextContent('\\notARealCommand{value}')
+ expect(container.querySelectorAll('.katex')).toHaveLength(2)
+ expect(container).toHaveTextContent(
+ '\\notARealCommand'
+ )
+ expect(container.querySelector('script')).not.toBeInTheDocument()
+ expect(
+ container.querySelector('a[href^="javascript:"]')
+ ).not.toBeInTheDocument()
+ })
+
it('updates table accessibility copy when the locale changes', async () => {
render(
{`| Name |
diff --git a/src/renderer/src/MarkdownRenderer.tsx b/src/renderer/src/MarkdownRenderer.tsx
index 0f6c4d4..4ffe144 100644
--- a/src/renderer/src/MarkdownRenderer.tsx
+++ b/src/renderer/src/MarkdownRenderer.tsx
@@ -1,6 +1,8 @@
import { memo, useMemo } from 'react'
+import rehypeKatex from 'rehype-katex'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
+import remarkMath from 'remark-math'
import type { Components } from 'react-markdown'
import { useTranslation } from 'react-i18next'
@@ -43,6 +45,100 @@ type MarkdownRendererProps = {
const wholeMarkdownFence =
/^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu
+function replaceLatexDelimiters(line: string): string {
+ let output = ''
+ let index = 0
+ let codeDelimiterLength = 0
+
+ while (index < line.length) {
+ if (line[index] === '`') {
+ let runLength = 1
+ while (line[index + runLength] === '`') {
+ runLength += 1
+ }
+ if (
+ codeDelimiterLength === 0 ||
+ codeDelimiterLength === runLength
+ ) {
+ codeDelimiterLength =
+ codeDelimiterLength === 0 ? runLength : 0
+ }
+ output += line.slice(index, index + runLength)
+ index += runLength
+ continue
+ }
+ if (
+ codeDelimiterLength === 0 &&
+ line[index] === '\\' &&
+ line[index - 1] !== '\\' &&
+ (line[index + 1] === '(' || line[index + 1] === ')')
+ ) {
+ output += '$'
+ index += 2
+ continue
+ }
+ output += line[index]
+ index += 1
+ }
+ return output
+}
+
+function normalizeLatexDelimiters(content: string): string {
+ let fence:
+ | {
+ character: '`' | '~'
+ length: number
+ }
+ | undefined
+
+ return content
+ .split(/\r?\n/u)
+ .map((line) => {
+ if (fence) {
+ const closingFence = new RegExp(
+ `^ {0,3}${fence.character}{${fence.length},}\\s*$`,
+ 'u'
+ )
+ if (closingFence.test(line)) {
+ fence = undefined
+ }
+ return line
+ }
+ const openingFence = /^ {0,3}(`{3,}|~{3,})/u.exec(line)
+ if (openingFence) {
+ const marker = openingFence[1]!
+ fence = {
+ character: marker[0] as '`' | '~',
+ length: marker.length
+ }
+ return line
+ }
+ const standaloneDollar =
+ /^ {0,3}\$(?!\$)(.+?)(?
- {unwrapMarkdownFence(children)}
+ {normalizeLatexDelimiters(unwrapMarkdownFence(children))}
)
})
diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx
index 69ada46..fe22214 100644
--- a/src/renderer/src/main.tsx
+++ b/src/renderer/src/main.tsx
@@ -1,6 +1,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource-variable/noto-sans-sc/wght.css'
+import 'katex/dist/katex.min.css'
+import './markdown-math.css'
import App from './App'
import { installBundledUiFonts } from './fonts'
import { changeUiLocale } from './i18n'
diff --git a/src/renderer/src/markdown-math.css b/src/renderer/src/markdown-math.css
new file mode 100644
index 0000000..200093e
--- /dev/null
+++ b/src/renderer/src/markdown-math.css
@@ -0,0 +1,27 @@
+.markdown-content .katex {
+ color: inherit;
+ font-size: 1.05em;
+}
+
+.markdown-content .katex-display {
+ max-width: 100%;
+ margin: 0.85em 0;
+ padding: var(--space-1) 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ text-align: center;
+}
+
+.markdown-content .katex-display > .katex {
+ width: max-content;
+ min-width: max-content;
+ margin-inline: auto;
+ text-align: center;
+}
+
+.markdown-content .katex-error {
+ color: var(--danger);
+ font-family: var(--font-family-mono);
+ font-size: 0.9em;
+ overflow-wrap: anywhere;
+}