- let LabelAccumulator = require('./LabelAccumulator').default;
- let ComponentUtil = require('./ComponentUtil').default;
-
- // Hash of prepared regexp's for tags
- var tagPattern = {
- // HTML
- '<b>': /<b>/,
- '<i>': /<i>/,
- '<code>': /<code>/,
- '</b>': /<\/b>/,
- '</i>': /<\/i>/,
- '</code>': /<\/code>/,
- // Markdown
- '*': /\*/, // bold
- '_': /\_/, // ital
- '`': /`/, // mono
- 'afterBold': /[^\*]/,
- 'afterItal': /[^_]/,
- 'afterMono': /[^`]/,
- };
-
-
- /**
- * Internal helper class for parsing the markup tags for HTML and Markdown.
- *
- * NOTE: Sequences of tabs and spaces are reduced to single space.
- * Scan usage of `this.spacing` within method
- */
- class MarkupAccumulator {
-
- /**
- * Create an instance
- *
- * @param {string} text text to parse for markup
- */
- constructor(text) {
- this.text = text;
- this.bold = false;
- this.ital = false;
- this.mono = false;
- this.spacing = false;
- this.position = 0;
- this.buffer = "";
- this.modStack = [];
-
- this.blocks = [];
- }
-
-
- /**
- * Return the mod label currently on the top of the stack
- *
- * @returns {string} label of topmost mod
- * @private
- */
- mod() {
- return (this.modStack.length === 0) ? 'normal' : this.modStack[0];
- }
-
-
- /**
- * Return the mod label currently active
- *
- * @returns {string} label of active mod
- * @private
- */
- modName() {
- if (this.modStack.length === 0)
- return 'normal';
- else if (this.modStack[0] === 'mono')
- return 'mono';
- else {
- if (this.bold && this.ital) {
- return 'boldital';
- } else if (this.bold) {
- return 'bold';
- } else if (this.ital) {
- return 'ital';
- }
- }
- }
-
-
- /**
- * @private
- */
- emitBlock() {
- if (this.spacing) {
- this.add(" ");
- this.spacing = false;
- }
- if (this.buffer.length > 0) {
- this.blocks.push({ text: this.buffer, mod: this.modName() });
- this.buffer = "";
- }
- }
-
-
- /**
- * Output text to buffer
- *
- * @param {string} text text to add
- * @private
- */
- add(text) {
- if (text === " ") {
- this.spacing = true;
- }
- if (this.spacing) {
- this.buffer += " ";
- this.spacing = false;
- }
- if (text != " ") {
- this.buffer += text;
- }
- }
-
-
- /**
- * Handle parsing of whitespace
- *
- * @param {string} ch the character to check
- * @returns {boolean} true if the character was processed as whitespace, false otherwise
- */
- parseWS(ch) {
- if (/[ \t]/.test(ch)) {
- if (!this.mono) {
- this.spacing = true;
- } else {
- this.add(ch);
- }
- return true;
- }
-
- return false;
- }
-
-
- /**
- * @param {string} tagName label for block type to set
- * @private
- */
- setTag(tagName) {
- this.emitBlock();
- this[tagName] = true;
- this.modStack.unshift(tagName);
- }
-
-
- /**
- * @param {string} tagName label for block type to unset
- * @private
- */
- unsetTag(tagName) {
- this.emitBlock();
- this[tagName] = false;
- this.modStack.shift();
- }
-
-
- /**
- * @param {string} tagName label for block type we are currently processing
- * @param {string|RegExp} tag string to match in text
- * @returns {boolean} true if the tag was processed, false otherwise
- */
- parseStartTag(tagName, tag) {
- // Note: if 'mono' passed as tagName, there is a double check here. This is OK
- if (!this.mono && !this[tagName] && this.match(tag)) {
- this.setTag(tagName);
- return true;
- }
-
- return false;
- }
-
-
- /**
- * @param {string|RegExp} tag
- * @param {number} [advance=true] if set, advance current position in text
- * @returns {boolean} true if match at given position, false otherwise
- * @private
- */
- match(tag, advance = true) {
- let [regExp, length] = this.prepareRegExp(tag);
- let matched = regExp.test(this.text.substr(this.position, length));
-
- if (matched && advance) {
- this.position += length - 1;
- }
-
- return matched;
- }
-
-
- /**
- * @param {string} tagName label for block type we are currently processing
- * @param {string|RegExp} tag string to match in text
- * @param {RegExp} [nextTag] regular expression to match for characters *following* the current tag
- * @returns {boolean} true if the tag was processed, false otherwise
- */
- parseEndTag(tagName, tag, nextTag) {
- let checkTag = (this.mod() === tagName);
- if (tagName === 'mono') { // special handling for 'mono'
- checkTag = checkTag && this.mono;
- } else {
- checkTag = checkTag && !this.mono;
- }
-
- if (checkTag && this.match(tag)) {
- if (nextTag !== undefined) {
- // Purpose of the following match is to prevent a direct unset/set of a given tag
- // E.g. '*bold **still bold*' => '*bold still bold*'
- if ((this.position === this.text.length-1) || this.match(nextTag, false)) {
- this.unsetTag(tagName);
- }
- } else {
- this.unsetTag(tagName);
- }
-
- return true;
- }
-
- return false;
- }
-
-
- /**
- * @param {string|RegExp} tag string to match in text
- * @param {value} value string to replace tag with, if found at current position
- * @returns {boolean} true if the tag was processed, false otherwise
- */
- replace(tag, value) {
- if (this.match(tag)) {
- this.add(value);
- this.position += length - 1;
- return true;
- }
-
- return false;
- }
-
-
- /**
- * Create a regular expression for the tag if it isn't already one.
- *
- * The return value is an array `[RegExp, number]`, with exactly two value, where:
- * - RegExp is the regular expression to use
- * - number is the lenth of the input string to match
- *
- * @param {string|RegExp} tag string to match in text
- * @returns {Array} regular expression to use and length of input string to match
- * @private
- */
- prepareRegExp(tag) {
- let length;
- let regExp;
- if (tag instanceof RegExp) {
- regExp = tag;
- length = 1; // ASSUMPTION: regexp only tests one character
- } else {
- // use prepared regexp if present
- var prepared = tagPattern[tag];
- if (prepared !== undefined) {
- regExp = prepared;
- } else {
- regExp = new RegExp(tag);
- }
-
- length = tag.length;
- }
-
- return [regExp, length];
- }
- }
-
-
- /**
- * 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 (!ComponentUtil.isValidLabel(text)) {
- return this.lines.finalize();
- }
-
- var font = this.parent.fontOptions;
-
- // 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 (font.multi) {
- // Multi-font case: styling tags active
- for (let i = 0; i < lineCount; i++) {
- let blocks = this.splitBlocks(nlLines[i], 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 (font.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 (font.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 s = new MarkupAccumulator(text);
-
- let parseEntities = (ch) => {
- if (/&/.test(ch)) {
- let parsed = s.replace(s.text, '<', '<')
- || s.replace(s.text, '&', '&');
-
- if (!parsed) {
- s.add("&");
- }
-
- return true;
- }
-
- return false;
- };
-
- while (s.position < s.text.length) {
- let ch = s.text.charAt(s.position);
-
- let parsed = s.parseWS(ch)
- || (/</.test(ch) && (
- s.parseStartTag('bold', '<b>')
- || s.parseStartTag('ital', '<i>')
- || s.parseStartTag('mono', '<code>')
- || s.parseEndTag('bold', '</b>')
- || s.parseEndTag('ital', '</i>')
- || s.parseEndTag('mono', '</code>')))
- || parseEntities(ch);
-
- if (!parsed) {
- s.add(ch);
- }
- s.position++
- }
- s.emitBlock();
- return s.blocks;
- }
-
-
- /**
- *
- * @param {string} text
- * @returns {Array}
- */
- splitMarkdownBlocks(text) {
- let s = new MarkupAccumulator(text);
- let beginable = true;
-
- let parseOverride = (ch) => {
- if (/\\/.test(ch)) {
- if (s.position < this.text.length + 1) {
- s.position++;
- ch = this.text.charAt(s.position);
- if (/ \t/.test(ch)) {
- s.spacing = true;
- } else {
- s.add(ch);
- beginable = false;
- }
- }
-
- return true
- }
-
- return false;
- }
-
- while (s.position < s.text.length) {
- let ch = s.text.charAt(s.position);
-
- let parsed = s.parseWS(ch)
- || parseOverride(ch)
- || ((beginable || s.spacing) && (
- s.parseStartTag('bold', '*')
- || s.parseStartTag('ital', '_')
- || s.parseStartTag('mono', '`')))
- || s.parseEndTag('bold', '*', 'afterBold')
- || s.parseEndTag('ital', '_', 'afterItal')
- || s.parseEndTag('mono', '`', 'afterMono');
-
- if (!parsed) {
- s.add(ch);
- beginable = false;
- }
- s.position++
- }
- s.emitBlock();
- return s.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;
|