11

I have a string which contains a lot of text, text, in my JavaScript file. I also have an element, div#container that is styled (using separate CSS) with potentially nonstandard line-height, font-size, font-face, and maybe others. It has a fixed height and width.

I'd like to get the maximum amount of text that can fit into div#container without any overflow from the string. What's the best way of doing this?

This needs to be able to work with text formatted with tags, for example:

<strong>Hello person that is this is long and may take more than a</strong> 
line and so on.

Currently, I've got a JQuery plugin that works for plain text, code follows:

// returns the part of the string that cannot fit into the object
$.fn.func = function(str) {
    var height = this.height();

    this.height("auto");
    while(true) {
        if(str == "") {
            this.height(height);
            return str; // the string is empty, we're done
        }

        var r = sfw(str); // r = [word, rest of String] (sfw is a split first word function defined elsewhere
        var w = r[0], s = r[1];

        var old_html = this.html();
        this.html(old_html + " " + w);

        if(this.height() > height)
        {
            this.html(old_html);
            this.height(height);
            return str; // overflow, return to last working version
        }

        str = s;

    }
}

UPDATE:

The data looks like this:

<ol>
  <li>
     <h2>Title</h2>
     <ol>
        <li>Character</li>
        <ol>
          <li>Line one that might go on a long time, SHOULD NOT BE BROKEN</li>
          <li>Line two can be separated from line one, but not from itself</li>
        </ol>
      </ol>
     <ol>
        <li>This can be split from other</li>
        <ol>
          <li>Line one that might go on a long time, SHOULD NOT BE BROKEN</li>
          <li>Line two can be separated from line one, but not from itself</li>
        </ol>
      </ol>
   </li>  <li>
     <h2>Title</h2>
     <ol>
        <li>Character</li>
        <ol>
          <li>Line one that might go on a long time, SHOULD NOT BE BROKEN</li>
          <li>Line two can be separated from line one, but not from itself</li>
        </ol>
      </ol>
     <ol>
        <li>This can be split from other</li>
        <ol>
          <li>Line one that might go on a long time, SHOULD NOT BE BROKEN</li>
          <li>Line two can be separated from line one, but not from itself</li>
        </ol>
      </ol>
   </li>
</ol>
Aaron Yodaiken
  • 19,163
  • 32
  • 103
  • 184
  • 1
    I think you'll have to fill (an invisible copy of) the container until it breaks – Pekka Feb 07 '11 at 00:37
  • 2
    Do you mean you can't just set `overflow:hidden;` on it? – Robert Koritnik Feb 07 '11 at 00:38
  • No. I have to know how much of it is displayed. – Aaron Yodaiken Feb 07 '11 at 00:38
  • I'm pretty sure Pekka is right. You'll have to keep checking offsetHeight on the container as you add more text until offsetHeight > height. – SpliFF Feb 07 '11 at 00:40
  • Duplicate of [this question](http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript)? – Leigh Feb 07 '11 at 09:33
  • 1
    @Leigh: It's similar but not duplicate. – Robert Koritnik Feb 09 '11 at 08:39
  • 2
    And this one? http://stackoverflow.com/questions/4929107/calculate-how-many-characters-from-a-string-will-fit-into-a-div-without-making – JCOC611 Feb 15 '11 at 00:36
  • Nope. That question is pretty different, and doesn't give an answer that would be acceptable. – Aaron Yodaiken Feb 15 '11 at 00:39
  • So in the updated question, why doesn't the code that works for plain text work for the example text? Setting the inline HTML of the DIV ought to render the tags. Or do you not want them rendered when considering the 'width' of the text? – David Bullock Feb 15 '11 at 01:27
  • because the tags aren't closed if they happen to break over pagination/not started again on the next "page". additionally, jquery autocloses all tags so they're limited to one word. – Aaron Yodaiken Feb 15 '11 at 02:14

5 Answers5

5

To get longest possible first line:

  1. Create a DIV with visibility:hidden; (so it will have dimension) but position it as position:absolute; so it won't break your page flow
  2. set its type style to the same values as your resulting DIV
  3. Set it's height the same as resulting DIV but keep width:auto;
  4. Add text to it
  5. Keep cutting off text until width drops below resulting DIV's width.

The result is the text you can put in.

Adjust the algorithm if you need to find amount of lines that fit into container to keep height:auto; and set fixed width.

The same technique is used by auto-adjusting textareas that auto-grow while users type in text.

