mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-21 11:13:20 +08:00
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:
@@ -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\\$'
|
||||
|
||||
Reference in New Issue
Block a user