html text truncate (v3)

Revision 3 of this benchmark created by neurostep on


Setup

function Utility() {}
    
    /**
     * @static
     * @method truncate
     * @param {String} string string needs to be truncated
     * @param {Number} maxLength length of truncated string
     * @param {Object} options (optional)
     * @param {Boolean} [options.keepImageTag] flag to specify if keep image tag, false by default
     * @param {Boolean|String} [options.ellipsis] omission symbol for truncated string, '...' by default
     * @param {Boolean} [options.truncateLastWord] truncates last word, true by default
     * @return {String} truncated string
     */
    Utility.truncate = function (string, maxLength, options) {
        var EMPTY_OBJECT = {},
            EMPTY_STRING = '',
            DEFAULT_TRUNCATE_SYMBOL = '...',
            EXCLUDE_TAGS = ['img'],         // non-closed tags
            items = [],                     // stack for saving tags
            total = 0,                      // record how many characters we traced so far
            content = EMPTY_STRING,         // truncated text storage
            KEY_VALUE_REGEX = '(\\w+\\s*=\\s*"[^"]*"\\s*)*',
            IS_CLOSE_REGEX = '\\s*\\/?\\s*',
            CLOSE_REGEX = '\\s*\\/\\s*',
            SELF_CLOSE_REGEX = new RegExp('<\\/?\\w+\\s*' + KEY_VALUE_REGEX + CLOSE_REGEX + '>'),
            HTML_TAG_REGEX = new RegExp('<\\/?\\w+\\s*' + KEY_VALUE_REGEX + IS_CLOSE_REGEX + '>'),
            IMAGE_TAG_REGEX = new RegExp('<img\\s*' + KEY_VALUE_REGEX + IS_CLOSE_REGEX + '>'),
            matches = true,
            result,
            index,
            tail,
            tag,
            selfClose;
    
        /**
         * @private
         * @method _removeImageTag
         * @param {String} string not-yet-processed string
         * @description helper to dump all close tags and append to truncated content while reaching upperbound
         * @return {String} string without image tags
         */
        function _removeImageTag(string) {
            var match = IMAGE_TAG_REGEX.exec(string),
                index,
                len;
    
            if (!match) {
                return string;
            }
    
            index = match.index;
            len = match[0].length;
    
            return string.substring(0, index) + string.substring(index + len);
        }
    
        /**
         * @private
         * @method _dumpCloseTag
         * @param {String[]} tags a list of tags which should be closed
         * @description helper to dump all close tags and append to truncated content while reaching upperbound
         * @return {String} well-formatted html
         */
        function _dumpCloseTag(tags) {
            var html = '';
    
            tags.reverse().forEach(function (tag, index) {
                // dump non-excluded tags only
                if (-1 === EXCLUDE_TAGS.indexOf(tag)) {
                    html += '</' + tag + '>';
                }
            });
    
            return html;
        }
    
        /**
         * @private
         * @method _getTag
         * @param {String} string original html
         * @description processed tag string to get pure tag name
         * @return {String} tag name
         */
        function _getTag(string) {
            var tail = string.indexOf(' ');
    
            // TODO: 
            // we have to figure out how to handle non-well-formatted HTML case
            if (-1 === tail) {
                tail = string.indexOf('>');
                if (-1 === tail) {
                    throw new Error('HTML tag is not well-formed : ' + string);
                }
            }
    
            return string.substring(1, tail);
        }
    
        options = options || EMPTY_OBJECT;
        options.ellipsis = options.ellipsis || DEFAULT_TRUNCATE_SYMBOL;
        options.truncateLastWord = (options.truncateLastWord === undefined) ? true : options.truncateLastWord;
    
        while (matches) {
            matches = HTML_TAG_REGEX.exec(string);
    
            if (!matches) {
                if (total < maxLength) {
                    content += string.substring(0, maxLength - total);
                }
                break;
            }
    
            result = matches[0];
            index = matches.index;
    
            if (total + index > maxLength) {
                // exceed given `maxLength`, dump everything to clear stack
                content += (string.substring(0, maxLength - total));
                break;
            } else {
                total += index;
                content += string.substring(0, index);
            }
    
            if ('/' === result[1]) {
                // move out open tag
                items.pop();
            } else {
                selfClose = SELF_CLOSE_REGEX.exec(result);
                if (!selfClose) {
                    tag = _getTag(result);
    
                    items.push(tag);
                }
            }
    
            if (selfClose) {
                content += selfClose[0];
            } else {
                content += result;
            }
            string = string.substring(index + result.length);
        }
    
        if (string.length > maxLength && options.ellipsis) {
            if (options.truncateLastWord) {
                content += options.ellipsis;
            } else {
                content = content.replace(/ \w*$/, options.ellipsis);
            }
        }
        content += _dumpCloseTag(items);
    
        if (!options.keepImageTag) {
            content = _removeImageTag(content);
        }
    
        return content;
    };
    
    var htmlTextTruncate = function(text, limit, postfix, forceClosingTags) {
        if (!limit || text.length < limit) return text;
    
        var tags = [], count = 0, finalPos = 0;
    
        for (var i = 0; i < text.length; i++) {
                var symbol = text.charAt(i);
                if (symbol === "<") {
                        var tail = text.indexOf(">", i);
                        if (tail < 0) return text;
                        var source = text.substring(i + 1, tail);
                        var tag = {"name": "", "closing": false};
                        if (source.charAt(0) === "/") {
                                tag.closing = true;
                                source = source.substring(1);
                        }
                        tag.name = source.match(/(\w)+/)[0];
                        if (tag.closing) {
                                var current = tags.pop();
                                if (!current || current.name !== tag.name) return text;
                        }                       
          i = tail;
                } else if (symbol === "&" && text.substring(i).match(/^(\S)+;/)) {
                        i = text.indexOf(";", i);
                } else {
                        if (count === limit) {
                                finalPos = i;
                                break;
                        }
                        count++;
                }
        }
        if (finalPos || forceClosingTags) {
                if (finalPos) {
                        text = text.substring(0, finalPos) + (postfix || "");
                }
                for (var i = tags.length - 1; i >= 0; i--) {
                        text += "</" + tags[i].name + ">";
                }
        }
        return text;
    };
    
    var htmlTextTruncate2 = function(text, limit, postfix, forceClosingTags) {
        if (!limit || text.length < limit) return text;
    
        var state = {"tags": [], "count": 0, "position": 0, "lastWordChar": 0};
        var finalState;
    
        var copyState = function(state) {
                return {
                                "position": state.position,
                                "count": state.count,
                                "lastWordChar": state.lastWordChar,
                                "tags": state.tags.slice()
                        };
        };
        var checkWordEnd = function(state, symbol) {
                if (
                        (symbol.length !== 1 && symbol !== "&nbsp")
                        || /^\w$/.test(symbol)
                ) {
                        state.lastWordChar = state.position;
                        if (state.position === text.length - 1) {
                                state.position++;
                                finalState = copyState(state);
                                return true;
                        }
    
                } else if (state.lastWordChar == state.position - 1) {
                        finalState = copyState(state);
                        return true;
                }
                return false;
        }
    
        for (var i = 0; i < text.length; i++) {
                var symbol = text.charAt(i);
                state.position = i;
                if (symbol === "<") {
                        //checkWordEnd(state, symbol, text);
                        var tail = text.indexOf(">", i);
                        if (tail < 0) return text;
                        var source = text.substring(i + 1, tail);
                        var tag = {"name": "", "closing": false};
                        if (source.charAt(0) === "/") {
                                tag.closing = true;
                                source = source.substring(1);
                        }
                        tag.name = source.match(/(\w)+/)[0];
                        if (tag.closing) {
                                var current = state.tags.pop();
                                if (!current || current.name !== tag.name) return text;
                        }
                        i = tail;
                } else if (symbol === "&" && text.substring(i).test(/^(\S)+;/)) {
          var _t = text.indexOf(";", i);
                        //checkWordEnd(state, text.substring(i, _t));
                        i = _t;
                        state.count++;
    
                } else {
                        if (state.count === limit) {
                                break;
                        }
                        //checkWordEnd(state, symbol);
                        state.count++;
                }
        }
        
        if ((state.count === limit && state.position < text.length) || forceClosingTags) {
                state = finalState || state;
                if (state.position) {
                        text = text.substring(0, state.position) + (postfix || "");
                }
                for (var i = state.tags.length - 1; i >= 0; i--) {
                        text += "</" + state.tags[i].name + ">";
                }
        }
        return text;
    };
    
    var html = "\n          <div class=\"echo-item-text\">#nyc #broadway</div>\n          <div class=\"echo-item-files\">\n            <div class=\"echo-item-photo\">\n              <a _target=\"blank\" href=\"http://media.getchute.com/m/64LtQwiql/c/2318453\">\n                <img width=\"200\" data-src-full=\"http://media.getchute.com/m/64LtQwiql/c/2318453\" data-src-web=\"http://media.getchute.com/m/64LtQwiql/c/2318453\" alt=\"http://media.getchute.com/m/64LtQwiql/c/2318453\" data-src-preview=\"http://media.getchute.com/media/64LtQwiql/200x200\" src=\"http://media.getchute.com/m/64LtQwiql/c/2318453\" title=\"http://media.getchute.com/m/64LtQwiql/c/2318453\">\n              </a>\n            </div>\n          <div>alskdjfalskdjfla;sd alkdsjfalskdjf alskdjflaksdjflkasjdfl;kj asldkjflaskdjfl;askdjflk laskdjflaksjdflkjasdlkfj alsdkjflaskdjflkasdjfl;kasd salkdjflaskdjflkasdj aldskjfalsdkjflaksd laskdjflaskdjflaskdjf alsdkjflasdkjflas;kdj asldkfjsalkjflk asldkjflasdkjflk asdlkfjalsdkjfla;skd asldkfjalsdkjfl;asdf alkfjalsdkjf asdflkjasdlkjf aldskfjalsdkjfa aldsfkja;sdkfj al;sdkfjalsdkfjlk;a alskdfjalskdjfa; alkfjalsdkjflaksdfj alkdsjfalsdkjflaks alskdfjlaskdjflkasj alsdkjflaskdjflask asldkjfalskjfdalskdj asdlkfjalsdjflaskdjf asdlkfjalskdfjlaskd alsdkfjalsdkjflaksdjf asldkjf lkjasldfkjalskj dlaskdjflaskdj ljadfslkjfl jsaldkjflaksdjflaskdj lkafdsjl fkjasdlf </div></div>\n        ";

Test runner

Ready to run.

Testing in
TestOps/sec
Utility.truncate
Utility.truncate(html, 100);
ready
htmlTextTruncate
htmlTextTruncate(html, 100);
ready
htmlTextTruncate2
htmlTextTruncate2(html, 100);
ready

Revisions

You can edit these tests or add more tests to this page by appending /edit to the URL.

  • Revision 1: published by neurostep on
  • Revision 3: published by neurostep on