From ec0c651cfced1e9baf8b8a7015f662eb51404a50 Mon Sep 17 00:00:00 2001 From: averydev Date: Sat, 12 Sep 2026 14:31:47 -0700 Subject: [PATCH] fix(editor): accept indented display math with inner dollars, escape pipes in cell attributes Review comments on the previous commit: - Display math whose body holds a `$` (`$$\n\$5\n$$`) fell back to prose because the body pattern refused every dollar; it now runs to the closing `$$`. - Display math indented by spaces or a tab no longer opened, because the line-start check wanted `$$` right after the newline. The start pattern tolerates the indent, and points marked at the newline rather than the first `$` so the indent does not stay behind in the paragraph. - `|` inside a link or image attribute in a table cell was still written bare, so the row split on the next load. The cell context now reaches destinations, titles and alt text as well as text. # Conflicts: # src/renderer/src/components/editor/rich-markdown-extensions.ts --- .../editor/markdown-round-trip.test.ts | 20 ++++++++ .../editor/rich-markdown-extensions.ts | 30 ++++++++---- .../rich-markdown-serializer-fidelity.ts | 48 +++++++++++-------- 3 files changed, 70 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index 547462d0c36..7bb005d93d1 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -735,6 +735,26 @@ describe('rich markdown round trip', () => { expect(roundTripMarkdown('text\n\n$$\nx^2\n$$\n')).toBe('text\n\n$$\nx^2\n$$') }) + it('parses display math that is indented or holds an escaped dollar', () => { + for (const source of [ + 'text\n\n $$\nx^2\n$$\n', + 'text\n $$\nx^2\n$$\n', + 'text\n\t$$\nx^2\n$$\n' + ]) { + expect(roundTripMarkdown(source)).toBe('text\n\n$$\nx^2\n$$') + } + expect(roundTripMarkdown('$$\n\\$5 + x\n$$\n')).toBe('$$\n\\$5 + x\n$$') + }) + + it('escapes pipes inside link and image attributes in table cells', () => { + const once = roundTripMarkdown( + '| a | b |\n|---|---|\n| ![x \\| y](img.png) | [c \\| d](http://e "f \\| g") |\n' + ) + expect(once).toContain('![x \\| y](img.png)') + expect(once).toContain('[c \\| d](http://e "f \\| g")') + expect(roundTripMarkdown(`${once}\n`)).toBe(once) + }) + it('preserves markdown tables', () => { expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a') }) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index 55e89ebf613..0dbbfea421b 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -41,7 +41,6 @@ import { RichMarkdownCodeSpanPadding } from './rich-markdown-code-span-padding' import { RichMarkdownListItem } from './rich-markdown-list-item' import { RichMarkdownProseEntities } from './rich-markdown-prose-entities' import { RichMarkdownParagraph } from './rich-markdown-paragraph' -import { RichMarkdownInlineMath } from './rich-markdown-inline-math' import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight' import { RichMarkdownEscapedCharacter } from './rich-markdown-escaped-character' import { RichMarkdownSerializerFidelity } from './rich-markdown-serializer-fidelity' @@ -56,18 +55,33 @@ const RichMarkdownLink = Link.extend({ priority: 90 }) -// Why: marked ends a paragraph wherever a block tokenizer's `start` points, so -// display math only opens at a line start. +// Why: Pandoc's rule keeps money as text — both `$` must touch the formula, the closing one +// must not be followed by a digit, and an escaped `\$` never closes. +const INLINE_MATH_PATTERN = /^\$(?![\s$])((?:\\[\s\S]|[^$\\])*?)(? src.indexOf('$'), + tokenize: (src: string) => { const match = src.match(INLINE_MATH_PATTERN); if (!match) return undefined; return { type: 'inlineMath', raw: match[0], latex: match[1] } } + } +}) +const BLOCK_MATH_START_PATTERN = /\n[ \t]*\$\$/ +const BLOCK_MATH_PATTERN = /^[ \t]*\$\$((?:(?!\$\$)[\s\S])+?)\$\$/ +const RichMarkdownBlockMath = BlockMath.extend({ + markdownTokenizer: { + name: 'blockMath', level: 'block', + start: (src: string) => BLOCK_MATH_START_PATTERN.exec(src)?.index ?? -1, + tokenize: (src: string) => { const match = src.match(BLOCK_MATH_PATTERN); if (!match) return undefined; return { type: 'blockMath', raw: match[0], latex: match[1].trim() } } + } +}) const RichMarkdownBlockMath = BlockMath.extend({ markdownTokenizer: { name: 'blockMath', level: 'block', - start: (src: string) => { - const index = src.indexOf('\n$$') - return index === -1 ? -1 : index + 1 - }, + // Why: marked cuts the paragraph at the returned index + 1; pointing at the newline keeps + // the indent out of the paragraph and lets the block tokenizer see the whole opener line. + start: (src: string) => BLOCK_MATH_START_PATTERN.exec(src)?.index ?? -1, tokenize: (src: string) => { - const match = src.match(/^\$\$([^$]+)\$\$/) + const match = src.match(BLOCK_MATH_PATTERN) if (!match) { return undefined } diff --git a/src/renderer/src/components/editor/rich-markdown-serializer-fidelity.ts b/src/renderer/src/components/editor/rich-markdown-serializer-fidelity.ts index 854d829f9ef..47087c924a7 100644 --- a/src/renderer/src/components/editor/rich-markdown-serializer-fidelity.ts +++ b/src/renderer/src/components/editor/rich-markdown-serializer-fidelity.ts @@ -35,7 +35,7 @@ function toSourceForm(node: JSONContent, context: SerializerContext): JSONConten if (node.type === 'text') { return textToSourceForm(node, context) } - const next: JSONContent = node.type === 'image' ? imageToSourceForm(node) : node + const next: JSONContent = node.type === 'image' ? imageToSourceForm(node, context) : node if (!next.content) { return next } @@ -47,21 +47,20 @@ function toSourceForm(node: JSONContent, context: SerializerContext): JSONConten } function textToSourceForm(node: JSONContent, context: SerializerContext): JSONContent { - const marks = (node.marks ?? []).map(linkMarkToSourceForm) + const marks = (node.marks ?? []).map((mark) => linkMarkToSourceForm(mark, context)) const escaped = marks.some((mark) => mark.type === RICH_MARKDOWN_ESCAPED_CHARACTER_MARK) const insideCode = marks.some((mark) => mark.type === 'code') - let text = node.text ?? '' - if (escaped) { - text = escapedCharacterSourceText(text, insideCode) - } else if (context.insideTableCell) { - // Why: marked splits cells on unescaped `|` before inline lexing, code spans included. - text = text.replace(/\|/g, '\\|') - } + const text = escaped + ? escapedCharacterSourceText(node.text ?? '', insideCode) + : escapeTableCellPipes(node.text ?? '', context) const kept = marks.filter((mark) => mark.type !== RICH_MARKDOWN_ESCAPED_CHARACTER_MARK) return kept.length > 0 ? { ...node, text, marks: kept } : { type: 'text', text } } -function linkMarkToSourceForm(mark: NonNullable[number]) { +function linkMarkToSourceForm( + mark: NonNullable[number], + context: SerializerContext +) { if (mark.type !== 'link' || !mark.attrs) { return mark } @@ -69,13 +68,13 @@ function linkMarkToSourceForm(mark: NonNullable[number]) { ...mark, attrs: { ...mark.attrs, - href: destinationToSourceForm(mark.attrs.href), - title: titleToSourceForm(mark.attrs.title) + href: destinationToSourceForm(mark.attrs.href, context), + title: titleToSourceForm(mark.attrs.title, context) } } } -function imageToSourceForm(node: JSONContent): JSONContent { +function imageToSourceForm(node: JSONContent, context: SerializerContext): JSONContent { if (!node.attrs) { return node } @@ -83,22 +82,31 @@ function imageToSourceForm(node: JSONContent): JSONContent { ...node, attrs: { ...node.attrs, - src: destinationToSourceForm(node.attrs.src), + src: destinationToSourceForm(node.attrs.src, context), alt: typeof node.attrs.alt === 'string' - ? node.attrs.alt.replace(/[\\[\]]/g, '\\$&') + ? escapeTableCellPipes(node.attrs.alt.replace(/[\\[\]]/g, '\\$&'), context) : node.attrs.alt, - title: titleToSourceForm(node.attrs.title) + title: titleToSourceForm(node.attrs.title, context) } } } // Why: a bare destination ends at an unbalanced `)`; marked unescapes `\(` and `\)` back on load. // (The `<…>` form is not an option here: Orca's raw-HTML pass would placeholder it before parsing.) -function destinationToSourceForm(destination: unknown): unknown { - return typeof destination === 'string' ? destination.replace(/[()]/g, '\\$&') : destination +function destinationToSourceForm(destination: unknown, context: SerializerContext): unknown { + return typeof destination === 'string' + ? escapeTableCellPipes(destination.replace(/[()]/g, '\\$&'), context) + : destination } -function titleToSourceForm(title: unknown): unknown { - return typeof title === 'string' ? title.replace(/"/g, '\\"') : title +function titleToSourceForm(title: unknown, context: SerializerContext): unknown { + return typeof title === 'string' + ? escapeTableCellPipes(title.replace(/"/g, '\\"'), context) + : title +} + +// Why: marked splits cells on unescaped `|` before parsing anything inside them, attributes included. +function escapeTableCellPipes(text: string, context: SerializerContext): string { + return context.insideTableCell ? text.replace(/\|/g, '\\|') : text }