diff --git a/lib/moodlelib.php b/lib/moodlelib.php
index 690fc1ee4f8..5b70ae16b7a 100644
--- a/lib/moodlelib.php
+++ b/lib/moodlelib.php
@@ -9156,13 +9156,13 @@ function shorten_text($text, $ideal=30, $exact = false, $ending='...') {
global $CFG;
- // if the plain text is shorter than the maximum length, return the whole text
+ // If the plain text is shorter than the maximum length, return the whole text.
if (textlib::strlen(preg_replace('/<.*?>/', '', $text)) <= $ideal) {
return $text;
}
// Splits on HTML tags. Each open/close/empty tag will be the first thing
- // and only tag in its 'line'
+ // and only tag in its 'line'.
preg_match_all('/(<.+?>)?([^<>]*)/s', $text, $lines, PREG_SET_ORDER);
$total_length = textlib::strlen($ending);
@@ -9171,37 +9171,43 @@ function shorten_text($text, $ideal=30, $exact = false, $ending='...') {
// This array stores information about open and close tags and their position
// in the truncated string. Each item in the array is an object with fields
// ->open (true if open), ->tag (tag name in lower case), and ->pos
- // (byte position in truncated text)
+ // (byte position in truncated text).
$tagdetails = array();
foreach ($lines as $line_matchings) {
- // if there is any html-tag in this line, handle it and add it (uncounted) to the output
+ // 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 (f.e.
)
+ // If it's an "empty element" with or without xhtml-conform closing slash (f.e.
).
if (preg_match('/^<(\s*.+?\/\s*|\s*(img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param)(\s.+?)?)>$/is', $line_matchings[1])) {
- // do nothing
- // if tag is a closing tag (f.e. )
+ // Do nothing.
+
} else if (preg_match('/^<\s*\/([^\s]+?)\s*>$/s', $line_matchings[1], $tag_matchings)) {
- // record closing tag
- $tagdetails[] = (object)array('open'=>false,
- 'tag'=>textlib::strtolower($tag_matchings[1]), 'pos'=>textlib::strlen($truncate));
- // if tag is an opening tag (f.e. )
+ // Record closing tag.
+ $tagdetails[] = (object) array(
+ 'open' => false,
+ 'tag' => textlib::strtolower($tag_matchings[1]),
+ 'pos' => textlib::strlen($truncate),
+ );
+
} else if (preg_match('/^<\s*([^\s>!]+).*?>$/s', $line_matchings[1], $tag_matchings)) {
- // record opening tag
- $tagdetails[] = (object)array('open'=>true,
- 'tag'=>textlib::strtolower($tag_matchings[1]), 'pos'=>textlib::strlen($truncate));
+ // Record opening tag.
+ $tagdetails[] = (object) array(
+ 'open' => true,
+ 'tag' => textlib::strtolower($tag_matchings[1]),
+ 'pos' => textlib::strlen($truncate),
+ );
}
- // add html-tag to $truncate'd text
+ // 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
+ // Calculate the length of the plain text part of the line; handle entities as one character.
$content_length = textlib::strlen(preg_replace('/&[0-9a-z]{2,8};|[0-9]{1,7};|[0-9a-f]{1,6};/i', ' ', $line_matchings[2]));
- if ($total_length+$content_length > $ideal) {
- // the number of characters which are left
+ if ($total_length + $content_length > $ideal) {
+ // The number of characters which are left.
$left = $ideal - $total_length;
$entities_length = 0;
- // search for html entities
+ // Search for html entities.
if (preg_match_all('/&[0-9a-z]{2,8};|[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) {
@@ -9214,7 +9220,32 @@ function shorten_text($text, $ideal=30, $exact = false, $ending='...') {
}
}
}
- $truncate .= textlib::substr($line_matchings[2], 0, $left+$entities_length);
+ $breakpos = $left + $entities_length;
+
+ // if the words shouldn't be cut in the middle...
+ if (!$exact) {
+ // ...search the last occurence of a space...
+ for (; $breakpos > 0; $breakpos--) {
+ if ($char = textlib::substr($line_matchings[2], $breakpos, 1)) {
+ if ($char === '.' or $char === ' ') {
+ $breakpos += 1;
+ break;
+ } else if (strlen($char) > 2) { // Chinese/Japanese/Korean text
+ $breakpos += 1; // can be truncated at any UTF-8
+ break; // character boundary.
+ }
+ }
+ }
+ }
+ if ($breakpos == 0) {
+ // This deals with the test_shorten_text_no_spaces case.
+ $breakpos = $left + $entities_length;
+ } else if ($breakpos > $left + $entities_length) {
+ // This deals with the previous for loop breaking on the first char.
+ $breakpos = $left + $entities_length;
+ }
+
+ $truncate .= textlib::substr($line_matchings[2], 0, $breakpos);
// maximum length is reached, so get off the loop
break;
} else {
@@ -9222,55 +9253,31 @@ function shorten_text($text, $ideal=30, $exact = false, $ending='...') {
$total_length += $content_length;
}
- // if the maximum length is reached, get off the loop
+ // If the maximum length is reached, get off the loop.
if($total_length >= $ideal) {
break;
}
}
- // if the words shouldn't be cut in the middle...
- if (!$exact) {
- // ...search the last occurence of a space...
- for ($k=textlib::strlen($truncate);$k>0;$k--) {
- if ($char = textlib::substr($truncate, $k, 1)) {
- if ($char === '.' or $char === ' ') {
- $breakpos = $k+1;
- break;
- } else if (strlen($char) > 2) { // Chinese/Japanese/Korean text
- $breakpos = $k+1; // can be truncated at any UTF-8
- break; // character boundary.
- }
- }
- }
-
- if (isset($breakpos)) {
- // ...and cut the text in this position
- $truncate = textlib::substr($truncate, 0, $breakpos);
- }
- }
-
- // add the defined ending to the text
+ // Add the defined ending to the text.
$truncate .= $ending;
- // Now calculate the list of open html tags based on the truncate position
+ // Now calculate the list of open html tags based on the truncate position.
$open_tags = array();
foreach ($tagdetails as $taginfo) {
- if(isset($breakpos) && $taginfo->pos >= $breakpos) {
- // Don't include tags after we made the break!
- break;
- }
- if($taginfo->open) {
- // add tag to the beginning of $open_tags list
+ if ($taginfo->open) {
+ // Add tag to the beginning of $open_tags list.
array_unshift($open_tags, $taginfo->tag);
} else {
- $pos = array_search($taginfo->tag, array_reverse($open_tags, true)); // can have multiple exact same open tags, close the last one
+ // Can have multiple exact same open tags, close the last one.
+ $pos = array_search($taginfo->tag, array_reverse($open_tags, true));
if ($pos !== false) {
unset($open_tags[$pos]);
}
}
}
- // close all unclosed html-tags
+ // Close all unclosed html-tags.
foreach ($open_tags as $tag) {
$truncate .= '' . $tag . '>';
}
diff --git a/lib/tests/moodlelib_test.php b/lib/tests/moodlelib_test.php
index 12e756b29ed..13e7d1eac4e 100644
--- a/lib/tests/moodlelib_test.php
+++ b/lib/tests/moodlelib_test.php
@@ -1094,62 +1094,95 @@ class moodlelib_testcase extends advanced_testcase {
}
}
- function test_shorten_text() {
+ function test_shorten_text_no_tags_already_short_enough() {
+ // ......12345678901234567890123456.
$text = "short text already no tags";
$this->assertEquals($text, shorten_text($text));
+ }
+ function test_shorten_text_with_tags_already_short_enough() {
+ // .........123456...7890....12345678.......901234567.
$text = "
short text already
with tags
"; $this->assertEquals($text, shorten_text($text)); + } + function test_shorten_text_no_tags_needs_shortening() { + // Default truncation is after 30 chars, but allowing 3 for the final '...'. + // ......12345678901234567890123456789023456789012345678901234. $text = "long text without any tags blah de blah blah blah what"; $this->assertEquals('long text without any tags ...', shorten_text($text)); + } + function test_shorten_text_with_tags_needs_shortening() { + // .......................................123456789012345678901234567890... $text = "Long text with tags that will ". "be chopped off but should be added back again
Long text with " . "tags that ...
Žluťoučký koníček přeskočil potůček
"; $this->assertEquals($text, shorten_text($text, 60)); $this->assertEquals("Žluťoučký koníček ...
", shorten_text($text, 21)); $this->assertEquals("Žluťoučký koníče...
", shorten_text($text, 19, true)); $this->assertEquals("Žluťoučký ...
", shorten_text($text, 19, false)); - // And try it with 2-less (that are, in bytes, the middle of a sequence) + // And try it with 2 fewer (that are, in bytes, the middle of a sequence). $this->assertEquals("Žluťoučký koní...
", shorten_text($text, 17, true)); $this->assertEquals("Žluťoučký ...
", shorten_text($text, 17, false)); - // And try over one tag (start/end), it does proper text len + // And try over one tag (start/end), it does proper text len. $this->assertEquals("Žluťoučký koníček př...
", shorten_text($text, 23, true)); $this->assertEquals("Žluťoučký koníček přeskočil pot...
", shorten_text($text, 34, true)); - // And in the middle of one tag + // And in the middle of one tag. $this->assertEquals("Žluťoučký koníček přeskočil...
", shorten_text($text, 30, true)); + } + function test_shorten_text_utf8_oriental() { // Japanese + // text without tags + // ......123456789012345678901234. $text = '言語設定言語設定abcdefghijkl'; $this->assertEquals($text, shorten_text($text)); // 30 chars by default $this->assertEquals("言語設定言語...", shorten_text($text, 9, true)); @@ -1158,13 +1191,27 @@ class moodlelib_testcase extends advanced_testcase { $this->assertEquals("言語設定言語設定...", shorten_text($text, 13, false)); // Chinese + // text without tags + // ......123456789012345678901234. $text = '简体中文简体中文abcdefghijkl'; $this->assertEquals($text, shorten_text($text)); // 30 chars by default $this->assertEquals("简体中文简体...", shorten_text($text, 9, true)); $this->assertEquals("简体中文简体...", shorten_text($text, 9, false)); $this->assertEquals("简体中文简体中文ab...", shorten_text($text, 13, true)); $this->assertEquals("简体中文简体中文...", shorten_text($text, 13, false)); + } + function test_shorten_text_multilang() { + // This is not necessaryily specific to multilang. The issue is really + // tags with attributes, where before we were generating invalid HTML + // output like shorten_text('A B', 1); + // returning ''. It is just that multilang + // requires the sort of HTML that is quite likely to trigger this. + // ........................................1... + $text = 'A' . + 'B'; + $this->assertEquals('...', + shorten_text($text, 1)); } function test_usergetdate() {