From ec2e6f7ddac889ac12b9affdede03fefc1f641b9 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 20 Jul 2026 18:54:31 +0200 Subject: [PATCH] fix(equations): resolve display math parsing failure with adjacent inline math - Replace naive $$ regexes and dollar counters with a state-machine parser `findDisplayMathBlocks`. - Correctly handle adjacent single-dollar inline math delimiters (e.g. `$[\text{charge}]$$^2$`) without misinterpreting them as opening display math blocks. - Update `findMathBlocks` in live preview, callout block equation extraction in `provider-equation`, and `checkAndFixCalloutMath` in fixer utils. - Add unit test coverage for adjacent inline math blocks and display math boundary detection. --- src/core/equations/live-preview-equations.ts | 57 ++------ src/core/equations/provider-equation.ts | 26 ++-- src/utils/fixer.ts | 84 +++++++---- src/utils/parse.ts | 142 +++++++++++++++++++ test_helpers/utils.test.ts | 27 +++- 5 files changed, 244 insertions(+), 92 deletions(-) diff --git a/src/core/equations/live-preview-equations.ts b/src/core/equations/live-preview-equations.ts index e8faa79..c7062f3 100644 --- a/src/core/equations/live-preview-equations.ts +++ b/src/core/equations/live-preview-equations.ts @@ -4,7 +4,12 @@ import { editorInfoField } from 'obsidian'; import LatexReferencer from 'main'; import { CONVERTER, getEqNumberPrefix } from 'utils/format'; import type { PluginSettings } from 'settings/settings'; -import { CALLOUT_PREFIX_REGEX, getCalloutPrefix, isStructuralCalloutLine } from 'utils/parse'; +import { + CALLOUT_PREFIX_REGEX, + getCalloutPrefix, + isStructuralCalloutLine, + findDisplayMathBlocks +} from 'utils/parse'; /** * The in-memory state for the TagManager. It holds only the information @@ -25,67 +30,27 @@ type EquationState = readonly EquationInfo[]; */ function findMathBlocks(state: EditorState): readonly { from: number; to: number }[] { const text = state.doc.toString(); - const codeBlockRanges: { from: number; to: number }[] = []; + const mathBlockRanges = findDisplayMathBlocks(text); - const fencedCodeRegex = /^```[\s\S]*?^```/gm; - let fencedMatch: RegExpExecArray | null; - while ((fencedMatch = fencedCodeRegex.exec(text)) !== null) { - codeBlockRanges.push({ - from: fencedMatch.index, - to: fencedMatch.index + fencedMatch[0].length - }); - } - - const inlineCodeRegex = /(`+)(?:(?!\1|(?:\r\n|\n){2})[\s\S])+?\1/g; - let inlineMatch: RegExpExecArray | null; - while ((inlineMatch = inlineCodeRegex.exec(text)) !== null) { - const currentMatch = inlineMatch; - const isInsideFencedBlock = codeBlockRanges.some( - range => - currentMatch.index >= range.from && currentMatch.index + currentMatch[0].length <= range.to - ); - if (!isInsideFencedBlock) { - codeBlockRanges.push({ - from: currentMatch.index, - to: currentMatch.index + currentMatch[0].length - }); - } - } - - const mathBlockRanges: { from: number; to: number }[] = []; - const mathRegex = /\$\$(.*?)\$\$/gs; - let mathMatch: RegExpExecArray | null; - while ((mathMatch = mathRegex.exec(text)) !== null) { + return mathBlockRanges.filter(mathRange => { // Guard against lazy-continuation callout math blocks. // When the opening $$ is on a callout line (e.g. "> $$") but the closing // $$ is NOT (lazy continuation), the tag manager would corrupt the document // by reconstructing the closing with the opening's prefix. // Only skip blocks with MISMATCHED prefixes; properly-formed callout // equations (where both $$ share the same prefix) are fine. - const openPos = mathMatch.index; + const openPos = mathRange.from; const openLineStart = text.lastIndexOf('\n', openPos - 1) + 1; const openPrefix = text.substring(openLineStart, openPos); - const closePos = mathMatch.index + mathMatch[0].length - 2; // position of closing $$ + const closePos = mathRange.to - 2; // position of closing $$ const closeLineStart = text.lastIndexOf('\n', closePos - 1) + 1; const closePrefix = text.substring(closeLineStart, closePos); const openCallout = (openPrefix.match(CALLOUT_PREFIX_REGEX) || [''])[0]; const closeCallout = (closePrefix.match(CALLOUT_PREFIX_REGEX) || [''])[0]; - if (openCallout !== closeCallout) { - continue; - } - - mathBlockRanges.push({ from: mathMatch.index, to: mathMatch.index + mathMatch[0].length }); - } - - const validMathBlocks = mathBlockRanges.filter(mathRange => { - return !codeBlockRanges.some( - codeRange => mathRange.from >= codeRange.from && mathRange.to <= codeRange.to - ); + return openCallout === closeCallout; }); - - return validMathBlocks; } const mathBlockPositionsField = StateField.define({ diff --git a/src/core/equations/provider-equation.ts b/src/core/equations/provider-equation.ts index 50f253e..30aaf09 100644 --- a/src/core/equations/provider-equation.ts +++ b/src/core/equations/provider-equation.ts @@ -1,6 +1,11 @@ import { TFile, App, CachedMetadata, Pos } from 'obsidian'; import { EquationBlock } from 'types'; -import { trimMathText, parseMarkdownComment, parseYamlLike } from 'utils/parse'; +import { + trimMathText, + parseMarkdownComment, + parseYamlLike, + findDisplayMathBlocks +} from 'utils/parse'; export class ActiveNoteEquationProvider { constructor(public app: App) {} @@ -100,21 +105,14 @@ export class ActiveNoteEquationProvider { processedText = cleanLines.join('\n'); } - // Handle split/lazy blockquotes (odd number of $$) by appending a closing one - if ((processedText.match(/\$\$/g) || []).length % 2 !== 0) { - processedText += '\n$$'; - } + const mathBlocks = findDisplayMathBlocks(processedText); - const mathRegex = /\$\$([\s\S]*?)\$\$/g; - let match; - - while ((match = mathRegex.exec(processedText)) !== null) { - const mathContent = match[1]; - const matchIndex = match.index; - - const prefix = processedText.substring(0, matchIndex); + for (const block of mathBlocks) { + const mathContent = processedText.substring(block.from + 2, block.to - 2); + const prefix = processedText.substring(0, block.from); const startLineOffset = (prefix.match(/\n/g) || []).length; - const contentLineCount = (match[0].match(/\n/g) || []).length; + const blockText = processedText.substring(block.from, block.to); + const contentLineCount = (blockText.match(/\n/g) || []).length; const absStartLine = section.position.start.line + startLineOffset; const absEndLine = section.position.start.line + startLineOffset + contentLineCount; diff --git a/src/utils/fixer.ts b/src/utils/fixer.ts index 817a898..6605e4a 100644 --- a/src/utils/fixer.ts +++ b/src/utils/fixer.ts @@ -1,4 +1,4 @@ -import { getCalloutPrefix } from './parse'; +import { getCalloutPrefix, findDisplayMathBlocks } from './parse'; /** * Checks and fixes broken math blocks inside callouts. @@ -9,43 +9,65 @@ import { getCalloutPrefix } from './parse'; * @returns The fixed content string if changes were made, or null if no changes were needed. */ export function checkAndFixCalloutMath(content: string): string | null { + const blocks = findDisplayMathBlocks(content); + if (blocks.length === 0) return null; + const lines = content.split(/\r?\n/); - let changed = false; - let inMathBlock = false; - let mathBlockStartLevel = 0; + const lineOffsets: number[] = [0]; + for (let i = 0; i < lines.length; i++) { + lineOffsets.push(lineOffsets[i] + lines[i].length + 1); // +1 for \n + } - const newLines = lines.map(line => { - // Determine current blockquote level - const prefix = getCalloutPrefix(line); - const currentLevel = (prefix.match(/>/g) || []).length; + interface BlockLineRange { + startLine: number; + endLine: number; + calloutLevel: number; + } - const dollars = (line.match(/\$\$/g) || []).length; + const blockRanges: BlockLineRange[] = []; - let processedLine = line; - - // If inside a math block and the indentation level dropped, fix it. - // We only fix if we are strictly inside a block (after the opening line). - if (inMathBlock && currentLevel < mathBlockStartLevel) { - const missingLevels = mathBlockStartLevel - currentLevel; - const patch = '> '.repeat(missingLevels); - processedLine = patch + line; - changed = true; - } - - // Toggle block state if we see an odd number of '$$' on the line. - // This handles standard start/end tags. - if (dollars % 2 !== 0) { - if (!inMathBlock) { - inMathBlock = true; - mathBlockStartLevel = currentLevel; - // Note: The opening line itself defines the level, so we don't fix it based on itself. - } else { - inMathBlock = false; + for (const b of blocks) { + let startLine = -1; + let endLine = -1; + for (let i = 0; i < lines.length; i++) { + const lineStart = lineOffsets[i]; + const lineEnd = lineOffsets[i + 1] - 1; + if (b.from >= lineStart && b.from <= lineEnd) { + startLine = i; + } + if (b.to > lineStart && b.to <= lineEnd + 1) { + endLine = i; } } - return processedLine; - }); + if (startLine !== -1 && endLine !== -1) { + const startLinePrefix = getCalloutPrefix(lines[startLine]); + const calloutLevel = (startLinePrefix.match(/>/g) || []).length; + if (calloutLevel > 0) { + blockRanges.push({ startLine, endLine, calloutLevel }); + } + } + } + + if (blockRanges.length === 0) return null; + + let changed = false; + const newLines = [...lines]; + + for (const { startLine, endLine, calloutLevel } of blockRanges) { + for (let i = startLine + 1; i <= endLine; i++) { + const line = newLines[i]; + const prefix = getCalloutPrefix(line); + const currentLevel = (prefix.match(/>/g) || []).length; + + if (currentLevel < calloutLevel) { + const missingLevels = calloutLevel - currentLevel; + const patch = '> '.repeat(missingLevels); + newLines[i] = patch + line; + changed = true; + } + } + } if (!changed) return null; return newLines.join('\n'); diff --git a/src/utils/parse.ts b/src/utils/parse.ts index f6ebd96..3015966 100644 --- a/src/utils/parse.ts +++ b/src/utils/parse.ts @@ -1,7 +1,149 @@ export function trimMathText(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith('$$') && trimmed.endsWith('$$') && trimmed.length >= 4) { + return trimmed.slice(2, -2).trim(); + } return text.match(/\$\$([\s\S]*)\$\$/)?.[1].trim() ?? ''; } +export interface MathBlockRange { + from: number; + to: number; +} + +/** + * Finds all display math ($$ ... $$) block ranges in the given markdown text, + * correctly ignoring fenced/inline code blocks, inline math ($ ... $), + * and escaped dollar signs (\$ or \\$). + */ +export function findDisplayMathBlocks(text: string): MathBlockRange[] { + const codeBlockRanges: MathBlockRange[] = []; + + const fencedCodeRegex = /^```[\s\S]*?^```/gm; + let fencedMatch: RegExpExecArray | null; + while ((fencedMatch = fencedCodeRegex.exec(text)) !== null) { + codeBlockRanges.push({ + from: fencedMatch.index, + to: fencedMatch.index + fencedMatch[0].length + }); + } + + const inlineCodeRegex = /(`+)(?:(?!\1|(?:\r\n|\n){2})[\s\S])+?\1/g; + let inlineMatch: RegExpExecArray | null; + while ((inlineMatch = inlineCodeRegex.exec(text)) !== null) { + const currentMatch = inlineMatch; + const isInsideFencedBlock = codeBlockRanges.some( + range => + currentMatch.index >= range.from && currentMatch.index + currentMatch[0].length <= range.to + ); + if (!isInsideFencedBlock) { + codeBlockRanges.push({ + from: currentMatch.index, + to: currentMatch.index + currentMatch[0].length + }); + } + } + + const mathBlockRanges: MathBlockRange[] = []; + + let pos = 0; + let state: 'OUTSIDE' | 'INLINE' | 'DISPLAY' = 'OUTSIDE'; + let displayStartPos = -1; + + const isEscaped = (p: number): boolean => { + let count = 0; + let k = p - 1; + while (k >= 0 && text[k] === '\\') { + count++; + k--; + } + return count % 2 !== 0; + }; + + const getUnescapedDollarCount = (p: number): number => { + if (p >= text.length || isEscaped(p) || text[p] !== '$') return 0; + let count = 0; + while (p + count < text.length && text[p + count] === '$' && !isEscaped(p + count)) { + count++; + } + return count; + }; + + while (pos < text.length) { + // Skip code block ranges + const codeRange = codeBlockRanges.find(r => pos >= r.from && pos < r.to); + if (codeRange) { + if (state === 'INLINE') { + state = 'OUTSIDE'; + } + pos = codeRange.to; + continue; + } + + const dCount = getUnescapedDollarCount(pos); + + if (state === 'OUTSIDE') { + if (dCount >= 2) { + const prevChar = pos > 0 ? text[pos - 1] : ''; + const nextChar = pos + dCount < text.length ? text[pos + dCount] : ''; + const isSandwiched = + prevChar && !/\s|\$/.test(prevChar) && nextChar && !/\s|\$/.test(nextChar); + + if (isSandwiched) { + // Adjacent inline math delimiters like $a$$b$ or $[a]$$^2$ + state = 'INLINE'; + pos += 1; + } else { + state = 'DISPLAY'; + displayStartPos = pos; + pos += 2; + } + } else if (dCount === 1) { + // Check if valid inline math start: next char is non-whitespace and not $ + const nextChar = text[pos + 1]; + if (nextChar && !/\s|\$/.test(nextChar)) { + state = 'INLINE'; + pos += 1; + } else { + pos += 1; + } + } else { + pos += 1; + } + } else if (state === 'INLINE') { + // Inline math cannot span across blank lines (\n\n) + if (text[pos] === '\n' && text[pos + 1] === '\n') { + state = 'OUTSIDE'; + pos += 2; + } else if (dCount >= 1) { + // Check if valid inline math end: prev char is non-whitespace + const prevChar = text[pos - 1]; + if (prevChar && !/\s/.test(prevChar)) { + state = 'OUTSIDE'; + pos += 1; + } else { + pos += 1; + } + } else { + pos += 1; + } + } else if (state === 'DISPLAY') { + if (dCount >= 2) { + mathBlockRanges.push({ + from: displayStartPos, + to: pos + 2 + }); + state = 'OUTSIDE'; + pos += 2; + } else { + pos += 1; + } + } + } + + return mathBlockRanges; +} + /** Parse the given markdown text and returns all comments in it as an array of lines. */ export function parseMarkdownComment(markdown: string): string[] { const comments: string[] = []; diff --git a/test_helpers/utils.test.ts b/test_helpers/utils.test.ts index d7fbb6c..b6e5d92 100644 --- a/test_helpers/utils.test.ts +++ b/test_helpers/utils.test.ts @@ -11,7 +11,8 @@ import { parseMarkdownComment, parseYamlLike, getCalloutPrefix, - isStructuralCalloutLine + isStructuralCalloutLine, + findDisplayMathBlocks } from '../src/utils/parse'; import { splitIntoLines, insertAt } from '../src/utils/general'; @@ -73,6 +74,30 @@ describe('parse.ts tests', () => { expect(trimMathText('no math block')).toBe(''); }); + it('findDisplayMathBlocks handles adjacent inline math like $[text]$$^2$', () => { + const content = `$[\\text{charge}]$$^2$ Refer Eq. [[#^eq-robp7m93]] +$$ +\\mathbf{D}_0=\\begin{pmatrix}\\alpha_0^{\\,2}\\,Z_i Z_j & \\alpha_0\\alpha_2\\,Z_i\\\\-\\alpha_0\\alpha_2\\,Z_j & 0\\end{pmatrix} +% id: eq-robp7m93 +$$`; + const blocks = findDisplayMathBlocks(content); + expect(blocks.length).toBe(1); + const matchedText = content.substring(blocks[0].from, blocks[0].to); + expect(matchedText).toContain('% id: eq-robp7m93'); + expect(matchedText.startsWith('$$')).toBe(true); + expect(matchedText.endsWith('$$')).toBe(true); + }); + + it('findDisplayMathBlocks ignores math in code blocks', () => { + const content = `\`\`\` +$$ fake math $$ +\`\`\` +$$ real math $$`; + const blocks = findDisplayMathBlocks(content); + expect(blocks.length).toBe(1); + expect(content.substring(blocks[0].from, blocks[0].to)).toBe('$$ real math $$'); + }); + it('parseMarkdownComment', () => { const text = 'Some text\n%% comment line 1\ncomment line 2 %%\nOther text %% single comment %%'; const comments = parseMarkdownComment(text);