From cf76f7e8dee0ca723422534e154ccfbc7056041b Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Sun, 1 Mar 2026 09:28:09 +0900 Subject: [PATCH 01/10] refactor(types): correct types for `createText.ts` Fix the TypeScript types for `createText.ts`, allowing us to remove the `@ts-nocheck` statement. I've also made sure to get rid of all the `any` types in this file. --- .../mermaid/src/rendering-util/createText.ts | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/mermaid/src/rendering-util/createText.ts b/packages/mermaid/src/rendering-util/createText.ts index 165fa77bd97..5e5e57064e3 100644 --- a/packages/mermaid/src/rendering-util/createText.ts +++ b/packages/mermaid/src/rendering-util/createText.ts @@ -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'; @@ -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( + dom: d3.Selection, + styleFn?: Parameters[1] +) { if (styleFn) { dom.attr('style', styleFn); } @@ -28,10 +30,10 @@ function applyStyle(dom, styleFn) { const maxSafeSizeForWidth = 16384; async function addHtmlSpan( - element, - node, - width, - classes, + element: D3Selection, + node: { label: string; labelStyle: string; isNode: boolean }, + width: number, + classes: string, addBackground = false, // TODO: Make config mandatory config: MermaidConfig = getConfig() @@ -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('xhtml:div'); const sanitizedLabel = hasKatex(node.label) ? await renderKatexSanitized(node.label.replace(common.lineBreakRegex, '\n'), config) : sanitizeText(node.label, config); @@ -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()!; } /** @@ -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, + lineIndex: number, + lineHeight: number +) { return textElement .append('tspan') .attr('class', 'text-outer-tspan') @@ -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, + 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; } @@ -128,7 +138,7 @@ export function computeDimensionOfText( */ function createFormattedText( width: number, - g: any, + g: D3Selection, structuredText: MarkdownWord[][], addBackground = false ) { @@ -153,7 +163,7 @@ function createFormattedText( } } if (addBackground) { - const bbox = textElement.node().getBBox(); + const bbox = textElement.node()!.getBBox(); const padding = 2; bkg .attr('x', bbox.x - padding) @@ -161,9 +171,9 @@ function createFormattedText( .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()!; } } @@ -174,7 +184,10 @@ function createFormattedText( * @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, + wrappedLine: MarkdownWord[] +) { tspan.text(''); wrappedLine.forEach((word, index) => { @@ -227,7 +240,7 @@ 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' export const createText = async ( - el, + el: D3Selection, text = '', { style = '', From 2ef4c3732f97c788a93b7aa69d7b27595cb24dc6 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Sun, 1 Mar 2026 10:11:01 +0900 Subject: [PATCH 02/10] docs: add a TSDoc comment to `createText()` --- packages/mermaid/src/rendering-util/createText.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/mermaid/src/rendering-util/createText.ts b/packages/mermaid/src/rendering-util/createText.ts index 5e5e57064e3..91ac899931e 100644 --- a/packages/mermaid/src/rendering-util/createText.ts +++ b/packages/mermaid/src/rendering-util/createText.ts @@ -239,6 +239,20 @@ 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 `` as HTML. + * - `useHtmlLabels` is `false`, the text will be added as a `` element using `.text` + * + * @param el - The parent SVG `` 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 `` or a `` element depending on the options. + */ export const createText = async ( el: D3Selection, text = '', From 220f9d708d449ca6290e5f6b25969019e2eef481 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 2 Mar 2026 19:30:47 +0900 Subject: [PATCH 03/10] docs: add small TSDoc comment to decodeEntities() I'm not exactly sure why `decodeEntities()` is needed and what it does, but I added a brief comment to make it a bit more clear that it's **NOT** decoding HTML entities, but just decoding what `encodeEntities` is doing. --- packages/mermaid/src/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mermaid/src/utils.ts b/packages/mermaid/src/utils.ts index 0f1bfbecfd1..4a61a993716 100644 --- a/packages/mermaid/src/utils.ts +++ b/packages/mermaid/src/utils.ts @@ -918,6 +918,7 @@ export const encodeEntities = function (text: string): string { }; /** + * Partially reverts encoding done via {@link encodeEntities} * * @param text - text to be decoded * @returns From 11081847bef919916cd924a6b0e944a69a6d4c09 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 2 Mar 2026 19:46:42 +0900 Subject: [PATCH 04/10] refactor: `decodeEntities` if `htmlLabels: false` In the `createText()` function, we are calling `decodeEntities()` on the input if `htmlLabels: true`, but we weren't doing it `htmlLabels: false` was set. I can't seem to find anywhere this is actually impacting diagrams, since we're calling `decodeEntities()` already normally before calling `createText()`, hence why this is just a `refactor` commit. Original-commit: https://github.com/mermaid-js/mermaid/pull/7297 Co-authored-by: chandershekhar22 --- packages/mermaid/src/rendering-util/createText.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mermaid/src/rendering-util/createText.ts b/packages/mermaid/src/rendering-util/createText.ts index 91ac899931e..05b2c9f5cb2 100644 --- a/packages/mermaid/src/rendering-util/createText.ts +++ b/packages/mermaid/src/rendering-util/createText.ts @@ -300,7 +300,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
- const sanitizeBR = text.replace(//g, '
'); + const sanitizeBR = decodeEntities(text.replace(//g, '
')); const structuredText = markdown ? markdownToLines(sanitizeBR.replace('
', '
'), config) : nonMarkdownToLines(sanitizeBR); From 57b70b3acfaaab358ad39004469c2338c6388661 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 2 Mar 2026 21:12:06 +0900 Subject: [PATCH 05/10] 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 < 4 && 12 > 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 `<`/`>`/`&`-> `<`/`>`/`&` 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> --- .changeset/tough-baths-hunt.md | 8 ++++ .../rendering/flowchart-v2.spec.js | 9 ++++ .../src/rendering-util/createText.spec.ts | 41 ++++++++++++++++++- .../mermaid/src/rendering-util/createText.ts | 30 +++++++++++++- 4 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 .changeset/tough-baths-hunt.md diff --git a/.changeset/tough-baths-hunt.md b/.changeset/tough-baths-hunt.md new file mode 100644 index 00000000000..283b548df7e --- /dev/null +++ b/.changeset/tough-baths-hunt.md @@ -0,0 +1,8 @@ +--- +'mermaid': patch +--- + +fix: prevent escaping `<` and `&` when `htmlLabels: false` + +user: @aloisklink +user: @BambioGaming diff --git a/cypress/integration/rendering/flowchart-v2.spec.js b/cypress/integration/rendering/flowchart-v2.spec.js index bcee3d899e3..801a1256a0d 100644 --- a/cypress/integration/rendering/flowchart-v2.spec.js +++ b/cypress/integration/rendering/flowchart-v2.spec.js @@ -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**:
5 > 3 && 2 < 4\`"] + `, + { htmlLabels: false } + ); + }); it('6: should render non-escaped with html labels', () => { imgSnapshotTest( `flowchart TD diff --git a/packages/mermaid/src/rendering-util/createText.spec.ts b/packages/mermaid/src/rendering-util/createText.spec.ts index dd7bc00b644..205cb7897cc 100644 --- a/packages/mermaid/src/rendering-util/createText.spec.ts +++ b/packages/mermaid/src/rendering-util/createText.spec.ts @@ -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 > 3 && 2 < 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); + } + ); +}); diff --git a/packages/mermaid/src/rendering-util/createText.ts b/packages/mermaid/src/rendering-util/createText.ts index 05b2c9f5cb2..bdb777cab73 100644 --- a/packages/mermaid/src/rendering-util/createText.ts +++ b/packages/mermaid/src/rendering-util/createText.ts @@ -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)); } }); } From 9767066920ff83410f16d0adc019ef6b4caae51a Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 2 Mar 2026 22:31:54 +0900 Subject: [PATCH 06/10] refactor: handle `node.padding==undefined` TypeScript wasn't catching these earlier, since `bbox` was `any`, but now that it's been typed correctly, all the `node.padding` uses in expressions with `bbox` are throwing TypeScript errors. --- .../rendering-util/rendering-elements/shapes/bowTieRect.ts | 4 ++-- .../src/rendering-util/rendering-elements/shapes/card.ts | 4 ++-- .../rendering-util/rendering-elements/shapes/cylinder.ts | 4 ++-- .../rendering-util/rendering-elements/shapes/dividedRect.ts | 4 ++-- .../rendering-util/rendering-elements/shapes/document.ts | 4 ++-- .../src/rendering-util/rendering-elements/shapes/hexagon.ts | 4 ++-- .../rendering-util/rendering-elements/shapes/question.ts | 4 ++-- .../src/rendering-util/rendering-elements/shapes/stadium.ts | 4 ++-- .../rendering-util/rendering-elements/shapes/subroutine.ts | 6 +++--- .../src/rendering-util/rendering-elements/shapes/text.ts | 4 ++-- .../rendering-util/rendering-elements/shapes/trapezoid.ts | 4 ++-- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/bowTieRect.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/bowTieRect.ts index d4b41103f8b..9429c020dca 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/bowTieRect.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/bowTieRect.ts @@ -75,8 +75,8 @@ export async function bowTieRect(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); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/card.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/card.ts index 4aaf9222a7a..0f004944dfa 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/card.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/card.ts @@ -19,9 +19,9 @@ export async function card(parent: D3Selection, 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; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/cylinder.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/cylinder.ts index 9d2cd63f6d7..f09653bc32e 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/cylinder.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/cylinder.ts @@ -54,10 +54,10 @@ export async function cylinder(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 | D3Selection; const { cssStyles } = node; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/dividedRect.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/dividedRect.ts index 77df5815541..a25ccd39b57 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/dividedRect.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/dividedRect.ts @@ -12,8 +12,8 @@ export async function dividedRectangle( 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; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/document.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/document.ts index d5ea3aa7620..5d59b463f71 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/document.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/document.ts @@ -54,10 +54,10 @@ export async function cylinder(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 | D3Selection; const { cssStyles } = node; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/hexagon.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/hexagon.ts index 52a4397a2f6..910cb7b6c01 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/hexagon.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/hexagon.ts @@ -30,9 +30,9 @@ export async function hexagon(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 }, diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts index 87adc4814b7..988d0794782 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/question.ts @@ -22,8 +22,8 @@ export async function question(parent: D3Selection node.labelStyle = labelStyles; const { shapeSvg, bbox } = 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 s = w + h; const adjustment = 0.5; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/stadium.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/stadium.ts index 117176b197d..32b00191180 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/stadium.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/stadium.ts @@ -61,8 +61,8 @@ export async function stadium(parent: D3Selection< node.labelStyle = labelStyles; const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node)); - const h = bbox.height + node.padding; - const w = bbox.width + h / 4 + node.padding; + const h = bbox.height + (node.padding ?? 0); + const w = bbox.width + h / 4 + (node.padding ?? 0); const radius = h / 2; const { cssStyles } = node; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/subroutine.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/subroutine.ts index ab24af29c87..746e684b837 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/subroutine.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/subroutine.ts @@ -37,9 +37,9 @@ export async function subroutine(parent: D3Selecti const { labelStyles, nodeStyles } = styles2String(node); node.labelStyle = labelStyles; const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node)); - const halfPadding = (node?.padding || 0) / 2; - const w = bbox.width + node.padding; - const h = bbox.height + node.padding; + const halfPadding = (node.padding ?? 0) / 2; + const w = bbox.width + (node.padding ?? 0); + const h = bbox.height + (node.padding ?? 0); const x = -bbox.width / 2 - halfPadding; const y = -bbox.height / 2 - halfPadding; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/text.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/text.ts index cf253fd1750..417b7b9409d 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/text.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/text.ts @@ -10,8 +10,8 @@ export async function text(parent: D3Selection, const { shapeSvg, bbox } = await labelHelper(parent, node, getNodeClasses(node)); - const totalWidth = Math.max(bbox.width + node.padding, node?.width || 0); - const totalHeight = Math.max(bbox.height + node.padding, node?.height || 0); + const totalWidth = Math.max(bbox.width + (node.padding ?? 0), node?.width || 0); + const totalHeight = Math.max(bbox.height + (node.padding ?? 0), node?.height || 0); const x = -totalWidth / 2; const y = -totalHeight / 2; diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/trapezoid.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/trapezoid.ts index d0228d55bd8..1a0efee9a27 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/trapezoid.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/trapezoid.ts @@ -26,8 +26,8 @@ export async function trapezoid(parent: D3Selectio node.labelStyle = labelStyles; const { shapeSvg, bbox } = 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 points = [ { x: (-3 * h) / 6, y: 0 }, { x: w + (3 * h) / 6, y: 0 }, From 026e65dd9a5739f7593cb656c137df1eee16c082 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 2 Mar 2026 23:06:13 +0900 Subject: [PATCH 07/10] refactor(types): assert `.textContent` is non-null According to MDN, this is only `null` if the `Node` is a `Document`. I'm not 100% sure why TypeScript is throwing an error on this, since my VS Code shows that `Element.textContent` will never be `null`, since `Document` can never be an `Element`, but we do have other non-null assertions for this scattered over the place. See: https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent --- packages/mermaid/src/diagrams/class/shapeUtil.ts | 2 +- .../src/rendering-util/rendering-elements/shapes/erBox.ts | 6 +++--- .../rendering-elements/shapes/requirementBox.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/mermaid/src/diagrams/class/shapeUtil.ts b/packages/mermaid/src/diagrams/class/shapeUtil.ts index 94c8f817aef..967b91b2b66 100644 --- a/packages/mermaid/src/diagrams/class/shapeUtil.ts +++ b/packages/mermaid/src/diagrams/class/shapeUtil.ts @@ -146,7 +146,7 @@ async function addText( numberOfLines = text.children.length; const textChild = text.children[0]; - if (text.textContent === '' || text.textContent.includes('>')) { + if (text.textContent === '' || text.textContent!.includes('>')) { textChild.textContent = textContent[0] + textContent.substring(1).replaceAll('>', '>').replaceAll('<', '<').trim(); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/erBox.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/erBox.ts index 9dd4c90e2b2..4d35f06200f 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/erBox.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/erBox.ts @@ -350,12 +350,12 @@ async function addText( ); // Undo work around now that text passed through correctly if (labelText.includes('<') || labelText.includes('>')) { - let child = text.children[0]; - child.textContent = child.textContent.replaceAll('<', '<').replaceAll('>', '>'); + let child: Element | ChildNode = text.children[0]; + child.textContent = child.textContent!.replaceAll('<', '<').replaceAll('>', '>'); while (child.childNodes[0]) { child = child.childNodes[0]; // Replace its text content - child.textContent = child.textContent.replaceAll('<', '<').replaceAll('>', '>'); + child.textContent = child.textContent!.replaceAll('<', '<').replaceAll('>', '>'); } } diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts index ed764f5eecc..684018f1708 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts @@ -198,7 +198,7 @@ async function addText( if (!useHtmlLabels) { const textChild = text.children[0]; for (const child of textChild.children) { - child.textContent = child.textContent.replaceAll('>', '>').replaceAll('<', '<'); + child.textContent = child.textContent!.replaceAll('>', '>').replaceAll('<', '<'); if (style) { child.setAttribute('style', style); } From dafc6dd377adc3f66e4b5917a00218dcec5690e0 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 2 Mar 2026 23:10:28 +0900 Subject: [PATCH 08/10] refactor(types): type known `
` elements We know that the output of `createText` when `htmlLabels: true` is set is a `` that contains a `
`, so this can fix our TypeScript errors. --- .../src/rendering-util/rendering-elements/shapes/erBox.ts | 2 +- .../src/rendering-util/rendering-elements/shapes/util.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/erBox.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/erBox.ts index 4d35f06200f..adecf2e0c00 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/erBox.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/erBox.ts @@ -361,7 +361,7 @@ async function addText( 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(); diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts index 568a08c39cc..d06df9d97b1 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/util.ts @@ -63,7 +63,7 @@ export const labelHelper = async ( const halfPadding = (node?.padding ?? 0) / 2; if (useHtmlLabels) { - const div = text.children[0]; + const div = text.children[0] as HTMLDivElement; const dv = select(text); // if there are images, need to wait for them to load before getting the bounding box From 461d2eeec09025c550062bcaedf81a0cd4ec988b Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Tue, 3 Mar 2026 00:59:50 +0900 Subject: [PATCH 09/10] test: fix duplicate `createText` test case See: https://github.com/mermaid-js/mermaid/pull/7436#pullrequestreview-3876840611 --- packages/mermaid/src/rendering-util/createText.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mermaid/src/rendering-util/createText.spec.ts b/packages/mermaid/src/rendering-util/createText.spec.ts index 205cb7897cc..0a46222682d 100644 --- a/packages/mermaid/src/rendering-util/createText.spec.ts +++ b/packages/mermaid/src/rendering-util/createText.spec.ts @@ -80,7 +80,7 @@ describe('createText', () => { markdown: true, }, { - useHtmlLabels: true, + useHtmlLabels: false, markdown: false, }, { From 4037351f8a66aea1913ccaec63fd1c1dc9fd383b Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Tue, 3 Mar 2026 01:53:16 +0900 Subject: [PATCH 10/10] refactor: remove unused requirement box unescaping Replacing `<` and `>` in requirement shapes is no longer necessary, now that `createText` does it automatically when `htmlLabels: false`. --- .../rendering-util/rendering-elements/shapes/requirementBox.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts index 684018f1708..ed6c43b9ba8 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts @@ -198,12 +198,11 @@ async function addText( if (!useHtmlLabels) { const textChild = text.children[0]; for (const child of textChild.children) { - child.textContent = child.textContent!.replaceAll('>', '>').replaceAll('<', '<'); if (style) { child.setAttribute('style', style); } } - // Get the bounding box after the text update + // Get the bounding box after the style update bbox = text.getBBox(); // Add extra height so it is similar to the html labels bbox.height += 6;