diff --git a/.changeset/katex-label-line-breaks.md b/.changeset/katex-label-line-breaks.md new file mode 100644 index 00000000000..48294f39fe8 --- /dev/null +++ b/.changeset/katex-label-line-breaks.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: line breaks are no longer ignored in flowchart node and edge labels that contain `$$…$$` KaTeX math expressions. Both `
` and hard line breaks in the label source now render on their own row, matching the behavior of labels without math. diff --git a/packages/mermaid/src/rendering-util/createText.spec.ts b/packages/mermaid/src/rendering-util/createText.spec.ts index 0a46222682d..6e385b15fcd 100644 --- a/packages/mermaid/src/rendering-util/createText.spec.ts +++ b/packages/mermaid/src/rendering-util/createText.spec.ts @@ -99,4 +99,75 @@ describe('createText', () => { expect(output.textContent).toEqual(expected); } ); + + it('renders text after
on a new line in labels containing KaTeX math', async () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const svgGroup = svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'g')); + const output = await createText( + select(svgGroup), + 'Input
$$x$$', + { useHtmlLabels: true }, + { forceLegacyMathML: true, securityLevel: 'strict' } + ); + + const span = output.querySelector('span.nodeLabel'); + const rows = [...(span?.children ?? [])]; + expect(rows).toHaveLength(2); + expect(rows[0].outerHTML).toEqual('
Input
'); + expect(rows[1].querySelector('.katex')).not.toBeNull(); + }); + + it('renders each KaTeX line separately when
separates two math expressions', async () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const svgGroup = svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'g')); + const output = await createText( + select(svgGroup), + '$$x$$
$$f(x)$$', + { useHtmlLabels: true }, + { forceLegacyMathML: true, securityLevel: 'strict' } + ); + + const span = output.querySelector('span.nodeLabel'); + const rows = [...(span?.children ?? [])]; + expect(rows).toHaveLength(2); + expect(rows[0].querySelector('.katex')).not.toBeNull(); + expect(rows[1].querySelector('.katex')).not.toBeNull(); + }); + + it('renders hard line breaks on separate lines in labels containing KaTeX math', async () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const svgGroup = svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'g')); + // The flowchart lexer passes node text through verbatim, so a hard-wrapped label + // reaches createText with literal newlines rather than
. + const output = await createText( + select(svgGroup), + 'line 0\n line 1\n $$x=42$$', + { useHtmlLabels: true }, + { forceLegacyMathML: true, securityLevel: 'strict' } + ); + + const span = output.querySelector('span.nodeLabel'); + const rows = [...(span?.children ?? [])]; + expect(rows).toHaveLength(3); + expect(rows[0].outerHTML).toEqual('
line 0
'); + expect(rows[1].outerHTML).toEqual('
line 1
'); + expect(rows[2].querySelector('.katex')).not.toBeNull(); + }); + + it('keeps math intact when a hard line break follows a math expression', async () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const svgGroup = svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'g')); + const output = await createText( + select(svgGroup), + '$$a_1 + b_2$$\n$$x * y$$', + { useHtmlLabels: true }, + { forceLegacyMathML: true, securityLevel: 'strict' } + ); + + const span = output.querySelector('span.nodeLabel'); + const rows = [...(span?.children ?? [])]; + expect(rows).toHaveLength(2); + expect(rows[0].querySelector('.katex')).not.toBeNull(); + expect(rows[1].querySelector('.katex')).not.toBeNull(); + }); }); diff --git a/packages/mermaid/src/rendering-util/createText.ts b/packages/mermaid/src/rendering-util/createText.ts index a9942d81376..bee7f5261ab 100644 --- a/packages/mermaid/src/rendering-util/createText.ts +++ b/packages/mermaid/src/rendering-util/createText.ts @@ -1,7 +1,7 @@ import { select } from 'd3'; import type { MermaidConfig } from '../config.type.js'; import type { SVGGroup } from '../diagram-api/types.js'; -import common, { hasKatex, renderKatexSanitized, sanitizeText } from '../diagrams/common/common.js'; +import { hasKatex, renderKatexSanitized, sanitizeText } from '../diagrams/common/common.js'; import type { D3TSpanElement, D3TextElement } from '../diagrams/common/commonTypes.js'; import { log } from '../logger.js'; import { profiler } from '../profiler.js'; @@ -30,6 +30,21 @@ function applyStyle( // We assume that nobody will want to create labels larger than 16384 pixels wide const maxSafeSizeForWidth = 16384; +/** + * Creates a `` containing the label as HTML. + * + * If the label contains KaTeX (`$$…$$`) delimiters, it is rendered through + * `renderKatexSanitized`, which places each `
`-separated line in its own + * `
`; otherwise the label is sanitized as plain HTML. + * + * @param element - The parent group to append the `` to. + * @param node - The label text, its style, and whether it belongs to a node or an edge. + * @param width - The maximum width of the label. + * @param classes - Additional CSS classes for the label span. + * @param addBackground - Whether to add a background class to the containing div. + * @param config - Mermaid configuration object. + * @returns The created `` element. + */ async function addHtmlSpan( element: D3Selection, node: { label: string; labelStyle: string; isNode: boolean }, @@ -47,7 +62,7 @@ async function addHtmlSpan( const div = fo.append('xhtml:div'); const sanitizedLabel = hasKatex(node.label) - ? await renderKatexSanitized(node.label.replace(common.lineBreakRegex, '\n'), config) + ? await renderKatexSanitized(node.label, config) : sanitizeText(node.label, config); const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel'; const span = div.append('span'); @@ -334,7 +349,10 @@ export const createText = async ( const decodedReplacedText = await replaceIconSubstring(decodeEntities(htmlText), config); //for Katex the text could contain escaped characters, \\relax that should be transformed to \relax - const inputForKatex = text.replace(/\\\\/g, '\\'); + //KaTeX labels bypass the markdown/non-markdown conversion above, so hard line breaks in the + //source label would never reach `renderKatexSanitized`, which splits its input on `
`. + //Convert them here using the same rule `markdownToHTML` applies to plain text. + const inputForKatex = text.replace(/\\\\/g, '\\').replace(/\n */g, '
'); const node = { isNode,