/** * @license * Visual Blocks Editor * * Copyright 2019 Google Inc. * https://developers.google.com/blockly/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @fileoverview Utility methods for string manipulation. * These methods are not specific to Blockly, and could be factored out into * a JavaScript framework such as Closure. * @author fraser@google.com (Neil Fraser) */ 'use strict'; /** * @name Blockly.utils.string * @namespace */ goog.provide('Blockly.utils.string'); /** * Fast prefix-checker. * Copied from Closure's goog.string.startsWith. * @param {string} str The string to check. * @param {string} prefix A string to look for at the start of `str`. * @return {boolean} True if `str` begins with `prefix`. */ Blockly.utils.string.startsWith = function(str, prefix) { return str.lastIndexOf(prefix, 0) == 0; }; /** * Given an array of strings, return the length of the shortest one. * @param {!Array.} array Array of strings. * @return {number} Length of shortest string. */ Blockly.utils.string.shortestStringLength = function(array) { if (!array.length) { return 0; } return array.reduce(function(a, b) { return a.length < b.length ? a : b; }).length; }; /** * Given an array of strings, return the length of the common prefix. * Words may not be split. Any space after a word is included in the length. * @param {!Array.} array Array of strings. * @param {number=} opt_shortest Length of shortest string. * @return {number} Length of common prefix. */ Blockly.utils.string.commonWordPrefix = function(array, opt_shortest) { if (!array.length) { return 0; } else if (array.length == 1) { return array[0].length; } var wordPrefix = 0; var max = opt_shortest || Blockly.utils.string.shortestStringLength(array); for (var len = 0; len < max; len++) { var letter = array[0][len]; for (var i = 1; i < array.length; i++) { if (letter != array[i][len]) { return wordPrefix; } } if (letter == ' ') { wordPrefix = len + 1; } } for (var i = 1; i < array.length; i++) { var letter = array[i][len]; if (letter && letter != ' ') { return wordPrefix; } } return max; }; /** * Given an array of strings, return the length of the common suffix. * Words may not be split. Any space after a word is included in the length. * @param {!Array.} array Array of strings. * @param {number=} opt_shortest Length of shortest string. * @return {number} Length of common suffix. */ Blockly.utils.string.commonWordSuffix = function(array, opt_shortest) { if (!array.length) { return 0; } else if (array.length == 1) { return array[0].length; } var wordPrefix = 0; var max = opt_shortest || Blockly.utils.string.shortestStringLength(array); for (var len = 0; len < max; len++) { var letter = array[0].substr(-len - 1, 1); for (var i = 1; i < array.length; i++) { if (letter != array[i].substr(-len - 1, 1)) { return wordPrefix; } } if (letter == ' ') { wordPrefix = len + 1; } } for (var i = 1; i < array.length; i++) { var letter = array[i].charAt(array[i].length - len - 1); if (letter && letter != ' ') { return wordPrefix; } } return max; }; /** * Wrap text to the specified width. * @param {string} text Text to wrap. * @param {number} limit Width to wrap each line. * @return {string} Wrapped text. */ Blockly.utils.string.wrap = function(text, limit) { var lines = text.split('\n'); for (var i = 0; i < lines.length; i++) { lines[i] = Blockly.utils.string.wrapLine_(lines[i], limit); } return lines.join('\n'); }; /** * Wrap single line of text to the specified width. * @param {string} text Text to wrap. * @param {number} limit Width to wrap each line. * @return {string} Wrapped text. * @private */ Blockly.utils.string.wrapLine_ = function(text, limit) { if (text.length <= limit) { // Short text, no need to wrap. return text; } // Split the text into words. var words = text.trim().split(/\s+/); // Set limit to be the length of the largest word. for (var i = 0; i < words.length; i++) { if (words[i].length > limit) { limit = words[i].length; } } var lastScore; var score = -Infinity; var lastText; var lineCount = 1; do { lastScore = score; lastText = text; // Create a list of booleans representing if a space (false) or // a break (true) appears after each word. var wordBreaks = []; // Seed the list with evenly spaced linebreaks. var steps = words.length / lineCount; var insertedBreaks = 1; for (var i = 0; i < words.length - 1; i++) { if (insertedBreaks < (i + 1.5) / steps) { insertedBreaks++; wordBreaks[i] = true; } else { wordBreaks[i] = false; } } wordBreaks = Blockly.utils.string.wrapMutate_(words, wordBreaks, limit); score = Blockly.utils.string.wrapScore_(words, wordBreaks, limit); text = Blockly.utils.string.wrapToText_(words, wordBreaks); lineCount++; } while (score > lastScore); return lastText; }; /** * Compute a score for how good the wrapping is. * @param {!Array.} words Array of each word. * @param {!Array.} wordBreaks Array of line breaks. * @param {number} limit Width to wrap each line. * @return {number} Larger the better. * @private */ Blockly.utils.string.wrapScore_ = function(words, wordBreaks, limit) { // If this function becomes a performance liability, add caching. // Compute the length of each line. var lineLengths = [0]; var linePunctuation = []; for (var i = 0; i < words.length; i++) { lineLengths[lineLengths.length - 1] += words[i].length; if (wordBreaks[i] === true) { lineLengths.push(0); linePunctuation.push(words[i].charAt(words[i].length - 1)); } else if (wordBreaks[i] === false) { lineLengths[lineLengths.length - 1]++; } } var maxLength = Math.max.apply(Math, lineLengths); var score = 0; for (var i = 0; i < lineLengths.length; i++) { // Optimize for width. // -2 points per char over limit (scaled to the power of 1.5). score -= Math.pow(Math.abs(limit - lineLengths[i]), 1.5) * 2; // Optimize for even lines. // -1 point per char smaller than max (scaled to the power of 1.5). score -= Math.pow(maxLength - lineLengths[i], 1.5); // Optimize for structure. // Add score to line endings after punctuation. if ('.?!'.indexOf(linePunctuation[i]) != -1) { score += limit / 3; } else if (',;)]}'.indexOf(linePunctuation[i]) != -1) { score += limit / 4; } } // All else being equal, the last line should not be longer than the // previous line. For example, this looks wrong: // aaa bbb // ccc ddd eee if (lineLengths.length > 1 && lineLengths[lineLengths.length - 1] <= lineLengths[lineLengths.length - 2]) { score += 0.5; } return score; }; /** * Mutate the array of line break locations until an optimal solution is found. * No line breaks are added or deleted, they are simply moved around. * @param {!Array.} words Array of each word. * @param {!Array.} wordBreaks Array of line breaks. * @param {number} limit Width to wrap each line. * @return {!Array.} New array of optimal line breaks. * @private */ Blockly.utils.string.wrapMutate_ = function(words, wordBreaks, limit) { var bestScore = Blockly.utils.string.wrapScore_(words, wordBreaks, limit); var bestBreaks; // Try shifting every line break forward or backward. for (var i = 0; i < wordBreaks.length - 1; i++) { if (wordBreaks[i] == wordBreaks[i + 1]) { continue; } var mutatedWordBreaks = [].concat(wordBreaks); mutatedWordBreaks[i] = !mutatedWordBreaks[i]; mutatedWordBreaks[i + 1] = !mutatedWordBreaks[i + 1]; var mutatedScore = Blockly.utils.string.wrapScore_(words, mutatedWordBreaks, limit); if (mutatedScore > bestScore) { bestScore = mutatedScore; bestBreaks = mutatedWordBreaks; } } if (bestBreaks) { // Found an improvement. See if it may be improved further. return Blockly.utils.string.wrapMutate_(words, bestBreaks, limit); } // No improvements found. Done. return wordBreaks; }; /** * Reassemble the array of words into text, with the specified line breaks. * @param {!Array.} words Array of each word. * @param {!Array.} wordBreaks Array of line breaks. * @return {string} Plain text. * @private */ Blockly.utils.string.wrapToText_ = function(words, wordBreaks) { var text = []; for (var i = 0; i < words.length; i++) { text.push(words[i]); if (wordBreaks[i] !== undefined) { text.push(wordBreaks[i] ? '\n' : ' '); } } return text.join(''); };