fix: prevent escaping < and & when htmlLabels: false

When creating labels using `htmlLabels: false`, e.g.

```mermaid
---
config:
    htmlLabels: false
---
flowchart TD
    A[2 < 4 && 12 > 14]
```

The SVG node label gets rendered as
`2 &lt; 4 &amp;&amp; 12 &gt; 14`. This is fine for HTML text, where we
use `.innerHTML` to set the value. But for non-HTML Labels, we use
`.textContent`, so we need to pass the unescaped values.

Ideally we would stop calling DOMPurify on this label when
`.textContent` is used, since the content doesn't need to be sanitized,
but adding a quick `&lt;`/`&gt;`/`&amp;`-> `<`/`>`/`&` also works.

I've adapted this commit from https://github.com/mermaid-js/mermaid/pull/6406.

Closes: https://github.com/mermaid-js/mermaid/pull/6406
Co-authored-by: khalil <5alil.landolsi@gmail.com>
This commit is contained in:
Alois Klink
2026-03-02 21:12:06 +09:00
parent 11081847be
commit 57b70b3acf
4 changed files with 85 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'mermaid': patch
---
fix: prevent escaping `<` and `&` when `htmlLabels: false`
user: @aloisklink
user: @BambioGaming
@@ -71,6 +71,15 @@ describe('Flowchart v2', () => {
{ htmlLabels: false, flowchart: { htmlLabels: false } }
);
});
it('5a: angle brackets should be work without html labels', () => {
imgSnapshotTest(
`flowchart TD
a["**Plain text**:\n 5 > 3 && 2 < 4"]
b["\`**Markdown**:<br> 5 > 3 && 2 < 4\`"]
`,
{ htmlLabels: false }
);
});
it('6: should render non-escaped with html labels', () => {
imgSnapshotTest(
`flowchart TD
@@ -1,7 +1,8 @@
import { select } from 'd3';
import { describe, expect, it } from 'vitest';
import { sanitizeText } from '../diagram-api/diagramAPI.js';
import mermaid from '../mermaid.js';
import { replaceIconSubstring } from './createText.js';
import { createText, replaceIconSubstring } from './createText.js';
describe('replaceIconSubstring', () => {
it('converts FontAwesome icon notations to HTML tags', async () => {
@@ -61,3 +62,41 @@ describe('replaceIconSubstring', () => {
expect(output).toContain(expected);
});
});
describe('createText', () => {
beforeEach(() => {
// JSDom has no SVGTSpanElement, so we need to mock getComputedTextLength to avoid errors in createText
const mock = vi.mockObject(window.SVGElement.prototype);
(mock as unknown as SVGTSpanElement).getComputedTextLength = vi.fn(() => 123.456);
});
it.for([
{
useHtmlLabels: false,
markdown: true,
},
{
useHtmlLabels: true,
markdown: true,
},
{
useHtmlLabels: true,
markdown: false,
},
{
useHtmlLabels: true,
markdown: false,
},
])(
'decodes HTML entities in text when useHtmlLabels is $useHtmlLabels and markdown is $markdown',
async ({ useHtmlLabels, markdown }) => {
const input = '5 &gt; 3 &amp;&amp; 2 &lt; 4';
const expected = '5 > 3 && 2 < 4';
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, { useHtmlLabels, markdown });
expect(output.textContent).toEqual(expected);
}
);
});
@@ -177,6 +177,32 @@ function createFormattedText(
}
}
/**
* Our HTML code uses `.innerHTML` to apply the text,
* however our plain text SVG code uses `.textContent` to apply the text,
* which means that HTML entities are not decoded in SVG text.
*
* This means that we need to decode any HTML entities that `sanitizeText` encodes.
*
* TODO: If we're using `.textContent`, we can probably skip sanitization entirely.
*/
function decodeHTMLEntities(text: string): string {
// We only need to decode the few entries that `sanitizeText` encodes.
const regex = /&(amp|lt|gt);/g;
return text.replace(regex, (match, entity) => {
switch (entity) {
case 'amp':
return '&';
case 'lt':
return '<';
case 'gt':
return '>';
default:
return match;
}
});
}
/**
* Updates the text content and styles of the given tspan element based on the
* provided wrappedLine data.
@@ -197,10 +223,10 @@ function updateTextContentAndStyles(
.attr('class', 'text-inner-tspan')
.attr('font-weight', word.type === 'strong' ? 'bold' : 'normal');
if (index === 0) {
innerTspan.text(word.content);
innerTspan.text(decodeHTMLEntities(word.content));
} else {
// TODO: check what joiner to use.
innerTspan.text(' ' + word.content);
innerTspan.text(' ' + decodeHTMLEntities(word.content));
}
});
}