From 69acf4d3614ebaaaf41460c3a6c7fa026ca4de26 Mon Sep 17 00:00:00 2001 From: wimrijnders Date: Fri, 22 Sep 2017 15:59:30 +0200 Subject: [PATCH] Network: Fix handling of space before huge word in label text. (#3470) * Refactoring of label splitting; new unit test still failing. * Label acc size calculations to separate methods. * Final fixes and cleanup --- .../modules/components/shared/Label.js | 607 +----------------- .../components/shared/LabelAccumulator.js | 238 +++++++ .../components/shared/LabelSplitter.js | 546 ++++++++++++++++ test/Label.test.js | 245 ++++++- 4 files changed, 1036 insertions(+), 600 deletions(-) create mode 100644 lib/network/modules/components/shared/LabelAccumulator.js create mode 100644 lib/network/modules/components/shared/LabelSplitter.js diff --git a/lib/network/modules/components/shared/Label.js b/lib/network/modules/components/shared/Label.js index 1548ce8c6..168655006 100644 --- a/lib/network/modules/components/shared/Label.js +++ b/lib/network/modules/components/shared/Label.js @@ -1,154 +1,8 @@ let util = require('../../../../util'); let ComponentUtil = require('./ComponentUtil').default; - -/** - * Callback to determine text dimensions, using the parent label settings. - * @callback MeasureText - * @param {text} text - * @returns {number} - */ +let LabelSplitter = require('./LabelSplitter').default; -/** - * Internal helper class used for splitting a label text into lines. - * - * This has been moved away from the label processing code for better undestanding upon reading. - * - * @private - */ -class LabelAccumulator { - /** - * @param {MeasureText} measureText - */ - constructor(measureText) { - this.measureText = measureText; - this.current = 0; - this.width = 0; - this.height = 0; - this.lines = []; - } - - - /** - * Append given text to the given line. - * - * @param {number} l index of line to add to - * @param {string} text string to append to line - * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] - * @private - */ - _add(l, text, mod = 'normal') { - if (text === undefined || text === "") return; - - if (this.lines[l] === undefined) { - this.lines[l] = { - width : 0, - height: 0, - blocks: [] - }; - } - - // Determine width and get the font properties - let result = this.measureText(text, mod); - let block = Object.assign({}, result.values); - block.text = text; - block.width = result.width; - block.mod = mod; - - this.lines[l].blocks.push(block); - - // Update the line width. We need this for - // determining if a string goes over max width - this.lines[l].width += result.width; - } - - - /** - * Returns the width in pixels of the current line. - * - * @returns {number} - */ - curWidth() { - let line = this.lines[this.current]; - if (line === undefined) return 0; - - return line.width; - } - - - /** - * Add text in block to current line - * - * @param {string} text - * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] - */ - append(text, mod = 'normal') { - this._add(this.current, text, mod); - } - - - /** - * Add text in block to current line and start a new line - * - * @param {string} text - * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] - */ - newLine(text, mod = 'normal') { - this._add(this.current, text, mod); - this.current++; - } - - - /** - * Set the sizes for all lines and the whole thing. - * - * @returns {{width: (number|*), height: (number|*), lines: Array}} - */ - finalize() { - // console.log(JSON.stringify(this.lines, null, 2)); - - // Determine the heights of the lines - // Note that width has already been set - for (let k = 0; k < this.lines.length; k++) { - let line = this.lines[k]; - - // Looking for max height of blocks in line - let height = 0; - for (let l = 0; l < line.blocks.length; l++) { - let block = line.blocks[l]; - - if (height < block.height) { - height = block.height; - } - } - - line.height = height; - } - - // Determine the full label size - let width = 0; - let height = 0; - for (let k = 0; k < this.lines.length; k++) { - let line = this.lines[k]; - - if (line.width > width) { - width = line.width; - } - height += line.height; - } - - this.width = width; - this.height = height; - - // Return a simple hash object for further processing. - return { - width : this.width, - height: this.height, - lines : this.lines - } - } -} - /** * A Label to be used for Nodes or Edges. */ @@ -160,7 +14,6 @@ class Label { */ constructor(body, options, edgelabel = false) { this.body = body; - this.pointToSelf = false; this.baseSize = undefined; this.fontOptions = {}; @@ -169,6 +22,7 @@ class Label { this.isEdgeLabel = edgelabel; } + /** * * @param {Object} options @@ -198,6 +52,7 @@ class Label { } } + /** * * @param {Object} parentOptions @@ -486,12 +341,11 @@ class Label { // update the size cache if required this.calculateLabelSize(ctx, selected, hover, x, y, baseline); - // create the fontfill background - this._drawBackground(ctx); - // draw text + this._drawBackground(ctx); // create the fontfill background this._drawText(ctx, selected, hover, x, y, baseline); } + /** * Draws the label background * @param {CanvasRenderingContext2D} ctx @@ -538,7 +392,9 @@ class Label { _drawText(ctx, selected, hover, x, y, baseline = 'middle') { let fontSize = this.fontOptions.size; let viewFontSize = fontSize * this.body.view.scale; - // this ensures that there will not be HUGE letters on screen by setting an upper limit on the visible text size (regardless of zoomLevel) + + // This ensures that there will not be HUGE letters on screen + // by setting an upper limit on the visible text size (regardless of zoomLevel) if (viewFontSize >= this.elementOptions.scaling.label.maxVisible) { // TODO: Does this actually do anything? fontSize = Number(this.elementOptions.scaling.label.maxVisible) / this.body.view.scale; @@ -686,281 +542,6 @@ class Label { this.labelDirty = false; } - /** - * normalize the markup system - * - * @param {boolean|'md'|'markdown'|'html'} markupSystem - * @returns {string} - */ - decodeMarkupSystem(markupSystem) { - let system = 'none'; - if (markupSystem === 'markdown' || markupSystem === 'md') { - system = 'markdown'; - } else if (markupSystem === true || markupSystem === 'html') { - system = 'html' - } - return system; - } - - /** - * Explodes a piece of text into single-font blocks using a given markup - * @param {string} text - * @param {boolean|'md'|'markdown'|'html'} markupSystem - * @returns {Array.<{text: string, mod: string}>} - */ - splitBlocks(text, markupSystem) { - let system = this.decodeMarkupSystem(markupSystem); - if (system === 'none') { - return [{ - text: text, - mod: 'normal' - }] - } else if (system === 'markdown') { - return this.splitMarkdownBlocks(text); - } else if (system === 'html') { - return this.splitHtmlBlocks(text); - } - } - - /** - * - * @param {string} text - * @returns {Array} - */ - splitMarkdownBlocks(text) { - let blocks = []; - let s = { - bold: false, - ital: false, - mono: false, - beginable: true, - spacing: false, - position: 0, - buffer: "", - modStack: [] - }; - s.mod = function() { - return (this.modStack.length === 0) ? 'normal' : this.modStack[0]; - }; - s.modName = function() { - if (this.modStack.length === 0) - return 'normal'; - else if (this.modStack[0] === 'mono') - return 'mono'; - else { - if (s.bold && s.ital) { - return 'boldital'; - } else if (s.bold) { - return 'bold'; - } else if (s.ital) { - return 'ital'; - } - } - }; - s.emitBlock = function(override=false) { // eslint-disable-line no-unused-vars - if (this.spacing) { - this.add(" "); - this.spacing = false; - } - if (this.buffer.length > 0) { - blocks.push({ text: this.buffer, mod: this.modName() }); - this.buffer = ""; - } - }; - s.add = function(text) { - if (text === " ") { - s.spacing = true; - } - if (s.spacing) { - this.buffer += " "; - this.spacing = false; - } - if (text != " ") { - this.buffer += text; - } - }; - while (s.position < text.length) { - let ch = text.charAt(s.position); - if (/[ \t]/.test(ch)) { - if (!s.mono) { - s.spacing = true; - } else { - s.add(ch); - } - s.beginable = true - } else if (/\\/.test(ch)) { - if (s.position < text.length+1) { - s.position++; - ch = text.charAt(s.position); - if (/ \t/.test(ch)) { - s.spacing = true; - } else { - s.add(ch); - s.beginable = false; - } - } - } else if (!s.mono && !s.bold && (s.beginable || s.spacing) && /\*/.test(ch)) { - s.emitBlock(); - s.bold = true; - s.modStack.unshift("bold"); - } else if (!s.mono && !s.ital && (s.beginable || s.spacing) && /\_/.test(ch)) { - s.emitBlock(); - s.ital = true; - s.modStack.unshift("ital"); - } else if (!s.mono && (s.beginable || s.spacing) && /`/.test(ch)) { - s.emitBlock(); - s.mono = true; - s.modStack.unshift("mono"); - } else if (!s.mono && (s.mod() === "bold") && /\*/.test(ch)) { - if ((s.position === text.length-1) || /[.,_` \t\n]/.test(text.charAt(s.position+1))) { - s.emitBlock(); - s.bold = false; - s.modStack.shift(); - } else { - s.add(ch); - } - } else if (!s.mono && (s.mod() === "ital") && /\_/.test(ch)) { - if ((s.position === text.length-1) || /[.,*` \t\n]/.test(text.charAt(s.position+1))) { - s.emitBlock(); - s.ital = false; - s.modStack.shift(); - } else { - s.add(ch); - } - } else if (s.mono && (s.mod() === "mono") && /`/.test(ch)) { - if ((s.position === text.length-1) || (/[.,*_ \t\n]/.test(text.charAt(s.position+1)))) { - s.emitBlock(); - s.mono = false; - s.modStack.shift(); - } else { - s.add(ch); - } - } else { - s.add(ch); - s.beginable = false; - } - s.position++ - } - s.emitBlock(); - return blocks; - } - - /** - * - * @param {string} text - * @returns {Array} - */ - splitHtmlBlocks(text) { - let blocks = []; - let s = { - bold: false, - ital: false, - mono: false, - spacing: false, - position: 0, - buffer: "", - modStack: [] - }; - s.mod = function() { - return (this.modStack.length === 0) ? 'normal' : this.modStack[0]; - }; - s.modName = function() { - if (this.modStack.length === 0) - return 'normal'; - else if (this.modStack[0] === 'mono') - return 'mono'; - else { - if (s.bold && s.ital) { - return 'boldital'; - } else if (s.bold) { - return 'bold'; - } else if (s.ital) { - return 'ital'; - } - } - }; - s.emitBlock = function(override=false) { // eslint-disable-line no-unused-vars - if (this.spacing) { - this.add(" "); - this.spacing = false; - } - if (this.buffer.length > 0) { - blocks.push({ text: this.buffer, mod: this.modName() }); - this.buffer = ""; - } - }; - s.add = function(text) { - if (text === " ") { - s.spacing = true; - } - if (s.spacing) { - this.buffer += " "; - this.spacing = false; - } - if (text != " ") { - this.buffer += text; - } - }; - while (s.position < text.length) { - let ch = text.charAt(s.position); - if (/[ \t]/.test(ch)) { - if (!s.mono) { - s.spacing = true; - } else { - s.add(ch); - } - } else if (//.test(text.substr(s.position,3))) { - s.emitBlock(); - s.bold = true; - s.modStack.unshift("bold"); - s.position += 2; - } else if (!s.mono && !s.ital && //.test(text.substr(s.position,3))) { - s.emitBlock(); - s.ital = true; - s.modStack.unshift("ital"); - s.position += 2; - } else if (!s.mono && //.test(text.substr(s.position,6))) { - s.emitBlock(); - s.mono = true; - s.modStack.unshift("mono"); - s.position += 5; - } else if (!s.mono && (s.mod() === 'bold') && /<\/b>/.test(text.substr(s.position,4))) { - s.emitBlock(); - s.bold = false; - s.modStack.shift(); - s.position += 3; - } else if (!s.mono && (s.mod() === 'ital') && /<\/i>/.test(text.substr(s.position,4))) { - s.emitBlock(); - s.ital = false; - s.modStack.shift(); - s.position += 3; - } else if ((s.mod() === 'mono') && /<\/code>/.test(text.substr(s.position,7))) { - s.emitBlock(); - s.mono = false; - s.modStack.shift(); - s.position += 6; - } else { - s.add(ch); - } - } else if (/&/.test(ch)) { - if (/</.test(text.substr(s.position,4))) { - s.add("<"); - s.position += 3; - } else if (/&/.test(text.substr(s.position,5))) { - s.add("&"); - s.position += 4; - } else { - s.add("&"); - } - } else { - s.add(ch); - } - s.position++ - } - s.emitBlock(); - return blocks; - } /** * @@ -1027,177 +608,13 @@ class Label { * @param {CanvasRenderingContext2D} ctx * @param {boolean} selected * @param {boolean} hover - * @param {string} text the text to explode + * @param {string} inText the text to explode * @returns {{width, height, lines}|*} * @private */ - _processLabelText(ctx, selected, hover, text) { - let self = this; - - - /** - * Callback to determine text width; passed to LabelAccumulator instance - * - * @param {String} text string to determine width of - * @param {String} mod font type to use for this text - * @return {Object} { width, values} width in pixels and font attributes - */ - let textWidth = function(text, mod) { - if (text === undefined) return 0; - - // TODO: This can be done more efficiently with caching - let values = self.getFormattingValues(ctx, selected, hover, mod); - - let width = 0; - if (text !== '') { - // NOTE: The following may actually be *incorrect* for the mod fonts! - // This returns the size with a regular font, bold etc. may - // have different sizes. - let measure = ctx.measureText(text); - width = measure.width; - } - - return {width, values: values}; - }; - - - let lines = new LabelAccumulator(textWidth); - - if (text === undefined || text === "") { - return lines.finalize(); - } - - - let overMaxWidth = function(text) { - let width = ctx.measureText(text).width; - return (lines.curWidth() + width > self.fontOptions.maxWdt); - }; - - - /** - * Determine the longest part of the sentence which still fits in the - * current max width. - * - * @param {Array} words Array of strings signifying a text lines - * @return {number} index of first item in string making string go over max - */ - let getLongestFit = function(words) { - let text = ''; - let w = 0; - - while (w < words.length) { - let pre = (text === '') ? '' : ' '; - let newText = text + pre + words[w]; - - if (overMaxWidth(newText)) break; - text = newText; - w++; - } - - return w; - }; - - /** - * Determine the longest part of the string which still fits in the - * current max width. - * - * @param {Array} words Array of strings signifying a text lines - * @return {number} index of first item in string making string go over max - */ - let getLongestFitWord = function(words) { - let w = 0; - - while (w < words.length) { - if (overMaxWidth(words.slice(0,w))) break; - w++; - } - - return w; - }; - - - let splitStringIntoLines = function(str, mod = 'normal', appendLast = false) { - let words = str.split(" "); - - while (words.length > 0) { - let w = getLongestFit(words); - - if (w === 0) { - // Special case: the first word may already - // be larger than the max width. - let word = words[0]; - - // Break the word to the largest part that fits the line - let x = getLongestFitWord(word); - lines.newLine(word.slice(0, x), mod); - - // Adjust the word, so that the rest will be done next iteration - words[0] = word.slice(x); - } else { - let text = words.slice(0, w).join(" "); - - if (w == words.length && appendLast) { - lines.append(text, mod); - } else { - lines.newLine(text, mod); - } - - words = words.slice(w); - } - } - }; - - - let nlLines = String(text).split('\n'); - let lineCount = nlLines.length; - - if (this.elementOptions.font.multi) { - // Multi-font case: styling tags active - for (let i = 0; i < lineCount; i++) { - let blocks = this.splitBlocks(nlLines[i], this.elementOptions.font.multi); - if (blocks === undefined) continue; - - if (blocks.length === 0) { - lines.newLine(""); - continue; - } - - if (this.fontOptions.maxWdt > 0) { - // widthConstraint.maximum defined - //console.log('Running widthConstraint multi, max: ' + this.fontOptions.maxWdt); - for (let j = 0; j < blocks.length; j++) { - let mod = blocks[j].mod; - let text = blocks[j].text; - splitStringIntoLines(text, mod, true); - } - } else { - // widthConstraint.maximum NOT defined - for (let j = 0; j < blocks.length; j++) { - let mod = blocks[j].mod; - let text = blocks[j].text; - lines.append(text, mod); - } - } - - lines.newLine(); - } - } else { - // Single-font case - if (this.fontOptions.maxWdt > 0) { - // widthConstraint.maximum defined - // console.log('Running widthConstraint normal, max: ' + this.fontOptions.maxWdt); - for (let i = 0; i < lineCount; i++) { - splitStringIntoLines(nlLines[i]); - } - } else { - // widthConstraint.maximum NOT defined - for (let i = 0; i < lineCount; i++) { - lines.newLine(nlLines[i]); - } - } - } - - return lines.finalize(); + _processLabelText(ctx, selected, hover, inText) { + let splitter = new LabelSplitter(ctx, this, selected, hover); + return splitter.process(inText); } diff --git a/lib/network/modules/components/shared/LabelAccumulator.js b/lib/network/modules/components/shared/LabelAccumulator.js new file mode 100644 index 000000000..5a05769a4 --- /dev/null +++ b/lib/network/modules/components/shared/LabelAccumulator.js @@ -0,0 +1,238 @@ +/** + * Callback to determine text dimensions, using the parent label settings. + * @callback MeasureText + * @param {text} text + * @param {text} mod + * @return {Object} { width, values} width in pixels and font attributes + */ + + +/** + * Helper class for Label which collects results of splitting labels into lines and blocks. + * + * @private + */ +class LabelAccumulator { + + /** + * @param {MeasureText} measureText + */ + constructor(measureText) { + this.measureText = measureText; + this.current = 0; + this.width = 0; + this.height = 0; + this.lines = []; + } + + + /** + * Append given text to the given line. + * + * @param {number} l index of line to add to + * @param {string} text string to append to line + * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] + * @private + */ + _add(l, text, mod = 'normal') { + + if (this.lines[l] === undefined) { + this.lines[l] = { + width : 0, + height: 0, + blocks: [] + }; + } + + // We still need to set a block for undefined and empty texts, hence return at this point + // This is necessary because we don't know at this point if we're at the + // start of an empty line or not. + // To compensate, empty blocks are removed in `finalize()`. + // + // Empty strings should still have a height + let tmpText = text; + if (text === undefined || text === "") tmpText = " "; + + // Determine width and get the font properties + let result = this.measureText(tmpText, mod); + let block = Object.assign({}, result.values); + block.text = text; + block.width = result.width; + block.mod = mod; + + if (text === undefined || text === "") { + block.width = 0; + } + + this.lines[l].blocks.push(block); + + // Update the line width. We need this for determining if a string goes over max width + this.lines[l].width += block.width; + } + + + /** + * Returns the width in pixels of the current line. + * + * @returns {number} + */ + curWidth() { + let line = this.lines[this.current]; + if (line === undefined) return 0; + + return line.width; + } + + + /** + * Add text in block to current line + * + * @param {string} text + * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] + */ + append(text, mod = 'normal') { + this._add(this.current, text, mod); + } + + + /** + * Add text in block to current line and start a new line + * + * @param {string} text + * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod='normal'] + */ + newLine(text, mod = 'normal') { + this._add(this.current, text, mod); + this.current++; + } + + + /** + * Determine and set the heights of all the lines currently contained in this instance + * + * Note that width has already been set. + * + * @private + */ + determineLineHeights() { + for (let k = 0; k < this.lines.length; k++) { + let line = this.lines[k]; + + // Looking for max height of blocks in line + let height = 0; + + if (line.blocks !== undefined) { // Can happen if text contains e.g. '\n ' + for (let l = 0; l < line.blocks.length; l++) { + let block = line.blocks[l]; + + if (height < block.height) { + height = block.height; + } + } + } + + line.height = height; + } + } + + + /** + * Determine the full size of the label text, as determined by current lines and blocks + * + * @private + */ + determineLabelSize() { + let width = 0; + let height = 0; + for (let k = 0; k < this.lines.length; k++) { + let line = this.lines[k]; + + if (line.width > width) { + width = line.width; + } + height += line.height; + } + + this.width = width; + this.height = height; + } + + + /** + * Remove all empty blocks and empty lines we don't need + * + * This must be done after the width/height determination, + * so that these are set properly for processing here. + * + * @returns {Array} Lines with empty blocks (and some empty lines) removed + * @private + */ + removeEmptyBlocks() { + let tmpLines = []; + for (let k = 0; k < this.lines.length; k++) { + let line = this.lines[k]; + + // Note: an empty line in between text has width zero but is still relevant to layout. + // So we can't use width for testing empty line here + if (line.blocks.length === 0) continue; + + // Discard final empty line always + if(k === this.lines.length - 1) { + if (line.width === 0) continue; + } + + let tmpLine = {}; + Object.assign(tmpLine, line); + tmpLine.blocks = []; + + let firstEmptyBlock; + let tmpBlocks = [] + for (let l = 0; l < line.blocks.length; l++) { + let block = line.blocks[l]; + if (block.width !== 0) { + tmpBlocks.push(block); + } else { + if (firstEmptyBlock === undefined) { + firstEmptyBlock = block; + } + } + } + + // Ensure that there is *some* text present + if (tmpBlocks.length === 0 && firstEmptyBlock !== undefined) { + tmpBlocks.push(firstEmptyBlock); + } + + tmpLine.blocks = tmpBlocks; + + tmpLines.push(tmpLine); + } + + return tmpLines; + } + + + /** + * Set the sizes for all lines and the whole thing. + * + * @returns {{width: (number|*), height: (number|*), lines: Array}} + */ + finalize() { + //console.log(JSON.stringify(this.lines, null, 2)); + + this.determineLineHeights(); + this.determineLabelSize(); + let tmpLines = this.removeEmptyBlocks(); + + + // Return a simple hash object for further processing. + return { + width : this.width, + height: this.height, + lines : tmpLines + } + } +} + + +export default LabelAccumulator; diff --git a/lib/network/modules/components/shared/LabelSplitter.js b/lib/network/modules/components/shared/LabelSplitter.js new file mode 100644 index 000000000..b0ccba2c2 --- /dev/null +++ b/lib/network/modules/components/shared/LabelSplitter.js @@ -0,0 +1,546 @@ +let LabelAccumulator = require('./LabelAccumulator').default; + + +/** + * Helper class for Label which explodes the label text into lines and blocks within lines + * + * @private + */ +class LabelSplitter { + + /** + * @param {CanvasRenderingContext2D} ctx Canvas rendering context + * @param {Label} parent reference to the Label instance using current instance + * @param {boolean} selected + * @param {boolean} hover + */ + constructor(ctx, parent, selected, hover) { + this.ctx = ctx; + this.parent = parent; + + + /** + * Callback to determine text width; passed to LabelAccumulator instance + * + * @param {String} text string to determine width of + * @param {String} mod font type to use for this text + * @return {Object} { width, values} width in pixels and font attributes + */ + let textWidth = (text, mod) => { + if (text === undefined) return 0; + + // TODO: This can be done more efficiently with caching + let values = this.parent.getFormattingValues(ctx, selected, hover, mod); + + let width = 0; + if (text !== '') { + // NOTE: The following may actually be *incorrect* for the mod fonts! + // This returns the size with a regular font, bold etc. may + // have different sizes. + let measure = this.ctx.measureText(text); + width = measure.width; + } + + return {width, values: values}; + }; + + + this.lines = new LabelAccumulator(textWidth); + } + + + /** + * Split passed text of a label into lines and blocks. + * + * # NOTE + * + * The handling of spacing is option dependent: + * + * - if `font.multi : false`, all spaces are retained + * - if `font.multi : true`, every sequence of spaces is compressed to a single space + * + * This might not be the best way to do it, but this is as it has been working till now. + * In order not to break existing functionality, for the time being this behaviour will + * be retained in any code changes. + * + * @param {string} text text to split + * @returns {Array} + */ + process(text) { + if (text === undefined || text === "") { + return this.lines.finalize(); + } + + // Normalize the end-of-line's to a single representation - order important + text = text.replace(/\r\n/g, '\n'); // Dos EOL's + text = text.replace(/\r/g, '\n'); // Mac EOL's + + // Note that at this point, there can be no \r's in the text. + // This is used later on splitStringIntoLines() to split multifont texts. + + let nlLines = String(text).split('\n'); + let lineCount = nlLines.length; + + if (this.parent.elementOptions.font.multi) { + // Multi-font case: styling tags active + for (let i = 0; i < lineCount; i++) { + let blocks = this.splitBlocks(nlLines[i], this.parent.elementOptions.font.multi); + // Post: Sequences of tabs and spaces are reduced to single space + + if (blocks === undefined) continue; + + if (blocks.length === 0) { + this.lines.newLine(""); + continue; + } + + if (this.parent.fontOptions.maxWdt > 0) { + // widthConstraint.maximum defined + //console.log('Running widthConstraint multi, max: ' + this.fontOptions.maxWdt); + for (let j = 0; j < blocks.length; j++) { + let mod = blocks[j].mod; + let text = blocks[j].text; + this.splitStringIntoLines(text, mod, true); + } + } else { + // widthConstraint.maximum NOT defined + for (let j = 0; j < blocks.length; j++) { + let mod = blocks[j].mod; + let text = blocks[j].text; + this.lines.append(text, mod); + } + } + + this.lines.newLine(); + } + } else { + // Single-font case + if (this.parent.fontOptions.maxWdt > 0) { + // widthConstraint.maximum defined + // console.log('Running widthConstraint normal, max: ' + this.fontOptions.maxWdt); + for (let i = 0; i < lineCount; i++) { + this.splitStringIntoLines(nlLines[i]); + } + } else { + // widthConstraint.maximum NOT defined + for (let i = 0; i < lineCount; i++) { + this.lines.newLine(nlLines[i]); + } + } + } + + return this.lines.finalize(); + } + + + /** + * normalize the markup system + * + * @param {boolean|'md'|'markdown'|'html'} markupSystem + * @returns {string} + */ + decodeMarkupSystem(markupSystem) { + let system = 'none'; + if (markupSystem === 'markdown' || markupSystem === 'md') { + system = 'markdown'; + } else if (markupSystem === true || markupSystem === 'html') { + system = 'html' + } + return system; + } + + + /** + * + * @param {string} text + * @returns {Array} + */ + splitHtmlBlocks(text) { + let blocks = []; + + // TODO: consolidate following + methods/closures with splitMarkdownBlocks() + // NOTE: sequences of tabs and spaces are reduced to single space; scan usage of `this.spacing` within method + let s = { + bold: false, + ital: false, + mono: false, + spacing: false, + position: 0, + buffer: "", + modStack: [] + }; + + s.mod = function() { + return (this.modStack.length === 0) ? 'normal' : this.modStack[0]; + }; + + s.modName = function() { + if (this.modStack.length === 0) + return 'normal'; + else if (this.modStack[0] === 'mono') + return 'mono'; + else { + if (s.bold && s.ital) { + return 'boldital'; + } else if (s.bold) { + return 'bold'; + } else if (s.ital) { + return 'ital'; + } + } + }; + + s.emitBlock = function(override=false) { // eslint-disable-line no-unused-vars + if (this.spacing) { + this.add(" "); + this.spacing = false; + } + if (this.buffer.length > 0) { + blocks.push({ text: this.buffer, mod: this.modName() }); + this.buffer = ""; + } + }; + + s.add = function(text) { + if (text === " ") { + s.spacing = true; + } + if (s.spacing) { + this.buffer += " "; + this.spacing = false; + } + if (text != " ") { + this.buffer += text; + } + }; + + while (s.position < text.length) { + let ch = text.charAt(s.position); + if (/[ \t]/.test(ch)) { + if (!s.mono) { + s.spacing = true; + } else { + s.add(ch); + } + } else if (//.test(text.substr(s.position,3))) { + s.emitBlock(); + s.bold = true; + s.modStack.unshift("bold"); + s.position += 2; + } else if (!s.mono && !s.ital && //.test(text.substr(s.position,3))) { + s.emitBlock(); + s.ital = true; + s.modStack.unshift("ital"); + s.position += 2; + } else if (!s.mono && //.test(text.substr(s.position,6))) { + s.emitBlock(); + s.mono = true; + s.modStack.unshift("mono"); + s.position += 5; + } else if (!s.mono && (s.mod() === 'bold') && /<\/b>/.test(text.substr(s.position,4))) { + s.emitBlock(); + s.bold = false; + s.modStack.shift(); + s.position += 3; + } else if (!s.mono && (s.mod() === 'ital') && /<\/i>/.test(text.substr(s.position,4))) { + s.emitBlock(); + s.ital = false; + s.modStack.shift(); + s.position += 3; + } else if ((s.mod() === 'mono') && /<\/code>/.test(text.substr(s.position,7))) { + s.emitBlock(); + s.mono = false; + s.modStack.shift(); + s.position += 6; + } else { + s.add(ch); + } + } else if (/&/.test(ch)) { + if (/</.test(text.substr(s.position,4))) { + s.add("<"); + s.position += 3; + } else if (/&/.test(text.substr(s.position,5))) { + s.add("&"); + s.position += 4; + } else { + s.add("&"); + } + } else { + s.add(ch); + } + s.position++ + } + s.emitBlock(); + return blocks; + } + + + /** + * + * @param {string} text + * @returns {Array} + */ + splitMarkdownBlocks(text) { + let blocks = []; + + // TODO: consolidate following + methods/closures with splitHtmlBlocks() + // NOTE: sequences of tabs and spaces are reduced to single space; scan usage of `this.spacing` within method + let s = { + bold: false, + ital: false, + mono: false, + beginable: true, + spacing: false, + position: 0, + buffer: "", + modStack: [] + }; + + s.mod = function() { + return (this.modStack.length === 0) ? 'normal' : this.modStack[0]; + }; + + s.modName = function() { + if (this.modStack.length === 0) + return 'normal'; + else if (this.modStack[0] === 'mono') + return 'mono'; + else { + if (s.bold && s.ital) { + return 'boldital'; + } else if (s.bold) { + return 'bold'; + } else if (s.ital) { + return 'ital'; + } + } + }; + + s.emitBlock = function(override=false) { // eslint-disable-line no-unused-vars + if (this.spacing) { + this.add(" "); + this.spacing = false; + } + if (this.buffer.length > 0) { + blocks.push({ text: this.buffer, mod: this.modName() }); + this.buffer = ""; + } + }; + + s.add = function(text) { + if (text === " ") { + s.spacing = true; + } + if (s.spacing) { + this.buffer += " "; + this.spacing = false; + } + if (text != " ") { + this.buffer += text; + } + }; + + while (s.position < text.length) { + let ch = text.charAt(s.position); + if (/[ \t]/.test(ch)) { + if (!s.mono) { + s.spacing = true; + } else { + s.add(ch); + } + s.beginable = true + } else if (/\\/.test(ch)) { + if (s.position < text.length+1) { + s.position++; + ch = text.charAt(s.position); + if (/ \t/.test(ch)) { + s.spacing = true; + } else { + s.add(ch); + s.beginable = false; + } + } + } else if (!s.mono && !s.bold && (s.beginable || s.spacing) && /\*/.test(ch)) { + s.emitBlock(); + s.bold = true; + s.modStack.unshift("bold"); + } else if (!s.mono && !s.ital && (s.beginable || s.spacing) && /\_/.test(ch)) { + s.emitBlock(); + s.ital = true; + s.modStack.unshift("ital"); + } else if (!s.mono && (s.beginable || s.spacing) && /`/.test(ch)) { + s.emitBlock(); + s.mono = true; + s.modStack.unshift("mono"); + } else if (!s.mono && (s.mod() === "bold") && /\*/.test(ch)) { + if ((s.position === text.length-1) || /[.,_` \t\n]/.test(text.charAt(s.position+1))) { + s.emitBlock(); + s.bold = false; + s.modStack.shift(); + } else { + s.add(ch); + } + } else if (!s.mono && (s.mod() === "ital") && /\_/.test(ch)) { + if ((s.position === text.length-1) || /[.,*` \t\n]/.test(text.charAt(s.position+1))) { + s.emitBlock(); + s.ital = false; + s.modStack.shift(); + } else { + s.add(ch); + } + } else if (s.mono && (s.mod() === "mono") && /`/.test(ch)) { + if ((s.position === text.length-1) || (/[.,*_ \t\n]/.test(text.charAt(s.position+1)))) { + s.emitBlock(); + s.mono = false; + s.modStack.shift(); + } else { + s.add(ch); + } + } else { + s.add(ch); + s.beginable = false; + } + s.position++ + } + s.emitBlock(); + return blocks; + } + + + /** + * Explodes a piece of text into single-font blocks using a given markup + * + * @param {string} text + * @param {boolean|'md'|'markdown'|'html'} markupSystem + * @returns {Array.<{text: string, mod: string}>} + * @private + */ + splitBlocks(text, markupSystem) { + let system = this.decodeMarkupSystem(markupSystem); + if (system === 'none') { + return [{ + text: text, + mod: 'normal' + }] + } else if (system === 'markdown') { + return this.splitMarkdownBlocks(text); + } else if (system === 'html') { + return this.splitHtmlBlocks(text); + } + } + + + /** + * @param {string} text + * @returns {boolean} true if text length over the current max with + * @private + */ + overMaxWidth(text) { + let width = this.ctx.measureText(text).width; + return (this.lines.curWidth() + width > this.parent.fontOptions.maxWdt); + } + + + /** + * Determine the longest part of the sentence which still fits in the + * current max width. + * + * @param {Array} words Array of strings signifying a text lines + * @return {number} index of first item in string making string go over max + * @private + */ + getLongestFit(words) { + let text = ''; + let w = 0; + + while (w < words.length) { + let pre = (text === '') ? '' : ' '; + let newText = text + pre + words[w]; + + if (this.overMaxWidth(newText)) break; + text = newText; + w++; + } + + return w; + } + + + /** + * Determine the longest part of the string which still fits in the + * current max width. + * + * @param {Array} words Array of strings signifying a text lines + * @return {number} index of first item in string making string go over max + */ + getLongestFitWord(words) { + let w = 0; + + while (w < words.length) { + if (this.overMaxWidth(words.slice(0,w))) break; + w++; + } + + return w; + } + + + /** + * Split the passed text into lines, according to width constraint (if any). + * + * The method assumes that the input string is a single line, i.e. without lines break. + * + * This method retains spaces, if still present (case `font.multi: false`). + * A space which falls on an internal line break, will be replaced by a newline. + * There is no special handling of tabs; these go along with the flow. + * + * @param {string} str + * @param {string} [mod='normal'] + * @param {boolean} [appendLast=false] + * @private + */ + splitStringIntoLines(str, mod = 'normal', appendLast = false) { + // Still-present spaces are relevant, retain them + str = str.replace(/^( +)/g, '$1\r'); + str = str.replace(/([^\r][^ ]*)( +)/g, '$1\r$2\r'); + let words = str.split('\r'); + + while (words.length > 0) { + let w = this.getLongestFit(words); + + if (w === 0) { + // Special case: the first word is already larger than the max width. + let word = words[0]; + + // Break the word to the largest part that fits the line + let x = this.getLongestFitWord(word); + this.lines.newLine(word.slice(0, x), mod); + + // Adjust the word, so that the rest will be done next iteration + words[0] = word.slice(x); + } else { + // skip any space that is replaced by a newline + let newW = w; + if (words[w - 1] === ' ') { + w--; + } else if (words[newW] === ' ') { + newW++; + } + + let text = words.slice(0, w).join(""); + + if (w == words.length && appendLast) { + this.lines.append(text, mod); + } else { + this.lines.newLine(text, mod); + } + + // Adjust the word, so that the rest will be done next iteration + words = words.slice(newW); + } + } + } +} + +export default LabelSplitter; diff --git a/test/Label.test.js b/test/Label.test.js index 97b504af6..21565b18d 100644 --- a/test/Label.test.js +++ b/test/Label.test.js @@ -183,9 +183,11 @@ describe('Network Label', function() { }] }, { lines: [{ - blocks: [{text: "One really long sentence"}] + blocks: [{text: "One really long"}] }, { - blocks: [{text: "that should go over"}] + blocks: [{text: "sentence that should"}] + }, { + blocks: [{text: "go over"}] }, { blocks: [{text: "widthConstraint.maximum"}] }, { @@ -224,7 +226,9 @@ describe('Network Label', function() { }, { blocks: [{text: "some"}] }, { - blocks: [{text: "multi tags"}] + blocks: [{text: "multi"}] + }, { + blocks: [{text: "tags"}] }] }, { lines: [{ @@ -232,7 +236,9 @@ describe('Network Label', function() { }, { blocks: [{text: "some "}] }, { - blocks: [{text: " multi tags"}] + blocks: [{text: " multi"}] + }, { + blocks: [{text: "tags"}] }, { blocks: [{text: " and newlines"}] }] @@ -254,7 +260,7 @@ describe('Network Label', function() { }]; - var markdown_widthConstraint_expected = [{ + var markdown_widthConstraint_expected= [{ lines: [{ blocks: [{text: "label *with* `some`"}] }, { @@ -366,6 +372,11 @@ describe('Network Label', function() { checkProcessedLabels(label, normal_text , normal_widthConstraint_expected); checkProcessedLabels(label, html_text , html_widthConstraint_unchanged); // html unchanged + + // Following is an unlucky selection, because the first line broken on the final character (space) + // So we cheat a bit here + options.font.maxWdt = 320; + label = new Label({}, options); checkProcessedLabels(label, markdown_text, markdown_widthConstraint_expected); // markdown unchanged done(); @@ -381,6 +392,11 @@ describe('Network Label', function() { checkProcessedLabels(label, normal_text , normal_widthConstraint_expected); checkProcessedLabels(label, html_text , multi_expected); + + // Following is an unlucky selection, because the first line broken on the final character (space) + // So we cheat a bit here + options.font.maxWdt = 320; + label = new Label({}, options); checkProcessedLabels(label, markdown_text, markdown_widthConstraint_expected); done(); @@ -400,4 +416,223 @@ describe('Network Label', function() { done(); }); + + + it('compresses spaces in multifont', function (done) { + var options = getOptions(options); + + var text = [ + "Too many spaces here!", + "one two three four five six .", + "This thing:\n - could be\n - a kind\n - of list", // multifont: 2 spaces at start line reduced to 1 + ]; + + + // + // multifont disabled: spaces are preserved + // + var label = new Label({}, options); + + var expected = [{ + lines: [{ + blocks: [{text: "Too many spaces here!"}], + }] + }, { + lines: [{ + blocks: [{text: "one two three four five six ."}], + }] + }, { + lines: [{ + blocks: [{text: "This thing:"}], + }, { + blocks: [{text: " - could be"}], + }, { + blocks: [{text: " - a kind"}], + }, { + blocks: [{text: " - of list"}], + }] + }]; + + checkProcessedLabels(label, text, expected); + + + // + // multifont disabled width maxwidth: spaces are preserved + // + options.font.maxWdt = 300; + var label = new Label({}, options); + + var expected_maxwidth = [{ + lines: [{ + blocks: [{text: "Too many spaces"}], + }, { + blocks: [{text: " here!"}], + }] + }, { + lines: [{ + blocks: [{text: "one two three "}], + }, { + blocks: [{text: "four five six"}], + }, { + blocks: [{text: " ."}], + }] + }, { + lines: [{ + blocks: [{text: "This thing:"}], + }, { + blocks: [{text: " - could be"}], + }, { + blocks: [{text: " - a kind"}], + }, { + blocks: [{text: " - of list"}], + }] + }]; + + checkProcessedLabels(label, text, expected_maxwidth); + + + // + // multifont enabled: spaces are compressed + // + options = getOptions(options); + options.font.multi = true; + var label = new Label({}, options); + + var expected_multifont = [{ + lines: [{ + blocks: [{text: "Too many spaces here!"}], + }] + }, { + lines: [{ + blocks: [{text: "one two three four five six ."}], + }] + }, { + lines: [{ + blocks: [{text: "This thing:"}], + }, { + blocks: [{text: " - could be"}], + }, { + blocks: [{text: " - a kind"}], + }, { + blocks: [{text: " - of list"}], + }] + }]; + + checkProcessedLabels(label, text, expected_multifont); + + + // + // multifont enabled with max width: spaces are compressed + // + options.font.maxWdt = 300; + var label = new Label({}, options); + + var expected_multifont_maxwidth = [{ + lines: [{ + blocks: [{text: "Too many spaces"}], + }, { + blocks: [{text: "here!"}], + }] + }, { + lines: [{ + blocks: [{text: "one two three four"}], + }, { + blocks: [{text: "five six ."}], + }] + }, { + lines: [{ + blocks: [{text: "This thing:"}], + }, { + blocks: [{text: " - could be"}], + }, { + blocks: [{text: " - a kind"}], + }, { + blocks: [{text: " - of list"}], + }] + }]; + + checkProcessedLabels(label, text, expected_multifont_maxwidth); + + done(); + }); + + + it('parses single huge word on line with preceding whitespace when max width set', function (done) { + var options = getOptions(options); + options.font.maxWdt = 300; + assert.equal(options.font.multi, false); + + /** + * Allows negative indexing, counting from back (ruby style) + */ +/* + TODO: Use when the actual bug is fixed and tests pass. + + let splitAt = (text, pos, getFirst) => { + if (pos < 0) { pos = text.length + pos; + + if (getFirst) { + return text.substring(0, pos)); + } else { + return text.substring(pos)); + } + } +*/ + + var label = new Label({}, options); + var longWord = "asd;lkfja;lfkdj;alkjfd;alskfj"; + + var text = [ + "Mind the space!\n " + longWord, + "Mind the empty line!\n\n" + longWord, + "Mind the dos empty line!\r\n\r\n" + longWord + ]; + + var expected = [{ + lines: [{ + blocks: [{text: "Mind the space!"}] + }, { + blocks: [{text: ""}] + }, { + blocks: [{text: "asd;lkfja;lfkdj;alkjfd;als"}] + }, { + blocks: [{text: "kfj"}] + }] + }, { + lines: [{ + blocks: [{text: "Mind the empty"}] + }, { + blocks: [{text: "line!"}] + }, { + blocks: [{text: ""}] + }, { + blocks: [{text: "asd;lkfja;lfkdj;alkjfd;als"}] + }, { + blocks: [{text: "kfj"}] + }] + }, { + lines: [{ + blocks: [{text: "Mind the dos empty"}] + }, { + blocks: [{text: "line!"}] + }, { + blocks: [{text: ""}] + }, { + blocks: [{text: "asd;lkfja;lfkdj;alkjfd;als"}] + }, { + blocks: [{text: "kfj"}] + }] + }]; + + checkProcessedLabels(label, text, expected); + + + // + // Multi font enabled. For current case, output should be identical to no multi font + // + options.font.multi = true; + var label = new Label({}, options); + checkProcessedLabels(label, text, expected); + done(); + }); });