Saturday, February 19, 2011

Truncate a string nicely to fit within a given pixel width.

Sometimes you have strings that must fit within a certain pixel width. This function attempts to do so efficiently. Please post your suggestions or refactorings below :)

function fitStringToSize(str,len) {
    var shortStr = str;
    var f = document.createElement("span");
    f.style.display = 'hidden';
    f.style.padding = '0px';
    document.body.appendChild(f);

    // on first run, check if string fits into the length already.
    f.innerHTML = str;
    diff = f.offsetWidth - len;

    // if string is too long, shorten it by the approximate 
    // difference in characters (to make for fewer iterations). 
    while(diff > 0)
    {
        shortStr = substring(str,0,(str.length - Math.ceil(diff / 5))) + '…';
        f.innerHTML = shortStr;
        diff = f.offsetWidth - len;
    }

    while(f.lastChild) {
        f.removeChild(f.lastChild);
    }
    document.body.removeChild(f);

    // if the string was too long, put the original string 
    // in the title element of the abbr, and append an ellipsis
    if(shortStr.length < str.length)
    {
        return '<abbr title="' + str + '">' + shortStr + '</abbr>';
    }
    // if the string was short enough in the first place, just return it.
    else
    {
        return str;
    }
}

UPDATE: @some's solution below is much better; please use that.

Update 2: Code now posted as a gist; feel free to fork and submit patches :)

