diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5957f2df64..f3a0bd994d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -89,6 +89,7 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/
- Excluded side effects from module entry points to prevent global scope modifications, in PR [#5329](https://github.com/microsoft/BotFramework-WebChat/pull/5329), by [@OEvgeny](https://github.com/OEvgeny)
- Moved to `micromark` for rendering Markdown, instead of `markdown-it`, in PR [#5330](https://github.com/microsoft/BotFramework-WebChat/pull/5330), by [@compulim](https://github.com/compulim)
- Improved view code dialog UI in Fluent theme with better styling and accessibility, in PR [#5337](https://github.com/microsoft/BotFramework-WebChat/pull/5337), by [@OEvgeny](https://github.com/OEvgeny)
+- Switched math block syntax from `$$` to Tex-style `\[ \]` and `\( \)` delimiters with improved rendering and error handling, in PR [#5353](https://github.com/microsoft/BotFramework-WebChat/pull/5353), by [@OEvgeny](https://github.com/OEvgeny)
### Fixed
diff --git a/__tests__/html2/markdown/math.html b/__tests__/html2/markdown/math.html
index c85b0ae2e6..2225ffdc96 100644
--- a/__tests__/html2/markdown/math.html
+++ b/__tests__/html2/markdown/math.html
@@ -30,29 +30,29 @@
1. Determine the number of shares you could buy on 1/1/2018:
-$$
+\\[
\\text{Number of shares} = \\frac{\\text{Investment amount}}{\\text{Stock price purchase date}} = \\frac{1000}{85.95}
-$$
+\\]
2. Calculate the total value when selling the shares on 12/1/2021:
-$$
+\\[
\\text{Total value} = \\text{Number of shares} \\times \\text{Stock price on sale date}
-$$
+\\]
Let's do the math:
1. Number of shares you could buy on 1/1/2018:
-$$
+\\[
\\text{Number of shares} = \\frac{1000}{85.95} \\approx 11.63\\text{ shares}
-$$
+\\]
2. Total value when selling the shares on 12/1/2021:
-$$
+\\[
\\text{Total value} = 11.63 \\times 330.08 \\approx \\$3839.63
-$$
+\\]
So, if you invested $1000 in Microsoft on January 1, 2018, and sold the shares on December 1, 2021, your investment would be worth approximately **$3839.63**. Please note that this calculation does not account for dividends, taxes, or transaction fees, which could affect the final amount. If you need a more precise calculation including these factors, I recommend consulting a financial advisor or using a detailed investment calculator.
`,
diff --git a/__tests__/html2/markdown/math2.html b/__tests__/html2/markdown/math2.html
index b734ef0a1b..45c7421280 100644
--- a/__tests__/html2/markdown/math2.html
+++ b/__tests__/html2/markdown/math2.html
@@ -26,12 +26,12 @@
await directLine.emulateIncomingActivity({
text: `I've graphed the parametric equations you provided. Here's the result:
-$$
+\\[
x = \\sin(t)(e^{\\cos(t)}-2\\cos(4t)-\\sin^{5}(\\frac{t}{12}))
-$$
-$$
+\\]
+\\[
y = \\cos(t)(e^{\\cos(t)}-2\\cos(4t)-\\sin^{5}(\\frac{t}{12}))
-$$
+\\]
The graph is a representation of these equations plotted over a range of ( t ) values from 0 to ( 2\\pi ).`,
type: 'message'
diff --git a/__tests__/html2/markdown/math2.html.snap-1.png b/__tests__/html2/markdown/math2.html.snap-1.png
index 9df09e9e51..b9882ceaee 100644
Binary files a/__tests__/html2/markdown/math2.html.snap-1.png and b/__tests__/html2/markdown/math2.html.snap-1.png differ
diff --git a/__tests__/html2/markdown/math3.html b/__tests__/html2/markdown/math3.html
new file mode 100644
index 0000000000..edc901e779
--- /dev/null
+++ b/__tests__/html2/markdown/math3.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Basic Math
+
+1. Simple arithmetic:
+\(2 + 2 = 4\)
+
+2. Fractions:
+\[\frac{1}{2} + \frac{1}{3} = \frac{5}{6}\]
+
+## Algebra
+
+3. Quadratic formula:
+\[x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}\]
+
+4. Binomial expansion:
+\[(x + y)^2 = x^2 + 2xy + y^2\]
+
+
+## Calculus
+
+5. Derivative notation:
+\[\frac{d}{dx} x^n = nx^{n-1}\]
+
+6. Integration:
+\[\int_0^1 x^2 dx = \frac{1}{3}\]
+
+7. Taylor series:
+\[e^x = \sum_{n=0}^{\infty} \frac{x^n}{n!}\]
+
+## Linear Algebra
+
+8. Matrix multiplication:
+\[
+\begin{pmatrix}
+a & b \\
+c & d
+\end{pmatrix}
+\begin{pmatrix}
+x \\
+y
+\end{pmatrix} =
+\begin{pmatrix}
+ax + by \\
+cx + dy
+\end{pmatrix}
+\]
+
+9. Determinant:
+\[\det\begin{pmatrix}
+a & b \\
+c & d
+\end{pmatrix} = ad - bc\]
+
+
+## Physics
+
+10. Einstein's mass-energy equivalence:
+\[E = mc^2\]
+
+11. Schrödinger equation:
+\[i\hbar\frac{\partial}{\partial t}\Psi = \hat{H}\Psi\]
+
+## Invalid input examples
+
+12. Wrong expression is rendered:
+\(2++2\)
+
+13. Inline closing delimeter is required:
+\(x^2
+
+14. Katex syntax error:
+\[\int_0^\infty e^{-x} dx = 1 +}\]
+
+
+
+
+
+
+
diff --git a/__tests__/html2/markdown/math3.html.snap-1.png b/__tests__/html2/markdown/math3.html.snap-1.png
new file mode 100644
index 0000000000..31e802f8e3
Binary files /dev/null and b/__tests__/html2/markdown/math3.html.snap-1.png differ
diff --git a/__tests__/html2/markdown/math3.html.snap-2.png b/__tests__/html2/markdown/math3.html.snap-2.png
new file mode 100644
index 0000000000..bf7c5b62ec
Binary files /dev/null and b/__tests__/html2/markdown/math3.html.snap-2.png differ
diff --git a/__tests__/html2/markdown/math3.html.snap-3.png b/__tests__/html2/markdown/math3.html.snap-3.png
new file mode 100644
index 0000000000..b82477683d
Binary files /dev/null and b/__tests__/html2/markdown/math3.html.snap-3.png differ
diff --git a/__tests__/html2/markdown/math4.html b/__tests__/html2/markdown/math4.html
new file mode 100644
index 0000000000..8b255d49fd
--- /dev/null
+++ b/__tests__/html2/markdown/math4.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Basic Notation
+
+Regular text with inline math \(x + y\) in the middle of a sentence.
+
+## Variables and Exponents
+
+1. Powers: Let's calculate \(x^2\) and \(y^3\)
+2. Subscripts: The sequence \(a_1, a_2, ..., a_n\)
+3. Both: Temperature is \(T_{room} = 20^{\circ}C\)
+
+## Greek Letters
+
+1. Common variables: \(\alpha\), \(\beta\), \(\gamma\), \(\theta\)
+2. Physics constants: Planck's constant \(\hbar\)
+3. Math constants: \(\pi \approx 3.14159\)
+
+
+## Operators
+
+1. Multiplication: \(5 \times 4\), \(5 \cdot 4\)
+2. Division: \(a \div b\), \(\frac{a}{b}\)
+3. Plus/minus: \(x \pm y\)
+
+## Functions
+
+1. Trigonometry: \(\sin(x)\), \(\cos(x)\), \(\tan(x)\)
+2. Logarithms: \(\log(x)\), \(\ln(x)\)
+3. Limits: \(\lim_{x \to \infty}\)
+
+## Special Symbols
+
+1. Infinity: \(\infty\)
+2. Approximately: \(\approx\)
+3. Not equal: \(\neq\)
+4. Less than or equal: \(\leq\)
+5. Greater than or equal: \(\geq\)
+
+
+## Complex Examples
+
+1. Chemical equation: \(H_2O + CO_2 \rightarrow H_2CO_3\)
+2. Physics formula: \(F = ma\)
+3. Statistics: \(\bar{x} = \frac{1}{n}\sum_{i=1}^n x_i\)
+4. Probability: \(P(A|B) = \frac{P(B|A)P(A)}{P(B)}\)
+
+## Common Errors to Avoid
+
+1. Closing parenthesis is required: \(x^2
+2. Invalid operator is rendered: \(5 +* 3\)
+3. Syntax error renders error: \([\frac{1}{2\)
+
+
+
+
+
+
+
diff --git a/__tests__/html2/markdown/math4.html.snap-1.png b/__tests__/html2/markdown/math4.html.snap-1.png
new file mode 100644
index 0000000000..dd6a5b9e10
Binary files /dev/null and b/__tests__/html2/markdown/math4.html.snap-1.png differ
diff --git a/__tests__/html2/markdown/math4.html.snap-2.png b/__tests__/html2/markdown/math4.html.snap-2.png
new file mode 100644
index 0000000000..9ff584d13c
Binary files /dev/null and b/__tests__/html2/markdown/math4.html.snap-2.png differ
diff --git a/__tests__/html2/markdown/math4.html.snap-3.png b/__tests__/html2/markdown/math4.html.snap-3.png
new file mode 100644
index 0000000000..6734377968
Binary files /dev/null and b/__tests__/html2/markdown/math4.html.snap-3.png differ
diff --git a/package-lock.json b/package-lock.json
index 4170596e57..f205976b87 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4896,12 +4896,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/katex": {
- "version": "0.16.7",
- "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
- "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
- "license": "MIT"
- },
"node_modules/@types/math-random": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/math-random/-/math-random-1.0.2.tgz",
@@ -16611,25 +16605,6 @@
"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.0",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz",
@@ -24349,12 +24324,13 @@
"botframework-webchat-core": "0.0.0-0",
"classnames": "2.5.1",
"core-js": "3.37.0",
+ "katex": "^0.16.11",
"math-random": "2.0.1",
"mdast-util-from-markdown": "2.0.0",
"memoize-one": "6.0.0",
"micromark": "^4.0.0",
"micromark-extension-gfm": "^3.0.0",
- "micromark-extension-math": "^3.1.0",
+ "micromark-util-character": "^2.1.0",
"microsoft-cognitiveservices-speech-sdk": "1.17.0",
"prop-types": "15.8.1",
"punycode": "2.3.1",
@@ -24385,6 +24361,7 @@
"esbuild": "^0.21.1",
"isomorphic-react": "^0.0.0-0",
"isomorphic-react-dom": "^0.0.0-0",
+ "micromark-util-types": "^2.0.0",
"tsd": "^0.31.0",
"type-fest": "^4.18.2",
"typescript": "^5.4.5"
diff --git a/packages/bundle/package.json b/packages/bundle/package.json
index 07251f3398..5ec5f367b0 100644
--- a/packages/bundle/package.json
+++ b/packages/bundle/package.json
@@ -127,12 +127,13 @@
"botframework-webchat-core": "0.0.0-0",
"classnames": "2.5.1",
"core-js": "3.37.0",
+ "katex": "^0.16.11",
"math-random": "2.0.1",
"mdast-util-from-markdown": "2.0.0",
"memoize-one": "6.0.0",
"micromark": "^4.0.0",
"micromark-extension-gfm": "^3.0.0",
- "micromark-extension-math": "^3.1.0",
+ "micromark-util-character": "^2.1.0",
"microsoft-cognitiveservices-speech-sdk": "1.17.0",
"prop-types": "15.8.1",
"punycode": "2.3.1",
@@ -163,6 +164,7 @@
"esbuild": "^0.21.1",
"isomorphic-react": "^0.0.0-0",
"isomorphic-react-dom": "^0.0.0-0",
+ "micromark-util-types": "^2.0.0",
"tsd": "^0.31.0",
"type-fest": "^4.18.2",
"typescript": "^5.4.5"
diff --git a/packages/bundle/src/markdown/mathExtension/constants.ts b/packages/bundle/src/markdown/mathExtension/constants.ts
new file mode 100644
index 0000000000..67481ccdc1
--- /dev/null
+++ b/packages/bundle/src/markdown/mathExtension/constants.ts
@@ -0,0 +1,5 @@
+export const BACKSLASH = 92; // '\'
+export const OPEN_PAREN = 40; // '('
+export const CLOSE_PAREN = 41; // ')'
+export const OPEN_BRACKET = 91; // '['
+export const CLOSE_BRACKET = 93; // ']'
diff --git a/packages/bundle/src/markdown/mathExtension/htmlRenderer.ts b/packages/bundle/src/markdown/mathExtension/htmlRenderer.ts
new file mode 100644
index 0000000000..71d10a1f92
--- /dev/null
+++ b/packages/bundle/src/markdown/mathExtension/htmlRenderer.ts
@@ -0,0 +1,45 @@
+import { type HtmlExtension, type Token } from 'micromark-util-types';
+
+export type CreateHtmlRendererOptions = {
+ renderMath?: ((content: string, isDisplay: boolean) => string) | undefined;
+};
+
+function extractMathContent(value) {
+ const isDisplay = value.startsWith('\\[');
+ const start = value.indexOf(isDisplay ? '[' : '(') + 1;
+ const end = value.lastIndexOf(isDisplay ? ']' : ')') - 1;
+ return {
+ content: value.slice(start, end).trim(),
+ isDisplay
+ };
+}
+
+export default function createHtmlRenderer(options: CreateHtmlRendererOptions = {}): HtmlExtension {
+ return {
+ exit: {
+ math(token: Token) {
+ const { content, isDisplay } = extractMathContent(this.sliceSerialize(token));
+ const defaults = isDisplay
+ ? ({ tag: options.renderMath ? 'figure' : 'pre', type: 'block' } as const)
+ : ({ tag: 'span', type: 'inline' } as const);
+
+ const render = (
+ content: string,
+ type: 'block' | 'inline' | 'error' = defaults.type,
+ tag: 'figure' | 'span' | 'pre' | 'code' = defaults.tag
+ ) => {
+ this.tag(`<${tag} data-math-type="${type}">`);
+ this.raw(content);
+ this.tag(`${tag}>`);
+ };
+
+ try {
+ render(options.renderMath?.(content, isDisplay) ?? content);
+ } catch (error) {
+ console.warn('Math rendering error:', error);
+ render(content, 'error', isDisplay ? 'pre' : 'code');
+ }
+ }
+ }
+ } as any;
+}
diff --git a/packages/bundle/src/markdown/mathExtension/index.ts b/packages/bundle/src/markdown/mathExtension/index.ts
new file mode 100644
index 0000000000..0fe2b747de
--- /dev/null
+++ b/packages/bundle/src/markdown/mathExtension/index.ts
@@ -0,0 +1,17 @@
+import { BACKSLASH } from './constants';
+import { createTokenizer } from './tokenizer';
+import { type Extension } from 'micromark-util-types';
+
+export function math(): Extension {
+ const construct = {
+ name: 'math',
+ tokenize: createTokenizer
+ };
+
+ return {
+ text: { [BACKSLASH]: construct },
+ flow: { [BACKSLASH]: construct }
+ } as any;
+}
+
+export { type CreateHtmlRendererOptions as mathHtmlOptions, default as mathHtml } from './htmlRenderer';
diff --git a/packages/bundle/src/markdown/mathExtension/tokenizer.ts b/packages/bundle/src/markdown/mathExtension/tokenizer.ts
new file mode 100644
index 0000000000..9878678ff5
--- /dev/null
+++ b/packages/bundle/src/markdown/mathExtension/tokenizer.ts
@@ -0,0 +1,68 @@
+/* eslint-disable @typescript-eslint/no-use-before-define */
+import { BACKSLASH, OPEN_PAREN, CLOSE_PAREN, OPEN_BRACKET, CLOSE_BRACKET } from './constants';
+import { markdownLineEnding } from 'micromark-util-character';
+import { type Code, type Effects, type State } from 'micromark-util-types';
+
+type MathTokenTypes = 'math' | 'mathChunk';
+
+type MathEffects = Omit & {
+ enter(type: MathTokenTypes): void;
+ exit(type: MathTokenTypes): void;
+};
+
+export function createTokenizer(effects: MathEffects, ok: State, nok: State) {
+ let isDisplay = false;
+
+ return start;
+
+ function start(code: Code): State {
+ if (code !== BACKSLASH) {
+ return nok(code);
+ }
+ effects.enter('math');
+ effects.consume(code);
+ return openDelimiter;
+ }
+
+ function openDelimiter(code: Code): State {
+ if (code === OPEN_PAREN || code === OPEN_BRACKET) {
+ isDisplay = code === OPEN_BRACKET;
+ effects.consume(code);
+ effects.enter('mathChunk');
+ return content;
+ }
+ return nok(code);
+ }
+
+ function content(code: Code): State {
+ if (code === null) {
+ return nok(code);
+ }
+
+ if (code === BACKSLASH) {
+ effects.consume(code);
+ return escaped;
+ }
+
+ effects.consume(code);
+
+ if (markdownLineEnding(code)) {
+ effects.exit('mathChunk');
+ effects.enter('mathChunk');
+ }
+
+ return content;
+ }
+
+ function escaped(code) {
+ if ((!isDisplay && code === CLOSE_PAREN) || (isDisplay && code === CLOSE_BRACKET)) {
+ effects.consume(code);
+ effects.exit('mathChunk');
+ effects.exit('math');
+ return ok(code);
+ }
+
+ effects.consume(code);
+ return content;
+ }
+}
diff --git a/packages/bundle/src/markdown/renderMarkdown.ts b/packages/bundle/src/markdown/renderMarkdown.ts
index 43e21b357b..ade29c2c16 100644
--- a/packages/bundle/src/markdown/renderMarkdown.ts
+++ b/packages/bundle/src/markdown/renderMarkdown.ts
@@ -3,10 +3,11 @@ import {
serializeDocumentFragmentIntoString
} from 'botframework-webchat-component/internal';
import { onErrorResumeNext } from 'botframework-webchat-core';
+import katex from 'katex';
import { micromark } from 'micromark';
import { gfm, gfmHtml } from 'micromark-extension-gfm';
-import { math, mathHtml } from 'micromark-extension-math';
+import { math, mathHtml } from './mathExtension';
import betterLinkDocumentMod, { BetterLinkDocumentModDecoration } from './private/betterLinkDocumentMod';
import iterateLinkDefinitions from './private/iterateLinkDefinitions';
import { pre as respectCRLFPre } from './private/respectCRLF';
@@ -98,12 +99,17 @@ export default function render(
// We need to handle links like cite:1 or other URL handlers.
// And we will remove dangerous protocol during sanitization.
allowDangerousProtocol: true,
- extensions: [
- gfm(),
- // Disabling single dollar inline math block to prevent easy collision.
- math({ singleDollarTextMath: false })
- ],
- htmlExtensions: [gfmHtml(), mathHtml({ output: 'mathml' })]
+ extensions: [gfm(), math()],
+ htmlExtensions: [
+ gfmHtml(),
+ mathHtml({
+ renderMath: (content, isDisplay) =>
+ katex.renderToString(content, {
+ displayMode: isDisplay,
+ output: 'mathml'
+ })
+ })
+ ]
});
// TODO: [P1] In some future, we should apply "better link" and "sanitization" outside of the Markdown engine.
diff --git a/packages/component/src/Styles/StyleSet/RenderMarkdown.ts b/packages/component/src/Styles/StyleSet/RenderMarkdown.ts
index d388fe362e..74aa8bba6a 100644
--- a/packages/component/src/Styles/StyleSet/RenderMarkdown.ts
+++ b/packages/component/src/Styles/StyleSet/RenderMarkdown.ts
@@ -67,12 +67,18 @@ export default function createMarkdownStyle() {
content: "'['"
},
- '& math': {
+ '& [data-math-type=block] math': {
alignItems: 'center',
display: 'flex',
flexDirection: 'column'
},
+ '& [data-math-type=inline] math': {
+ alignItems: 'center',
+ display: 'inline-flex',
+ flexDirection: 'column'
+ },
+
'& .webchat__render-markdown__code-block': {
whiteSpace: 'pre-wrap'
}
diff --git a/packages/component/src/providers/HTMLContentTransformCOR/useTransformHTMLContent.ts b/packages/component/src/providers/HTMLContentTransformCOR/useTransformHTMLContent.ts
index 149db157fb..74fff246be 100644
--- a/packages/component/src/providers/HTMLContentTransformCOR/useTransformHTMLContent.ts
+++ b/packages/component/src/providers/HTMLContentTransformCOR/useTransformHTMLContent.ts
@@ -17,10 +17,11 @@ const DEFAULT_ALLOWED_TAGS: ReadonlyMap