Skip to content
Merged
8 changes: 8 additions & 0 deletions .changeset/tough-baths-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'mermaid': patch
---

fix: prevent escaping `<` and `&` when `htmlLabels: false`

user: @aloisklink
user: @BambioGaming
9 changes: 9 additions & 0 deletions cypress/integration/rendering/flowchart-v2.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/mermaid/src/diagrams/class/shapeUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ async function addText<T extends SVGGraphicsElement>(
numberOfLines = text.children.length;

const textChild = text.children[0];
if (text.textContent === '' || text.textContent.includes('&gt')) {
if (text.textContent === '' || text.textContent!.includes('&gt')) {
textChild.textContent =
textContent[0] +
textContent.substring(1).replaceAll('&gt;', '>').replaceAll('&lt;', '<').trim();
Expand Down
41 changes: 40 additions & 1 deletion packages/mermaid/src/rendering-util/createText.spec.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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: false,
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);
}
);
});
99 changes: 76 additions & 23 deletions packages/mermaid/src/rendering-util/createText.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// @ts-nocheck TODO: Fix types
import { select } from 'd3';
import type { MermaidConfig } from '../config.type.js';
import type { SVGGroup } from '../diagram-api/types.js';
Expand All @@ -17,8 +15,12 @@ import { getIconSVG, isIconAvailable } from './icons.js';
import { splitLineToFitWidth } from './splitText.js';
import type { MarkdownLine, MarkdownWord } from './types.js';
import { getConfig } from '../config.js';
import type { D3Selection } from '../types.js';

