From d5aebc14215b2d8d7b33dd59271e1e8663c5c81b Mon Sep 17 00:00:00 2001 From: donteatfriedrice Date: Thu, 10 Apr 2025 12:15:24 +0000 Subject: [PATCH] fix(editor): enhance markdown latex preprocessing (#11597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close [BS-2440](https://linear.app/affine-design/issue/BS-2440/inline-latex-markdown-adapter-需要更精确的处理) --- .../__tests__/adapters/markdown.unit.spec.ts | 42 +++++++++ .../src/adapters/markdown/preprocessor.ts | 85 ++++++++++++++++--- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts index 1b731d1ad..b815765d5 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts @@ -3769,6 +3769,48 @@ bbb }); expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); }); + + test('escapes dollar signs followed by a digit or space and digit', async () => { + const markdown = + 'The price of the T-shirt is $9.15 and the price of the hat is $ 8\n'; + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: + 'The price of the T-shirt is $9.15 and the price of the hat is $ 8', + }, + ], + }, + }, + children: [], + }, + ], + }; + const mdAdapter = new MarkdownAdapter(createJob(), provider); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); }); test('reference', async () => { diff --git a/blocksuite/affine/blocks/latex/src/adapters/markdown/preprocessor.ts b/blocksuite/affine/blocks/latex/src/adapters/markdown/preprocessor.ts index 1d6e7985f..7f1128a3b 100644 --- a/blocksuite/affine/blocks/latex/src/adapters/markdown/preprocessor.ts +++ b/blocksuite/affine/blocks/latex/src/adapters/markdown/preprocessor.ts @@ -3,21 +3,84 @@ import { MarkdownPreprocessorExtension, } from '@blocksuite/affine-shared/adapters'; +function escapeBrackets(text: string) { + const pattern = + /(```[\S\s]*?```|`.*?`)|\\\[([\S\s]*?[^\\])\\]|\\\((.*?)\\\)/g; + return text.replaceAll( + pattern, + (match, codeBlock, squareBracket, roundBracket) => { + if (codeBlock) { + return codeBlock; + } else if (squareBracket) { + return `$$${squareBracket}$$`; + } else if (roundBracket) { + return `$${roundBracket}$`; + } + return match; + } + ); +} + +function escapeMhchem(text: string) { + return text.replaceAll('$\\ce{', '$\\\\ce{').replaceAll('$\\pu{', '$\\\\pu{'); +} + +/** + * Preprocess the content to protect code blocks and LaTeX expressions + * reference issue: https://github.com/remarkjs/react-markdown/issues/785 + * reference comment: https://github.com/remarkjs/react-markdown/issues/785#issuecomment-2307567823 + * @param content - The content to preprocess + * @returns The preprocessed content + */ +function preprocessLatex(content: string) { + // Protect code blocks + const codeBlocks: string[] = []; + let preprocessedContent = content; + preprocessedContent = preprocessedContent.replace( + /(```[\s\S]*?```|`[^`\n]+`)/g, + (_, code) => { + codeBlocks.push(code); + return `<>`; + } + ); + + // Protect existing LaTeX expressions + const latexExpressions: string[] = []; + preprocessedContent = preprocessedContent.replace( + /(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\(.*?\\\))/g, + match => { + latexExpressions.push(match); + return `<>`; + } + ); + + // Escape dollar signs that are likely currency indicators + preprocessedContent = preprocessedContent.replace(/\$(?=\d)/g, '\\$'); + + // Restore LaTeX expressions + preprocessedContent = preprocessedContent.replace( + /<>/g, + (_, index) => latexExpressions[parseInt(index)] + ); + + // Restore code blocks + preprocessedContent = preprocessedContent.replace( + /<>/g, + (_, index) => codeBlocks[parseInt(index)] + ); + + // Apply additional escaping functions + preprocessedContent = escapeBrackets(preprocessedContent); + preprocessedContent = escapeMhchem(preprocessedContent); + + return preprocessedContent; +} + const latexPreprocessor: MarkdownAdapterPreprocessor = { name: 'latex', levels: ['block', 'slice', 'doc'], preprocess: content => { - // Replace block-level LaTeX delimiters \[ \] with $$ $$ - const blockProcessedContent = content.replace( - /\\\[(.*?)\\\]/gs, - (_, equation) => `$$${equation}$$` - ); - // Replace inline LaTeX delimiters \( \) with $ $ - const inlineProcessedContent = blockProcessedContent.replace( - /\\\((.*?)\\\)/gs, - (_, equation) => `$${equation}$` - ); - return inlineProcessedContent; + return preprocessLatex(content); }, };