diff --git a/src/utils/mathMarkdown.test.ts b/src/utils/mathMarkdown.test.ts index 97aa1f4b..e52ca22b 100644 --- a/src/utils/mathMarkdown.test.ts +++ b/src/utils/mathMarkdown.test.ts @@ -95,6 +95,58 @@ describe('math markdown round-trip', () => { ) }) + it('round-trips inline math inside table cells', () => { + const tableCellMath = preProcessMathMarkdown({ markdown: '$a+b$' }) + const blocks = [{ + type: 'table', + content: { + type: 'tableContent', + rows: [{ + cells: [{ + type: 'tableCell', + content: [{ type: 'text', text: tableCellMath, styles: {} }], + }], + }], + }, + children: [], + }] + + const [tableBlock] = injectMathInBlocks(blocks) as Array<{ + content: { rows: Array<{ cells: Array<{ content: unknown[] }> }> } + }> + + expect(tableBlock.content.rows[0].cells[0].content).toEqual([{ + type: MATH_INLINE_TYPE, + props: { latex: 'a+b' }, + content: undefined, + }]) + + const editor = { + blocksToMarkdownLossy: vi.fn(() => '| Formula |\n| --- |\n| $a+b$ |'), + } + + expect(serializeMathAwareBlocks(editor, [tableBlock])).toBe('| Formula |\n| --- |\n| $a+b$ |') + expect(editor.blocksToMarkdownLossy).toHaveBeenCalledWith([{ + type: 'table', + content: { + type: 'tableContent', + rows: [{ + cells: [{ + type: 'tableCell', + content: [{ type: 'text', text: '$a+b$' }], + }], + }], + }, + children: [], + }]) + }) + + it('leaves display-style math inside table cells as Markdown source', () => { + expect(preProcessMathMarkdown({ markdown: '| Formula |\n| --- |\n| $$c$$ |' })).toBe( + '| Formula |\n| --- |\n| $$c$$ |', + ) + }) + it('leaves inline code and fenced code math-looking text untouched', () => { const markdown = [ 'Keep `$not_math$` literal.', diff --git a/src/utils/mathMarkdown.ts b/src/utils/mathMarkdown.ts index 08bd859f..55b767b3 100644 --- a/src/utils/mathMarkdown.ts +++ b/src/utils/mathMarkdown.ts @@ -19,12 +19,33 @@ interface InlineItem { interface BlockLike { type?: string - content?: InlineItem[] + content?: BlockContent props?: Record children?: BlockLike[] [key: string]: unknown } +type BlockContent = InlineItem[] | TableContentLike | unknown + +interface TableContentLike { + type?: string + rows?: TableRowLike[] + [key: string]: unknown +} + +interface TableRowLike { + cells?: TableCellValue[] + [key: string]: unknown +} + +interface TableCellLike { + content?: InlineItem[] + [key: string]: unknown +} + +type TableCellValue = TableCellLike | string +type InlineContentTransform = (content: InlineItem[]) => InlineItem[] + interface MarkdownSerializer { blocksToMarkdownLossy: (blocks: unknown[]) => string } @@ -111,7 +132,7 @@ function isCodeFence({ text: line }: { text: string }): boolean { } function isSingleDollar({ text, index }: TextPosition): boolean { - return text[index] === '$' && text[index + 1] !== '$' + return text[index] === '$' && text[index - 1] !== '$' && text[index + 1] !== '$' } function isInlineMathEnd(position: TextPosition): boolean { @@ -274,10 +295,47 @@ function restoreInlineMath(content: InlineItem[]): InlineItem[] { }) } +function isTableContent(content: BlockContent): content is TableContentLike { + return Boolean( + content + && typeof content === 'object' + && !Array.isArray(content) + && (content as TableContentLike).type === 'tableContent' + && Array.isArray((content as TableContentLike).rows), + ) +} + +function transformTableCell(cell: TableCellValue, transform: InlineContentTransform): TableCellValue { + if (typeof cell === 'string' || !Array.isArray(cell.content)) return cell + return { ...cell, content: transform(cell.content) } +} + +function transformTableContent( + content: TableContentLike, + transform: InlineContentTransform, +): TableContentLike { + return { + ...content, + rows: content.rows?.map((row) => ({ + ...row, + cells: row.cells?.map((cell) => transformTableCell(cell, transform)), + })), + } +} + +function transformBlockContent( + content: BlockContent, + transform: InlineContentTransform, +): BlockContent { + if (Array.isArray(content)) return transform(content) + if (isTableContent(content)) return transformTableContent(content, transform) + return content +} + function injectMathInBlock(block: BlockLike): BlockLike { - const content = Array.isArray(block.content) ? expandInlineMath(block.content) : block.content + const content = transformBlockContent(block.content, expandInlineMath) const children = Array.isArray(block.children) ? block.children.map(injectMathInBlock) : block.children - const latex = readDisplayMathToken(content) + const latex = Array.isArray(content) ? readDisplayMathToken(content) : null if (latex !== null) { return buildMathBlock({ block, latex }) @@ -303,7 +361,7 @@ function buildMathBlock({ block, latex }: { block: BlockLike } & LatexPayload): } function restoreInlineMathInBlock(block: BlockLike): BlockLike { - const content = Array.isArray(block.content) ? restoreInlineMath(block.content) : block.content + const content = transformBlockContent(block.content, restoreInlineMath) const children = Array.isArray(block.children) ? block.children.map(restoreInlineMathInBlock) : block.children return { ...block, content, children } } diff --git a/tests/smoke/latex-math-notes.spec.ts b/tests/smoke/latex-math-notes.spec.ts index c8b12edf..916aa237 100644 --- a/tests/smoke/latex-math-notes.spec.ts +++ b/tests/smoke/latex-math-notes.spec.ts @@ -9,6 +9,8 @@ let tempVaultDir: string const INLINE_LATEX = 'E=mc^2' const DISPLAY_LATEX = '\\int_0^1 x^2 \\, dx = \\frac{1}{3}' const MALFORMED_LATEX = '\\frac{' +const TABLE_INLINE_LATEX = '\\frac{a}{b}+c' +const TABLE_DISPLAY_STYLE_LATEX = '\\sum_{i=1}^{n} i' test.beforeEach(async ({ page }, testInfo) => { testInfo.setTimeout(90_000) @@ -107,6 +109,11 @@ ${DISPLAY_LATEX} $$ Malformed math $${MALFORMED_LATEX}$ stays visible. + +| Kind | Formula | +| --- | --- | +| inline | $${TABLE_INLINE_LATEX}$ | +| display-style | $$${TABLE_DISPLAY_STYLE_LATEX}$$ | ` await setRawEditorContent(page, nextContent) @@ -117,15 +124,20 @@ Malformed math $${MALFORMED_LATEX}$ stays visible. await toggleRawMode(page, '.bn-editor') await expectMathNode(page, '.math--inline', INLINE_LATEX) + await expectMathNode(page, '.math--inline', TABLE_INLINE_LATEX) await expectMathNode(page, '.math--block', DISPLAY_LATEX) await expectMathNode(page, '.math--inline', MALFORMED_LATEX) - await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(3) + await expect(page.locator('table')).toHaveCount(1) + await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(4) await toggleRawMode(page, '.cm-content') const rawAfterRichMode = await getRawEditorContent(page) expect(rawAfterRichMode).toContain(`$${INLINE_LATEX}$`) expect(rawAfterRichMode).toContain(`$$\n${DISPLAY_LATEX}\n$$`) expect(rawAfterRichMode).toContain(`$${MALFORMED_LATEX}$`) + expect(rawAfterRichMode).toContain(`$${TABLE_INLINE_LATEX}$`) + expect(rawAfterRichMode).toContain(`$$${TABLE_DISPLAY_STYLE_LATEX}$$`) + expect(rawAfterRichMode).not.toContain('@@TOLARIA_MATH') await toggleRawMode(page, '.bn-editor') await openNote(page, 'Note C') @@ -136,4 +148,7 @@ Malformed math $${MALFORMED_LATEX}$ stays visible. expect(reopenedRaw).toContain(`$${INLINE_LATEX}$`) expect(reopenedRaw).toContain(`$$\n${DISPLAY_LATEX}\n$$`) expect(reopenedRaw).toContain(`$${MALFORMED_LATEX}$`) + expect(reopenedRaw).toContain(`$${TABLE_INLINE_LATEX}$`) + expect(reopenedRaw).toContain(`$$${TABLE_DISPLAY_STYLE_LATEX}$$`) + expect(reopenedRaw).not.toContain('@@TOLARIA_MATH') })