From stackoverflow
  • At a quick glance, it looks good to me. Here are some minor suggestions:

    • Use a binary search to find the optimal size instead of a linear one.

    • (optionally) add a mouseover so that a tooltip would give the full string.

    Aeon : the title property will give a mouseover tooltip in all modern browsers :) When you say binary search, what do you mean? Right now it tries to make the string shorter by the estimated number of characters that it's overlong, so it should have only 1-3 iterations no matter how long the string
  • also try posting to http://refactormycode.com/ for refactoring suggestions.

    Looks good but..

    Aeon : Yeah I was thinking about it, but didn't do it yet ;)
  • There are a couple of problems with your code.

    • Why "/ 5" ? The width of the characters depends on font-family and font-size.
    • You must escape "str" in the abbr title (or else an " will make the code invalid).
    • diff is not declared and ends up in the global scope
    • The "substring" is not supposed to work like that. What browser are you using?
    • "hidden" is not a valid value of style.display. To hide it you should use the value "none" but then the browser don't calculate the offsetWidth. Use style.visibility="hidden" instead.
    • The search for the right length is very inefficient.
    • Must escape "</abbr>"

    I rewrote it for you and added className so you can use a style to set the font-family and font-size. Mr Fooz suggested that you use a mouseover to show the whole string. That is not necessary since modern browsers do that for you (tested with FF, IE, Opera and Chrome)

        function fitStringToSize(str,len,className) {
        var result = str; // set the result to the whole string as default
        var span = document.createElement("span");
        span.className=className; //Allow a classname to be set to get the right font-size.
        span.style.visibility = 'hidden';
        span.style.padding = '0px';
        document.body.appendChild(span);
    
    
        // check if the string don't fit 
        span.innerHTML = result;
        if (span.offsetWidth > len) {
         var posStart = 0, posMid, posEnd = str.length;
            while (true) {
          // Calculate the middle position
          posMid = posStart + Math.ceil((posEnd - posStart) / 2);
          // Break the loop if this is the last round
          if (posMid==posEnd || posMid==posStart) break;
    
          span.innerHTML = str.substring(0,posMid) + '&hellip;';
    
          // Test if the width at the middle position is
          // too wide (set new end) or too narrow (set new start).
          if ( span.offsetWidth > len ) posEnd = posMid; else posStart=posMid;
         }
         //Escape
         var title = str.replace("\"","&#34;");
         //Escape < and >
         var body = str.substring(0,posStart).replace("<","&lt;").replace(">","&gt;");
         result = '<abbr title="' + title + '">' + body + '&hellip;<\/abbr>';
        }
        document.body.removeChild(span);
        return result;
        }
    

    Edit: While testing a little more I found a couple of bugs.

    • I used Math.ceil instead of the intended Math.floor (I blame this on that English isn't my native language)

    • If the input string had html-tags then the result would be undefined (it's not good to truncate a tag in the middle or to leave open tags)

    Improvements:

    • Escape the string that is copied to the span on all places. You can still use html-entities, but no tags are allowed (< and > will be displayed)
    • Rewrote the while-statement (it is a little faster, but the main reason was to get rid of the bug that caused extra rounds and to get rid of the break-statement)
    • Renamed the function to fitStringToWidth

    Version 2:

    function fitStringToWidth(str,width,className) {
      // str    A string where html-entities are allowed but no tags.
      // width  The maximum allowed width in pixels
      // className  A CSS class name with the desired font-name and font-size. (optional)
      // ----
      // _escTag is a helper to escape 'less than' and 'greater than'
      function _escTag(s){ return s.replace("<","&lt;").replace(">","&gt;");}
    
      //Create a span element that will be used to get the width
      var span = document.createElement("span");
      //Allow a classname to be set to get the right font-size.
      if (className) span.className=className;
      span.style.display='inline';
      span.style.visibility = 'hidden';
      span.style.padding = '0px';
      document.body.appendChild(span);
    
      var result = _escTag(str); // default to the whole string
      span.innerHTML = result;
      // Check if the string will fit in the allowed width. NOTE: if the width
      // can't be determinated (offsetWidth==0) the whole string will be returned.
      if (span.offsetWidth > width) {
        var posStart = 0, posMid, posEnd = str.length, posLength;
        // Calculate (posEnd - posStart) integer division by 2 and
        // assign it to posLength. Repeat until posLength is zero.
        while (posLength = (posEnd - posStart) >> 1) {
          posMid = posStart + posLength;
          //Get the string from the begining up to posMid;
          span.innerHTML = _escTag(str.substring(0,posMid)) + '&hellip;';
    
          // Check if the current width is too wide (set new end)
          // or too narrow (set new start)
          if ( span.offsetWidth > width ) posEnd = posMid; else posStart=posMid;
        }
    
        result = '<abbr title="' +
          str.replace("\"","&quot;") + '">' +
          _escTag(str.substring(0,posStart)) +
          '&hellip;<\/abbr>';
      }
      document.body.removeChild(span);
      return result;
    }
    
    Aeon : Very nice, thank you. /5 was chosen based on eyeballing it in my application (m is 10px, f is 3px). It's actually kind of crap because the font size will change depending on the styling of the div that the string will be placed into, so ideally we wouldn't be appending to body, but to that div.
    Aeon : escaping: good point! Didn't catch that...
    Aeon : substring: gah, I think that was a typo, forgot the range start operator.
    some : If the element that you are going to insert the sting into already exists in the dom, you could modify the function above to insert it in there. But if that element has style.display="none" you get no width... Using body and a classname is not a bad idea after all.
    Aeon : I also added .replace(/(\s.)?\s*$/,'') to the result assignment to get rid of trailing spaces or widowed single characters.
    some : And if anyone find this code useful, you are hereby permitted to use and modify it the way you want.
    martsraits : The line where quotes are replaced with " enities should be modified a little. Current str.replace("\"",""") should be replaced with str.replace(/\"/g,""") as otherwise only first quote is replaced.
  • can you write same function but to fit width and height of a div? I have a div with fixed width and height, where I need to put text from database. if text is too big for div, I want to cut it and ad ... at the end? Possible? Thank you

    EDIT: I found JS solution for my question:

    <p id="truncateMe">Lorem ipsum dolor sit amet, consectetuer adipiscing
    elit. Aenean consectetuer. Etiam venenatis. Sed ultricies, pede sit
    amet aliquet lobortis, nisi ante sagittis sapien, in rhoncus lectus
    mauris quis massa. Integer porttitor, mi sit amet viverra faucibus,
    urna libero viverra nibh, sed dictum nisi mi et diam. Nulla nunc eros,
    convallis sed, varius ac, commodo et, magna. Proin vel
    risus. Vestibulum eu urna. Maecenas lobortis, pede ac dictum pulvinar,
    nibh ante vestibulum tortor, eget fermentum urna ipsum ac neque. Nam
    urna nulla, mollis blandit, pretium id, tristique vitae, neque. Etiam
    id tellus. Sed pharetra enim non nisl.</p>
    
    <script type="text/javascript">
    
    var len = 100;
    var p = document.getElementById('truncateMe');
    if (p) {
    
      var trunc = p.innerHTML;
      if (trunc.length > len) {
    
        /* Truncate the content of the P, then go back to the end of the
           previous word to ensure that we don't truncate in the middle of
           a word */
        trunc = trunc.substring(0, len);
        trunc = trunc.replace(/\w+$/, '');
    
        /* Add an ellipses to the end and make it a link that expands
           the paragraph back to its original size */
        trunc += '<a href="#" ' +
          'onclick="this.parentNode.innerHTML=' +
          'unescape(\''+escape(p.innerHTML)+'\');return false;">' +
          '...<\/a>';
        p.innerHTML = trunc;
      }
    }
    
    </script>
    

    For my purpose I removed link from ..., as I have another tab on my page that holds complete text.

  • Luckily, CSS3 text-overflow should eventually take care of that.

    If anyone is using ASP.NET and is interested in a server-side solution, check this blog post:

    http://waldev.blogspot.com/2010/09/truncate-text-string-aspnet-fit-width.html

0 comments:

Post a Comment