Handle proper formatting for markdown strings

This commit is contained in:
Sidharth Vinod
2023-07-06 20:34:17 +05:30
parent f5484636aa
commit 60a93f7377
6 changed files with 145 additions and 111 deletions

View File

@@ -1,25 +1,17 @@
/* 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'; import { splitLineToFitWidth } from './splitText.js';
/** import { MarkdownLine, MarkdownWord } from './types.js';
* @param dom
* @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');
@@ -65,12 +57,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')
@@ -79,55 +71,41 @@ 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;
} }
/** function createFormattedText(
* Creates a formatted text element by breaking lines and applying styles based on width: number,
* the given structuredText. g: any,
* structuredText: MarkdownWord[][],
* @param {number} width - The maximum allowed width of the text. addBackground = false
* @param {object} g - The parent group element to append the formatted text. ) {
* @param {Array} structuredText - The structured text data to format.
* @param addBackground
*/
function createFormattedText(width, g, structuredText, 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');
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) =>
const checkWidth = (str) => computeWidthOfText(labelGroup, lineHeight, str) <= width; computeWidthOfText(labelGroup, lineHeight, line) <= width;
const linesUnderWidth = checkWidth(fullStr) const linesUnderWidth = checkWidth(line) ? [line] : splitLineToFitWidth(line, checkWidth);
? [fullStr]
: splitLineToFitWidth(fullStr, checkWidth);
/** 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;
@@ -143,44 +121,25 @@ function createFormattedText(width, g, structuredText, addBackground = false) {
} }
} }
/** function updateTextContentAndStyles(tspan: any, wrappedLine: MarkdownWord[]) {
* Updates the text content and styles of the given tspan element based on the
* provided wrappedLine data.
*
* @param {object} tspan - The tspan element to update.
* @param {Array} wrappedLine - The line data to apply to the tspan element.
*/
function updateTextContentAndStyles(tspan, wrappedLine) {
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 = ['"', "'", '.', ',', ':', ';', '!', '?', '(', ')', '[', ']', '{', '}']; // 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 = (
@@ -210,7 +169,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);

View File

@@ -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', () => {

View File

