Compare commits

...

9 Commits

Author SHA1 Message Date
darshanr0107
dabc220ed2 fix: use new frontmatter in test case
on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
2025-08-20 18:12:44 +05:30
darshanr0107
d2c5cbd408 Merge branch 'develop' into 4743-timeline-html-formatting 2025-07-11 17:36:16 +05:30
darshanr0107
20467bcbe6 resolve visual regression issues and fix broken diagram in PR 2025-07-11 13:53:17 +05:30
darshanr0107
dd213fe86c Merge branch 'develop' into 4743-timeline-html-formatting 2025-07-01 12:34:09 +05:30
darshanr0107
800f23fc01 Merge branch 'develop' into 4743-timeline-html-formatting 2025-06-25 13:09:25 +05:30
darshanr0107
67aa1a4dc1 Merge branch 'develop' into 4743-timeline-html-formatting 2025-06-20 11:50:35 +05:30
darshanr0107
c1bcdcfbad added changeset 2025-06-20 11:49:37 +05:30
darshanr0107
f528e2daa4 fix multicharacter sanitization 2025-06-19 12:59:51 +05:30
darshanr0107
a867842f32 fix HTML formatting in timeline diagrams 2025-06-19 12:05:22 +05:30
4 changed files with 170 additions and 41 deletions

View File

@@ -0,0 +1,5 @@
---
'mermaid': major
---
Currently, HTML tags such as <em>, <strong>, <sup>, <a>, <ul>, and <li> are supported in Flowchart and Class diagram labels but not in Timeline diagrams. This change introduces support for basic HTML formatting in Timeline labels, enabling richer text formatting and better usability for multi-line content like descriptions, footnotes, and styled annotations

View File

@@ -225,4 +225,24 @@ timeline
{} {}
); );
}); });
it('13: should render markdown htmlLabels', () => {
imgSnapshotTest(
`---
config:
theme: forest
---
timeline
title Timeline of Industrial Revolution
section 17th-20th century
Industry 1.0 : Machinery, Water power, Steam <br>power
Industry 2.0 : Electricity, <strong>Internal combustion engine </strong>, Mass production
Industry 3.0 : Electronics, Computers, Automation
section 21st century
Industry 4.0 : Internet, Robotics, Internet of Things
Industry 5.0 : Artificial intelligence, Big data, 3D printing
`,
{}
);
});
}); });

View File

