diff --git a/django/utils/html.py b/django/utils/html.py index 89d6a00eb2..de515ef8e9 100644 --- a/django/utils/html.py +++ b/django/utils/html.py @@ -17,7 +17,12 @@ from django.utils.text import normalize_newlines from .html_parser import HTMLParseError, HTMLParser # Configuration for urlize() function. -TRAILING_PUNCTUATION = ['.', ',', ':', ';', '.)', '"', '\'', '!'] +TRAILING_PUNCTUATION_RE = re.compile( + '^' # Beginning of word + '(.*?)' # The URL in word + '([.,:;!]+)' # Allowed non-wrapping, trailing punctuation + '$' # End of word +) WRAPPING_PUNCTUATION = [('(', ')'), ('<', '>'), ('[', ']'), ('<', '>'), ('"', '"'), ('\'', '\'')] # List of possible strings used for bullets in bulleted lists. @@ -268,24 +273,46 @@ def urlize(text, trim_url_limit=None, nofollow=False, autoescape=False): trail = '' return text, unescaped, trail - words = word_split_re.split(force_text(text)) - for i, word in enumerate(words): - if '.' in word or '@' in word or ':' in word: - # Deal with punctuation. - lead, middle, trail = '', word, '' - for punctuation in TRAILING_PUNCTUATION: - if middle.endswith(punctuation): - middle = middle[:-len(punctuation)] - trail = punctuation + trail + def trim_punctuation(lead, middle, trail): + """ + Trim trailing and wrapping punctuation from `middle`. Return the items + of the new state. + """ + # Continue trimming until middle remains unchanged. + trimmed_something = True + while trimmed_something: + trimmed_something = False + + # Trim trailing punctuation. + match = TRAILING_PUNCTUATION_RE.match(middle) + if match: + middle = match.group(1) + trail = match.group(2) + trail + trimmed_something = True + + # Trim wrapping punctuation. for opening, closing in WRAPPING_PUNCTUATION: if middle.startswith(opening): middle = middle[len(opening):] - lead = lead + opening + lead += opening + trimmed_something = True # Keep parentheses at the end only if they're balanced. - if (middle.endswith(closing) - and middle.count(closing) == middle.count(opening) + 1): + if (middle.endswith(closing) and + middle.count(closing) == middle.count(opening) + 1): middle = middle[:-len(closing)] trail = closing + trail + trimmed_something = True + return lead, middle, trail + + words = word_split_re.split(force_text(text)) + for i, word in enumerate(words): + if '.' in word or '@' in word or ':' in word: + # lead: Current punctuation trimmed from the beginning of the word. + # middle: Current state of the word. + # trail: Current punctuation trimmed from the end of the word. + lead, middle, trail = '', word, '' + # Deal with punctuation. + lead, middle, trail = trim_punctuation(lead, middle, trail) # Make URL we want to point to. url = None diff --git a/tests/template_tests/filter_tests/test_urlize.py b/tests/template_tests/filter_tests/test_urlize.py index 9cf3f982a8..6822092943 100644 --- a/tests/template_tests/filter_tests/test_urlize.py +++ b/tests/template_tests/filter_tests/test_urlize.py @@ -246,6 +246,24 @@ class FunctionTests(SimpleTestCase): '(Go to http://www.example.com/foo.)', ) + def test_trailing_multiple_punctuation(self): + self.assertEqual( + urlize('A test http://testing.com/example..'), + 'A test http://testing.com/example..' + ) + self.assertEqual( + urlize('A test http://testing.com/example!!'), + 'A test http://testing.com/example!!' + ) + self.assertEqual( + urlize('A test http://testing.com/example!!!'), + 'A test http://testing.com/example!!!' + ) + self.assertEqual( + urlize('A test http://testing.com/example.,:;)"!'), + 'A test http://testing.com/example.,:;)"!' + ) + def test_brackets(self): """ #19070 - Check urlize handles brackets properly