Browse Source

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
jittering-top
wimrijnders 7 years ago
committed by Yotam Berkowitz
parent
commit
9f7b1f90f0
4 changed files with 1036 additions and 600 deletions
  1. +12
    -595
      lib/network/modules/components/shared/Label.js
  2. +238
    -0
      lib/network/modules/components/shared/LabelAccumulator.js
  3. +546
    -0
      lib/network/modules/components/shared/LabelSplitter.js
  4. +240
    -5
      test/Label.test.js

+ 12
- 595
lib/network/modules/components/shared/Label.js View File

@ -1,154 +1,8 @@
let util = require('../../../../util'); let util = require('../../../../util');
let ComponentUtil = require('./ComponentUtil').default; 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. * A Label to be used for Nodes or Edges.
*/ */
@ -160,7 +14,6 @@ class Label {
*/ */
constructor(body, options, edgelabel = false) { constructor(body, options, edgelabel = false) {
this.body = body; this.body = body;
this.pointToSelf = false; this.pointToSelf = false;
this.baseSize = undefined; this.baseSize = undefined;
this.fontOptions = {}; this.fontOptions = {};
@ -169,6 +22,7 @@ class Label {
this.isEdgeLabel = edgelabel; this.isEdgeLabel = edgelabel;
} }
/** /**
* *
* @param {Object} options * @param {Object} options
@ -198,6 +52,7 @@ class Label {
} }
} }
/** /**
* *
* @param {Object} parentOptions * @param {Object} parentOptions
@ -486,12 +341,11 @@ class Label {
// update the size cache if required // update the size cache if required
this.calculateLabelSize(ctx, selected, hover, x, y, baseline); 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); this._drawText(ctx, selected, hover, x, y, baseline);
} }
/** /**
* Draws the label background * Draws the label background
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
@ -538,7 +392,9 @@ class Label {
_drawText(ctx, selected, hover, x, y, baseline = 'middle') { _drawText(ctx, selected, hover, x, y, baseline = 'middle') {
let fontSize = this.fontOptions.size; let fontSize = this.fontOptions.size;
let viewFontSize = fontSize * this.body.view.scale; 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) { if (viewFontSize >= this.elementOptions.scaling.label.maxVisible) {
// TODO: Does this actually do anything? // TODO: Does this actually do anything?
fontSize = Number(this.elementOptions.scaling.label.maxVisible) / this.body.view.scale; fontSize = Number(this.elementOptions.scaling.label.maxVisible) / this.body.view.scale;
@ -686,281 +542,6 @@ class Label {
this.labelDirty = false; 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(ch)) {
if (!s.mono && !s.bold && /<b>/.test(text.substr(s.position,3))) {
s.emitBlock();
s.bold = true;
s.modStack.unshift("bold");
s.position += 2;
} else if (!s.mono && !s.ital && /<i>/.test(text.substr(s.position,3))) {
s.emitBlock();
s.ital = true;
s.modStack.unshift("ital");
s.position += 2;
} else if (!s.mono && /<code>/.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 (/&lt;/.test(text.substr(s.position,4))) {
s.add("<");
s.position += 3;
} else if (/&amp;/.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 {CanvasRenderingContext2D} ctx
* @param {boolean} selected * @param {boolean} selected
* @param {boolean} hover * @param {boolean} hover
* @param {string} text the text to explode
* @param {string} inText the text to explode
* @returns {{width, height, lines}|*} * @returns {{width, height, lines}|*}
* @private * @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);
} }

+ 238
- 0
lib/network/modules/components/shared/LabelAccumulator.js View File

@ -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<Line>} 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;

+ 546
- 0
lib/network/modules/components/shared/LabelSplitter.js View File

@ -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<line>}
*/
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(ch)) {
if (!s.mono && !s.bold && /<b>/.test(text.substr(s.position,3))) {
s.emitBlock();
s.bold = true;
s.modStack.unshift("bold");
s.position += 2;
} else if (!s.mono && !s.ital && /<i>/.test(text.substr(s.position,3))) {
s.emitBlock();
s.ital = true;
s.modStack.unshift("ital");
s.position += 2;
} else if (!s.mono && /<code>/.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 (/&lt;/.test(text.substr(s.position,4))) {
s.add("<");
s.position += 3;
} else if (/&amp;/.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;

+ 240
- 5
test/Label.test.js View File

@ -183,9 +183,11 @@ describe('Network Label', function() {
}] }]
}, { }, {
lines: [{ 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"}] blocks: [{text: "widthConstraint.maximum"}]
}, { }, {
@ -224,7 +226,9 @@ describe('Network Label', function() {
}, { }, {
blocks: [{text: "<code>some</code>"}] blocks: [{text: "<code>some</code>"}]
}, { }, {
blocks: [{text: "<i>multi <b>tags</b></i>"}]
blocks: [{text: "<i>multi"}]
}, {
blocks: [{text: "<b>tags</b></i>"}]
}] }]
}, { }, {
lines: [{ lines: [{
@ -232,7 +236,9 @@ describe('Network Label', function() {
}, { }, {
blocks: [{text: "<code>some</code> "}] blocks: [{text: "<code>some</code> "}]
}, { }, {
blocks: [{text: " <i>multi <b>tags</b></i>"}]
blocks: [{text: " <i>multi"}]
}, {
blocks: [{text: "<b>tags</b></i>"}]
}, { }, {
blocks: [{text: " and newlines"}] blocks: [{text: " and newlines"}]
}] }]
@ -254,7 +260,7 @@ describe('Network Label', function() {
}]; }];
var markdown_widthConstraint_expected = [{
var markdown_widthConstraint_expected= [{
lines: [{ lines: [{
blocks: [{text: "label *with* `some`"}] blocks: [{text: "label *with* `some`"}]
}, { }, {
@ -366,6 +372,11 @@ describe('Network Label', function() {
checkProcessedLabels(label, normal_text , normal_widthConstraint_expected); checkProcessedLabels(label, normal_text , normal_widthConstraint_expected);
checkProcessedLabels(label, html_text , html_widthConstraint_unchanged); // html unchanged 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 checkProcessedLabels(label, markdown_text, markdown_widthConstraint_expected); // markdown unchanged
done(); done();
@ -381,6 +392,11 @@ describe('Network Label', function() {
checkProcessedLabels(label, normal_text , normal_widthConstraint_expected); checkProcessedLabels(label, normal_text , normal_widthConstraint_expected);
checkProcessedLabels(label, html_text , multi_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); checkProcessedLabels(label, markdown_text, markdown_widthConstraint_expected);
done(); done();
@ -400,4 +416,223 @@ describe('Network Label', function() {
done(); 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();
});
}); });

Loading…
Cancel
Save