diff --git a/docs/plans/2026-05-23-latex-rendering.md b/docs/plans/2026-05-23-latex-rendering.md index bc9aad4..6c14d5b 100644 --- a/docs/plans/2026-05-23-latex-rendering.md +++ b/docs/plans/2026-05-23-latex-rendering.md @@ -2,9 +2,9 @@ > **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. -**Goal:** Add reliable LaTeX/math rendering to Hermes Web UI chat Markdown messages so formulas render visually instead of appearing as raw TeX. +**Goal:** Add reliable LaTeX/math rendering to Hermes Web UI chat Markdown messages so formulas render visually instead of appearing as raw TeX, including explicit fenced math blocks like ```latex. -**Architecture:** Keep the existing `markdown-it` renderer in `MarkdownRenderer.vue` and add a KaTeX-backed math plugin there. Load KaTeX styles globally once, cover inline and block math in component tests, then verify with a production build and a local Web UI smoke test. +**Architecture:** Keep the existing `markdown-it` renderer in `MarkdownRenderer.vue` and add a KaTeX-backed math plugin there, plus a small fence override for explicit LaTeX code fences. Load KaTeX styles globally once, cover inline, display, and fenced math in component tests, then verify with a production build and a local Web UI smoke test. **Tech Stack:** Vue 3, TypeScript, Vite, markdown-it, KaTeX, Vitest, @vue/test-utils. @@ -14,8 +14,9 @@ - Chat messages render inline math like `$x^2 + y^2 = z^2$` as KaTeX HTML. - Chat messages render block math like `$$\n\\int_0^1 x^2 dx = \\frac{1}{3}\n$$` as display KaTeX HTML. +- Chat messages render explicit fenced math blocks like ```latex, ```tex, or ```math as display KaTeX HTML. - Existing Markdown features still work: code fences, Mermaid fences, headings, local file links, mentions. -- Math inside fenced code blocks stays literal and is not rendered. +- Ordinary fenced code blocks stay literal and are not rendered as math. - Invalid/unsupported math syntax does not crash the chat renderer. - `npm run test -- tests/client/markdown-rendering.test.ts` passes. - `npm run build` passes. diff --git a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue index 2826e0e..0c235db 100644 --- a/packages/client/src/components/hermes/chat/MarkdownRenderer.vue +++ b/packages/client/src/components/hermes/chat/MarkdownRenderer.vue @@ -19,8 +19,45 @@ import { } from './mermaidRenderer' import { downloadFile, getDownloadUrl, fetchFileText } from '@/api/hermes/download' +const LATEX_FENCE_LANGS = new Set(['latex', 'tex', 'math', 'katex']) const PREVIEW_AREA_WIDTH = 'min(800px, 100vw)' +function getFenceLanguage(info: string): string { + return info.trim().split(/\s+/)[0]?.toLowerCase() ?? '' +} + +function isLatexFence(info: string): boolean { + return LATEX_FENCE_LANGS.has(getFenceLanguage(info)) +} + +function normalizeLatexFenceContent(content: string): string { + const trimmed = content.trim() + + if (trimmed.startsWith('\\[') && trimmed.endsWith('\\]')) { + return trimmed.slice(2, -2).trim() + } + + if (trimmed.startsWith('$$') && trimmed.endsWith('$$')) { + return trimmed.slice(2, -2).trim() + } + + if (trimmed.startsWith('\\(') && trimmed.endsWith('\\)')) { + return trimmed.slice(2, -2).trim() + } + + return trimmed +} + +function renderLatexFence(content: string): string { + const latex = normalizeLatexFenceContent(content) + return `
${katex.renderToString(latex, { + displayMode: true, + output: 'htmlAndMathml', + throwOnError: false, + strict: 'ignore', + })}
` +} + const props = withDefaults(defineProps<{ content: string mentionNames?: string[] @@ -46,12 +83,17 @@ const md: MarkdownIt = new MarkdownItConstructor({ md.use(markdownItKatex, { katex, throwOnError: false, + strict: 'ignore', }) const defaultFenceRenderer = md.renderer.rules.fence?.bind(md.renderer.rules) md.renderer.rules.fence = (tokens, idx, options, env, self) => { const token = tokens[idx] + if (isLatexFence(token.info)) { + return renderLatexFence(token.content) + } + if (isMermaidFence(token.info)) { return renderMermaidPlaceholder(token.content) } diff --git a/tests/client/markdown-rendering.test.ts b/tests/client/markdown-rendering.test.ts index ef4f595..28264ee 100644 --- a/tests/client/markdown-rendering.test.ts +++ b/tests/client/markdown-rendering.test.ts @@ -611,7 +611,21 @@ describe('MarkdownRenderer', () => { expect(body.text()).not.toContain('$$') }) - it('does not render latex inside fenced code blocks', () => { + it('renders explicit latex fenced blocks with katex', () => { + const wrapper = mount(MarkdownRenderer, { + props: { + content: '```latex\n\\[\\text{Итог} = \\operatorname{Округление}\\!\\left(0.5\\,O_1 + 0.5\\,O_2\\right)\\]\n```', + }, + }) + + const body = wrapper.find('.markdown-body') + expect(body.find('.katex-display').exists()).toBe(true) + expect(body.find('.katex').exists()).toBe(true) + expect(body.text()).not.toContain('```latex') + expect(body.text()).toContain('Округление') + }) + + it('does not render latex inside ordinary fenced code blocks', () => { const wrapper = mount(MarkdownRenderer, { props: { content: '```ts\nconst formula = "$x^2 + y^2 = z^2$"\n```',