title: Cut html input while preserving tags with PHP
body: <p>I need to cut html input to a certain length while still preserving tags. The total length <strong>must include the html tags</strong>. I am unable to find a solution that includes the html tag length in the final length. How can I cut html input to a certain length without breaking the tags and making sure that all opening tags have a closing?</p>
  
  <p>Basically, I am getting html input from a user and I need to make it fit within a certain length. To do this, I do not want to break any of the html tags but I need to make sure that the total length (including tags) is less than the maximum.</p>
  
  <p>For example, cutting this string to 20 characters:</p>
  
  <pre><code>&lt;p&gt;this is an example&lt;/p&gt;
  </code></pre>
  
  <p>should give an output of</p>
  
  <pre><code>&lt;p&gt;this is an ex&lt;/p&gt;
  </code></pre>
  
  <p>And this cut to 50 chars</p>
  
  <pre><code>&lt;p&gt;this &lt;a href="http://example.com"&gt;click me&lt;/a&gt;jiasd&lt;/p&gt;
  </code></pre>
  
  <p>Should give</p>
  
  <pre><code>&lt;p&gt;this &lt;a href="http://example.com"&gt;click&lt;/a&gt;&lt;/p&gt;
  </code></pre>
  
  <p>I have tried this solution and it works well for cutting the text to a certain length, but I cannot find a way to get it to count tag length in the total:</p>
  
  <pre><code>function truncateHtml($text, $length = 100, $ending = '...', $exact = false, $considerHtml = true) {
      if ($considerHtml) {
          // if the plain text is shorter than the maximum length, return the whole text
          if (strlen(preg_replace('/&lt;.*?&gt;/', '', $text)) &lt;= $length) {
              return $text;
          }
          // splits all html-tags to scanable lines
          preg_match_all('/(&lt;.+?&gt;)?([^&lt;&gt;]*)/s', $text, $lines, PREG_SET_ORDER);
          $total_length = strlen($ending);
          $open_tags = array();
          $truncate = '';
          foreach ($lines as $line_matchings) {
              // if there is any html-tag in this line, handle it and add it (uncounted) to the output
              if (!empty($line_matchings[1])) {
                  // if it's an "empty element" with or without xhtml-conform closing slash
                  if (preg_match('/^&lt;(\s*.+?\/\s*|\s*(img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param)(\s.+?)?)&gt;$/is', $line_matchings[1])) {
                      // do nothing
                  // if tag is a closing tag
                  } else if (preg_match('/^&lt;\s*\/([^\s]+?)\s*&gt;$/s', $line_matchings[1], $tag_matchings)) {
                      // delete tag from $open_tags list
                      $pos = array_search($tag_matchings[1], $open_tags);
                      if ($pos !== false) {
                      unset($open_tags[$pos]);
                      }
                  // if tag is an opening tag
                  } else if (preg_match('/^&lt;\s*([^\s&gt;!]+).*?&gt;$/s', $line_matchings[1], $tag_matchings)) {
                      // add tag to the beginning of $open_tags list
                      array_unshift($open_tags, strtolower($tag_matchings[1]));
                  }
                  // add html-tag to $truncate'd text
                  $truncate .= $line_matchings[1];
              }
              // calculate the length of the plain text part of the line; handle entities as one character
              $content_length = strlen(preg_replace('/&amp;[0-9a-z]{2,8};|&amp;#[0-9]{1,7};|[0-9a-f]{1,6};/i', ' ', $line_matchings[2]));
              if ($total_length+$content_length&gt; $length) {
                  // the number of characters which are left
                  $left = $length - $total_length;
                  $entities_length = 0;
                  // search for html entities
                  if (preg_match_all('/&amp;[0-9a-z]{2,8};|&amp;#[0-9]{1,7};|[0-9a-f]{1,6};/i', $line_matchings[2], $entities, PREG_OFFSET_CAPTURE)) {
                      // calculate the real length of all entities in the legal range
                      foreach ($entities[0] as $entity) {
                          if ($entity[1]+1-$entities_length &lt;= $left) {
                              $left--;
                              $entities_length += strlen($entity[0]);
                          } else {
                              // no more characters left
                              break;
                          }
                      }
                  }
                  $truncate .= substr($line_matchings[2], 0, $left+$entities_length);
                  // maximum lenght is reached, so get off the loop
                  break;
              } else {
                  $truncate .= $line_matchings[2];
                  $total_length += $content_length;
              }
              // if the maximum length is reached, get off the loop
              if($total_length&gt;= $length) {
                  break;
              }
          }
      } else {
          if (strlen($text) &lt;= $length) {
              return $text;
          } else {
              $truncate = substr($text, 0, $length - strlen($ending));
          }
      }
      // if the words shouldn't be cut in the middle...
      if (!$exact) {
          // ...search the last occurance of a space...
          $spacepos = strrpos($truncate, ' ');
          if (isset($spacepos)) {
              // ...and cut the text in this position
              $truncate = substr($truncate, 0, $spacepos);
          }
      }
      // add the defined ending to the text
      $truncate .= $ending;
      if($considerHtml) {
          // close all unclosed html-tags
          foreach ($open_tags as $tag) {
              $truncate .= '&lt;/' . $tag . '&gt;';
          }
      }
      return $truncate;
  }
  </code></pre>