Robert Koritnik
  • 103,639
  • 52
  • 277
  • 404
  • This method doesn't really work if I have tags within the text, is there a good way of handling them? – Aaron Yodaiken Feb 09 '11 at 01:36
  • @aharon: That makes things more complicated but could still be done. Can container `DIV` have content with any tags or is tag set limited to a certain number of them? The latter would be easier to solve. – Robert Koritnik Feb 09 '11 at 08:41
  • @aharon: I suppose "nesting?" is a question I shouldn't ask either because you'd vehemently say "Yes, even deep nesting." ;) – Robert Koritnik Feb 09 '11 at 22:52
  • nesting up to 5 levels will be standard use case :| Sorry for making things difficult! – Aaron Yodaiken Feb 09 '11 at 23:12
5

well, let me try to solve it ;) actually thinking about solution I noticed that I don't know enough about requirements you have, so I decided to develop simple JavaScript code and show you result; after trying it you can tell me what's wrong so I can fix/change it, deal?

I used pure JavaScript, no jQuery (it can be rewritten if needed). The principle is similar to your jQuery plugin:

  1. we take characters one by one (instead of words as sfw function does; it can be changed)
  2. if it is part of opening tag, browser does not show it, so I didn't processed it special way, just appended one by one characters from tag name and checked height of container... no idea if it is that bad. I mean when I write container.innerHTML = "My String has a link <a href='#'"; in browser I see "My String has a link", so "unfinished" tag does not influence size of container (at least in all browsers where I tested)
  3. check size of container, and if it is bigger than we expect it to be, then previous string (actually current string without last character) is what we are looking for
  4. now we have to close all opening tags, which are not closed because of cutting

HTML page to test it:

<html>

  <head>
    <style>
    div {
      font-family: Arial;
      font-size: 20px;
      width: 200px;
      height: 25px;
      overflow: hidden;
    }
    </style>
  </head>

  <body>
     <div id="container"> <strong><i>Strong text with <a href="#">link</a> </i> and </strong> simple text </div>

     <script>
     /**
      * this function crops text inside div element, leaving DOMstructure valid (as much as possible ;).
      * also it makes visible part as "big" as possible, meaning that last visible word will be split 
      * to show its first letters if possible
      *
      * @param container {HTMLDivElement} - container which can also have html elements inside
      * @return {String} - visible part of html inside div element given
      */
     function cropInnerText( container ) {
       var fullText = container.innerHTML; // initial html text inside container 
       var realHeight = container.clientHeight; // remember initial height of the container 
       container.style.height = "auto"; // change height to "auto", now div "fits" its content 

       var i = 0;
       var croppedText = "";
       while(true) {
         // if initial container content is the same that cropped one then there is nothing left to do
         if(croppedText == fullText) { 
           container.style.height = realHeight + "px";
           return croppedText;
         }

         // actually append fullText characters one by one...    
         var nextChar = fullText.charAt( i );
         container.innerHTML = croppedText + nextChar;  

         // ... and check current height, if we still fit size needed
         // if we don't, then we found that visible part of string
         if ( container.clientHeight > realHeight ) {
           // take all opening tags in cropped text 
           var openingTags = croppedText.match( /<[^<>\/]+>/g );
           if ( openingTags != null ) {
             // take all closing tags in cropped text 
             var closingTags = croppedText.match( /<\/[^<>]+>/g ) || [];
             // for each opening tags, which are not closed, in right order...
             for ( var j = openingTags.length - closingTags.length - 1; j > -1; j-- ) {
               var openingTag; 
               if ( openingTags[j].indexOf(' ') > -1 ) {
                 // if there are attributes, then we take only tag name
                 openingTag = openingTags[j].substr(1, openingTags[j].indexOf(' ')-1 ) + '>';
               }
               else {
                 openingTag = openingTags[j].substr(1);
               }
               // ... close opening tag to have valid html
               croppedText += '</' + openingTag;
             }
           }

           // return height of container back ... 
           container.style.height = realHeight + "px";
           // ... as well as its visible content 
           container.innerHTML = croppedText;
           return croppedText;
         }

         i++;
         croppedText += nextChar;
       }

     }

     var container = document.getElementById("container");
     var str = cropInnerText( container );
     console.info( str ); // in this case it prints '<strong><i>Strong text with <a href="#">link</a></i></strong>'
   </script>

</body>

Possible improvements / changes:

  1. I do not create any new DOM elements, so I just reuse current container (to be sure I take into account all css styles); this way I change its content all the time, but after taking visible text you can write fullText back into container if needed (which I also do not change)
  2. Processing original text word by word will let us make less changes in DOM (we will write word by word instead of character by character), so this way should be faster. You already have sfw function, so you can change it easily.
  3. If we have two words "our sentence", it is possible that visible will be only first one ("our"), and "sentence" should be cut (overflow:hidden will work this way). In my case, I will append character by character, so my result can be "our sent". Again, this is not a complex part of algorithm, so based on your jQuery plugin code, you can change mine to work with words.

