mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-02-04 07:13:25 +08:00
Merge pull request #4470 from mermaid-js/sidv/splitUnicode
Split formatted markdown strings with unicode support.
This commit is contained in:
commit
c99e1c689e
@ -228,7 +228,7 @@ mermaid fully supports webpack. Here is a [working demo](https://github.com/merm
|
|||||||
|
|
||||||
The main idea of the API is to be able to call a render function with the graph definition as a string. The render function will render the graph and call a callback with the resulting SVG code. With this approach it is up to the site creator to fetch the graph definition from the site (perhaps from a textarea), render it and place the graph somewhere in the site.
|
The main idea of the API is to be able to call a render function with the graph definition as a string. The render function will render the graph and call a callback with the resulting SVG code. With this approach it is up to the site creator to fetch the graph definition from the site (perhaps from a textarea), render it and place the graph somewhere in the site.
|
||||||
|
|
||||||
The example below show an outline of how this could be used. The example just logs the resulting SVG to the JavaScript console.
|
The example below shows an example of how this could be used. The example just logs the resulting SVG to the JavaScript console.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
// @ts-ignore - no types
|
|
||||||
import { LALRGenerator } from 'jison';
|
|
||||||
|
|
||||||
const getAbsolutePath = (relativePath: string) => {
|
|
||||||
return fileURLToPath(new URL(relativePath, import.meta.url));
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('class diagram grammar', function () {
|
|
||||||
it('should have no conflicts', async function () {
|
|
||||||
const grammarSource = await readFile(getAbsolutePath('./parser/classDiagram.jison'), 'utf8');
|
|
||||||
const grammarParser = new LALRGenerator(grammarSource, {});
|
|
||||||
expect(grammarParser.conflicts).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,31 +1,20 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
// @ts-nocheck TODO: Fix types
|
||||||
import { log } from '../logger.js';
|
import { log } from '../logger.js';
|
||||||
import { decodeEntities } from '../mermaidAPI.js';
|
import { decodeEntities } from '../mermaidAPI.js';
|
||||||
import { markdownToHTML, markdownToLines } from '../rendering-util/handle-markdown-text.js';
|
import { markdownToHTML, markdownToLines } from '../rendering-util/handle-markdown-text.js';
|
||||||
/**
|
import { splitLineToFitWidth } from './splitText.js';
|
||||||
* @param dom
|
import { MarkdownLine, MarkdownWord } from './types.js';
|
||||||
* @param styleFn
|
|
||||||
*/
|
|
||||||
function applyStyle(dom, styleFn) {
|
function applyStyle(dom, styleFn) {
|
||||||
if (styleFn) {
|
if (styleFn) {
|
||||||
dom.attr('style', styleFn);
|
dom.attr('style', styleFn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param element
|
|
||||||
* @param {any} node
|
|
||||||
* @param width
|
|
||||||
* @param classes
|
|
||||||
* @param addBackground
|
|
||||||
* @returns {SVGForeignObjectElement} Node
|
|
||||||
*/
|
|
||||||
function addHtmlSpan(element, node, width, classes, addBackground = false) {
|
function addHtmlSpan(element, node, width, classes, addBackground = false) {
|
||||||
const fo = element.append('foreignObject');
|
const fo = element.append('foreignObject');
|
||||||
// const newEl = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
|
||||||
// const newEl = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
|
||||||
const div = fo.append('xhtml:div');
|
const div = fo.append('xhtml:div');
|
||||||
// const div = body.append('div');
|
|
||||||
// const div = fo.append('div');
|
|
||||||
|
|
||||||
const label = node.label;
|
const label = node.label;
|
||||||
const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel';
|
const labelClass = node.isNode ? 'nodeLabel' : 'edgeLabel';
|
||||||
@ -64,12 +53,12 @@ function addHtmlSpan(element, node, width, classes, addBackground = false) {
|
|||||||
/**
|
/**
|
||||||
* Creates a tspan element with the specified attributes for text positioning.
|
* Creates a tspan element with the specified attributes for text positioning.
|
||||||
*
|
*
|
||||||
* @param {object} textElement - The parent text element to append the tspan element.
|
* @param textElement - The parent text element to append the tspan element.
|
||||||
* @param {number} lineIndex - The index of the current line in the structuredText array.
|
* @param lineIndex - The index of the current line in the structuredText array.
|
||||||
* @param {number} lineHeight - The line height value for the text.
|
* @param lineHeight - The line height value for the text.
|
||||||
* @returns {object} The created tspan element.
|
* @returns The created tspan element.
|
||||||
*/
|
*/
|
||||||
function createTspan(textElement, lineIndex, lineHeight) {
|
function createTspan(textElement: any, lineIndex: number, lineHeight: number) {
|
||||||
return textElement
|
return textElement
|
||||||
.append('tspan')
|
.append('tspan')
|
||||||
.attr('class', 'text-outer-tspan')
|
.attr('class', 'text-outer-tspan')
|
||||||
@ -78,17 +67,10 @@ function createTspan(textElement, lineIndex, lineHeight) {
|
|||||||
.attr('dy', lineHeight + 'em');
|
.attr('dy', lineHeight + 'em');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function computeWidthOfText(parentNode: any, lineHeight: number, line: MarkdownLine): number {
|
||||||
* Compute the width of rendered text
|
|
||||||
* @param {object} parentNode
|
|
||||||
* @param {number} lineHeight
|
|
||||||
* @param {string} text
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
function computeWidthOfText(parentNode, lineHeight, text) {
|
|
||||||
const testElement = parentNode.append('text');
|
const testElement = parentNode.append('text');
|
||||||
const testSpan = createTspan(testElement, 1, lineHeight);
|
const testSpan = createTspan(testElement, 1, lineHeight);
|
||||||
updateTextContentAndStyles(testSpan, [{ content: text, type: 'normal' }]);
|
updateTextContentAndStyles(testSpan, line);
|
||||||
const textLength = testSpan.node().getComputedTextLength();
|
const textLength = testSpan.node().getComputedTextLength();
|
||||||
testElement.remove();
|
testElement.remove();
|
||||||
return textLength;
|
return textLength;
|
||||||
@ -98,59 +80,37 @@ function computeWidthOfText(parentNode, lineHeight, text) {
|
|||||||
* Creates a formatted text element by breaking lines and applying styles based on
|
* Creates a formatted text element by breaking lines and applying styles based on
|
||||||
* the given structuredText.
|
* the given structuredText.
|
||||||
*
|
*
|
||||||
* @param {number} width - The maximum allowed width of the text.
|
* @param width - The maximum allowed width of the text.
|
||||||
* @param {object} g - The parent group element to append the formatted text.
|
* @param g - The parent group element to append the formatted text.
|
||||||
* @param {Array} structuredText - The structured text data to format.
|
* @param structuredText - The structured text data to format.
|
||||||
* @param addBackground
|
* @param addBackground - Whether to add a background to the text.
|
||||||
*/
|
*/
|
||||||
function createFormattedText(width, g, structuredText, addBackground = false) {
|
function createFormattedText(
|
||||||
|
width: number,
|
||||||
|
g: any,
|
||||||
|
structuredText: MarkdownWord[][],
|
||||||
|
addBackground = false
|
||||||
|
) {
|
||||||
const lineHeight = 1.1;
|
const lineHeight = 1.1;
|
||||||
const labelGroup = g.append('g');
|
const labelGroup = g.append('g');
|
||||||
let bkg = labelGroup.insert('rect').attr('class', 'background');
|
const bkg = labelGroup.insert('rect').attr('class', 'background');
|
||||||
const textElement = labelGroup.append('text').attr('y', '-10.1');
|
const textElement = labelGroup.append('text').attr('y', '-10.1');
|
||||||
// .attr('dominant-baseline', 'middle')
|
|
||||||
// .attr('text-anchor', 'middle');
|
|
||||||
// .attr('text-anchor', 'middle');
|
|
||||||
let lineIndex = 0;
|
let lineIndex = 0;
|
||||||
structuredText.forEach((line) => {
|
for (const line of structuredText) {
|
||||||
/**
|
/**
|
||||||
* Preprocess raw string content of line data
|
* Preprocess raw string content of line data
|
||||||
* Creating an array of strings pre-split to satisfy width limit
|
* Creating an array of strings pre-split to satisfy width limit
|
||||||
*/
|
*/
|
||||||
let fullStr = line.map((data) => data.content).join(' ');
|
const checkWidth = (line: MarkdownLine) =>
|
||||||
let tempStr = '';
|
computeWidthOfText(labelGroup, lineHeight, line) <= width;
|
||||||
let linesUnderWidth = [];
|
const linesUnderWidth = checkWidth(line) ? [line] : splitLineToFitWidth(line, checkWidth);
|
||||||
let prevIndex = 0;
|
|
||||||
if (computeWidthOfText(labelGroup, lineHeight, fullStr) <= width) {
|
|
||||||
linesUnderWidth.push(fullStr);
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i <= fullStr.length; i++) {
|
|
||||||
tempStr = fullStr.slice(prevIndex, i);
|
|
||||||
log.info(tempStr, prevIndex, i);
|
|
||||||
if (computeWidthOfText(labelGroup, lineHeight, tempStr) > width) {
|
|
||||||
const subStr = fullStr.slice(prevIndex, i);
|
|
||||||
// Break at space if any
|
|
||||||
const lastSpaceIndex = subStr.lastIndexOf(' ');
|
|
||||||
if (lastSpaceIndex > -1) {
|
|
||||||
i = prevIndex + lastSpaceIndex + 1;
|
|
||||||
}
|
|
||||||
linesUnderWidth.push(fullStr.slice(prevIndex, i).trim());
|
|
||||||
prevIndex = i;
|
|
||||||
tempStr = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tempStr != null) {
|
|
||||||
linesUnderWidth.push(tempStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** Add each prepared line as a tspan to the parent node */
|
/** Add each prepared line as a tspan to the parent node */
|
||||||
const preparedLines = linesUnderWidth.map((w) => ({ content: w, type: line.type }));
|
for (const preparedLine of linesUnderWidth) {
|
||||||
for (const preparedLine of preparedLines) {
|
const tspan = createTspan(textElement, lineIndex, lineHeight);
|
||||||
let tspan = createTspan(textElement, lineIndex, lineHeight);
|
updateTextContentAndStyles(tspan, preparedLine);
|
||||||
updateTextContentAndStyles(tspan, [preparedLine]);
|
|
||||||
lineIndex++;
|
lineIndex++;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
if (addBackground) {
|
if (addBackground) {
|
||||||
const bbox = textElement.node().getBBox();
|
const bbox = textElement.node().getBBox();
|
||||||
const padding = 2;
|
const padding = 2;
|
||||||
@ -159,7 +119,6 @@ function createFormattedText(width, g, structuredText, addBackground = false) {
|
|||||||
.attr('y', -padding)
|
.attr('y', -padding)
|
||||||
.attr('width', bbox.width + 2 * padding)
|
.attr('width', bbox.width + 2 * padding)
|
||||||
.attr('height', bbox.height + 2 * padding);
|
.attr('height', bbox.height + 2 * padding);
|
||||||
// .style('fill', 'red');
|
|
||||||
|
|
||||||
return labelGroup.node();
|
return labelGroup.node();
|
||||||
} else {
|
} else {
|
||||||
@ -171,40 +130,27 @@ function createFormattedText(width, g, structuredText, addBackground = false) {
|
|||||||
* Updates the text content and styles of the given tspan element based on the
|
* Updates the text content and styles of the given tspan element based on the
|
||||||
* provided wrappedLine data.
|
* provided wrappedLine data.
|
||||||
*
|
*
|
||||||
* @param {object} tspan - The tspan element to update.
|
* @param tspan - The tspan element to update.
|
||||||
* @param {Array} wrappedLine - The line data to apply to the tspan element.
|
* @param wrappedLine - The line data to apply to the tspan element.
|
||||||
*/
|
*/
|
||||||
function updateTextContentAndStyles(tspan, wrappedLine) {
|
function updateTextContentAndStyles(tspan: any, wrappedLine: MarkdownWord[]) {
|
||||||
tspan.text('');
|
tspan.text('');
|
||||||
|
|
||||||
wrappedLine.forEach((word, index) => {
|
wrappedLine.forEach((word, index) => {
|
||||||
const innerTspan = tspan
|
const innerTspan = tspan
|
||||||
.append('tspan')
|
.append('tspan')
|
||||||
.attr('font-style', word.type === 'em' ? 'italic' : 'normal')
|
.attr('font-style', word.type === 'emphasis' ? 'italic' : 'normal')
|
||||||
.attr('class', 'text-inner-tspan')
|
.attr('class', 'text-inner-tspan')
|
||||||
.attr('font-weight', word.type === 'strong' ? 'bold' : 'normal');
|
.attr('font-weight', word.type === 'strong' ? 'bold' : 'normal');
|
||||||
const special = ['"', "'", '.', ',', ':', ';', '!', '?', '(', ')', '[', ']', '{', '}'];
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
innerTspan.text(word.content);
|
innerTspan.text(word.content);
|
||||||
} else {
|
} else {
|
||||||
|
// TODO: check what joiner to use.
|
||||||
innerTspan.text(' ' + word.content);
|
innerTspan.text(' ' + word.content);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param el
|
|
||||||
* @param {*} text
|
|
||||||
* @param {*} param1
|
|
||||||
* @param root0
|
|
||||||
* @param root0.style
|
|
||||||
* @param root0.isTitle
|
|
||||||
* @param root0.classes
|
|
||||||
* @param root0.useHtmlLabels
|
|
||||||
* @param root0.isNode
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
// Note when using from flowcharts converting the API isNode means classes should be set accordingly. When using htmlLabels => to sett 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 sett classes to'nodeLabel' when isNode=true otherwise 'edgeLabel'
|
||||||
// When not using htmlLabels => to set classes to 'title-row' when isTitle=true otherwise 'title-row'
|
// When not using htmlLabels => to set classes to 'title-row' when isTitle=true otherwise 'title-row'
|
||||||
export const createText = (
|
export const createText = (
|
||||||
@ -234,7 +180,7 @@ export const createText = (
|
|||||||
),
|
),
|
||||||
labelStyle: style.replace('fill:', 'color:'),
|
labelStyle: style.replace('fill:', 'color:'),
|
||||||
};
|
};
|
||||||
let vertexNode = addHtmlSpan(el, node, width, classes, addSvgBackground);
|
const vertexNode = addHtmlSpan(el, node, width, classes, addSvgBackground);
|
||||||
return vertexNode;
|
return vertexNode;
|
||||||
} else {
|
} else {
|
||||||
const structuredText = markdownToLines(text);
|
const structuredText = markdownToLines(text);
|
@ -152,9 +152,8 @@ test('markdownToLines - Only italic formatting', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('markdownToLines - Mixed formatting', () => {
|
it('markdownToLines - Mixed formatting', () => {
|
||||||
const input = `*Italic* and **bold** formatting`;
|
let input = `*Italic* and **bold** formatting`;
|
||||||
|
let expected = [
|
||||||
const expectedOutput = [
|
|
||||||
[
|
[
|
||||||
{ content: 'Italic', type: 'emphasis' },
|
{ content: 'Italic', type: 'emphasis' },
|
||||||
{ content: 'and', type: 'normal' },
|
{ content: 'and', type: 'normal' },
|
||||||
@ -162,9 +161,21 @@ it('markdownToLines - Mixed formatting', () => {
|
|||||||
{ content: 'formatting', type: 'normal' },
|
{ content: 'formatting', type: 'normal' },
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
expect(markdownToLines(input)).toEqual(expected);
|
||||||
|
|
||||||
const output = markdownToLines(input);
|
input = `*Italic with space* and **bold ws** formatting`;
|
||||||
expect(output).toEqual(expectedOutput);
|
expected = [
|
||||||
|
[
|
||||||
|
{ content: 'Italic', type: 'emphasis' },
|
||||||
|
{ content: 'with', type: 'emphasis' },
|
||||||
|
{ content: 'space', type: 'emphasis' },
|
||||||
|
{ content: 'and', type: 'normal' },
|
||||||
|
{ content: 'bold', type: 'strong' },
|
||||||
|
{ content: 'ws', type: 'strong' },
|
||||||
|
{ content: 'formatting', type: 'normal' },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
expect(markdownToLines(input)).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('markdownToLines - Mixed formatting', () => {
|
it('markdownToLines - Mixed formatting', () => {
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
|
import type { Content } from 'mdast';
|
||||||
import { fromMarkdown } from 'mdast-util-from-markdown';
|
import { fromMarkdown } from 'mdast-util-from-markdown';
|
||||||
import { dedent } from 'ts-dedent';
|
import { dedent } from 'ts-dedent';
|
||||||
|
import { MarkdownLine, MarkdownWordType } from './types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} markdown markdown to process
|
* @param markdown - markdown to process
|
||||||
* @returns {string} processed markdown
|
* @returns processed markdown
|
||||||
*/
|
*/
|
||||||
function preprocessMarkdown(markdown) {
|
function preprocessMarkdown(markdown: string): string {
|
||||||
// Replace multiple newlines with a single newline
|
// Replace multiple newlines with a single newline
|
||||||
const withoutMultipleNewlines = markdown.replace(/\n{2,}/g, '\n');
|
const withoutMultipleNewlines = markdown.replace(/\n{2,}/g, '\n');
|
||||||
// Remove extra spaces at the beginning of each line
|
// Remove extra spaces at the beginning of each line
|
||||||
@ -14,19 +16,15 @@ function preprocessMarkdown(markdown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} markdown markdown to split into lines
|
* @param markdown - markdown to split into lines
|
||||||
*/
|
*/
|
||||||
export function markdownToLines(markdown) {
|
export function markdownToLines(markdown: string): MarkdownLine[] {
|
||||||
const preprocessedMarkdown = preprocessMarkdown(markdown);
|
const preprocessedMarkdown = preprocessMarkdown(markdown);
|
||||||
const { children } = fromMarkdown(preprocessedMarkdown);
|
const { children } = fromMarkdown(preprocessedMarkdown);
|
||||||
const lines = [[]];
|
const lines: MarkdownLine[] = [[]];
|
||||||
let currentLine = 0;
|
let currentLine = 0;
|
||||||
|
|
||||||
/**
|
function processNode(node: Content, parentType: MarkdownWordType = 'normal') {
|
||||||
* @param {import('mdast').Content} node
|
|
||||||
* @param {string} [parentType]
|
|
||||||
*/
|
|
||||||
function processNode(node, parentType = 'normal') {
|
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
const textLines = node.value.split('\n');
|
const textLines = node.value.split('\n');
|
||||||
textLines.forEach((textLine, index) => {
|
textLines.forEach((textLine, index) => {
|
||||||
@ -58,17 +56,10 @@ export function markdownToLines(markdown) {
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function markdownToHTML(markdown: string) {
|
||||||
* @param {string} markdown markdown to convert to HTML
|
|
||||||
* @returns {string} HTML
|
|
||||||
*/
|
|
||||||
export function markdownToHTML(markdown) {
|
|
||||||
const { children } = fromMarkdown(markdown);
|
const { children } = fromMarkdown(markdown);
|
||||||
|
|
||||||
/**
|
function output(node: Content): string {
|
||||||
* @param {import('mdast').Content} node
|
|
||||||
*/
|
|
||||||
function output(node) {
|
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
return node.value.replace(/\n/g, '<br/>');
|
return node.value.replace(/\n/g, '<br/>');
|
||||||
} else if (node.type === 'strong') {
|
} else if (node.type === 'strong') {
|
147
packages/mermaid/src/rendering-util/splitText.spec.ts
Normal file
147
packages/mermaid/src/rendering-util/splitText.spec.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { splitTextToChars, splitLineToFitWidth, splitLineToWords } from './splitText.js';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import type { CheckFitFunction, MarkdownLine, MarkdownWordType } from './types.js';
|
||||||
|
|
||||||
|
describe('when Intl.Segmenter is available', () => {
|
||||||
|
describe('splitText', () => {
|
||||||
|
it.each([
|
||||||
|
{ str: '', split: [] },
|
||||||
|
{ str: '🏳️⚧️🏳️🌈👩🏾❤️👨🏻', split: ['🏳️⚧️', '🏳️🌈', '👩🏾❤️👨🏻'] },
|
||||||
|
{ str: 'ok', split: ['o', 'k'] },
|
||||||
|
{ str: 'abc', split: ['a', 'b', 'c'] },
|
||||||
|
])('should split $str into graphemes', ({ str, split }: { str: string; split: string[] }) => {
|
||||||
|
expect(splitTextToChars(str)).toEqual(split);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('split lines', () => {
|
||||||
|
it('should create valid checkFit function', () => {
|
||||||
|
const checkFit5 = createCheckFn(5);
|
||||||
|
expect(checkFit5([{ content: 'hello', type: 'normal' }])).toBe(true);
|
||||||
|
expect(
|
||||||
|
checkFit5([
|
||||||
|
{ content: 'hello', type: 'normal' },
|
||||||
|
{ content: 'world', type: 'normal' },
|
||||||
|
])
|
||||||
|
).toBe(false);
|
||||||
|
const checkFit1 = createCheckFn(1);
|
||||||
|
expect(checkFit1([{ content: 'A', type: 'normal' }])).toBe(true);
|
||||||
|
expect(checkFit1([{ content: '🏳️⚧️', type: 'normal' }])).toBe(true);
|
||||||
|
expect(checkFit1([{ content: '🏳️⚧️🏳️⚧️', type: 'normal' }])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
// empty string
|
||||||
|
{ str: 'hello world', width: 7, split: ['hello', 'world'] },
|
||||||
|
// width > full line
|
||||||
|
{ str: 'hello world', width: 20, split: ['hello world'] },
|
||||||
|
// width < individual word
|
||||||
|
{ str: 'hello world', width: 3, split: ['hel', 'lo', 'wor', 'ld'] },
|
||||||
|
{ str: 'hello 12 world', width: 4, split: ['hell', 'o 12', 'worl', 'd'] },
|
||||||
|
{ str: 'hello 1 2 world', width: 4, split: ['hell', 'o 1', '2', 'worl', 'd'] },
|
||||||
|
{ str: 'hello 1 2 world', width: 6, split: ['hello', ' 1 2', 'world'] },
|
||||||
|
// width = 0, impossible, so split into individual characters
|
||||||
|
{ str: '🏳️⚧️🏳️🌈👩🏾❤️👨🏻', width: 0, split: ['🏳️⚧️', '🏳️🌈', '👩🏾❤️👨🏻'] },
|
||||||
|
{ str: '🏳️⚧️🏳️🌈👩🏾❤️👨🏻', width: 1, split: ['🏳️⚧️', '🏳️🌈', '👩🏾❤️👨🏻'] },
|
||||||
|
{ str: '🏳️⚧️🏳️🌈👩🏾❤️👨🏻', width: 2, split: ['🏳️⚧️🏳️🌈', '👩🏾❤️👨🏻'] },
|
||||||
|
{ str: '🏳️⚧️🏳️🌈👩🏾❤️👨🏻', width: 3, split: ['🏳️⚧️🏳️🌈👩🏾❤️👨🏻'] },
|
||||||
|
{ str: '中文中', width: 1, split: ['中', '文', '中'] },
|
||||||
|
{ str: '中文中', width: 2, split: ['中文', '中'] },
|
||||||
|
{ str: '中文中', width: 3, split: ['中文中'] },
|
||||||
|
{ str: 'Flag 🏳️⚧️ this 🏳️🌈', width: 6, split: ['Flag 🏳️⚧️', 'this 🏳️🌈'] },
|
||||||
|
])(
|
||||||
|
'should split $str into lines of $width characters',
|
||||||
|
({ str, split, width }: { str: string; width: number; split: string[] }) => {
|
||||||
|
const checkFn = createCheckFn(width);
|
||||||
|
const line: MarkdownLine = getLineFromString(str);
|
||||||
|
expect(splitLineToFitWidth(line, checkFn)).toEqual(
|
||||||
|
split.map((str) => getLineFromString(str))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intl.Segmenter is not supported in Firefox yet,
|
||||||
|
* see https://bugzilla.mozilla.org/show_bug.cgi?id=1423593
|
||||||
|
*/
|
||||||
|
describe('when Intl.Segmenter is not available', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal('Intl', { Segmenter: undefined });
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ str: '', split: [] },
|
||||||
|
{
|
||||||
|
str: '🏳️⚧️🏳️🌈👩🏾❤️👨🏻',
|
||||||
|
split: [...'🏳️⚧️🏳️🌈👩🏾❤️👨🏻'],
|
||||||
|
},
|
||||||
|
{ str: 'ok', split: ['o', 'k'] },
|
||||||
|
{ str: 'abc', split: ['a', 'b', 'c'] },
|
||||||
|
])('should split $str into characters', ({ str, split }: { str: string; split: string[] }) => {
|
||||||
|
expect(splitTextToChars(str)).toEqual(split);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
// empty string
|
||||||
|
{ str: 'hello world', width: 7, split: ['hello', 'world'] },
|
||||||
|
// width > full line
|
||||||
|
{ str: 'hello world', width: 20, split: ['hello world'] },
|
||||||
|
// width < individual word
|
||||||
|
{ str: 'hello world', width: 3, split: ['hel', 'lo', 'wor', 'ld'] },
|
||||||
|
{ str: 'hello 12 world', width: 4, split: ['hell', 'o 12', 'worl', 'd'] },
|
||||||
|
{ str: 'hello 1 2 world', width: 4, split: ['hell', 'o 1', '2', 'worl', 'd'] },
|
||||||
|
{ str: 'hello 1 2 world', width: 6, split: ['hello', ' 1 2', 'world'] },
|
||||||
|
// width = 0, impossible, so split into individual characters
|
||||||
|
{ str: 'abc', width: 0, split: ['a', 'b', 'c'] },
|
||||||
|
{ str: '🏳️⚧️🏳️🌈👩🏾❤️👨🏻', width: 1, split: [...'🏳️⚧️🏳️🌈👩🏾❤️👨🏻'] },
|
||||||
|
{ str: '中文中', width: 1, split: ['中', '文', '中'] },
|
||||||
|
{ str: '中文中', width: 2, split: ['中文', '中'] },
|
||||||
|
{ str: '中文中', width: 3, split: ['中文中'] },
|
||||||
|
])(
|
||||||
|
'should split $str into lines of $width characters',
|
||||||
|
({ str, split, width }: { str: string; width: number; split: string[] }) => {
|
||||||
|
const checkFn = createCheckFn(width);
|
||||||
|
const line: MarkdownLine = getLineFromString(str);
|
||||||
|
expect(splitLineToFitWidth(line, checkFn)).toEqual(
|
||||||
|
split.map((str) => getLineFromString(str))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle strings with newlines', () => {
|
||||||
|
const checkFn: CheckFitFunction = createCheckFn(6);
|
||||||
|
const str = `Flag
|
||||||
|
🏳️⚧️ this 🏳️🌈`;
|
||||||
|
expect(() =>
|
||||||
|
splitLineToFitWidth(getLineFromString(str), checkFn)
|
||||||
|
).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
'"splitLineToFitWidth does not support newlines in the line"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLineFromString = (str: string, type: MarkdownWordType = 'normal'): MarkdownLine => {
|
||||||
|
return splitLineToWords(str).map((content) => ({
|
||||||
|
content,
|
||||||
|
type,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a checkFunction for a given width
|
||||||
|
* @param width - width of characters to fit in a line
|
||||||
|
* @returns checkFunction
|
||||||
|
*/
|
||||||
|
const createCheckFn = (width: number): CheckFitFunction => {
|
||||||
|
return (text: MarkdownLine) => {
|
||||||
|
// Join all words into a single string
|
||||||
|
const joinedContent = text.map((w) => w.content).join('');
|
||||||
|
const characters = splitTextToChars(joinedContent);
|
||||||
|
return characters.length <= width;
|
||||||
|
};
|
||||||
|
};
|
135
packages/mermaid/src/rendering-util/splitText.ts
Normal file
135
packages/mermaid/src/rendering-util/splitText.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import type { CheckFitFunction, MarkdownLine, MarkdownWord, MarkdownWordType } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a string into graphemes if available, otherwise characters.
|
||||||
|
*/
|
||||||
|
export function splitTextToChars(text: string): string[] {
|
||||||
|
if (Intl.Segmenter) {
|
||||||
|
return [...new Intl.Segmenter().segment(text)].map((s) => s.segment);
|
||||||
|
}
|
||||||
|
return [...text];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a string into words by using `Intl.Segmenter` if available, or splitting by ' '.
|
||||||
|
* `Intl.Segmenter` uses the default locale, which might be different across browsers.
|
||||||
|
*/
|
||||||
|
export function splitLineToWords(text: string): string[] {
|
||||||
|
if (Intl.Segmenter) {
|
||||||
|
return [...new Intl.Segmenter(undefined, { granularity: 'word' }).segment(text)].map(
|
||||||
|
(s) => s.segment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Split by ' ' removes the ' 's from the result.
|
||||||
|
const words = text.split(' ');
|
||||||
|
// Add the ' 's back to the result.
|
||||||
|
const wordsWithSpaces = words.flatMap((s) => [s, ' ']).filter((s) => s);
|
||||||
|
// Remove last space.
|
||||||
|
wordsWithSpaces.pop();
|
||||||
|
return wordsWithSpaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a word into two parts, the first part fits the width and the remaining part.
|
||||||
|
* @param checkFit - Function to check if word fits
|
||||||
|
* @param word - Word to split
|
||||||
|
* @returns [first part of word that fits, rest of word]
|
||||||
|
*/
|
||||||
|
export function splitWordToFitWidth(
|
||||||
|
checkFit: CheckFitFunction,
|
||||||
|
word: MarkdownWord
|
||||||
|
): [MarkdownWord, MarkdownWord] {
|
||||||
|
const characters = splitTextToChars(word.content);
|
||||||
|
return splitWordToFitWidthRecursion(checkFit, [], characters, word.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitWordToFitWidthRecursion(
|
||||||
|
checkFit: CheckFitFunction,
|
||||||
|
usedChars: string[],
|
||||||
|
remainingChars: string[],
|
||||||
|
type: MarkdownWordType
|
||||||
|
): [MarkdownWord, MarkdownWord] {
|
||||||
|
if (remainingChars.length === 0) {
|
||||||
|
return [
|
||||||
|
{ content: usedChars.join(''), type },
|
||||||
|
{ content: '', type },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const [nextChar, ...rest] = remainingChars;
|
||||||
|
const newWord = [...usedChars, nextChar];
|
||||||
|
if (checkFit([{ content: newWord.join(''), type }])) {
|
||||||
|
return splitWordToFitWidthRecursion(checkFit, newWord, rest, type);
|
||||||
|
}
|
||||||
|
if (usedChars.length === 0 && nextChar) {
|
||||||
|
// If the first character does not fit, split it anyway
|
||||||
|
usedChars.push(nextChar);
|
||||||
|
remainingChars.shift();
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ content: usedChars.join(''), type },
|
||||||
|
{ content: remainingChars.join(''), type },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a line into multiple lines that satisfy the checkFit function.
|
||||||
|
* @param line - Line to split
|
||||||
|
* @param checkFit - Function to check if line fits
|
||||||
|
* @returns Array of lines that fit
|
||||||
|
*/
|
||||||
|
export function splitLineToFitWidth(
|
||||||
|
line: MarkdownLine,
|
||||||
|
checkFit: CheckFitFunction
|
||||||
|
): MarkdownLine[] {
|
||||||
|
if (line.some(({ content }) => content.includes('\n'))) {
|
||||||
|
throw new Error('splitLineToFitWidth does not support newlines in the line');
|
||||||
|
}
|
||||||
|
return splitLineToFitWidthRecursion(line, checkFit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLineToFitWidthRecursion(
|
||||||
|
words: MarkdownWord[],
|
||||||
|
checkFit: CheckFitFunction,
|
||||||
|
lines: MarkdownLine[] = [],
|
||||||
|
newLine: MarkdownLine = []
|
||||||
|
): MarkdownLine[] {
|
||||||
|
// Return if there is nothing left to split
|
||||||
|
if (words.length === 0) {
|
||||||
|
// If there is a new line, add it to the lines
|
||||||
|
if (newLine.length > 0) {
|
||||||
|
lines.push(newLine);
|
||||||
|
}
|
||||||
|
return lines.length > 0 ? lines : [];
|
||||||
|
}
|
||||||
|
let joiner = '';
|
||||||
|
if (words[0].content === ' ') {
|
||||||
|
joiner = ' ';
|
||||||
|
words.shift();
|
||||||
|
}
|
||||||
|
const nextWord: MarkdownWord = words.shift() ?? { content: ' ', type: 'normal' };
|
||||||
|
const lineWithNextWord: MarkdownLine = [...newLine];
|
||||||
|
if (joiner !== '') {
|
||||||
|
lineWithNextWord.push({ content: joiner, type: 'normal' });
|
||||||
|
}
|
||||||
|
lineWithNextWord.push(nextWord);
|
||||||
|
|
||||||
|
if (checkFit(lineWithNextWord)) {
|
||||||
|
// nextWord fits, so we can add it to the new line and continue
|
||||||
|
return splitLineToFitWidthRecursion(words, checkFit, lines, lineWithNextWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextWord doesn't fit, so we need to split it
|
||||||
|
if (newLine.length > 0) {
|
||||||
|
// There was text in newLine, so add it to lines and push nextWord back into words.
|
||||||
|
lines.push(newLine);
|
||||||
|
words.unshift(nextWord);
|
||||||
|
} else if (nextWord.content) {
|
||||||
|
// There was no text in newLine, so we need to split nextWord
|
||||||
|
const [line, rest] = splitWordToFitWidth(checkFit, nextWord);
|
||||||
|
lines.push([line]);
|
||||||
|
if (rest.content) {
|
||||||
|
words.unshift(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return splitLineToFitWidthRecursion(words, checkFit, lines);
|
||||||
|
}
|
8
packages/mermaid/src/rendering-util/types.d.ts
vendored
Normal file
8
packages/mermaid/src/rendering-util/types.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export type MarkdownWordType = 'normal' | 'strong' | 'emphasis';
|
||||||
|
export interface MarkdownWord {
|
||||||
|
content: string;
|
||||||
|
type: MarkdownWordType;
|
||||||
|
}
|
||||||
|
export type MarkdownLine = MarkdownWord[];
|
||||||
|
/** Returns `true` if the line fits a constraint (e.g. it's under 𝑛 chars) */
|
||||||
|
export type CheckFitFunction = (text: MarkdownLine) => boolean;
|
@ -14,7 +14,7 @@
|
|||||||
"target": "ES6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
"target": "ES6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||||
"lib": [
|
"lib": [
|
||||||
"DOM",
|
"DOM",
|
||||||
"ES2021"
|
"ES2022"
|
||||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||||
|
Loading…
x
Reference in New Issue
Block a user