fix(editor): latex dollar preprocessor (#15622)

#### PR Dependency Tree


* **PR #15622** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-09-19 19:29:42 +08:00
committed by GitHub
parent 42457ef6f7
commit 10c4a1e7d6
4 changed files with 61 additions and 201 deletions
@@ -1,30 +0,0 @@
import { preprocessLatex } from '@blocksuite/affine-block-latex';
import { describe, expect, test } from 'vitest';
describe('latex preprocessor currency escaping', () => {
test.each([
['a plain price', 'costs $5 today', 'costs \\$5 today'],
['a bare amount', '$4', '\\$4'],
['two prices', 'costs $5 and $10', 'costs \\$5 and \\$10'],
])('escapes %s', (_, markdown, expected) => {
expect(preprocessLatex(markdown)).toBe(expected);
});
describe('a dollar that is already escaped', () => {
// Escaping it again turns the odd backslash run even, which leaves a
// literal backslash followed by an unescaped `$` for remark-math.
test.each([
['one backslash', 'costs \\$4 today'],
['three backslashes', '\\\\\\$4'],
['several amounts', '\\$4 and \\$10'],
])('is left alone with %s', (_, markdown) => {
expect(preprocessLatex(markdown)).toBe(markdown);
});
});
test('an even backslash run still has its dollar escaped', () => {
// `\\` is a literal backslash, so the `$` after it is unescaped and is a
// genuine currency candidate. The run itself must survive untouched.
expect(preprocessLatex('costs \\\\$4 today')).toBe('costs \\\\\\$4 today');
});
});
@@ -1,71 +0,0 @@
import { preprocessLatex } from '@blocksuite/affine-block-latex';
import { describe, expect, test } from 'vitest';
describe('latex markdown preprocessor', () => {
describe('inline math starting with a digit (issue #15588)', () => {
test.each([
['binary operators', '$4\\vee 6=12$'],
['relation', '$2<3$'],
['single digit', '$5$'],
])('leaves %s untouched', (_, markdown) => {
expect(preprocessLatex(markdown)).toBe(markdown);
});
test('keeps every expression in a sentence mixing math and prose', () => {
const markdown =
'$(\\mathbb Z^+,|)$ has $4\\vee 6=12$ and $4\\wedge 6=2$.';
expect(preprocessLatex(markdown)).toBe(markdown);
});
});
describe('currency still escapes', () => {
test.each([
[
'two prices in prose',
'costs $5 and $10 today',
'costs \\$5 and \\$10 today',
],
['a single price', 'it costs $5.00 total', 'it costs \\$5.00 total'],
['adjacent amounts', '$100$200', '\\$100\\$200'],
])('escapes %s', (_, markdown, expected) => {
expect(preprocessLatex(markdown)).toBe(expected);
});
});
describe('escaped dollars are not delimiters', () => {
test('an escaped dollar does not close inline math', () => {
expect(preprocessLatex('$5\\$ and $10')).toBe('\\$5\\$ and \\$10');
});
test('an escaped dollar does not open inline math', () => {
expect(preprocessLatex('\\$5 and x$')).toBe('\\\\$5 and x$');
});
test('an even backslash run is a literal backslash, not an escape', () => {
expect(preprocessLatex('\\\\$4\\vee 6=12$')).toBe('\\\\$4\\vee 6=12$');
});
test('a literal dollar inside math is kept', () => {
expect(preprocessLatex('$a\\$b$')).toBe('$a\\$b$');
});
});
describe('existing behaviour is unchanged', () => {
test('display math is left alone', () => {
expect(preprocessLatex('$$x=1$$')).toBe('$$x=1$$');
});
test('a dollar followed by whitespace does not open math', () => {
expect(preprocessLatex('$ 5 dollars$')).toBe('$ 5 dollars$');
});
test('backslash-paren math is rewritten to dollars', () => {
expect(preprocessLatex('\\(E=mc^2\\)')).toBe('$E=mc^2$');
});
test('code spans are protected', () => {
expect(preprocessLatex('`$4 is not math`')).toBe('`$4 is not math`');
});
});
});
@@ -4958,55 +4958,50 @@ bbb
describe('inline latex', () => {
test.each([
['dollar sign syntax', 'inline $E=mc^2$ latex\n'],
['backslash syntax', 'inline \\(E=mc^2\\) latex\n'],
])('should convert %s correctly', async (_, markdown) => {
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: 'inline ',
},
{
insert: ' ',
attributes: {
latex: 'E=mc^2',
},
},
{
insert: ' latex',
},
],
},
},
children: [],
},
[
'dollar sign syntax',
'inline $E=mc^2$ latex\n',
[
{ insert: 'inline ' },
{ insert: ' ', attributes: { latex: 'E=mc^2' } },
{ insert: ' latex' },
],
};
],
[
'backslash syntax',
'inline \\(E=mc^2\\) latex\n',
[
{ insert: 'inline ' },
{ insert: ' ', attributes: { latex: 'E=mc^2' } },
{ insert: ' latex' },
],
],
[
'digit-prefixed expressions',
'$(\\mathbb Z^+,|)$ has $4\\vee 6=12$ and $4\\wedge 6=2$.\n',
[
{ insert: ' ', attributes: { latex: '(\\mathbb Z^+,|)' } },
{ insert: ' has ' },
{ insert: ' ', attributes: { latex: '4\\vee 6=12' } },
{ insert: ' and ' },
{ insert: ' ', attributes: { latex: '4\\wedge 6=2' } },
{ insert: '.' },
],
],
[
'an even backslash run before the delimiter',
'\\\\$4\\vee 6=12$\n',
[
{ insert: '\\' },
{ insert: ' ', attributes: { latex: '4\\vee 6=12' } },
],
],
])('should convert %s correctly', async (_, markdown, expectedDelta) => {
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
expect(collectSnapshotDeltas(rawBlockSnapshot)).toEqual(expectedDelta);
});
});
@@ -5045,47 +5040,29 @@ 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: [],
},
],
};
describe('dollar currency', () => {
test.each([
[
'plain prices',
'The T-shirt is $9.15 and the hat is $ 8\n',
'The T-shirt is $9.15 and the hat is $ 8',
],
['adjacent amounts', '$100$200\n', '$100$200'],
['an escaped dollar', 'costs \\$4 today\n', 'costs $4 today'],
['an escaped closing dollar', '$5\\$ and $10\n', '$5$ and $10'],
['an escaped opening dollar', '\\$5 and x$\n', '$5 and x$'],
['an odd backslash run', '\\\\\\$4\n', '\\$4'],
['an even backslash run', 'costs \\\\$4 today\n', 'costs \\$4 today'],
])('keeps %s as text', async (_, markdown, expectedText) => {
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({
file: markdown,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
expect(collectSnapshotDeltas(rawBlockSnapshot)).toEqual([
{ insert: expectedText },
]);
});
});
@@ -32,7 +32,7 @@ function escapeMhchem(text: string) {
* @param content - The content to preprocess
* @returns The preprocessed content
*/
export function preprocessLatex(content: string) {
function preprocessLatex(content: string) {
// Protect code blocks
const codeBlocks: string[] = [];
let preprocessedContent = content;
@@ -44,18 +44,8 @@ export function preprocessLatex(content: string) {
}
);
// Protect existing LaTeX expressions.
//
// Single-dollar inline math is protected too, otherwise the currency escape
// below eats the opening `$` of expressions that start with a digit, such as
// `$4\vee 6=12$` (issue #15588). The delimiters follow the usual rule for
// telling math from prices: the opening `$` is not followed by whitespace,
// the closing `$` is not preceded by whitespace, and the closing `$` is not
// followed by a digit. That keeps `costs $5 and $10` and `$100$200` as
// currency while `$4\vee 6=12$` is recognised as math. Backslash escapes are
// consumed as a unit so an escaped `\$` neither opens nor closes math. An
// even-length backslash run before the opener is a literal backslash rather
// than an escape, so it is matched along with the expression it precedes.
// Protect existing LaTeX expressions. Inline delimiters use Pandoc's
// whitespace and digit rules, with backslash parity preserved.
const latexExpressions: string[] = [];
preprocessedContent = preprocessedContent.replace(
/(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\(.*?\\\)|(?<!\\)(?:\\\\)*\$(?!\s)(?:[^\n$\\]|\\.)*?(?<!\s)\$(?!\d))/g,
@@ -65,13 +55,7 @@ export function preprocessLatex(content: string) {
}
);
// Escape dollar signs that are likely currency indicators.
//
// A dollar preceded by an odd number of backslashes is already escaped, so
// adding another backslash makes the run even: `\$4` becomes `\\$4`, which is
// a literal backslash followed by an unescaped `$` that remark-math can then
// treat as a delimiter. Only escape a dollar whose preceding backslash run is
// even, and keep that run as it was.
// An odd backslash run already escapes the dollar sign.
preprocessedContent = preprocessedContent.replace(
/(?<!\\)((?:\\\\)*)\$(?=\d)/g,
'$1\\$'