@@ -1,6 +1,7 @@
import type { Content } from 'mdast'; 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 markdown - markdown to process * @param markdown - markdown to process
@@ -17,13 +18,13 @@ function preprocessMarkdown(markdown: string): string {
/** /**
* @param markdown - markdown to split into lines * @param markdown - markdown to split into lines
*/ */
export function markdownToLines(markdown: string) { export function markdownToLines(markdown: string): MarkdownLine[] {
const preprocessedMarkdown = preprocessMarkdown(markdown); const preprocessedMarkdown = preprocessMarkdown(markdown);
const { children } = fromMarkdown(preprocessedMarkdown); const { children } = fromMarkdown(preprocessedMarkdown);
const lines: { content: string; type: string }[][] = [[]]; const lines: MarkdownLine[] = [[]];
let currentLine = 0; let currentLine = 0;
function processNode(node: Content, parentType = 'normal') { function processNode(node: Content, parentType: MarkdownWordType = '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) => {

View File

@@ -1,5 +1,6 @@
import { splitTextToChars, splitLineToFitWidth, type CheckFitFunction } from './splitText.js'; import { splitTextToChars, splitLineToFitWidth, splitLineToWords } from './splitText.js';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import type { CheckFitFunction, MarkdownLine, MarkdownWordType } from './types.js';
describe('splitText', () => { describe('splitText', () => {
it.each([ it.each([
@@ -13,12 +14,35 @@ describe('splitText', () => {
}); });
describe('split lines', () => { describe('split lines', () => {
/**
* Creates a checkFunction for a given width
* @param width - width of characters to fit in a line
* @returns checkFunction
*/
const createCheckFn = (width: number): CheckFitFunction => { const createCheckFn = (width: number): CheckFitFunction => {
return (text: string) => { return (text: MarkdownLine) => {
return splitTextToChars(text).length <= width; // Join all words into a single string
const joinedContent = text.map((w) => w.content).join('');
const characters = splitTextToChars(joinedContent);
return characters.length <= width;
}; };
}; };
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([ it.each([
// empty string // empty string
{ str: 'hello world', width: 7, split: ['hello', 'world'] }, { str: 'hello world', width: 7, split: ['hello', 'world'] },
@@ -40,7 +64,10 @@ describe('split lines', () => {
'should split $str into lines of $width characters', 'should split $str into lines of $width characters',
({ str, split, width }: { str: string; width: number; split: string[] }) => { ({ str, split, width }: { str: string; width: number; split: string[] }) => {
const checkFn = createCheckFn(width); const checkFn = createCheckFn(width);
expect(splitLineToFitWidth(str, checkFn)).toEqual(split); const line: MarkdownLine = getLineFromString(str);
expect(splitLineToFitWidth(line, checkFn)).toEqual(
split.map((str) => splitLineToWords(str).map((content) => ({ content, type: 'normal' })))
);
} }
); );
@@ -48,8 +75,17 @@ describe('split lines', () => {
const checkFn: CheckFitFunction = createCheckFn(6); const checkFn: CheckFitFunction = createCheckFn(6);
const str = `Flag const str = `Flag
🏳️‍⚧️ this 🏳️‍🌈`; 🏳️‍⚧️ this 🏳️‍🌈`;
expect(() => splitLineToFitWidth(str, checkFn)).toThrowErrorMatchingInlineSnapshot( expect(() =>
splitLineToFitWidth(getLineFromString(str), checkFn)
).toThrowErrorMatchingInlineSnapshot(
'"splitLineToFitWidth does not support newlines in the line"' '"splitLineToFitWidth does not support newlines in the line"'
); );
}); });
}); });
const getLineFromString = (str: string, type: MarkdownWordType = 'normal'): MarkdownLine => {
return splitLineToWords(str).map((content) => ({
content,
type,
}));
};

View File

@@ -1,4 +1,4 @@
export type CheckFitFunction = (text: string) => boolean; import type { CheckFitFunction, MarkdownLine, MarkdownWord, MarkdownWordType } from './types.js';
/** /**
* Splits a string into graphemes if available, otherwise characters. * Splits a string into graphemes if available, otherwise characters.
@@ -13,7 +13,7 @@ export function splitTextToChars(text: string): string[] {
/** /**
* Splits a string into words. * Splits a string into words.
*/ */
function splitLineToWords(text: string): string[] { export function splitLineToWords(text: string): string[] {
if (Intl.Segmenter) { if (Intl.Segmenter) {
return [...new Intl.Segmenter(undefined, { granularity: 'word' }).segment(text)].map( return [...new Intl.Segmenter(undefined, { granularity: 'word' }).segment(text)].map(
(s) => s.segment (s) => s.segment
@@ -34,46 +34,61 @@ function splitLineToWords(text: string): string[] {
* @param word - Word to split * @param word - Word to split
* @returns [first part of word that fits, rest of word] * @returns [first part of word that fits, rest of word]
*/ */
export function splitWordToFitWidth(checkFit: CheckFitFunction, word: string): [string, string] { export function splitWordToFitWidth(
const characters = splitTextToChars(word); checkFit: CheckFitFunction,
word: MarkdownWord
): [MarkdownWord, MarkdownWord] {
const characters = splitTextToChars(word.content);
if (characters.length === 0) { if (characters.length === 0) {
return ['', '']; return [
{ content: '', type: word.type },
{ content: '', type: word.type },
];
} }
return splitWordToFitWidthRecursion(checkFit, [], characters); return splitWordToFitWidthRecursion(checkFit, [], characters, word.type);
} }
function splitWordToFitWidthRecursion( function splitWordToFitWidthRecursion(
checkFit: CheckFitFunction, checkFit: CheckFitFunction,
usedChars: string[], usedChars: string[],
remainingChars: string[] remainingChars: string[],
): [string, string] { type: MarkdownWordType
): [MarkdownWord, MarkdownWord] {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error({ usedChars, remainingChars }); console.error({ usedChars, remainingChars });
if (remainingChars.length === 0) { if (remainingChars.length === 0) {
return [usedChars.join(''), '']; return [
{ content: usedChars.join(''), type },
{ content: '', type },
];
} }
const [nextChar, ...rest] = remainingChars; const [nextChar, ...rest] = remainingChars;
const newWord = [...usedChars, nextChar]; const newWord = [...usedChars, nextChar];
if (checkFit(newWord.join(''))) { if (checkFit([{ content: newWord.join(''), type }])) {
return splitWordToFitWidthRecursion(checkFit, newWord, rest); return splitWordToFitWidthRecursion(checkFit, newWord, rest, type);
} }
return [usedChars.join(''), remainingChars.join('')]; return [
{ content: usedChars.join(''), type },
{ content: remainingChars.join(''), type },
];
} }
export function splitLineToFitWidth(line: string, checkFit: CheckFitFunction): string[] { export function splitLineToFitWidth(
if (line.includes('\n')) { line: MarkdownLine,
checkFit: CheckFitFunction
): MarkdownLine[] {
if (line.some(({ content }) => content.includes('\n'))) {
throw new Error('splitLineToFitWidth does not support newlines in the line'); throw new Error('splitLineToFitWidth does not support newlines in the line');
} }
const words = splitLineToWords(line); return splitLineToFitWidthRecursion(line, checkFit);
return splitLineToFitWidthRecursion(words, checkFit);
} }
function splitLineToFitWidthRecursion( function splitLineToFitWidthRecursion(
words: string[], words: MarkdownWord[],
checkFit: CheckFitFunction, checkFit: CheckFitFunction,
lines: string[] = [], lines: MarkdownLine[] = [],
newLine = '' newLine: MarkdownLine = []
): string[] { ): MarkdownLine[] {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error({ words, lines, newLine }); console.error({ words, lines, newLine });
// Return if there is nothing left to split // Return if there is nothing left to split
@@ -82,17 +97,22 @@ function splitLineToFitWidthRecursion(
if (newLine.length > 0) { if (newLine.length > 0) {
lines.push(newLine); lines.push(newLine);
} }
return lines.length > 0 ? lines : ['']; return lines.length > 0 ? lines : [];
} }
let joiner = ''; let joiner = '';
if (words[0] === ' ') { if (words[0].content === ' ') {
joiner = ' '; joiner = ' ';
words.shift(); words.shift();
} }
const nextWord = words.shift() ?? ' '; const nextWord: MarkdownWord = words.shift() ?? { content: ' ', type: 'normal' };
// const nextWordWithJoiner: MarkdownWord = { ...nextWord, content: joiner + nextWord.content };
const lineWithNextWord: MarkdownLine = [...newLine];
if (joiner !== '') {
lineWithNextWord.push({ content: joiner, type: 'normal' });
}
lineWithNextWord.push(nextWord);
const nextWordWithJoiner = joiner + nextWord;
const lineWithNextWord = newLine ? `${newLine}${nextWordWithJoiner}` : nextWordWithJoiner;
if (checkFit(lineWithNextWord)) { if (checkFit(lineWithNextWord)) {
// nextWord fits, so we can add it to the new line and continue // nextWord fits, so we can add it to the new line and continue
return splitLineToFitWidthRecursion(words, checkFit, lines, lineWithNextWord); return splitLineToFitWidthRecursion(words, checkFit, lines, lineWithNextWord);
@@ -106,7 +126,7 @@ function splitLineToFitWidthRecursion(
} else { } else {
// There was no text in newLine, so we need to split nextWord // There was no text in newLine, so we need to split nextWord
const [line, rest] = splitWordToFitWidth(checkFit, nextWord); const [line, rest] = splitWordToFitWidth(checkFit, nextWord);
lines.push(line); lines.push([line]);
words.unshift(rest); words.unshift(rest);
} }
return splitLineToFitWidthRecursion(words, checkFit, lines); return splitLineToFitWidthRecursion(words, checkFit, lines);

View File

@@ -0,0 +1,7 @@
export type MarkdownWordType = 'normal' | 'strong' | 'emphasis';
export interface MarkdownWord {
content: string;
type: MarkdownWordType;
}
export type MarkdownLine = MarkdownWord[];
export type CheckFitFunction = (text: MarkdownLine) => boolean;