@@ -1,6 +1,91 @@
import { arc as d3arc, select } from 'd3'; import { arc as d3arc, select } from 'd3';
import { createText } from '../../rendering-util/createText.js';
import DOMPurify from 'dompurify';
const MAX_SECTIONS = 12; const MAX_SECTIONS = 12;
/**
* Process HTML content in node descriptions
* @param {object} textElem - The SVG element to append text to
* @param {object} node - The node object containing description and dimensions
* @param {object} conf - Configuration object
* @param {boolean} isVirtual - Whether this is for virtual height calculation
*/
const processHtmlContent = async function (textElem, node, conf, isVirtual = false) {
// Create temporary text to get initial dimensions
const sanitizedHtml = DOMPurify.sanitize(node.descr, { ALLOWED_TAGS: [] });
const tempText = textElem
.append('text')
.text(sanitizedHtml)
.attr('dy', '1em')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
if (!isVirtual) {
tempText.attr('visibility', 'hidden');
}
const bbox = tempText.node().getBBox();
tempText.remove();
// Create the actual HTML content
const textObj = await createText(
textElem,
node.descr,
{
useHtmlLabels: true,
width: node.width,
classes: 'timeline-node-label',
isNode: true,
},
conf
);
if (!isVirtual) {
select(textObj).attr('transform', 'translate(0, 0)');
}
// Process the foreign object
const foreignObject = textElem.select('foreignObject');
if (foreignObject.node()) {
foreignObject.attr('width', `${10 * node.width}px`).attr('height', `${10 * node.width}px`);
const div = foreignObject.select('div');
if (div.node()) {
div
.style('display', 'table-cell')
.style('white-space', 'nowrap')
.style('line-height', '1.5')
.style('max-width', node.width + 'px')
.style('text-align', 'center');
let divBBox = div.node().getBoundingClientRect();
if (divBBox.width === node.width) {
div
.style('display', 'table')
.style('white-space', 'break-spaces')
.style('width', node.width + 'px');
divBBox = div.node().getBoundingClientRect();
}
foreignObject.attr('width', node.width).attr('height', divBBox.height);
if (!isVirtual) {
foreignObject.attr('x', -node.width / 2).attr('y', 3);
div.style('width', node.width + 'px');
}
bbox.height = divBBox.height;
}
}
return bbox;
};
export const drawRect = function (elem, rectData) { export const drawRect = function (elem, rectData) {
const rectElem = elem.append('rect'); const rectElem = elem.append('rect');
rectElem.attr('x', rectData.x); rectElem.attr('x', rectData.x);
@@ -409,6 +494,9 @@ const _drawTextCandidateFunc = (function () {
.style('display', 'table-cell') .style('display', 'table-cell')
.style('text-align', 'center') .style('text-align', 'center')
.style('vertical-align', 'middle') .style('vertical-align', 'middle')
.style('word-wrap', 'break-word')
.style('overflow-wrap', 'break-word')
.style('white-space', 'normal')
.text(content); .text(content);
byTspan(content, body, x, y, width, height, textAttrs, conf); byTspan(content, body, x, y, width, height, textAttrs, conf);
@@ -493,7 +581,7 @@ function wrap(text, width) {
}); });
} }
export const drawNode = function (elem, node, fullSection, conf) { export const drawNode = async function (elem, node, fullSection, conf) {
const section = (fullSection % MAX_SECTIONS) - 1; const section = (fullSection % MAX_SECTIONS) - 1;
const nodeElem = elem.append('g'); const nodeElem = elem.append('g');
node.section = section; node.section = section;
@@ -506,19 +594,28 @@ export const drawNode = function (elem, node, fullSection, conf) {
// Create the wrapped text element // Create the wrapped text element
const textElem = nodeElem.append('g'); const textElem = nodeElem.append('g');
const txt = textElem const hasHtml = /<[a-z][\S\s]*>/i.test(node.descr);
.append('text')
.text(node.descr) if (hasHtml) {
.attr('dy', '1em') const bbox = await processHtmlContent(textElem, node, conf, false);
.attr('alignment-baseline', 'middle') node.height = bbox.height + node.padding;
.attr('dominant-baseline', 'middle') node.height = Math.max(node.height, node.maxHeight);
.attr('text-anchor', 'middle') node.width = node.width + 2 * node.padding;
.call(wrap, node.width); } else {
const bbox = txt.node().getBBox(); const txt = textElem
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize; .append('text')
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding; .text(node.descr)
node.height = Math.max(node.height, node.maxHeight); .attr('dy', '1em')
node.width = node.width + 2 * node.padding; .attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
const bbox = txt.node().getBBox();
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
node.height = bbox.height + fontSize * 1.1 * 0.5 + node.padding;
node.height = Math.max(node.height, node.maxHeight);
node.width = node.width + 2 * node.padding;
}
textElem.attr('transform', 'translate(' + node.width / 2 + ', ' + node.padding / 2 + ')'); textElem.attr('transform', 'translate(' + node.width / 2 + ', ' + node.padding / 2 + ')');
@@ -528,17 +625,25 @@ export const drawNode = function (elem, node, fullSection, conf) {
return node; return node;
}; };
export const getVirtualNodeHeight = function (elem, node, conf) { export const getVirtualNodeHeight = async function (elem, node, conf) {
const textElem = elem.append('g'); const textElem = elem.append('g');
const txt = textElem
.append('text') const hasHtml = /<[a-z][\S\s]*>/i.test(node.descr);
.text(node.descr)
.attr('dy', '1em') let bbox;
.attr('alignment-baseline', 'middle') if (hasHtml) {
.attr('dominant-baseline', 'middle') bbox = await processHtmlContent(textElem, node, conf, true);
.attr('text-anchor', 'middle') } else {
.call(wrap, node.width); const txt = textElem
const bbox = txt.node().getBBox(); .append('text')
.text(node.descr)
.attr('dy', '1em')
.attr('alignment-baseline', 'middle')
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle')
.call(wrap, node.width);
bbox = txt.node().getBBox();
}
const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize; const fontSize = conf.fontSize?.replace ? conf.fontSize.replace('px', '') : conf.fontSize;
textElem.remove(); textElem.remove();
return bbox.height + fontSize * 1.1 * 0.5 + node.padding; return bbox.height + fontSize * 1.1 * 0.5 + node.padding;

View File

@@ -25,7 +25,7 @@ interface TimelineTask {
score: number; score: number;
events: string[]; events: string[];
} }
export const draw = function (text: string, id: string, version: string, diagObj: Diagram) { export const draw = async function (text: string, id: string, version: string, diagObj: Diagram) {
//1. Fetch the configuration //1. Fetch the configuration
const conf = getConfig(); const conf = getConfig();
const LEFT_MARGIN = conf.timeline?.leftMargin ?? 50; const LEFT_MARGIN = conf.timeline?.leftMargin ?? 50;
@@ -76,7 +76,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
let hasSections = true; let hasSections = true;
//Calculate the max height of the sections //Calculate the max height of the sections
sections.forEach(function (section: string) { for (const section of sections) {
const sectionNode: Block<string, number> = { const sectionNode: Block<string, number> = {
number: sectionNumber, number: sectionNumber,
descr: section, descr: section,
@@ -85,10 +85,10 @@ export const draw = function (text: string, id: string, version: string, diagObj
padding: 20, padding: 20,
maxHeight: maxSectionHeight, maxHeight: maxSectionHeight,
}; };
const sectionHeight = svgDraw.getVirtualNodeHeight(svg, sectionNode, conf); const sectionHeight = await svgDraw.getVirtualNodeHeight(svg, sectionNode, conf);
log.debug('sectionHeight before draw', sectionHeight); log.debug('sectionHeight before draw', sectionHeight);
maxSectionHeight = Math.max(maxSectionHeight, sectionHeight + 20); maxSectionHeight = Math.max(maxSectionHeight, sectionHeight + 20);
}); }
//tasks length and maxEventCount //tasks length and maxEventCount
let maxEventCount = 0; let maxEventCount = 0;
@@ -106,7 +106,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
padding: 20, padding: 20,
maxHeight: maxTaskHeight, maxHeight: maxTaskHeight,
}; };
const taskHeight = svgDraw.getVirtualNodeHeight(svg, taskNode, conf); const taskHeight = await svgDraw.getVirtualNodeHeight(svg, taskNode, conf);
log.debug('taskHeight before draw', taskHeight); log.debug('taskHeight before draw', taskHeight);
maxTaskHeight = Math.max(maxTaskHeight, taskHeight + 20); maxTaskHeight = Math.max(maxTaskHeight, taskHeight + 20);
@@ -123,9 +123,8 @@ export const draw = function (text: string, id: string, version: string, diagObj
padding: 20, padding: 20,
maxHeight: 50, maxHeight: 50,
}; };
maxEventLineLengthTemp += svgDraw.getVirtualNodeHeight(svg, eventNode, conf); maxEventLineLengthTemp += await svgDraw.getVirtualNodeHeight(svg, eventNode, conf);
} }
// Add spacing between events (10px per event except the last one)
if (task.events.length > 0) { if (task.events.length > 0) {
maxEventLineLengthTemp += (task.events.length - 1) * 10; maxEventLineLengthTemp += (task.events.length - 1) * 10;
} }
@@ -136,7 +135,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
log.debug('maxTaskHeight before draw', maxTaskHeight); log.debug('maxTaskHeight before draw', maxTaskHeight);
if (sections && sections.length > 0) { if (sections && sections.length > 0) {
sections.forEach((section) => { for (const section of sections) {
//filter task where tasks.section == section //filter task where tasks.section == section
const tasksForSection = tasks.filter((task) => task.section === section); const tasksForSection = tasks.filter((task) => task.section === section);
@@ -150,7 +149,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
}; };
log.debug('sectionNode', sectionNode); log.debug('sectionNode', sectionNode);
const sectionNodeWrapper = svg.append('g'); const sectionNodeWrapper = svg.append('g');
const node = svgDraw.drawNode(sectionNodeWrapper, sectionNode, sectionNumber, conf); const node = await svgDraw.drawNode(sectionNodeWrapper, sectionNode, sectionNumber, conf);
log.debug('sectionNode output', node); log.debug('sectionNode output', node);
sectionNodeWrapper.attr('transform', `translate(${masterX}, ${sectionBeginY})`); sectionNodeWrapper.attr('transform', `translate(${masterX}, ${sectionBeginY})`);
@@ -159,7 +158,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
//draw tasks for this section //draw tasks for this section
if (tasksForSection.length > 0) { if (tasksForSection.length > 0) {
drawTasks( await drawTasks(
svg, svg,
tasksForSection, tasksForSection,
sectionNumber, sectionNumber,
@@ -178,11 +177,11 @@ export const draw = function (text: string, id: string, version: string, diagObj
masterY = sectionBeginY; masterY = sectionBeginY;
sectionNumber++; sectionNumber++;
}); }
} else { } else {
//draw tasks //draw tasks
hasSections = false; hasSections = false;
drawTasks( await drawTasks(
svg, svg,
tasks, tasks,
sectionNumber, sectionNumber,
@@ -236,7 +235,7 @@ export const draw = function (text: string, id: string, version: string, diagObj
// addSVGAccessibilityFields(diagObj.db, diagram, id); // addSVGAccessibilityFields(diagObj.db, diagram, id);
}; };
export const drawTasks = function ( export const drawTasks = async function (
diagram: Selection<SVGElement, unknown, null, undefined>, diagram: Selection<SVGElement, unknown, null, undefined>,
tasks: TimelineTask[], tasks: TimelineTask[],
sectionColor: number, sectionColor: number,
@@ -265,7 +264,7 @@ export const drawTasks = function (
// create task wrapper // create task wrapper
const taskWrapper = diagram.append('g').attr('class', 'taskWrapper'); const taskWrapper = diagram.append('g').attr('class', 'taskWrapper');
const node = svgDraw.drawNode(taskWrapper, taskNode, sectionColor, conf); const node = await svgDraw.drawNode(taskWrapper, taskNode, sectionColor, conf);
const taskHeight = node.height; const taskHeight = node.height;
//log task height //log task height
log.debug('taskHeight after draw', taskHeight); log.debug('taskHeight after draw', taskHeight);
@@ -282,7 +281,7 @@ export const drawTasks = function (
//add margin to task //add margin to task
masterY += 100; masterY += 100;
lineLength = lineLength =
lineLength + drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf); lineLength + (await drawEvents(diagram, task.events, sectionColor, masterX, masterY, conf));
masterY -= 100; masterY -= 100;
lineWrapper lineWrapper
@@ -307,7 +306,7 @@ export const drawTasks = function (
masterY = masterY - 10; masterY = masterY - 10;
}; };
export const drawEvents = function ( export const drawEvents = async function (
diagram: Selection<SVGElement, unknown, null, undefined>, diagram: Selection<SVGElement, unknown, null, undefined>,
events: string[], events: string[],
sectionColor: number, sectionColor: number,
@@ -334,7 +333,7 @@ export const drawEvents = function (
log.debug('eventNode', eventNode); log.debug('eventNode', eventNode);
// create event wrapper // create event wrapper
const eventWrapper = diagram.append('g').attr('class', 'eventWrapper'); const eventWrapper = diagram.append('g').attr('class', 'eventWrapper');
const node = svgDraw.drawNode(eventWrapper, eventNode, sectionColor, conf); const node = await svgDraw.drawNode(eventWrapper, eventNode, sectionColor, conf);
const eventHeight = node.height; const eventHeight = node.height;
maxEventHeight = maxEventHeight + eventHeight; maxEventHeight = maxEventHeight + eventHeight;
eventWrapper.attr('transform', `translate(${masterX}, ${masterY})`); eventWrapper.attr('transform', `translate(${masterX}, ${masterY})`);