Questions, remarks, bugs found are welcome ;) I tested it in IE9, FF3.6, Chrome 9

UPDATE: Accroding to an issue with <li>, <h1> ... E.g. I have container with content:

<div id="container"> <strong><i>Strong text with <ul><li>link</li></ul> </i> and </strong> simple text </div>

In this case browser behaves this way (string by string what is in container and what I see it shows according to the algorithm):

...
"<strong><i>Strong text with <" -> "<strong><i>Strong text with <"
"<strong><i>Strong text with <u" -> "<strong><i>Strong text with "
"<strong><i>Strong text with <ul" -> "<strong><i>Strong text with <ul></ul>" // well I mean it recognizes ul tag and changes size of container

and result of algorithm is string "<strong><i>Strong text with <u</i></strong>" - with "<u", what is not nice. What I need to process in this case is that if we found our result string ("<strong><i>Strong text with <u" according to the algorithm), we need to removed last "unclosed" tag ("<u" in our case), so before closing tags to have valid html I added the following:

...
if ( container.clientHeight > realHeight ) {
  /* start of changes */
  var unclosedTags = croppedText.match(/<[\w]*/g);
  var lastUnclosedTag = unclosedTags[ unclosedTags.length - 1 ];
  if ( croppedText.lastIndexOf( lastUnclosedTag ) + lastUnclosedTag.length == croppedText.length ) {
    croppedText = croppedText.substr(0, croppedText.length - lastUnclosedTag.length );
  }
  /* end of changes */
  // take all opening tags in cropped text 
...

probably a bit lazy implementation, but it can be tuned if it slows down. What is done here

  1. take all tags without > (in our case it returns ["<strong", "<i", "<u"]);
  2. take last one ("<u")
  3. if it is end of croppedText string, then we remove it

after doing it, the result string becomes "<strong><i>Strong text with </i></strong>"

UPDATE2 thank you for example, so I see that you don't have just nested tags, but they also have "tree" structure, indeed I didn't take it into account, but it still can be fixed ;) At the beginning I wanted to write my appropriate "parser", but all the time I get an example when I does not work, so I thought it is better to find already written parser, and there is one: Pure JavaScript HTML Parser. There is also one shag to it:

While this library doesn't cover the full gamut of possible weirdness that HTML provides, it does handle a lot of the most obvious stuff.

but for your example it works; that library didn't take into account position of opening tag, but

  1. we rely that original html structure is fine (not broken);
  2. we close tags at the end of the result "string" (so this is ok)

I think that with that assumptions this library is nice to use. Then result function looks like:

<script src="http://ejohn.org/files/htmlparser.js"></script>
 <script>
 function cropInnerText( container ) {
   var fullText = container.innerHTML;
   var realHeight = container.clientHeight;
   container.style.height = "auto";

   var i = 0;
   var croppedText = "";
   while(true) {
     if(croppedText == fullText) { 
       container.style.height = realHeight + "px";
       return croppedText;
     }

     var nextChar = fullText.charAt( i );
     container.innerHTML = croppedText + nextChar;  

     if ( container.clientHeight > realHeight ) {
       // we still have to remove unended tag (like "<u" - with no closed bracket)
       var unclosedTags = croppedText.match(/<[\w]*/g);
       var lastUnclosedTag = unclosedTags[ unclosedTags.length - 1 ];
       if ( croppedText.lastIndexOf( lastUnclosedTag ) + lastUnclosedTag.length == croppedText.length ) {
         croppedText = croppedText.substr(0, croppedText.length - lastUnclosedTag.length );
       }

       // this part is now quite simple ;)
       croppedText = HTMLtoXML(croppedText);

       container.style.height = realHeight + "px";
       container.innerHTML = croppedText ;
       return croppedText;
     }

     i++;
     croppedText += nextChar;
   }

 }
 </script>
Maxym
  • 11,836
  • 3
  • 44
  • 48
  • I don't just have links... I have things like `
  • ` and `

    ` and such in the content that actually changes the length so I need to work with that. Also, jQuery or plain javascript works... whatever is easier, but I'm using jQuery anyway.

  • – Aaron Yodaiken Feb 17 '11 at 02:30
  • @aharon: I see an issue, I tried to fix it, if it does not work, or you have another "breaking" example, let me know – Maxym Feb 17 '11 at 07:59
  • @aharon: Any feedback about last changes? Still does not work? Or maybe I misunderstood you and move in the wrong direction? – Maxym Feb 18 '11 at 17:11
  • Okay, well, the problem is that the whole thing is in an unclosed tag. I'll be more precise about the data: it looks like edited question. – Aaron Yodaiken Feb 19 '11 at 00:35
  • This doesn't really work with that. Sorry for being vague, and thanks so much! – Aaron Yodaiken Feb 19 '11 at 00:39