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 @@ + + + + + + + + + + + + + +
+ + + + 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 @@ + + + + + + + + + + + + + +
+ + + + 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(``); + }; + + 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