function applyStyle(dom, styleFn) {
function applyStyle<T extends Element>(
dom: d3.Selection<T, unknown, Element | null, unknown>,
styleFn?: Parameters<typeof dom.attr>[1]
) {
if (styleFn) {
dom.attr('style', styleFn);
}
Expand All @@ -28,10 +30,10 @@ function applyStyle(dom, styleFn) {
const maxSafeSizeForWidth = 16384;

async function addHtmlSpan(
element,
node,
width,
classes,
element: D3Selection<SVGGElement>,
node: { label: string; labelStyle: string; isNode: boolean },
width: number,
classes: string,
addBackground = false,
// TODO: Make config mandatory
config: MermaidConfig = getConfig()
Expand All @@ -42,7 +44,7 @@ async function addHtmlSpan(
fo.attr('width', `${Math.min(10 * width, maxSafeSizeForWidth)}px`);
fo.attr('height', `${Math.min(10 * width, maxSafeSizeForWidth)}px`);

const div = fo.append('xhtml:div');
const div = fo.append<HTMLDivElement>('xhtml:div');
const sanitizedLabel = hasKatex(node.label)
? await renderKatexSanitized(node.label.replace(common.lineBreakRegex, '\n'), config)
: sanitizeText(node.label, config);
Expand All @@ -65,15 +67,15 @@ async function addHtmlSpan(
div.attr('class', 'labelBkg');
}

let bbox = div.node().getBoundingClientRect();
let bbox = div.node()!.getBoundingClientRect();
if (bbox.width === width) {
div.style('display', 'table');
div.style('white-space', 'break-spaces');
div.style('width', width + 'px');
bbox = div.node().getBoundingClientRect();
bbox = div.node()!.getBoundingClientRect();
}

return fo.node();
return fo.node()!;
}

/**
Expand All @@ -84,7 +86,11 @@ async function addHtmlSpan(
* @param lineHeight - The line height value for the text.
* @returns The created tspan element.
*/
function createTspan(textElement: any, lineIndex: number, lineHeight: number) {
function createTspan(
textElement: D3Selection<SVGTextElement>,
lineIndex: number,
lineHeight: number
) {
return textElement
.append('tspan')
.attr('class', 'text-outer-tspan')
Expand All @@ -93,11 +99,15 @@ function createTspan(textElement: any, lineIndex: number, lineHeight: number) {
.attr('dy', lineHeight + 'em');
}

function computeWidthOfText(parentNode: any, lineHeight: number, line: MarkdownLine): number {
function computeWidthOfText(
parentNode: D3Selection<SVGGElement>,
lineHeight: number,
line: MarkdownLine
): number {
const testElement = parentNode.append('text');
const testSpan = createTspan(testElement, 1, lineHeight);
updateTextContentAndStyles(testSpan, line);
const textLength = testSpan.node().getComputedTextLength();
const textLength = testSpan.node()!.getComputedTextLength();
testElement.remove();
return textLength;
}
Expand Down Expand Up @@ -128,7 +138,7 @@ export function computeDimensionOfText(
*/
function createFormattedText(
width: number,
g: any,
g: D3Selection<SVGGElement>,
structuredText: MarkdownWord[][],
addBackground = false
) {
Expand All @@ -153,28 +163,57 @@ function createFormattedText(
}
}
if (addBackground) {
const bbox = textElement.node().getBBox();
const bbox = textElement.node()!.getBBox();
const padding = 2;
bkg
.attr('x', bbox.x - padding)
.attr('y', bbox.y - padding)
.attr('width', bbox.width + 2 * padding)
.attr('height', bbox.height + 2 * padding);

return labelGroup.node();
return labelGroup.node()!;
} else {
return textElement.node();
return textElement.node()!;
}
}

/**
* 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.
*
* @param tspan - The tspan element to update.
* @param wrappedLine - The line data to apply to the tspan element.
*/
function updateTextContentAndStyles(tspan: any, wrappedLine: MarkdownWord[]) {
function updateTextContentAndStyles(
tspan: D3Selection<SVGTSpanElement>,
wrappedLine: MarkdownWord[]
) {
tspan.text('');

wrappedLine.forEach((word, index) => {
Expand All @@ -184,10 +223,10 @@ function updateTextContentAndStyles(tspan: any, wrappedLine: MarkdownWord[]) {
.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));
}
});
}
Expand Down Expand Up @@ -226,8 +265,22 @@ export async function replaceIconSubstring(

// Note when using from flowcharts converting the API isNode means classes should be set accordingly. When using htmlLabels => to set classes to 'nodeLabel' when isNode=true otherwise 'edgeLabel'
// When not using htmlLabels => to set classes to 'title-row' when isTitle=true otherwise 'title-row'
/**
* Creates a text element within the given SVG group element.
*
* If `markdown` is `true`, basic markdown syntax will be processed.
* Otherwise, if:
* - `useHtmlLabels` is `true`, the text will be sanitized and set in `<foreignObject>` as HTML.
* - `useHtmlLabels` is `false`, the text will be added as a `<text>` element using `.text`
*
* @param el - The parent SVG `<g>` element to append the text element to.
* @param text - The text content to be displayed.
* @param options - Optional options
* @param config - Mermaid configuration object
* @returns The created text element, either a `<foreignObject>` or a `<text>` element depending on the options.
*/
export const createText = async (
el,
el: D3Selection<SVGGElement>,
text = '',
{
style = '',
Expand Down Expand Up @@ -273,7 +326,7 @@ export const createText = async (
return vertexNode;
} else {
//sometimes the user might add br tags with 1 or more spaces in between, so we need to replace them with <br/>
const sanitizeBR = text.replace(/<br\s*\/?>/g, '<br/>');
const sanitizeBR = decodeEntities(text.replace(/<br\s*\/?>/g, '<br/>'));
const structuredText = markdown
? markdownToLines(sanitizeBR.replace('<br>', '<br/>'), config)
: nonMarkdownToLines(sanitizeBR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ export async function bowTieRect<T extends SVGGraphicsElement>(parent: D3Selecti
const { labelStyles, nodeStyles } = styles2String(node);
node.labelStyle = labelStyles;
const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node));
const w = bbox.width + node.padding + 20;
const h = bbox.height + node.padding;
const w = bbox.width + (node.padding ?? 0) + 20;
const h = bbox.height + (node.padding ?? 0);

const ry = h / 2;
const rx = ry / (2.5 + h / 50);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export async function card<T extends SVGGraphicsElement>(parent: D3Selection<T>,
node.labelStyle = labelStyles;
const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node));

const h = bbox.height + node.padding;
const h = bbox.height + (node.padding ?? 0);
const padding = 12;
const w = bbox.width + node.padding + padding;
const w = bbox.width + (node.padding ?? 0) + padding;
const left = 0;
const right = w;
const top = -h;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ export async function cylinder<T extends SVGGraphicsElement>(parent: D3Selection
const { labelStyles, nodeStyles } = styles2String(node);
node.labelStyle = labelStyles;
const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node));
const w = Math.max(bbox.width + node.padding, node.width ?? 0);
const w = Math.max(bbox.width + (node.padding ?? 0), node.width ?? 0);
const rx = w / 2;
const ry = rx / (2.5 + w / 50);
const h = Math.max(bbox.height + ry + node.padding, node.height ?? 0);
const h = Math.max(bbox.height + ry + (node.padding ?? 0), node.height ?? 0);

let cylinder: D3Selection<SVGPathElement> | D3Selection<SVGGElement>;
const { cssStyles } = node;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export async function dividedRectangle<T extends SVGGraphicsElement>(
const { labelStyles, nodeStyles } = styles2String(node);
node.labelStyle = labelStyles;
const { shapeSvg, bbox, label } = await labelHelper(parent, node, getNodeClasses(node));
const w = bbox.width + node.padding;
const h = bbox.height + node.padding;
const w = bbox.width + (node.padding ?? 0);
const h = bbox.height + (node.padding ?? 0);
const rectOffset = h * 0.2;

const x = -w / 2;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ export async function cylinder<T extends SVGGraphicsElement>(parent: D3Selection
const { labelStyles, nodeStyles } = styles2String(node);
node.labelStyle = labelStyles;
const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node));
const w = bbox.width + node.padding;
const w = bbox.width + (node.padding ?? 0);
const rx = w / 2;
const ry = rx / (2.5 + w / 50);
const h = bbox.height + ry + node.padding;
const h = bbox.height + ry + (node.padding ?? 0);

let cylinder: D3Selection<SVGPathElement> | D3Selection<SVGGElement>;
const { cssStyles } = node;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,18 +350,18 @@ async function addText<T extends SVGGraphicsElement>(
);
// Undo work around now that text passed through correctly
if (labelText.includes('&lt;') || labelText.includes('&gt;')) {
let child = text.children[0];
child.textContent = child.textContent.replaceAll('&lt;', '<').replaceAll('&gt;', '>');
let child: Element | ChildNode = text.children[0];
child.textContent = child.textContent!.replaceAll('&lt;', '<').replaceAll('&gt;', '>');
while (child.childNodes[0]) {
child = child.childNodes[0];
// Replace its text content
child.textContent = child.textContent.replaceAll('&lt;', '<').replaceAll('&gt;', '>');
child.textContent = child.textContent!.replaceAll('&lt;', '<').replaceAll('&gt;', '>');
}
}

let bbox = text.getBBox();
if (evaluate(config.htmlLabels)) {
const div = text.children[0];
const div = text.children[0] as HTMLDivElement;
div.style.textAlign = 'start';
const dv = select(text);
bbox = div.getBoundingClientRect();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export async function hexagon<T extends SVGGraphicsElement>(parent: D3Selection<
const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node));

const f = 4;
const h = bbox.height + node.padding;
const h = bbox.height + (node.padding ?? 0);
const m = h / f;
const w = bbox.width + 2 * m + node.padding;
const w = bbox.width + 2 * m + (node.padding ?? 0);
const points = [
{ x: m, y: 0 },
{ x: w - m, y: 0 },
Expand Down
Loading
Loading