mirror of
https://github.com/youfoundjk/TeXcore.git
synced 2026-07-22 07:33:31 +00:00
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:
parent
77f5f739b5
commit
ec2e6f7dda
5 changed files with 244 additions and 92 deletions
|
|
@ -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 }[]>({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue