Merge pull request #6282 from mermaid-js/saurabh/refactor-fontawesome-icon-usage

Refactor fontawesome icon usage.
This commit is contained in:
Sidharth Vinod
2025-04-20 01:04:40 -07:00
committed by GitHub
23 changed files with 228 additions and 59 deletions

View File

@@ -0,0 +1,5 @@
---
'mermaid': patch
---
FontAwesome icons can now be embedded as SVGs in flowcharts if they are registered via `mermaid.registerIconPacks`.

View File

@@ -26,6 +26,7 @@ dompurify
elkjs elkjs
fcose fcose
fontawesome fontawesome
Fonticons
Forgejo Forgejo
Foswiki Foswiki
Gitea Gitea

View File

@@ -0,0 +1,28 @@
import { imgSnapshotTest } from '../../helpers/util.ts';
const themes = ['default', 'forest', 'dark', 'base', 'neutral'];
describe('when rendering flowchart with icons', () => {
for (const theme of themes) {
it(`should render icons from fontawesome library on theme ${theme}`, () => {
imgSnapshotTest(
`flowchart TD
A("fab:fa-twitter Twitter") --> B("fab:fa-facebook Facebook")
B --> C("fa:fa-coffee Coffee")
C --> D("fa:fa-car Car")
D --> E("fab:fa-github GitHub")
`,
{ theme }
);
});
it(`should render registered icons on theme ${theme}`, () => {
imgSnapshotTest(
`flowchart TD
A("fa:fa-bell Bell")
`,
{ theme }
);
});
}
});

View File

@@ -99,7 +99,7 @@ describe('Flowchart v2', () => {
const style = svg.attr('style'); const style = svg.attr('style');
expect(style).to.match(/^max-width: [\d.]+px;$/); expect(style).to.match(/^max-width: [\d.]+px;$/);
const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join('')); const maxWidthValue = parseFloat(style.match(/[\d.]+/g).join(''));
expect(maxWidthValue).to.be.within(417 * 0.95, 417 * 1.05); expect(maxWidthValue).to.be.within(440 * 0.95, 440 * 1.05);
}); });
}); });
it('8: should render a flowchart when useMaxWidth is false', () => { it('8: should render a flowchart when useMaxWidth is false', () => {
@@ -118,7 +118,7 @@ describe('Flowchart v2', () => {
const width = parseFloat(svg.attr('width')); const width = parseFloat(svg.attr('width'));
// use within because the absolute value can be slightly different depending on the environment ±5% // use within because the absolute value can be slightly different depending on the environment ±5%
// expect(height).to.be.within(446 * 0.95, 446 * 1.05); // expect(height).to.be.within(446 * 0.95, 446 * 1.05);
expect(width).to.be.within(417 * 0.95, 417 * 1.05); expect(width).to.be.within(440 * 0.95, 440 * 1.05);
expect(svg).to.not.have.attr('style'); expect(svg).to.not.have.attr('style');
}); });
}); });

View File

@@ -7,7 +7,7 @@
rel="stylesheet" rel="stylesheet"
/> />
<style> <style>
svg { svg:not(svg svg) {
border: 2px solid darkred; border: 2px solid darkred;
} }
.exClass2 > rect, .exClass2 > rect,

View File

@@ -14,12 +14,28 @@ function markRendered() {
} }
} }
function loadFontAwesomeCSS() {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css';
document.head.appendChild(link);
return new Promise((resolve, reject) => {
link.onload = resolve;
link.onerror = () => reject(new Error('Failed to load FontAwesome'));
});
}
/** /**
* ##contentLoaded Callback function that is called when page is loaded. This functions fetches * ##contentLoaded Callback function that is called when page is loaded. This functions fetches
* configuration for mermaid rendering and calls init for rendering the mermaid diagrams on the * configuration for mermaid rendering and calls init for rendering the mermaid diagrams on the
* page. * page.
*/ */
const contentLoaded = async function () { const contentLoaded = async function () {
await loadFontAwesomeCSS();
await Promise.all(Array.from(document.fonts, (font) => font.load()));
let pos = document.location.href.indexOf('?graph='); let pos = document.location.href.indexOf('?graph=');
if (pos > 0) { if (pos > 0) {
pos = pos + 7; pos = pos + 7;
@@ -50,8 +66,13 @@ const contentLoaded = async function () {
mermaid.registerLayoutLoaders(layouts); mermaid.registerLayoutLoaders(layouts);
mermaid.initialize(graphObj.mermaid); mermaid.initialize(graphObj.mermaid);
/**
* CC-BY-4.0
* Copyright (c) Fonticons, Inc. - https://fontawesome.com/license/free
* https://fontawesome.com/icons/bell?f=classic&s=regular
*/
const staticBellIconPack = { const staticBellIconPack = {
prefix: 'fa6-regular', prefix: 'fa',
icons: { icons: {
bell: { bell: {
body: '<path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32v19.2C119 66 64 130.6 64 208v25.4c0 45.4-15.5 89.5-43.8 124.9L5.3 377c-5.8 7.2-6.9 17.1-2.9 25.4S14.8 416 24 416h400c9.2 0 17.6-5.3 21.6-13.6s2.9-18.2-2.9-25.4l-14.9-18.6c-28.3-35.5-43.8-79.6-43.8-125V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32m0 96c61.9 0 112 50.1 112 112v25.4c0 47.9 13.9 94.6 39.7 134.6H72.3c25.8-40 39.7-86.7 39.7-134.6V208c0-61.9 50.1-112 112-112m64 352H160c0 17 6.7 33.3 18.7 45.3S207 512 224 512s33.3-6.7 45.3-18.7S288 465 288 448"/>', body: '<path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32v19.2C119 66 64 130.6 64 208v25.4c0 45.4-15.5 89.5-43.8 124.9L5.3 377c-5.8 7.2-6.9 17.1-2.9 25.4S14.8 416 24 416h400c9.2 0 17.6-5.3 21.6-13.6s2.9-18.2-2.9-25.4l-14.9-18.6c-28.3-35.5-43.8-79.6-43.8-125V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32m0 96c61.9 0 112 50.1 112 112v25.4c0 47.9 13.9 94.6 39.7 134.6H72.3c25.8-40 39.7-86.7 39.7-134.6V208c0-61.9 50.1-112 112-112m64 352H160c0 17 6.7 33.3 18.7 45.3S207 512 224 512s33.3-6.7 45.3-18.7S288 465 288 448"/>',

View File

@@ -1950,6 +1950,19 @@ flowchart TD
B-->E(A fa:fa-camera-retro perhaps?) B-->E(A fa:fa-camera-retro perhaps?)
``` ```
There are two ways to display these FontAwesome icons:
### Register FontAwesome icon packs (v\<MERMAID_RELEASE_VERSION>+)
You can register your own FontAwesome icon pack following the ["Registering icon packs" instructions](../config/icons.md).
Supported prefixes: `fa`, `fab`, `fas`, `far`, `fal`, `fad`.
> **Note**
> Note that it will fall back to FontAwesome CSS if FontAwesome packs are not registered.
### Register FontAwesome CSS
Mermaid supports Font Awesome if the CSS is included on the website. Mermaid supports Font Awesome if the CSS is included on the website.
Mermaid does not have any restriction on the version of Font Awesome that can be used. Mermaid does not have any restriction on the version of Font Awesome that can be used.

View File

@@ -7,7 +7,7 @@ import { getConfig } from '../diagram-api/diagramAPI.js';
import { evaluate } from '../diagrams/common/common.js'; import { evaluate } from '../diagrams/common/common.js';
import { getSubGraphTitleMargins } from '../utils/subGraphTitleMargins.js'; import { getSubGraphTitleMargins } from '../utils/subGraphTitleMargins.js';
const rect = (parent, node) => { const rect = async (parent, node) => {
log.info('Creating subgraph rect for ', node.id, node); log.info('Creating subgraph rect for ', node.id, node);
const siteConfig = getConfig(); const siteConfig = getConfig();
@@ -31,7 +31,9 @@ const rect = (parent, node) => {
const text = const text =
node.labelType === 'markdown' node.labelType === 'markdown'
? createText(label, node.labelText, { style: node.labelStyle, useHtmlLabels }, siteConfig) ? createText(label, node.labelText, { style: node.labelStyle, useHtmlLabels }, siteConfig)
: label.node().appendChild(createLabel(node.labelText, node.labelStyle, undefined, true)); : label
.node()
.appendChild(await createLabel(node.labelText, node.labelStyle, undefined, true));
// Get the size of the label // Get the size of the label
let bbox = text.getBBox(); let bbox = text.getBBox();
@@ -129,7 +131,7 @@ const noteGroup = (parent, node) => {
return shapeSvg; return shapeSvg;
}; };
const roundedWithTitle = (parent, node) => { const roundedWithTitle = async (parent, node) => {
const siteConfig = getConfig(); const siteConfig = getConfig();
// Add outer g element // Add outer g element
@@ -144,7 +146,7 @@ const roundedWithTitle = (parent, node) => {
const text = label const text = label
.node() .node()
.appendChild(createLabel(node.labelText, node.labelStyle, undefined, true)); .appendChild(await createLabel(node.labelText, node.labelStyle, undefined, true));
// Get the size of the label // Get the size of the label
let bbox = text.getBBox(); let bbox = text.getBBox();
@@ -236,13 +238,13 @@ const shapes = { rect, roundedWithTitle, noteGroup, divider };
let clusterElems = {}; let clusterElems = {};
export const insertCluster = (elem, node) => { export const insertCluster = async (elem, node) => {
log.trace('Inserting cluster'); log.trace('Inserting cluster');
const shape = node.shape || 'rect'; const shape = node.shape || 'rect';
clusterElems[node.id] = shapes[shape](elem, node); clusterElems[node.id] = await shapes[shape](elem, node);
}; };
export const getClusterTitleWidth = (elem, node) => { export const getClusterTitleWidth = async (elem, node) => {
const label = createLabel(node.labelText, node.labelStyle, undefined, true); const label = await createLabel(node.labelText, node.labelStyle, undefined, true);
elem.node().appendChild(label); elem.node().appendChild(label);
const width = label.getBBox().width; const width = label.getBBox().width;
elem.node().removeChild(label); elem.node().removeChild(label);

View File

@@ -44,7 +44,7 @@ function addHtmlLabel(node) {
* @param isNode * @param isNode
* @deprecated svg-util/createText instead * @deprecated svg-util/createText instead
*/ */
const createLabel = (_vertexText, style, isTitle, isNode) => { const createLabel = async (_vertexText, style, isTitle, isNode) => {
let vertexText = _vertexText || ''; let vertexText = _vertexText || '';
if (typeof vertexText === 'object') { if (typeof vertexText === 'object') {
vertexText = vertexText[0]; vertexText = vertexText[0];
@@ -53,9 +53,10 @@ const createLabel = (_vertexText, style, isTitle, isNode) => {
// TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that? // TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that?
vertexText = vertexText.replace(/\\n|\n/g, '<br />'); vertexText = vertexText.replace(/\\n|\n/g, '<br />');
log.debug('vertexText' + vertexText); log.debug('vertexText' + vertexText);
const label = await replaceIconSubstring(decodeEntities(vertexText));
const node = { const node = {
isNode, isNode,
label: replaceIconSubstring(decodeEntities(vertexText)), label,
labelStyle: style.replace('fill:', 'color:'), labelStyle: style.replace('fill:', 'color:'),
}; };
let vertexNode = addHtmlLabel(node); let vertexNode = addHtmlLabel(node);

View File

@@ -17,7 +17,7 @@ export const clear = () => {
terminalLabels = {}; terminalLabels = {};
}; };
export const insertEdgeLabel = (elem, edge) => { export const insertEdgeLabel = async (elem, edge) => {
const config = getConfig(); const config = getConfig();
const useHtmlLabels = evaluate(config.flowchart.htmlLabels); const useHtmlLabels = evaluate(config.flowchart.htmlLabels);
// Create the actual text element // Create the actual text element
@@ -33,7 +33,7 @@ export const insertEdgeLabel = (elem, edge) => {
}, },
config config
) )
: createLabel(edge.label, edge.labelStyle); : await createLabel(edge.label, edge.labelStyle);
// Create outer g, edgeLabel, this will be positioned after graph layout // Create outer g, edgeLabel, this will be positioned after graph layout
const edgeLabel = elem.insert('g').attr('class', 'edgeLabel'); const edgeLabel = elem.insert('g').attr('class', 'edgeLabel');
@@ -63,7 +63,7 @@ export const insertEdgeLabel = (elem, edge) => {
let fo; let fo;
if (edge.startLabelLeft) { if (edge.startLabelLeft) {
// Create the actual text element // Create the actual text element
const startLabelElement = createLabel(edge.startLabelLeft, edge.labelStyle); const startLabelElement = await createLabel(edge.startLabelLeft, edge.labelStyle);
const startEdgeLabelLeft = elem.insert('g').attr('class', 'edgeTerminals'); const startEdgeLabelLeft = elem.insert('g').attr('class', 'edgeTerminals');
const inner = startEdgeLabelLeft.insert('g').attr('class', 'inner'); const inner = startEdgeLabelLeft.insert('g').attr('class', 'inner');
fo = inner.node().appendChild(startLabelElement); fo = inner.node().appendChild(startLabelElement);
@@ -77,7 +77,7 @@ export const insertEdgeLabel = (elem, edge) => {
} }
if (edge.startLabelRight) { if (edge.startLabelRight) {
// Create the actual text element // Create the actual text element
const startLabelElement = createLabel(edge.startLabelRight, edge.labelStyle); const startLabelElement = await createLabel(edge.startLabelRight, edge.labelStyle);
const startEdgeLabelRight = elem.insert('g').attr('class', 'edgeTerminals'); const startEdgeLabelRight = elem.insert('g').attr('class', 'edgeTerminals');
const inner = startEdgeLabelRight.insert('g').attr('class', 'inner'); const inner = startEdgeLabelRight.insert('g').attr('class', 'inner');
fo = startEdgeLabelRight.node().appendChild(startLabelElement); fo = startEdgeLabelRight.node().appendChild(startLabelElement);
@@ -93,7 +93,7 @@ export const insertEdgeLabel = (elem, edge) => {
} }
if (edge.endLabelLeft) { if (edge.endLabelLeft) {
// Create the actual text element // Create the actual text element
const endLabelElement = createLabel(edge.endLabelLeft, edge.labelStyle); const endLabelElement = await createLabel(edge.endLabelLeft, edge.labelStyle);
const endEdgeLabelLeft = elem.insert('g').attr('class', 'edgeTerminals'); const endEdgeLabelLeft = elem.insert('g').attr('class', 'edgeTerminals');
const inner = endEdgeLabelLeft.insert('g').attr('class', 'inner'); const inner = endEdgeLabelLeft.insert('g').attr('class', 'inner');
fo = inner.node().appendChild(endLabelElement); fo = inner.node().appendChild(endLabelElement);
@@ -110,7 +110,7 @@ export const insertEdgeLabel = (elem, edge) => {
} }
if (edge.endLabelRight) { if (edge.endLabelRight) {
// Create the actual text element // Create the actual text element
const endLabelElement = createLabel(edge.endLabelRight, edge.labelStyle); const endLabelElement = await createLabel(edge.endLabelRight, edge.labelStyle);
const endEdgeLabelRight = elem.insert('g').attr('class', 'edgeTerminals'); const endEdgeLabelRight = elem.insert('g').attr('class', 'edgeTerminals');
const inner = endEdgeLabelRight.insert('g').attr('class', 'inner'); const inner = endEdgeLabelRight.insert('g').attr('class', 'inner');

View File

@@ -120,7 +120,7 @@ const recursiveRender = async (_elem, graph, diagramType, id, parentCluster, sit
// Move the nodes to the correct place // Move the nodes to the correct place
let diff = 0; let diff = 0;
const { subGraphTitleTotalMargin } = getSubGraphTitleMargins(siteConfig); const { subGraphTitleTotalMargin } = getSubGraphTitleMargins(siteConfig);
sortNodesByHierarchy(graph).forEach(function (v) { for (const v of sortNodesByHierarchy(graph)) {
const node = graph.node(v); const node = graph.node(v);
log.info('Position ' + v + ': ' + JSON.stringify(graph.node(v))); log.info('Position ' + v + ': ' + JSON.stringify(graph.node(v)));
log.info( log.info(
@@ -141,14 +141,14 @@ const recursiveRender = async (_elem, graph, diagramType, id, parentCluster, sit
// A cluster in the non-recursive way // A cluster in the non-recursive way
// positionCluster(node); // positionCluster(node);
node.height += subGraphTitleTotalMargin; node.height += subGraphTitleTotalMargin;
insertCluster(clusters, node); await insertCluster(clusters, node);
clusterDb[node.id].node = node; clusterDb[node.id].node = node;
} else { } else {
node.y += subGraphTitleTotalMargin / 2; node.y += subGraphTitleTotalMargin / 2;
positionNode(node); positionNode(node);
} }
} }
}); }
// Move the edge labels to the correct place after layout // Move the edge labels to the correct place after layout
graph.edges().forEach(function (e) { graph.edges().forEach(function (e) {

View File

@@ -553,7 +553,7 @@ function applyNodePropertyBorders(rect, borders, totalWidth, totalHeight) {
rect.attr('stroke-dasharray', strokeDashArray.join(' ')); rect.attr('stroke-dasharray', strokeDashArray.join(' '));
} }
const rectWithTitle = (parent, node) => { const rectWithTitle = async (parent, node) => {
// const { shapeSvg, bbox, halfPadding } = labelHelper(parent, node, 'node ' + node.classes); // const { shapeSvg, bbox, halfPadding } = labelHelper(parent, node, 'node ' + node.classes);
let classes; let classes;
@@ -586,7 +586,7 @@ const rectWithTitle = (parent, node) => {
} }
log.info('Label text abc79', title, text2, typeof text2 === 'object'); log.info('Label text abc79', title, text2, typeof text2 === 'object');
const text = label.node().appendChild(createLabel(title, node.labelStyle, true, true)); const text = label.node().appendChild(await createLabel(title, node.labelStyle, true, true));
let bbox = { width: 0, height: 0 }; let bbox = { width: 0, height: 0 };
if (evaluate(getConfig().flowchart.htmlLabels)) { if (evaluate(getConfig().flowchart.htmlLabels)) {
const div = text.children[0]; const div = text.children[0];
@@ -601,7 +601,12 @@ const rectWithTitle = (parent, node) => {
const descr = label const descr = label
.node() .node()
.appendChild( .appendChild(
createLabel(textRows.join ? textRows.join('<br/>') : textRows, node.labelStyle, true, true) await createLabel(
textRows.join ? textRows.join('<br/>') : textRows,
node.labelStyle,
true,
true
)
); );
if (evaluate(getConfig().flowchart.htmlLabels)) { if (evaluate(getConfig().flowchart.htmlLabels)) {
@@ -876,7 +881,7 @@ const end = (parent, node) => {
return shapeSvg; return shapeSvg;
}; };
const class_box = (parent, node) => { const class_box = async (parent, node) => {
const halfPadding = node.padding / 2; const halfPadding = node.padding / 2;
const rowPadding = 4; const rowPadding = 4;
const lineHeight = 8; const lineHeight = 8;
@@ -910,7 +915,7 @@ const class_box = (parent, node) => {
: ''; : '';
const interfaceLabel = labelContainer const interfaceLabel = labelContainer
.node() .node()
.appendChild(createLabel(interfaceLabelText, node.labelStyle, true, true)); .appendChild(await createLabel(interfaceLabelText, node.labelStyle, true, true));
let interfaceBBox = interfaceLabel.getBBox(); let interfaceBBox = interfaceLabel.getBBox();
if (evaluate(getConfig().flowchart.htmlLabels)) { if (evaluate(getConfig().flowchart.htmlLabels)) {
const div = interfaceLabel.children[0]; const div = interfaceLabel.children[0];
@@ -935,7 +940,7 @@ const class_box = (parent, node) => {
} }
const classTitleLabel = labelContainer const classTitleLabel = labelContainer
.node() .node()
.appendChild(createLabel(classTitleString, node.labelStyle, true, true)); .appendChild(await createLabel(classTitleString, node.labelStyle, true, true));
select(classTitleLabel).attr('class', 'classTitle'); select(classTitleLabel).attr('class', 'classTitle');
let classTitleBBox = classTitleLabel.getBBox(); let classTitleBBox = classTitleLabel.getBBox();
if (evaluate(getConfig().flowchart.htmlLabels)) { if (evaluate(getConfig().flowchart.htmlLabels)) {
@@ -950,7 +955,7 @@ const class_box = (parent, node) => {
maxWidth = classTitleBBox.width; maxWidth = classTitleBBox.width;
} }
const classAttributes = []; const classAttributes = [];
node.classData.members.forEach((member) => { node.classData.members.forEach(async (member) => {
const parsedInfo = member.getDisplayDetails(); const parsedInfo = member.getDisplayDetails();
let parsedText = parsedInfo.displayText; let parsedText = parsedInfo.displayText;
if (getConfig().flowchart.htmlLabels) { if (getConfig().flowchart.htmlLabels) {
@@ -959,7 +964,7 @@ const class_box = (parent, node) => {
const lbl = labelContainer const lbl = labelContainer
.node() .node()
.appendChild( .appendChild(
createLabel( await createLabel(
parsedText, parsedText,
parsedInfo.cssStyle ? parsedInfo.cssStyle : node.labelStyle, parsedInfo.cssStyle ? parsedInfo.cssStyle : node.labelStyle,
true, true,
@@ -984,7 +989,7 @@ const class_box = (parent, node) => {
maxHeight += lineHeight; maxHeight += lineHeight;
const classMethods = []; const classMethods = [];
node.classData.methods.forEach((member) => { node.classData.methods.forEach(async (member) => {
const parsedInfo = member.getDisplayDetails(); const parsedInfo = member.getDisplayDetails();
let displayText = parsedInfo.displayText; let displayText = parsedInfo.displayText;
if (getConfig().flowchart.htmlLabels) { if (getConfig().flowchart.htmlLabels) {
@@ -993,7 +998,7 @@ const class_box = (parent, node) => {
const lbl = labelContainer const lbl = labelContainer
.node() .node()
.appendChild( .appendChild(
createLabel( await createLabel(
displayText, displayText,
parsedInfo.cssStyle ? parsedInfo.cssStyle : node.labelStyle, parsedInfo.cssStyle ? parsedInfo.cssStyle : node.labelStyle,
true, true,

View File

@@ -48,7 +48,12 @@ export const labelHelper = async (parent, node, _classes, isNode) => {
); );
} else { } else {
text = textNode.appendChild( text = textNode.appendChild(
createLabel(sanitizeText(decodeEntities(labelText), config), node.labelStyle, false, isNode) await createLabel(
sanitizeText(decodeEntities(labelText), config),
node.labelStyle,
false,
isNode
)
); );
} }
// Get the size of the label // Get the size of the label

View File

@@ -1,4 +1,5 @@
import * as khroma from 'khroma'; import * as khroma from 'khroma';
import { getIconStyles } from '../globalStyles.js';
/** Returns the styles given options */ /** Returns the styles given options */
export interface BlockChartStyleOptions { export interface BlockChartStyleOptions {
@@ -142,6 +143,7 @@ const getStyles = (options: BlockChartStyleOptions) =>
font-size: 18px; font-size: 18px;
fill: ${options.textColor}; fill: ${options.textColor};
} }
${getIconStyles()}
`; `;
export default getStyles; export default getStyles;

View File

@@ -1,3 +1,5 @@
import { getIconStyles } from '../globalStyles.js';
const getStyles = (options) => const getStyles = (options) =>
`g.classGroup text { `g.classGroup text {
fill: ${options.nodeBorder || options.classText}; fill: ${options.nodeBorder || options.classText};
@@ -157,6 +159,7 @@ g.classGroup line {
font-size: 18px; font-size: 18px;
fill: ${options.textColor}; fill: ${options.textColor};
} }
${getIconStyles()}
`; `;
export default getStyles; export default getStyles;

View File

@@ -1,5 +1,6 @@
// import khroma from 'khroma'; // import khroma from 'khroma';
import * as khroma from 'khroma'; import * as khroma from 'khroma';
import { getIconStyles } from '../globalStyles.js';
/** Returns the styles given options */ /** Returns the styles given options */
export interface FlowChartStyleOptions { export interface FlowChartStyleOptions {
@@ -177,6 +178,7 @@ const getStyles = (options: FlowChartStyleOptions) =>
} }
text-align: center; text-align: center;
} }
${getIconStyles()}
`; `;
export default getStyles; export default getStyles;

View File

@@ -0,0 +1,15 @@
export const getIconStyles = () => `
/* Font Awesome icon styling - consolidated */
.label-icon {
display: inline-block;
height: 1em;
overflow: visible;
vertical-align: -0.125em;
}
.node .label-icon path {
fill: currentColor;
stroke: revert;
stroke-width: revert;
}
`;

View File

@@ -1,6 +1,7 @@
// @ts-expect-error Incorrect khroma types // @ts-expect-error Incorrect khroma types
import { darken, lighten, isDark } from 'khroma'; import { darken, lighten, isDark } from 'khroma';
import type { DiagramStylesProvider } from '../../diagram-api/types.js'; import type { DiagramStylesProvider } from '../../diagram-api/types.js';
import { getIconStyles } from '../globalStyles.js';
const genSections: DiagramStylesProvider = (options) => { const genSections: DiagramStylesProvider = (options) => {
let sections = ''; let sections = '';
@@ -105,5 +106,6 @@ const getStyles: DiagramStylesProvider = (options) =>
dominant-baseline: middle; dominant-baseline: middle;
text-align: center; text-align: center;
} }
${getIconStyles()}
`; `;
export default getStyles; export default getStyles;

View File

@@ -1,3 +1,5 @@
import { getIconStyles } from '../globalStyles.js';
const getStyles = (options) => const getStyles = (options) =>
`.label { `.label {
font-family: ${options.fontFamily}; font-family: ${options.fontFamily};
@@ -131,6 +133,7 @@ const getStyles = (options) =>
.actor-5 { .actor-5 {
${options.actor5 ? `fill: ${options.actor5}` : ''}; ${options.actor5 ? `fill: ${options.actor5}` : ''};
} }
${getIconStyles()}
`; `;
export default getStyles; export default getStyles;

View File

@@ -1250,6 +1250,20 @@ flowchart TD
B-->E(A fa:fa-camera-retro perhaps?) B-->E(A fa:fa-camera-retro perhaps?)
``` ```
There are two ways to display these FontAwesome icons:
### Register FontAwesome icon packs (v<MERMAID_RELEASE_VERSION>+)
You can register your own FontAwesome icon pack following the ["Registering icon packs" instructions](../config/icons.md).
Supported prefixes: `fa`, `fab`, `fas`, `far`, `fal`, `fad`.
```note
Note that it will fall back to FontAwesome CSS if FontAwesome packs are not registered.
```
### Register FontAwesome CSS
Mermaid supports Font Awesome if the CSS is included on the website. Mermaid supports Font Awesome if the CSS is included on the website.
Mermaid does not have any restriction on the version of Font Awesome that can be used. Mermaid does not have any restriction on the version of Font Awesome that can be used.

View File

@@ -1,34 +1,62 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { replaceIconSubstring } from './createText.js'; import { replaceIconSubstring } from './createText.js';
import mermaid from '../mermaid.js';
describe('replaceIconSubstring', () => { describe('replaceIconSubstring', () => {
it('converts FontAwesome icon notations to HTML tags', () => { it('converts FontAwesome icon notations to HTML tags', async () => {
const input = 'This is an icon: fa:fa-user and fab:fa-github'; const input = 'This is an icon: fa:fa-user and fab:fa-github';
const output = replaceIconSubstring(input); const output = await replaceIconSubstring(input);
const expected = const expected = `This is an icon: <i class='fa fa-user'></i> and <i class='fab fa-github'></i>`;
"This is an icon: <i class='fa fa-user'></i> and <i class='fab fa-github'></i>";
expect(output).toEqual(expected); expect(output).toEqual(expected);
}); });
it('handles strings without FontAwesome icon notations', () => { it('handles strings without FontAwesome icon notations', async () => {
const input = 'This string has no icons'; const input = 'This string has no icons';
const output = replaceIconSubstring(input); const output = await replaceIconSubstring(input);
expect(output).toEqual(input); // No change expected expect(output).toEqual(input); // No change expected
}); });
it('correctly processes multiple FontAwesome icon notations in one string', () => { it('correctly processes multiple FontAwesome icon notations in one string', async () => {
const input = 'Icons galore: fa:fa-arrow-right, fak:fa-truck, fas:fa-home'; const input = 'Icons galore: fa:fa-arrow-right, fak:fa-truck, fas:fa-home';
const output = replaceIconSubstring(input); const output = await replaceIconSubstring(input);
const expected = const expected = `Icons galore: <i class='fa fa-arrow-right'></i>, <i class='fak fa-truck'></i>, <i class='fas fa-home'></i>`;
"Icons galore: <i class='fa fa-arrow-right'></i>, <i class='fak fa-truck'></i>, <i class='fas fa-home'></i>";
expect(output).toEqual(expected); expect(output).toEqual(expected);
}); });
it('correctly replaces a very long icon name with the fak prefix', () => { it('correctly replaces a very long icon name with the fak prefix', async () => {
const input = 'Here is a long icon: fak:fa-truck-driving-long-winding-road in use'; const input = 'Here is a long icon: fak:fa-truck-driving-long-winding-road in use';
const output = replaceIconSubstring(input); const output = await replaceIconSubstring(input);
const expected = const expected =
"Here is a long icon: <i class='fak fa-truck-driving-long-winding-road'></i> in use"; "Here is a long icon: <i class='fak fa-truck-driving-long-winding-road'></i> in use";
expect(output).toEqual(expected); expect(output).toEqual(expected);
}); });
it('correctly process the registered icons', async () => {
/**
* CC-BY-4.0
* Copyright (c) Fonticons, Inc. - https://fontawesome.com/license/free
* https://fontawesome.com/icons/bell?f=classic&s=regular
*/
const staticBellIconPack = {
prefix: 'fa',
icons: {
bell: {
body: '<path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32v19.2C119 66 64 130.6 64 208v25.4c0 45.4-15.5 89.5-43.8 124.9L5.3 377c-5.8 7.2-6.9 17.1-2.9 25.4S14.8 416 24 416h400c9.2 0 17.6-5.3 21.6-13.6s2.9-18.2-2.9-25.4l-14.9-18.6c-28.3-35.5-43.8-79.6-43.8-125V208c0-77.4-55-142-128-156.8V32c0-17.7-14.3-32-32-32m0 96c61.9 0 112 50.1 112 112v25.4c0 47.9 13.9 94.6 39.7 134.6H72.3c25.8-40 39.7-86.7 39.7-134.6V208c0-61.9 50.1-112 112-112m64 352H160c0 17 6.7 33.3 18.7 45.3S207 512 224 512s33.3-6.7 45.3-18.7S288 465 288 448"/>',
width: 448,
},
},
width: 512,
height: 512,
};
mermaid.registerIconPacks([
{
name: 'fa',
loader: () => Promise.resolve(staticBellIconPack),
},
]);
const input = 'Icons galore: fa:fa-bell';
const output = await replaceIconSubstring(input);
const expected = staticBellIconPack.icons.bell.body;
expect(output).toContain(expected);
});
}); });

View File

@@ -1,14 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
// @ts-nocheck TODO: Fix types // @ts-nocheck TODO: Fix types
import { getConfig } from '../diagram-api/diagramAPI.js';
import common, { hasKatex, renderKatex } from '../diagrams/common/common.js';
import { select } from 'd3'; import { select } from 'd3';
import type { MermaidConfig } from '../config.type.js'; import type { MermaidConfig } from '../config.type.js';
import { getConfig, sanitizeText } from '../diagram-api/diagramAPI.js';
import type { SVGGroup } from '../diagram-api/types.js'; import type { SVGGroup } from '../diagram-api/types.js';
import common, { hasKatex, renderKatex } from '../diagrams/common/common.js';
import type { D3TSpanElement, D3TextElement } from '../diagrams/common/commonTypes.js'; import type { D3TSpanElement, D3TextElement } from '../diagrams/common/commonTypes.js';
import { log } from '../logger.js'; import { log } from '../logger.js';
import { markdownToHTML, markdownToLines } from '../rendering-util/handle-markdown-text.js'; import { markdownToHTML, markdownToLines } from '../rendering-util/handle-markdown-text.js';
import { decodeEntities } from '../utils.js'; import { decodeEntities } from '../utils.js';
import { getIconSVG, isIconAvailable } from './icons.js';
import { splitLineToFitWidth } from './splitText.js'; import { splitLineToFitWidth } from './splitText.js';
import type { MarkdownLine, MarkdownWord } from './types.js'; import type { MarkdownLine, MarkdownWord } from './types.js';
@@ -180,14 +181,28 @@ function updateTextContentAndStyles(tspan: any, wrappedLine: MarkdownWord[]) {
/** /**
* Convert fontawesome labels into fontawesome icons by using a regex pattern * Convert fontawesome labels into fontawesome icons by using a regex pattern
* @param text - The raw string to convert * @param text - The raw string to convert
* @returns string with fontawesome icons as i tags * @returns string with fontawesome icons as svg if the icon is registered otherwise as i tags
*/ */
export function replaceIconSubstring(text: string) { export async function replaceIconSubstring(text: string) {
// The letters 'bklrs' stand for possible endings of the fontawesome prefix (e.g. 'fab' for brands, 'fak' for fa-kit) // cspell: disable-line const pendingReplacements: Promise<string>[] = [];
return text.replace( // cspell: disable-next-line
/fa[bklrs]?:fa-[\w-]+/g, // cspell: disable-line text.replace(/(fa[bklrs]?):fa-([\w-]+)/g, (fullMatch, prefix, iconName) => {
(s) => `<i class='${s.replace(':', ' ')}'></i>` pendingReplacements.push(
); (async () => {
const registeredIconName = `${prefix}:${iconName}`;
if (await isIconAvailable(registeredIconName)) {
return await getIconSVG(registeredIconName, undefined, { class: 'label-icon' });
} else {
return `<i class='${sanitizeText(fullMatch).replace(':', ' ')}'></i>`;
}
})()
);
return fullMatch;
});
const replacements = await Promise.all(pendingReplacements);
// cspell: disable-next-line
return text.replace(/(fa[bklrs]?):fa-([\w-]+)/g, () => replacements.shift() ?? '');
} }
// 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' // 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'
@@ -221,7 +236,7 @@ export const createText = async (
// TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that? // TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that?
const htmlText = markdownToHTML(text, config); const htmlText = markdownToHTML(text, config);
const decodedReplacedText = replaceIconSubstring(decodeEntities(htmlText)); const decodedReplacedText = await replaceIconSubstring(decodeEntities(htmlText));
//for Katex the text could contain escaped characters, \\relax that should be transformed to \relax //for Katex the text could contain escaped characters, \\relax that should be transformed to \relax
const inputForKatex = text.replace(/\\\\/g, '\\'); const inputForKatex = text.replace(/\\\\/g, '\\');

View File

@@ -85,7 +85,8 @@ export const isIconAvailable = async (iconName: string) => {
export const getIconSVG = async ( export const getIconSVG = async (
iconName: string, iconName: string,
customisations?: IconifyIconCustomisations & { fallbackPrefix?: string } customisations?: IconifyIconCustomisations & { fallbackPrefix?: string },
extraAttributes?: Record<string, string>
) => { ) => {
let iconData: ExtendedIconifyIcon; let iconData: ExtendedIconifyIcon;
try { try {
@@ -95,6 +96,9 @@ export const getIconSVG = async (
iconData = unknownIcon; iconData = unknownIcon;
} }
const renderData = iconToSVG(iconData, customisations); const renderData = iconToSVG(iconData, customisations);
const svg = iconToHTML(replaceIDs(renderData.body), renderData.attributes); const svg = iconToHTML(replaceIDs(renderData.body), {
...renderData.attributes,
...extraAttributes,
});
return svg; return svg;
}; };