Skip to content

Commit aabd80d

Browse files
committed
feat(ui): rendering of some latex formulas
1 parent cbe8a0e commit aabd80d

6 files changed

Lines changed: 133 additions & 22 deletions

File tree

‎apps/codever-ui/src/app/core/markdown/markdown.service.spec.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@ describe('Markdown transformer service', () => {
1818
);
1919
});
2020

21+
it('should preserve multiline equations through Markdown and sanitization', () => {
22+
const math = String.raw`\delta^{m+1}
23+
=
24+
\frac{\partial L}{\partial z^{m+1}}
25+
=
26+
\frac{\partial L}{\partial a^{m+1}}
27+
\odot
28+
\phi'_{m+1}(z^{m+1})`;
29+
const container = document.createElement('div');
30+
container.innerHTML = markdownService.toHtml(`\\[\n${math}\n\\]`);
31+
32+
expect(container.querySelectorAll('.katex').length).toBe(1);
33+
expect(container.querySelector('annotation')?.textContent).toBe(math);
34+
expect(container.querySelector('.katex-html')).not.toBeNull();
35+
expect(container.querySelector('h1')).toBeNull();
36+
expect(container.querySelector('.katex-error')).toBeNull();
37+
expect(container.textContent).not.toContain('CODEVERMATH');
38+
});
39+
2140
it('should generate hyperlinks', () => {
2241
expect(
2342
markdownService.toHtml('[Codingpedia](http://www.codingpedia.org)')

‎apps/codever-ui/src/app/core/markdown/markdown.service.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { Injectable } from '@angular/core';
22

33
import DOMPurify from 'dompurify';
44

5-
import { marked } from 'marked';
6-
import { renderLatex } from '../../shared/render-latex.util';
5+
import { renderMarkdownWithLatex } from '../../shared/render-latex.util';
76

87
// DOMPurify config that allows KaTeX-generated MathML elements
98
const KATEX_SANITIZE_CONFIG = {
@@ -27,7 +26,6 @@ export class MarkdownService {
2726
// converter object is not typescript
2827

2928
toHtml(text: string) {
30-
const withLatex = renderLatex(text);
31-
return DOMPurify.sanitize(marked.parse(withLatex), KATEX_SANITIZE_CONFIG);
29+
return DOMPurify.sanitize(renderMarkdownWithLatex(text), KATEX_SANITIZE_CONFIG);
3230
}
3331
}

‎apps/codever-ui/src/app/shared/notebook-renderer/notebook-renderer.component.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@ import {
77
} from '@angular/core';
88
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
99
import DOMPurify from 'dompurify';
10-
import { marked } from 'marked';
1110
import hljs from 'highlight.js';
1211
import katex from 'katex';
13-
import { renderLatex } from '../render-latex.util';
12+
import { renderMarkdownWithLatex } from '../render-latex.util';
1413
import { CopyCodeButtonDirective } from '../directive/copy-code-button.directive';
1514
import { NgClass } from '@angular/common';
1615

@@ -86,14 +85,11 @@ export class NotebookRendererComponent implements OnChanges {
8685
const source = this.joinSource(cell.source);
8786

8887
if (cell.cell_type === 'markdown') {
89-
// Pre-process LaTeX math ($...$, $$...$$, \(...\), \[...\]) before
90-
// passing through marked, so that _ and other TeX chars aren't mangled.
91-
const withLatex = renderLatex(source);
9288
return {
9389
type: 'markdown',
9490
executionCount: null,
9591
sourceHtml: this.trustHtml(
96-
this.sanitizeWithKatex(marked.parse(withLatex))
92+
this.sanitizeWithKatex(renderMarkdownWithLatex(source))
9793
),
9894
outputs: [],
9995
};

‎apps/codever-ui/src/app/shared/pipe/markdown2html.pipe.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import DOMPurify from 'dompurify';
44

55
import { marked } from 'marked';
66
import hljs from 'highlight.js';
7-
import { renderLatex } from '../render-latex.util';
7+
import { renderMarkdownWithLatex } from '../render-latex.util';
88

99
// Custom renderer to apply highlight.js syntax highlighting to fenced code blocks
1010
// and add the 'hljs' CSS class so the github-dark theme (included in angular.json) is applied
@@ -137,10 +137,8 @@ export class Markdown2HtmlPipe implements PipeTransform {
137137
transform(text: string): SafeHtml {
138138
// Reset the per-note task counter so reset-button indices start at 0.
139139
renderedTaskCount = 0;
140-
// Pre-process LaTeX math delimiters before markdown parsing
141-
const withLatex = renderLatex(text);
142140
const clean = DOMPurify.sanitize(
143-
marked.parse(withLatex),
141+
renderMarkdownWithLatex(text),
144142
KATEX_SANITIZE_CONFIG
145143
);
146144
// Bypass Angular's built-in sanitizer which strips inline style attributes

‎apps/codever-ui/src/app/shared/render-latex.util.spec.ts‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,75 @@
1-
import { renderLatex } from './render-latex.util';
1+
import katex from 'katex';
2+
import { renderLatex, renderMarkdownWithLatex } from './render-latex.util';
3+
4+
describe('renderMarkdownWithLatex', () => {
5+
const derivative = String.raw`\frac{\partial L}{\partial b_i^l}
6+
=
7+
\frac{\partial L}{\partial z_i^l}
8+
\cdot 1
9+
=
10+
\delta_i^l`;
11+
12+
for (const [open, close] of [['\\[', '\\]'], ['$$', '$$']]) {
13+
it(`should preserve multiline math HTML with ${open} delimiters`, () => {
14+
const result = renderMarkdownWithLatex(
15+
`${open}\n${derivative}\n${close}`
16+
);
17+
const expected = katex.renderToString(derivative, {
18+
displayMode: true,
19+
throwOnError: true,
20+
});
21+
22+
expect(result).toContain(expected);
23+
expect(result).not.toContain('<h1');
24+
expect(result).not.toContain('<em>');
25+
});
26+
}
27+
28+
it('should preserve math in headings, emphasis, and list items', () => {
29+
const math = String.raw`a_i^l = \phi_l(z_i^l)`;
30+
const expected = katex.renderToString(math);
31+
const result = renderMarkdownWithLatex(
32+
`## Activation \\(${math}\\)\n\n**\\(${math}\\)**\n\n- \\(${math}\\)`
33+
);
34+
const container = document.createElement('div');
35+
container.innerHTML = result;
36+
37+
expect(container.querySelector('h2 .katex')).not.toBeNull();
38+
expect(container.querySelector('strong .katex')).not.toBeNull();
39+
expect(container.querySelector('li .katex')).not.toBeNull();
40+
expect(result.split(expected).length - 1).toBe(3);
41+
});
42+
43+
it('should leave Markdown-like math text intact', () => {
44+
const math = String.raw`\text{a * b * c and $x^2$}`;
45+
expect(renderMarkdownWithLatex(`\\(${math}\\)`)).toContain(
46+
katex.renderToString(math)
47+
);
48+
});
49+
50+
it('should preserve code, currency, and ordinary Markdown', () => {
51+
const result = renderMarkdownWithLatex(
52+
'# Title\n\n**Bold** costs $5 or $40.\n\n' +
53+
'`\\(x^2\\)`\n\n```tex\n\\[x^2\\]\n```\n\n\\(y^2\\)'
54+
);
55+
56+
expect(result).toContain('<h1');
57+
expect(result).toContain('<strong>Bold</strong>');
58+
expect(result).toContain('costs $5 or $40.');
59+
expect(result).toContain('<code>\\(x^2\\)</code>');
60+
expect(result).toContain('\\[x^2\\]');
61+
expect((result.match(/class="katex"/g) || []).length).toBe(1);
62+
});
63+
64+
it('should not replace user text that resembles a math placeholder', () => {
65+
const result = renderMarkdownWithLatex(
66+
'CODEVERMATH0END CODEVERMATHX0END \\(x^2\\)'
67+
);
68+
69+
expect(result).toContain('CODEVERMATH0END CODEVERMATHX0END');
70+
expect(result).toContain(katex.renderToString('x^2'));
71+
});
72+
});
273

374
describe('renderLatex', () => {
475
// ---------------------------------------------------------------------------
@@ -207,4 +278,3 @@ describe('renderLatex', () => {
207278
expect(result).toBe(input);
208279
});
209280
});
210-

‎apps/codever-ui/src/app/shared/render-latex.util.ts‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
11
import katex from 'katex';
2+
import { marked } from 'marked';
3+
4+
/**
5+
* Keep generated math HTML (including its multiline TeX annotation) opaque to
6+
* Markdown, then restore it before the caller sanitizes the complete HTML.
7+
*/
8+
export function renderMarkdownWithLatex(text: string): string {
9+
let prefix = 'CODEVERMATH';
10+
while (text?.includes(prefix)) {
11+
prefix += 'X';
12+
}
13+
14+
const expressions: string[] = [];
15+
const protectedMarkdown = replaceLatex(text, (latex, displayMode) => {
16+
const index = expressions.push(renderKatex(latex, displayMode)) - 1;
17+
return `${prefix}${index}END`;
18+
});
19+
const html = marked.parse(protectedMarkdown);
20+
return html.replace(
21+
new RegExp(`${prefix}(\\d+)END`, 'g'),
22+
(_match, index) => expressions[Number(index)]
23+
);
24+
}
225

326
/**
427
* Pre-processes a Markdown/text string and replaces LaTeX math delimiters
5-
* with KaTeX-rendered HTML **before** the string is passed to `marked`.
28+
* with KaTeX-rendered HTML. For Markdown, use renderMarkdownWithLatex instead
29+
* so Markdown syntax inside math cannot corrupt the generated HTML.
630
*
731
* Supports:
832
* - Display math: $$...$$ and \[...\]
@@ -12,6 +36,13 @@ import katex from 'katex';
1236
* so that dollar signs inside code are not treated as math delimiters.
1337
*/
1438
export function renderLatex(text: string): string {
39+
return replaceLatex(text, renderKatex);
40+
}
41+
42+
function replaceLatex(
43+
text: string,
44+
renderMath: (latex: string, displayMode: boolean) => string
45+
): string {
1546
if (!text) {
1647
return text;
1748
}
@@ -32,17 +63,17 @@ export function renderLatex(text: string): string {
3263

3364
// Display math: $$...$$ (must come before inline $ to avoid conflicts)
3465
segment = segment.replace(/\$\$([\s\S]+?)\$\$/g, (_match, math) => {
35-
return renderKatex(math.trim(), true);
66+
return renderMath(math.trim(), true);
3667
});
3768

3869
// Display math: \[...\]
3970
segment = segment.replace(/\\\[([\s\S]+?)\\\]/g, (_match, math) => {
40-
return renderKatex(math.trim(), true);
71+
return renderMath(math.trim(), true);
4172
});
4273

4374
// Inline math: \(...\)
4475
segment = segment.replace(/\\\(([\s\S]+?)\\\)/g, (_match, math) => {
45-
return renderKatex(math.trim(), false);
76+
return renderMath(math.trim(), false);
4677
});
4778

4879
// Inline math: $...$
@@ -58,7 +89,7 @@ export function renderLatex(text: string): string {
5889
segment = segment.replace(
5990
/(?<![\\$])\$(?!\$)(\S(?:[^$\\\n]|\\.)*?\S|\S)\$(?!\d)/g,
6091
(match, math) => {
61-
return hasTexSignal(math) ? renderKatex(math.trim(), false) : match;
92+
return hasTexSignal(math) ? renderMath(math.trim(), false) : match;
6293
}
6394
);
6495

@@ -104,4 +135,3 @@ function renderKatex(latex: string, displayMode: boolean): string {
104135
return `<code class="katex-error" title="KaTeX parse error">${escaped}</code>`;
105136
}
106137
}
107-

0 commit comments

Comments
 (0)