Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/katex-label-line-breaks.md
Original file line number Diff line number Diff line change
@@ -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 `<br/>` and hard line breaks in the label source now render on their own row, matching the behavior of labels without math.
71 changes: 71 additions & 0 deletions packages/mermaid/src/rendering-util/createText.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,75 @@ describe('createText', () => {
expect(output.textContent).toEqual(expected);
}
);

it('renders text after <br/> 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<br/>$$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('<div>Input</div>');
expect(rows[1].querySelector('.katex')).not.toBeNull();
});

it('renders each KaTeX line separately when <br/> 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$$<br/>$$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 <br/>.
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('<div>line 0</div>');
expect(rows[1].outerHTML).toEqual('<div>line 1</div>');
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();
});
});
24 changes: 21 additions & 3 deletions packages/mermaid/src/rendering-util/createText.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,6 +30,21 @@ function applyStyle<T extends Element>(
// We assume that nobody will want to create labels larger than 16384 pixels wide
const maxSafeSizeForWidth = 16384;

/**
* Creates a `<foreignObject>` containing the label as HTML.
*
* If the label contains KaTeX (`$$…$$`) delimiters, it is rendered through
* `renderKatexSanitized`, which places each `<br/>`-separated line in its own
* `<div>`; otherwise the label is sanitized as plain HTML.
*
* @param element - The parent group to append the `<foreignObject>` 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 `<foreignObject>` element.
*/
async function addHtmlSpan(
element: D3Selection<SVGGElement>,
node: { label: string; labelStyle: string; isNode: boolean },
Expand All @@ -47,7 +62,7 @@ async function addHtmlSpan(

const div = fo.append<HTMLDivElement>('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');
Expand Down Expand Up @@ -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 `<br/>`.
//Convert them here using the same rule `markdownToHTML` applies to plain text.
const inputForKatex = text.replace(/\\\\/g, '\\').replace(/\n */g, '<br/>');

const node = {
isNode,
Expand Down
Loading