There is an annoying bug in the Joomla core JHtmlString truncate function. There is also an even more annoying (to me) flaw in the way it works.

Fixing these problems require modification to a core file  libraries/cms/html/string.php 

The blog post describes the problem in more detail, but here is the required new code for the truncate function in the file.

Since this is a mod to a core library file there is no way to override it. The buggy version will be restored at every Joomla update - so the fix below will need to be re-instated after any Joomla core update until such time as it gets sorted. This code needs to replace the existing truncate function in the file. NB use at your own risk, this has only been tested by me using it on sites I run and with browsers I use - it needs to be checked against the full Joomla unit tests but I am not competent to do that and don't intend to learn...

   public static function truncate($text, $length = 0, $noSplit = true, $allowHtml = true)
    {
        // Assume a lone open tag is invalid HTML.
        if ($length === 1 && $text[0] === '<')
        {
        	return '...';
        }

        // Check if HTML tags are allowed.
        if (!$allowHtml)
        {
            // Deal with spacing issues in the input.
            $text = str_replace('>', '> ', $text);
            $text = str_replace(array('&nbsp;', '&#160;'), ' ', $text);
            $text = StringHelper::trim(preg_replace('#\s+#mui', ' ', $text));

            // Strip the tags from the input and decode entities.
            $text = strip_tags($text);
            $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');

            // Remove remaining extra spaces.
            $text = str_replace('&nbsp;', ' ', $text);
            $text = StringHelper::trim(preg_replace('#\s+#mui', ' ', $text));
        }

        // Whether or not allowing HTML, truncate the item text if it is too long.
        if ($length > 0 && StringHelper::strlen($text) > $length)
        {
           //test if the next character is a space - if it is include it so we don't loose the word
           if ($text[$length] == ' ')
           {
		        ++$length;
           }
           //trim leading spaces, leave trailing ones so as not to loose the last word
           $tmp = ltrim(StringHelper::substr($text, 0, $length));
		    
           //test if all we have is an incomplete tag
           if ($tmp[0] === '<' && strpos($tmp, '>') === false)
           {
                return '...';
           }

           // $noSplit true means that we do not allow splitting of words.
           if ($noSplit)
           {
                // Find the position of the last space within the allowed length.
                $offset = StringHelper::strrpos($tmp, ' ');
                // If there are no spaces and the string is longer than the maximum
                // we need to just use the ellipsis. In that case we are done.
                if ($offset === false && strlen($text) > $length)
                {
                    return '...';
                }
                $tmp = StringHelper::substr($tmp, 0, $offset + 1);
           }

           if ($allowHtml)
           {
                // Put all opened tags into an array
                preg_match_all("#<([a-z][a-z0-9]*)\b.*?(?!/)>#i", $tmp, $result);
                $openedTags = $result[1];

                // Some tags self close so they do not need a separate close tag.
                $openedTags = array_diff($openedTags, array('img', 'hr', 'br'));
                $openedTags = array_values($openedTags);

                // Put all closed tags into an array
                preg_match_all("#</([a-z][a-z0-9]*)\b(?:[^>]*?)>#iU", $tmp, $result);
                $closedTags = $result[1];

                $numOpened = count($openedTags);

                // Check if we end inside a tag; if we are remove it to get rid of the fragment
                if (StringHelper::strrpos($tmp, '<') > StringHelper::strrpos($tmp, '>'))
                {
                    $offset = StringHelper::strrpos($tmp, '<');
                    $tmp = StringHelper::trim(StringHelper::substr($tmp, 0, $offset));
                }
                //now we can add the ellipsis
                $tmp .= '...';

                // Not all tags are closed so close them and finish.
                if (count($closedTags) !== $numOpened)
                {
                    // Closing tags need to be in the reverse order of opening tags.
                    $openedTags = array_reverse($openedTags);

                    // Close tags
                    for ($i = 0; $i < $numOpened; $i++)
                    {
                        if (!in_array($openedTags[$i], $closedTags))
                        {
                            $tmp .= '</' . $openedTags[$i] . '>';
                        } else {
                            unset($closedTags[array_search($openedTags[$i], $closedTags)]);
                        }
                    }
                }
            } else {
                // $allowHtml==false so just add an ellipsis
                $tmp .= '...';
            }

            if ($tmp === false || strlen($text) > strlen($tmp))
            {
                $text = trim($tmp); 
            }
        }

        // Clean up any internal spaces created by the processing.
        $text = str_replace(' </', '</', $text);
        $text = str_replace(' ...', '...', $text);

        return $text;
    }