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.
This commit is contained in:
JK 2026-07-20 18:54:31 +02:00
parent 77f5f739b5
commit ec2e6f7dda
5 changed files with 244 additions and 92 deletions

View file

@ -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<readonly { from: number; to: number }[]>({

View file

@ -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;

View file

@ -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');

View file

@ -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[] = [];

View file

@ -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);