2026-04-26 03:52:10 +02:00
|
|
|
import katex from 'katex'
|
|
|
|
|
|
|
|
|
|
export const MATH_INLINE_TYPE = 'mathInline'
|
|
|
|
|
export const MATH_BLOCK_TYPE = 'mathBlock'
|
|
|
|
|
|
|
|
|
|
const INLINE_TOKEN_PREFIX = '@@TOLARIA_MATH_INLINE:'
|
|
|
|
|
const BLOCK_TOKEN_PREFIX = '@@TOLARIA_MATH_BLOCK:'
|
|
|
|
|
const TOKEN_SUFFIX = '@@'
|
|
|
|
|
const INLINE_TOKEN_RE = /@@TOLARIA_MATH_INLINE:([^@]+)@@/g
|
|
|
|
|
const CODE_FENCE_PREFIXES = ['```', '~~~']
|
|
|
|
|
|
|
|
|
|
interface InlineItem {
|
|
|
|
|
type: string
|
|
|
|
|
text?: string
|
|
|
|
|
props?: Record<string, string>
|
|
|
|
|
content?: unknown
|
|
|
|
|
[key: string]: unknown
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface BlockLike {
|
|
|
|
|
type?: string
|
2026-04-28 16:47:52 +02:00
|
|
|
content?: BlockContent
|
2026-04-26 03:52:10 +02:00
|
|
|
props?: Record<string, string>
|
|
|
|
|
children?: BlockLike[]
|
|
|
|
|
[key: string]: unknown
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:47:52 +02:00
|
|
|
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[]
|
|
|
|
|
|
2026-04-26 03:52:10 +02:00
|
|
|
interface MarkdownSerializer {
|
|
|
|
|
blocksToMarkdownLossy: (blocks: unknown[]) => string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface LatexPayload {
|
|
|
|
|
latex: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EncodedPayload {
|
|
|
|
|
encoded: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TokenRequest extends LatexPayload {
|
|
|
|
|
prefix: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TokenReadRequest {
|
|
|
|
|
text: string
|
|
|
|
|
prefix: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TextPosition {
|
|
|
|
|
text: string
|
|
|
|
|
index: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface InlineMathMatch extends LatexPayload {
|
|
|
|
|
end: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 10:39:00 +02:00
|
|
|
interface CompletedInlineMathMatch extends InlineMathMatch {
|
|
|
|
|
start: number
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 03:52:10 +02:00
|
|
|
interface MarkdownSource {
|
|
|
|
|
markdown: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MarkdownLine {
|
|
|
|
|
line: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MarkdownLines {
|
|
|
|
|
lines: string[]
|
|
|
|
|
start: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MathRenderRequest extends LatexPayload {
|
|
|
|
|
displayMode: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function encodeLatex({ latex }: LatexPayload): string {
|
|
|
|
|
return encodeURIComponent(latex)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decodeLatex({ encoded }: EncodedPayload): string {
|
|
|
|
|
try {
|
|
|
|
|
return decodeURIComponent(encoded)
|
|
|
|
|
} catch {
|
|
|
|
|
return encoded
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mathToken({ prefix, latex }: TokenRequest): string {
|
|
|
|
|
return `${prefix}${encodeLatex({ latex })}${TOKEN_SUFFIX}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readMathToken({ text, prefix }: TokenReadRequest): string | null {
|
|
|
|
|
if (!text.startsWith(prefix) || !text.endsWith(TOKEN_SUFFIX)) return null
|
|
|
|
|
return decodeLatex({ encoded: text.slice(prefix.length, -TOKEN_SUFFIX.length) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isEscaped({ text, index }: TextPosition): boolean {
|
|
|
|
|
let slashCount = 0
|
|
|
|
|
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
|
|
|
|
|
slashCount++
|
|
|
|
|
}
|
|
|
|
|
return slashCount % 2 === 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCodeFence({ text: line }: { text: string }): boolean {
|
|
|
|
|
const trimmed = line.trimStart()
|
|
|
|
|
return CODE_FENCE_PREFIXES.some((prefix) => trimmed.startsWith(prefix))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isSingleDollar({ text, index }: TextPosition): boolean {
|
2026-04-28 16:47:52 +02:00
|
|
|
return text[index] === '$' && text[index - 1] !== '$' && text[index + 1] !== '$'
|
2026-04-26 03:52:10 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isInlineMathEnd(position: TextPosition): boolean {
|
|
|
|
|
return isSingleDollar(position) && !isEscaped(position)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findInlineMathEnd({ text, index: start }: TextPosition): number {
|
|
|
|
|
for (let i = start + 1; i < text.length; i++) {
|
|
|
|
|
if (isInlineMathEnd({ text, index: i })) {
|
|
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isValidInlineLatex({ latex }: LatexPayload): boolean {
|
|
|
|
|
return Boolean(latex.trim()) && !/^\s|\s$/.test(latex)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readInlineMath({ text, index }: TextPosition): InlineMathMatch | null {
|
|
|
|
|
if (!isSingleDollar({ text, index }) || isEscaped({ text, index })) return null
|
|
|
|
|
|
|
|
|
|
const end = findInlineMathEnd({ text, index })
|
|
|
|
|
if (end === -1) return null
|
|
|
|
|
|
|
|
|
|
const latex = text.slice(index + 1, end)
|
|
|
|
|
return isValidInlineLatex({ latex }) ? { latex, end } : null
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 10:39:00 +02:00
|
|
|
function isCompletedInlineMathEnd({ text, index }: TextPosition): boolean {
|
|
|
|
|
return isInlineMathEnd({ text, index }) && text[index - 1] !== '$'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findCompletedInlineMathStart({ text, index: end }: TextPosition): number {
|
|
|
|
|
for (let i = end - 1; i >= 0; i--) {
|
|
|
|
|
if (isInlineMathEnd({ text, index: i }) && text[i - 1] !== '$') {
|
|
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function readCompletedInlineMathAtEnd({ text }: { text: string }): CompletedInlineMathMatch | null {
|
|
|
|
|
const end = text.length - 1
|
|
|
|
|
if (end < 1 || !isCompletedInlineMathEnd({ text, index: end })) return null
|
|
|
|
|
|
|
|
|
|
const start = findCompletedInlineMathStart({ text, index: end })
|
|
|
|
|
if (start === -1) return null
|
|
|
|
|
|
|
|
|
|
const latex = text.slice(start + 1, end)
|
|
|
|
|
return isValidInlineLatex({ latex }) ? { latex, start, end } : null
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 03:52:10 +02:00
|
|
|
function replaceInlineMath({ line }: MarkdownLine): string {
|
|
|
|
|
let result = ''
|
|
|
|
|
let index = 0
|
|
|
|
|
let inCodeSpan = false
|
|
|
|
|
|
|
|
|
|
while (index < line.length) {
|
|
|
|
|
const char = line[index]
|
|
|
|
|
if (char === '`') {
|
|
|
|
|
inCodeSpan = !inCodeSpan
|
|
|
|
|
result += char
|
|
|
|
|
index++
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inlineMath = inCodeSpan ? null : readInlineMath({ text: line, index })
|
|
|
|
|
if (inlineMath) {
|
|
|
|
|
result += mathToken({ prefix: INLINE_TOKEN_PREFIX, latex: inlineMath.latex })
|
|
|
|
|
index = inlineMath.end + 1
|
|
|
|
|
} else {
|
|
|
|
|
result += char
|
|
|
|
|
index++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readSingleLineDisplayMath({ line }: MarkdownLine): InlineMathMatch | null {
|
|
|
|
|
const match = line.trim().match(/^\$\$(.+)\$\$$/)
|
|
|
|
|
const latex = match?.[1]?.trim()
|
|
|
|
|
return latex ? { latex, end: 0 } : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readMultilineDisplayMath({ lines, start }: MarkdownLines): InlineMathMatch | null {
|
|
|
|
|
if (lines[start].trim() !== '$$') return null
|
|
|
|
|
const end = lines.findIndex((line, index) => index > start && line.trim() === '$$')
|
|
|
|
|
return end === -1 ? null : { latex: lines.slice(start + 1, end).join('\n'), end }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readDisplayMath({ lines, start }: MarkdownLines): InlineMathMatch | null {
|
|
|
|
|
const trimmed = lines[start].trim()
|
|
|
|
|
const displayMath = trimmed === '$$'
|
|
|
|
|
? readMultilineDisplayMath({ lines, start })
|
|
|
|
|
: readSingleLineDisplayMath({ line: lines[start] })
|
|
|
|
|
return displayMath && displayMath.end === 0
|
|
|
|
|
? { ...displayMath, end: start }
|
|
|
|
|
: displayMath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function preProcessMathMarkdown({ markdown }: MarkdownSource): string {
|
|
|
|
|
const lines = markdown.split('\n')
|
|
|
|
|
const result: string[] = []
|
|
|
|
|
let inFence = false
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
const line = lines[i]
|
|
|
|
|
if (isCodeFence({ text: line })) {
|
|
|
|
|
inFence = !inFence
|
|
|
|
|
result.push(line)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (inFence) {
|
|
|
|
|
result.push(line)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const displayMath = readDisplayMath({ lines, start: i })
|
|
|
|
|
if (displayMath) {
|
|
|
|
|
result.push(mathToken({ prefix: BLOCK_TOKEN_PREFIX, latex: displayMath.latex }))
|
|
|
|
|
i = displayMath.end
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.push(replaceInlineMath({ line }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result.join('\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expandInlineMath(content: InlineItem[]): InlineItem[] {
|
|
|
|
|
return content.flatMap(expandInlineMathItem)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function expandInlineMathItem(item: InlineItem): InlineItem[] {
|
|
|
|
|
if (item.type !== 'text' || typeof item.text !== 'string') return [item]
|
|
|
|
|
|
|
|
|
|
return item.text
|
|
|
|
|
.split(INLINE_TOKEN_RE)
|
|
|
|
|
.flatMap((part, index) => inlineMathPartToItem({ source: item, part, index }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function inlineMathPartToItem({ source, part, index }: { source: InlineItem; part: string; index: number }): InlineItem[] {
|
|
|
|
|
if (!part) return []
|
|
|
|
|
if (index % 2 === 0) return [{ ...source, text: part }]
|
|
|
|
|
return [{
|
|
|
|
|
type: MATH_INLINE_TYPE,
|
|
|
|
|
props: { latex: decodeLatex({ encoded: part }) },
|
|
|
|
|
content: undefined,
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreInlineMath(content: InlineItem[]): InlineItem[] {
|
|
|
|
|
return content.map((item) => {
|
|
|
|
|
if (item.type !== MATH_INLINE_TYPE || !item.props?.latex) return item
|
|
|
|
|
return { type: 'text', text: `$${item.props.latex}$` }
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 16:47:52 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 03:52:10 +02:00
|
|
|
function injectMathInBlock(block: BlockLike): BlockLike {
|
2026-04-28 16:47:52 +02:00
|
|
|
const content = transformBlockContent(block.content, expandInlineMath)
|
2026-04-26 03:52:10 +02:00
|
|
|
const children = Array.isArray(block.children) ? block.children.map(injectMathInBlock) : block.children
|
2026-04-28 16:47:52 +02:00
|
|
|
const latex = Array.isArray(content) ? readDisplayMathToken(content) : null
|
2026-04-26 03:52:10 +02:00
|
|
|
|
|
|
|
|
if (latex !== null) {
|
|
|
|
|
return buildMathBlock({ block, latex })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { ...block, content, children }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readDisplayMathToken(content: InlineItem[] | undefined): string | null {
|
|
|
|
|
const onlyItem = content?.length === 1 ? content[0] : null
|
|
|
|
|
if (onlyItem?.type !== 'text' || typeof onlyItem.text !== 'string') return null
|
|
|
|
|
return readMathToken({ text: onlyItem.text, prefix: BLOCK_TOKEN_PREFIX })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildMathBlock({ block, latex }: { block: BlockLike } & LatexPayload): BlockLike {
|
|
|
|
|
return {
|
|
|
|
|
...block,
|
|
|
|
|
type: MATH_BLOCK_TYPE,
|
|
|
|
|
props: { ...(block.props ?? {}), latex },
|
|
|
|
|
content: undefined,
|
|
|
|
|
children: [],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreInlineMathInBlock(block: BlockLike): BlockLike {
|
2026-04-28 16:47:52 +02:00
|
|
|
const content = transformBlockContent(block.content, restoreInlineMath)
|
2026-04-26 03:52:10 +02:00
|
|
|
const children = Array.isArray(block.children) ? block.children.map(restoreInlineMathInBlock) : block.children
|
|
|
|
|
return { ...block, content, children }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isMathBlock(block: BlockLike): boolean {
|
|
|
|
|
return block.type === MATH_BLOCK_TYPE && typeof block.props?.latex === 'string'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function displayMathMarkdown({ latex }: LatexPayload): string {
|
|
|
|
|
return `$$\n${latex}\n$$`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function injectMathInBlocks(blocks: unknown[]): unknown[] {
|
|
|
|
|
return (blocks as BlockLike[]).map(injectMathInBlock)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function restoreMathInBlocks(blocks: unknown[]): unknown[] {
|
|
|
|
|
return (blocks as BlockLike[]).map(restoreInlineMathInBlock)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function serializeMathAwareBlocks(editor: MarkdownSerializer, blocks: unknown[]): string {
|
|
|
|
|
const chunks: string[] = []
|
|
|
|
|
let pending: unknown[] = []
|
|
|
|
|
|
|
|
|
|
const flushPending = () => {
|
|
|
|
|
if (pending.length === 0) return
|
|
|
|
|
const markdown = editor.blocksToMarkdownLossy(restoreMathInBlocks(pending)).trimEnd()
|
|
|
|
|
if (markdown) chunks.push(markdown)
|
|
|
|
|
pending = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const block of blocks as BlockLike[]) {
|
|
|
|
|
if (isMathBlock(block)) {
|
|
|
|
|
flushPending()
|
|
|
|
|
chunks.push(displayMathMarkdown({ latex: block.props!.latex }))
|
|
|
|
|
} else {
|
|
|
|
|
pending.push(block)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
flushPending()
|
|
|
|
|
|
|
|
|
|
return chunks.join('\n\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHtml({ text }: { text: string }): string {
|
|
|
|
|
return text
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function renderMathToHtml({ latex, displayMode }: MathRenderRequest): string {
|
|
|
|
|
try {
|
|
|
|
|
return katex.renderToString(latex, {
|
|
|
|
|
displayMode,
|
|
|
|
|
throwOnError: false,
|
|
|
|
|
trust: false,
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
return escapeHtml({ text: latex })
|
|
|
|
|
}
|
|
|
|
|
}
|