├── .gitignore ├── LICENSE.txt ├── __init__.py ├── readme.markdown ├── setup.py └── typogrify ├── __init__.py ├── templatetags ├── __init__.py └── typogrify_tags.py ├── tests ├── __init__.py ├── test_fuzzydate.py ├── test_tags.py └── test_titlecase.py └── titlecase.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.py? 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Typogrify: 2 | ========== 3 | 4 | Copyright (c) 2007, Christian Metts 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are 9 | met: 10 | 11 | * Redistributions of source code must retain the above copyright 12 | notice, this list of conditions and the following disclaimer. 13 | * Redistributions in binary form must reproduce the above 14 | copyright notice, this list of conditions and the following 15 | disclaimer in the documentation and/or other materials provided 16 | with the distribution. 17 | * Neither the name of the author nor the names of other 18 | contributors may be used to endorse or promote products derived 19 | from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 24 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 25 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 26 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 27 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 28 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 29 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 30 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | 33 | Fuzzy Date / Typogrify Updates: 34 | =============================== 35 | 36 | Copyright (c) 2010, Chris Drackett 37 | All rights reserved. 38 | 39 | Redistribution and use in source and binary forms, with or without 40 | modification, are permitted provided that the following conditions are 41 | met: 42 | 43 | * Redistributions of source code must retain the above copyright 44 | notice, this list of conditions and the following disclaimer. 45 | * Redistributions in binary form must reproduce the above 46 | copyright notice, this list of conditions and the following 47 | disclaimer in the documentation and/or other materials provided 48 | with the distribution. 49 | * Neither the name of the author nor the names of other 50 | contributors may be used to endorse or promote products derived 51 | from this software without specific prior written permission. 52 | 53 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 54 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 55 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 56 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 57 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 58 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 59 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 60 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 61 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 62 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 63 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 64 | 65 | titlecase.py 66 | ============ 67 | 68 | Original Perl version by: John Gruber http://daringfireball.net/ 10 May 2008 69 | Python version by Stuart Colville http://muffinresearch.co.uk 70 | License: http://www.opensource.org/licenses/mit-license.php -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisdrackett/django-typogrify/fb47a3bbbf7adfa524879cb3e61517f8c3cb73dc/__init__.py -------------------------------------------------------------------------------- /readme.markdown: -------------------------------------------------------------------------------- 1 | typogrify: Django template filters to make web typography easier 2 | ================================================================ 3 | 4 | This application provides a set of custom filters for the Django 5 | template system which automatically apply various transformations to 6 | plain text in order to yield typographically-improved HTML. 7 | 8 | Requirements 9 | ============ 10 | 11 | typogrify is designed to work with Django, and so requires a 12 | functioning installation of Django 0.96 or later. 13 | 14 | * **Django**: http://www.djangoproject.com/ 15 | 16 | Installation 17 | ============ 18 | 19 | 1. checkout the project into a folder called `typogrify` on your python path: 20 | 21 | git clone git@github.com:chrisdrackett/django-typogrify.git typogrify 22 | 23 | 2. Add 'typogrify' to your INSTALLED_APPS setting. 24 | 25 | 26 | Included filters 27 | ================ 28 | 29 | amp 30 | --- 31 | 32 | Wraps ampersands in HTML with `` so they can be 33 | styled with CSS. Ampersands are also normalized to &. Requires 34 | ampersands to have whitespace or an   on both sides. Will not 35 | change any ampersand which has already been wrapped in this fashion. 36 | 37 | caps 38 | ---- 39 | 40 | Wraps multiple capital letters in `` so they can 41 | be styled with CSS. 42 | 43 | initial_quotes 44 | -------------- 45 | 46 | Wraps initial quotes in `` for double quotes or 47 | `` for single quotes. Works inside these block 48 | elements: 49 | 50 | * h1, h2, h3, h4, h5, h6 51 | * p 52 | * li 53 | * dt 54 | * dd 55 | 56 | Also accounts for potential opening inline elements: a, em, 57 | strong, span, b, i. 58 | 59 | smartypants 60 | ----------- 61 | 62 | * Straight quotes ( " and ' ) into “curly” quote HTML entities (‘ | ’ | “ | ”) 63 | * Backticks-style quotes (``like this'') into “curly” quote HTML entities (‘ | ’ | “ | ”) 64 | * Dashes (“--” and “---”) into n-dash and m-dash entities (– | —) 65 | * Three consecutive dots (“...”) into an ellipsis entity (…) 66 | 67 | widont 68 | ------ 69 | 70 | Based on Shaun Inman's PHP utility of the same name, replaces the 71 | space between the last two words in a string with ` ` to avoid 72 | a final line of text with only one word. 73 | 74 | Works inside these block elements: 75 | 76 | * h1, h2, h3, h4, h5, h6 77 | * p 78 | * li 79 | * dt 80 | * dd 81 | 82 | Also accounts for potential closing inline elements: a, em, 83 | strong, span, b, i. 84 | 85 | titlecase 86 | --------- 87 | 88 | http://daringfireball.net/2008/05/title_case 89 | 90 | number_suffix 91 | ------------- 92 | 93 | wraps number suffix's in `` so they can be styled. 94 | 95 | fuzzydate 96 | --------- 97 | (uses code from http://djangosnippets.org/snippets/1347/) 98 | 99 | Returns the date in a more human readable format: 100 | 101 | * Today 102 | * Yesterday 103 | * 4 days ago 104 | * 3 weeks ago 105 | * in 3 years 106 | * etc. 107 | 108 | typogrify 109 | --------- 110 | 111 | Applies all of the following filters, in order: 112 | 113 | * force_unicode (from django.utils.encoding) 114 | * amp 115 | * widont 116 | * smartypants 117 | * caps 118 | * initial_quotes 119 | 120 | Examples 121 | ======== 122 | 123 | Apply all `django-typogrify` filters to template output: 124 | 125 | {% load typogrify_tags %} 126 | {{ blog_post.contents|typogrify }} 127 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | VERSION = '1.3.3' 5 | 6 | setup( 7 | name='django-typogrify', 8 | version=VERSION, 9 | description='Make type not look like crap (for django apps)', 10 | author='Chris Drackett', 11 | author_email='chris@tiltshiftstudio.com', 12 | url='http://github.com/chrisdrackett/django-typogrify', 13 | packages=[ 14 | "typogrify", 15 | "typogrify.templatetags", 16 | ], 17 | install_requires=[ 18 | 'smartypants>=1.8.3', 'num2words' 19 | ], 20 | classifiers=[ 21 | 'Development Status :: 5 - Production/Stable', 22 | 'Environment :: Web Environment', 23 | 'Intended Audience :: Developers', 24 | # 'Intended Audience :: Designers', 25 | 'Natural Language :: English', 26 | 'License :: OSI Approved :: BSD License', 27 | 'Operating System :: OS Independent', 28 | 'Programming Language :: Python', 29 | 'Framework :: Django', 30 | 'Topic :: Utilities', 31 | 'Topic :: Text Processing' 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /typogrify/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typogrify import templatetags, titlecase 3 | 4 | __all__ = ['templatetags', 'titlecase'] 5 | -------------------------------------------------------------------------------- /typogrify/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisdrackett/django-typogrify/fb47a3bbbf7adfa524879cb3e61517f8c3cb73dc/typogrify/templatetags/__init__.py -------------------------------------------------------------------------------- /typogrify/templatetags/typogrify_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import calendar 3 | import re 4 | from datetime import date, timedelta 5 | 6 | import smartypants as _smartypants 7 | import typogrify.titlecase as _titlecase 8 | from num2words import num2words 9 | 10 | from django import template 11 | from django.conf import settings 12 | from django.utils.encoding import force_text 13 | from django.utils.html import conditional_escape 14 | from django.utils.safestring import mark_safe 15 | from django.utils.translation import ugettext, ungettext, get_language 16 | 17 | register = template.Library() 18 | 19 | 20 | __all__ = ['amp', 'caps', 'date', 'fuzzydate', 'initial_quotes', 21 | 'number_suffix', 'smart_filter', 'super_fuzzydate', 'titlecase', 22 | 'widont'] 23 | 24 | 25 | def smart_filter(fn): 26 | ''' 27 | Escapes filter's content based on template autoescape mode and marks output as safe 28 | ''' 29 | def wrapper(text, autoescape=None): 30 | if autoescape: 31 | esc = conditional_escape 32 | else: 33 | esc = lambda x: x 34 | 35 | return mark_safe(fn(esc(text))) 36 | wrapper.needs_autoescape = True 37 | 38 | register.filter(fn.__name__, wrapper) 39 | return wrapper 40 | 41 | 42 | @smart_filter 43 | def amp(text, autoescape=None): 44 | """Wraps apersands in HTML with ```` so they can be 45 | styled with CSS. Apersands are also normalized to ``&``. Requires 46 | ampersands to have whitespace or an `` `` on both sides. 47 | 48 | >>> amp('One & two') 49 | u'One & two' 50 | >>> amp('One & two') 51 | u'One & two' 52 | >>> amp('One & two') 53 | u'One & two' 54 | 55 | >>> amp('One & two') 56 | u'One & two' 57 | 58 | It won't mess up & that are already wrapped, in entities or URLs 59 | 60 | >>> amp('One & two') 61 | u'One & two' 62 | >>> amp('“this” & that') 63 | u'“this” & that' 64 | 65 | It should ignore standalone amps that are in attributes 66 | >>> amp('xyz') 67 | u'xyz' 68 | """ 69 | 70 | # tag_pattern from http://haacked.com/archive/2004/10/25/usingregularexpressionstomatchhtml.aspx 71 | # it kinda sucks but it fixes the standalone amps in attributes bug 72 | tag_pattern = '\s]+))?)+\s*|\s*)/?>' 73 | amp_finder = re.compile(r"(\s| )(&|&|&\#38;)(\s| )") 74 | intra_tag_finder = re.compile( 75 | r'(?P(%s)?)(?P([^<]*))(?P(%s)?)' % (tag_pattern, tag_pattern)) 76 | 77 | def _amp_process(groups): 78 | prefix = groups.group('prefix') or '' 79 | text = amp_finder.sub( 80 | r"""\1&\3""", groups.group('text')) 81 | suffix = groups.group('suffix') or '' 82 | return prefix + text + suffix 83 | return intra_tag_finder.sub(_amp_process, text) 84 | 85 | 86 | @smart_filter 87 | def caps(text): 88 | """Wraps multiple capital letters in ```` 89 | so they can be styled with CSS. 90 | 91 | >>> caps("A message from KU") 92 | u'A message from KU' 93 | 94 | Uses the smartypants tokenizer to not screw with HTML or with tags it shouldn't. 95 | 96 | >>> caps("
CAPS
more CAPS") 97 | u'
CAPS
more CAPS' 98 | 99 | >>> caps("A message from 2KU2 with digits") 100 | u'A message from 2KU2 with digits' 101 | 102 | >>> caps("Dotted caps followed by spaces should never include them in the wrap D.O.T. like so.") 103 | u'Dotted caps followed by spaces should never include them in the wrap D.O.T. like so.' 104 | 105 | All caps with with apostrophes in them shouldn't break. Only handles dump apostrophes though. 106 | >>> caps("JIMMY'S") 107 | u'JIMMY\\'S' 108 | 109 | >>> caps("D.O.T.HE34TRFID") 110 | u'D.O.T.HE34TRFID' 111 | """ 112 | 113 | tokens = _smartypants._tokenize(text) 114 | result = [] 115 | in_skipped_tag = False 116 | 117 | cap_finder = re.compile(r"""( 118 | (\b[A-Z\d]* # Group 2: Any amount of caps and digits 119 | [A-Z]\d*[A-Z] # A cap string must at least include two caps (but they can have digits between them) 120 | [A-Z\d']*\b) # Any amount of caps and digits or dumb apostsrophes 121 | | (\b[A-Z]+\.\s? # OR: Group 3: Some caps, followed by a '.' and an optional space 122 | (?:[A-Z]+\.\s?)+) # Followed by the same thing at least once more 123 | (?:\s|\b|$)) 124 | """, re.VERBOSE) 125 | 126 | def _cap_wrapper(matchobj): 127 | """This is necessary to keep dotted cap strings to pick up extra spaces""" 128 | if matchobj.group(2): 129 | return """%s""" % matchobj.group(2) 130 | else: 131 | if matchobj.group(3)[-1] == " ": 132 | caps = matchobj.group(3)[:-1] 133 | tail = ' ' 134 | else: 135 | caps = matchobj.group(3) 136 | tail = '' 137 | return """%s%s""" % (caps, tail) 138 | 139 | tags_to_skip_regex = re.compile( 140 | "<(/)?(?:pre|code|kbd|script|math)[^>]*>", re.IGNORECASE) 141 | 142 | for token in tokens: 143 | if token[0] == "tag": 144 | # Don't mess with tags. 145 | result.append(token[1]) 146 | close_match = tags_to_skip_regex.match(token[1]) 147 | if close_match and close_match.group(1) is None: 148 | in_skipped_tag = True 149 | else: 150 | in_skipped_tag = False 151 | else: 152 | if in_skipped_tag: 153 | result.append(token[1]) 154 | else: 155 | result.append(cap_finder.sub(_cap_wrapper, token[1])) 156 | return "".join(result) 157 | 158 | 159 | @smart_filter 160 | def number_suffix(text): 161 | """Wraps date suffix in 162 | so they can be styled with CSS. 163 | 164 | >>> number_suffix("10th") 165 | u'10th' 166 | 167 | Uses the smartypants tokenizer to not screw with HTML or with tags it shouldn't. 168 | 169 | """ 170 | 171 | suffix_finder = re.compile(r'(?P[\d]+)(?Pst|nd|rd|th)') 172 | 173 | def _suffix_process(groups): 174 | number = groups.group('number') 175 | suffix = groups.group('ord') 176 | 177 | return "%s%s" % (number, suffix) 178 | return suffix_finder.sub(_suffix_process, text) 179 | 180 | 181 | @smart_filter 182 | def initial_quotes(text): 183 | """Wraps initial quotes in ``class="dquo"`` for double quotes or 184 | ``class="quo"`` for single quotes. Works in these block tags ``(h1-h6, p, li, dt, dd)`` 185 | and also accounts for potential opening inline elements ``a, em, strong, span, b, i`` 186 | 187 | >>> initial_quotes('"With primes"') 188 | u'"With primes"' 189 | >>> initial_quotes("'With single primes'") 190 | u'\\'With single primes\\'' 191 | 192 | >>> initial_quotes('"With primes and a link"') 193 | u'"With primes and a link"' 194 | 195 | >>> initial_quotes('“With smartypanted quotes”') 196 | u'With smartypanted quotes”' 197 | """ 198 | 199 | quote_finder = re.compile(r"""((<(p|h[1-6]|li|dt|dd)[^>]*>|^) # start with an opening p, h1-6, li, dd, dt or the start of the string 200 | \s* # optional white space! 201 | (<(a|em|span|strong|i|b)[^>]*>\s*)*) # optional opening inline tags, with more optional white space for each. 202 | (("|“|&\#8220;)|('|‘|&\#8216;)) # Find me a quote! (only need to find the left quotes and the primes) 203 | # double quotes are in group 7, singles in group 8 204 | """, re.VERBOSE) 205 | 206 | def _quote_wrapper(matchobj): 207 | if matchobj.group(7): 208 | classname = "dquo" 209 | quote = matchobj.group(7) 210 | else: 211 | classname = "quo" 212 | quote = matchobj.group(8) 213 | return """%s%s""" % (matchobj.group(1), classname, quote) 214 | output = quote_finder.sub(_quote_wrapper, text) 215 | return output 216 | 217 | 218 | @smart_filter 219 | def smartypants(text): 220 | """Applies smarty pants to curl quotes. 221 | 222 | >>> smartypants('The "Green" man') 223 | u'The “Green” man' 224 | """ 225 | 226 | return _smartypants.smartypants(text) 227 | 228 | 229 | @smart_filter 230 | def titlecase(text): 231 | """Support for titlecase.py's titlecasing 232 | 233 | >>> titlecase("this V that") 234 | u'This v That' 235 | 236 | >>> titlecase("this is just an example.com") 237 | u'This Is Just an example.com' 238 | """ 239 | 240 | return _titlecase.titlecase(text) 241 | 242 | 243 | @smart_filter 244 | def widont(text): 245 | """Replaces the space between the last two words in a string with `` `` 246 | Works in these block tags ``(h1-h6, p, li, dd, dt)`` and also accounts for 247 | potential closing inline elements ``a, em, strong, span, b, i`` 248 | 249 | >>> widont('A very simple test') 250 | u'A very simple test' 251 | 252 | Single word items shouldn't be changed 253 | >>> widont('Test') 254 | u'Test' 255 | >>> widont(' Test') 256 | u' Test' 257 | >>> widont('
  • Test

    • ') 258 | u'
      • Test

        • ' 259 | >>> widont('
          • Test

            • ') 260 | u'
              • Test

                • ' 261 | 262 | >>> widont('

                  In a couple of paragraphs

                  paragraph two

                  ') 263 | u'

                  In a couple of paragraphs

                  paragraph two

                  ' 264 | 265 | >>> widont('

                  In a link inside a heading

                  ') 266 | u'

                  In a link inside a heading

                  ' 267 | 268 | >>> widont('

                  In a link followed by other text

                  ') 269 | u'

                  In a link followed by other text

                  ' 270 | 271 | Empty HTMLs shouldn't error 272 | >>> widont('

                  ') 273 | u'

                  ' 274 | 275 | >>> widont('
                  Divs get no love!
                  ') 276 | u'
                  Divs get no love!
                  ' 277 | 278 | >>> widont('
                  Neither do PREs
                  ') 279 | u'
                  Neither do PREs
                  ' 280 | 281 | >>> widont('

                  But divs with paragraphs do!

                  ') 282 | u'

                  But divs with paragraphs do!

                  ' 283 | """ 284 | 285 | widont_finder = re.compile(r"""((?:]*>)|[^<>\s]) # must be proceeded by an approved inline opening or closing tag or a nontag/nonspace 286 | \s+ # the space to replace 287 | ([^<>\s]+ # must be flollowed by non-tag non-space characters 288 | \s* # optional white space! 289 | (\s*)* # optional closing inline tags with optional white space after each 290 | (()|$)) # end with a closing p, h1-6, li or the end of the string 291 | """, re.VERBOSE) 292 | 293 | output = widont_finder.sub(r'\1 \2', text) 294 | return output 295 | 296 | 297 | @register.filter 298 | def fuzzydate(value, cutoff=180): 299 | """ 300 | * takes a value (date) and cutoff (in days) 301 | 302 | If the date is within 1 day of Today: 303 | Returns 304 | 'today' 305 | 'yesterday' 306 | 'tomorrow' 307 | 308 | If the date is within Today +/- the cutoff: 309 | Returns 310 | '2 months ago' 311 | 'in 3 weeks' 312 | '2 years ago' 313 | etc. 314 | 315 | 316 | if this date is from the current year, but outside the cutoff: 317 | returns the value for 'CURRENT_YEAR_DATE_FORMAT' in settings if it exists. 318 | Otherwise returns: 319 | January 10th 320 | December 1st 321 | 322 | if the date is not from the current year and outside the cutoff: 323 | returns the value for 'DATE_FORMAT' in settings if it exists. 324 | """ 325 | 326 | try: 327 | value = date(value.year, value.month, value.day) 328 | except AttributeError: 329 | # Passed value wasn't a date object 330 | return value 331 | except ValueError: 332 | # Date arguments out of range 333 | return value 334 | 335 | today = date.today() 336 | delta = value - today 337 | 338 | if delta.days == 0: 339 | return u"today" 340 | elif delta.days == -1: 341 | return u"yesterday" 342 | elif delta.days == 1: 343 | return u"tomorrow" 344 | 345 | chunks = ( 346 | (365.0, lambda n: ungettext('year', 'years', n)), 347 | (30.0, lambda n: ungettext('month', 'months', n)), 348 | (7.0, lambda n: ungettext('week', 'weeks', n)), 349 | (1.0, lambda n: ungettext('day', 'days', n)), 350 | ) 351 | 352 | if abs(delta.days) <= cutoff: 353 | for i, (chunk, name) in enumerate(chunks): 354 | if abs(delta.days) >= chunk: 355 | count = abs(round(delta.days / chunk, 0)) 356 | break 357 | 358 | date_str = ugettext('%(number)d %(type)s') % { 359 | 'number': count, 'type': name(count)} 360 | 361 | if delta.days > 0: 362 | return "in " + date_str 363 | else: 364 | return date_str + " ago" 365 | else: 366 | if value.year == today.year: 367 | format = getattr(settings, "CURRENT_YEAR_DATE_FORMAT", "F jS") 368 | else: 369 | format = getattr(settings, "DATE_FORMAT") 370 | 371 | return template.defaultfilters.date(value, format) 372 | fuzzydate.is_safe = True 373 | 374 | 375 | @register.filter 376 | def super_fuzzydate(value): 377 | try: 378 | value = date(value.year, value.month, value.day) 379 | except AttributeError: 380 | # Passed value wasn't a date object 381 | return value 382 | except ValueError: 383 | # Date arguments out of range 384 | return value 385 | 386 | # today 387 | today = date.today() 388 | delta = value - today 389 | 390 | # get the easy values out of the way 391 | if delta.days == 0: 392 | return u"Today" 393 | elif delta.days == -1: 394 | return u"Yesterday" 395 | elif delta.days == 1: 396 | return u"Tomorrow" 397 | 398 | # if we're in the future... 399 | if value > today: 400 | end_of_week = today + timedelta(days=7 - today.isoweekday()) 401 | if value <= end_of_week: 402 | # return the name of the day (Wednesday) 403 | return u'this %s' % template.defaultfilters.date(value, "l") 404 | 405 | end_of_next_week = end_of_week + timedelta(weeks=1) 406 | if value <= end_of_next_week: 407 | # return the name of the day(Next Wednesday) 408 | return u"next %s" % template.defaultfilters.date(value, "l") 409 | 410 | end_of_month = today + \ 411 | timedelta( 412 | calendar.monthrange(today.year, today.month)[1] - today.day) 413 | if value <= end_of_month: 414 | # return the number of weeks (in two weeks) 415 | if value <= end_of_next_week + timedelta(weeks=1): 416 | return u"in two weeks" 417 | elif value <= end_of_next_week + timedelta(weeks=2): 418 | return u"in three weeks" 419 | elif value <= end_of_next_week + timedelta(weeks=3): 420 | return u"in four weeks" 421 | elif value <= end_of_next_week + timedelta(weeks=4): 422 | return u"in five weeks" 423 | 424 | if today.month == 12: 425 | next_month = 1 426 | else: 427 | next_month = today.month + 1 428 | 429 | end_of_next_month = date( 430 | today.year, next_month, calendar.monthrange(today.year, today.month)[1]) 431 | if value <= end_of_next_month: 432 | # if we're in next month 433 | return u'next month' 434 | 435 | # the last day of the year 436 | end_of_year = date(today.year, 12, 31) 437 | if value <= end_of_year: 438 | # return the month name (March) 439 | return template.defaultfilters.date(value, "F") 440 | 441 | # the last day of next year 442 | end_of_next_year = date(today.year + 1, 12, 31) 443 | if value <= end_of_next_year: 444 | return u'next %s' % template.defaultfilters.date(value, "F") 445 | 446 | return template.defaultfilters.date(value, "Y") 447 | else: 448 | # TODO add the past 449 | return fuzzydate(value) 450 | super_fuzzydate.is_safe = True 451 | 452 | 453 | @register.filter 454 | def text_whole_number(value): 455 | """ 456 | Takes a whole number and writes it out in text. 457 | """ 458 | 459 | try: 460 | value = int(value) 461 | except ValueError: 462 | # Not an int 463 | return value 464 | 465 | language = get_language() 466 | 467 | if language: 468 | result = num2words(value, lang=language) 469 | else: 470 | result = num2words(value) 471 | 472 | return result 473 | 474 | 475 | text_whole_number.is_safe = True 476 | 477 | 478 | @smart_filter 479 | def typogrify(text): 480 | """The super typography filter 481 | 482 | Applies the following filters: widont, smartypants, caps, amp, initial_quotes 483 | 484 | >>> typogrify('

                  "Jayhawks" & KU fans act extremely obnoxiously

                  ') 485 | u'

                  Jayhawks” & KU fans act extremely obnoxiously

                  ' 486 | 487 | Each filters properly handles autoescaping. 488 | >>> conditional_escape(typogrify('

                  "Jayhawks" & KU fans act extremely obnoxiously

                  ')) 489 | u'

                  Jayhawks” & KU fans act extremely obnoxiously

                  ' 490 | """ 491 | text = force_text(text) 492 | text = amp(text) 493 | text = widont(text) 494 | text = smartypants(text) 495 | text = caps(text) 496 | text = initial_quotes(text) 497 | text = number_suffix(text) 498 | 499 | return text 500 | -------------------------------------------------------------------------------- /typogrify/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typogrify.tests.test_fuzzydate import * 3 | from typogrify.tests.test_tags import * 4 | from typogrify.tests.test_titlecase import * 5 | 6 | 7 | __all__ = ['TestFuzzydate', 'TestTitlecase'] 8 | -------------------------------------------------------------------------------- /typogrify/tests/test_fuzzydate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime, timedelta 3 | 4 | from django.conf import settings 5 | from django.test import TestCase 6 | from typogrify.templatetags.typogrify_tags import fuzzydate 7 | 8 | 9 | class TestFuzzydate(TestCase): 10 | 11 | def setUp(self): 12 | settings.DATE_FORMAT = "F jS, Y" 13 | 14 | def test_returns_yesterday(self): 15 | yesterday = datetime.now() - timedelta(hours=24) 16 | self.assertEquals(fuzzydate(yesterday), "yesterday") 17 | 18 | two_days_ago = datetime.now() - timedelta(hours=48) 19 | self.assertNotEquals(fuzzydate(two_days_ago), "yesterday") 20 | 21 | def test_returns_today(self): 22 | today = datetime.now() 23 | self.assertEquals(fuzzydate(today), "today") 24 | 25 | def test_returns_tomorrow(self): 26 | tomorrow = datetime.now() + timedelta(hours=24) 27 | self.assertEquals(fuzzydate(tomorrow), "tomorrow") 28 | 29 | def test_formats_current_year(self): 30 | now = datetime.now() 31 | testdate = datetime.strptime("%s/10/10" % now.year, "%Y/%m/%d") 32 | 33 | expected = "October 10th" 34 | self.assertEquals(fuzzydate(testdate, 1), expected) 35 | 36 | def test_formats_other_years(self): 37 | testdate = datetime.strptime("1984/10/10", "%Y/%m/%d") 38 | 39 | expected = "October 10th, 1984" 40 | self.assertEquals(fuzzydate(testdate), expected) 41 | -------------------------------------------------------------------------------- /typogrify/tests/test_tags.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import doctest 3 | 4 | from typogrify.templatetags.typogrify_tags import * 5 | 6 | if __name__ == "__main__": 7 | doctest.testmod() 8 | -------------------------------------------------------------------------------- /typogrify/tests/test_titlecase.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import print_function 3 | 4 | import unittest 5 | 6 | from typogrify.titlecase import titlecase 7 | 8 | 9 | class TestTitlecase(unittest.TestCase): 10 | 11 | """Tests to ensure titlecase follows all of the rules""" 12 | 13 | def test_q_and_a(self): 14 | """ 15 | Testing: Q&A With Steve Jobs: 'That's What Happens In Technology' 16 | """ 17 | text = titlecase( 18 | "Q&A with steve jobs: 'that's what happens in technology'" 19 | ) 20 | result = "Q&A With Steve Jobs: 'That's What Happens in Technology'" 21 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 22 | 23 | def test_at_and_t(self): 24 | """Testing: What Is AT&T's Problem?""" 25 | 26 | text = titlecase("What is AT&T's problem?") 27 | result = "What Is AT&T's Problem?" 28 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 29 | 30 | def test_apple_deal(self): 31 | """Testing: Apple Deal With AT&T Falls Through""" 32 | 33 | text = titlecase("Apple deal with AT&T falls through") 34 | result = "Apple Deal With AT&T Falls Through" 35 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 36 | 37 | def test_this_v_that(self): 38 | """Testing: this v that""" 39 | text = titlecase("this v that") 40 | result = "This v That" 41 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 42 | 43 | def test_this_v_that2(self): 44 | """Testing: this v. that""" 45 | 46 | text = titlecase("this v. that") 47 | result = "This v. That" 48 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 49 | 50 | def test_this_vs_that(self): 51 | """Testing: this vs that""" 52 | 53 | text = titlecase("this vs that") 54 | result = "This vs That" 55 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 56 | 57 | def test_this_vs_that2(self): 58 | """Testing: this vs. that""" 59 | 60 | text = titlecase("this vs. that") 61 | result = "This vs. That" 62 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 63 | 64 | def test_apple_sec(self): 65 | """Testing: The SEC's Apple Probe: What You Need to Know""" 66 | 67 | text = titlecase("The SEC's Apple Probe: What You Need to Know") 68 | result = "The SEC's Apple Probe: What You Need to Know" 69 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 70 | 71 | def test_small_word_quoted(self): 72 | """Testing: 'by the Way, Small word at the start but within quotes.'""" 73 | 74 | text = titlecase( 75 | "'by the Way, small word at the start but within quotes.'" 76 | ) 77 | result = "'By the Way, Small Word at the Start but Within Quotes.'" 78 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 79 | 80 | def test_small_word_end(self): 81 | """Testing: Small word at end is nothing to be afraid of""" 82 | 83 | text = titlecase("Small word at end is nothing to be afraid of") 84 | result = "Small Word at End Is Nothing to Be Afraid Of" 85 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 86 | 87 | def test_sub_phrase_small_word(self): 88 | """Testing: Starting Sub-Phrase With a Small Word: a Trick, Perhaps?""" 89 | 90 | text = titlecase( 91 | "Starting Sub-Phrase With a Small Word: a Trick, Perhaps?" 92 | ) 93 | result = "Starting Sub-Phrase With a Small Word: A Trick, Perhaps?" 94 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 95 | 96 | def test_small_word_quotes(self): 97 | """Testing: Sub-Phrase With a Small Word in Quotes: 'a Trick...""" 98 | 99 | text = titlecase( 100 | "Sub-Phrase With a Small Word in Quotes: 'a Trick, Perhaps?'" 101 | ) 102 | result = "Sub-Phrase With a Small Word in Quotes: 'A Trick, Perhaps?'" 103 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 104 | 105 | def test_small_word_double_quotes(self): 106 | """Testing: Sub-Phrase With a Small Word in Quotes: \"a Trick...""" 107 | text = titlecase( 108 | 'Sub-Phrase With a Small Word in Quotes: "a Trick, Perhaps?"' 109 | ) 110 | result = 'Sub-Phrase With a Small Word in Quotes: "A Trick, Perhaps?"' 111 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 112 | 113 | def test_nothing_to_be_afraid_of(self): 114 | """Testing: \"Nothing to Be Afraid of?\"""" 115 | text = titlecase('"Nothing to Be Afraid of?"') 116 | result = '"Nothing to Be Afraid Of?"' 117 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 118 | 119 | def test_nothing_to_be_afraid_of2(self): 120 | """Testing: \"Nothing to Be Afraid Of?\"""" 121 | 122 | text = titlecase('"Nothing to be Afraid Of?"') 123 | result = '"Nothing to Be Afraid Of?"' 124 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 125 | 126 | def test_a_thing(self): 127 | """Testing: a thing""" 128 | 129 | text = titlecase('a thing') 130 | result = 'A Thing' 131 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 132 | 133 | def test_vapourware(self): 134 | """Testing: 2lmc Spool: 'Gruber on OmniFocus and Vapo(u)rware'""" 135 | text = titlecase( 136 | "2lmc Spool: 'gruber on OmniFocus and vapo(u)rware'" 137 | ) 138 | result = "2lmc Spool: 'Gruber on OmniFocus and Vapo(u)rware'" 139 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 140 | 141 | def test_domains(self): 142 | """Testing: this is just an example.com""" 143 | text = titlecase('this is just an example.com') 144 | result = 'This Is Just an example.com' 145 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 146 | 147 | def test_domains2(self): 148 | """Testing: this is something listed on an del.icio.us""" 149 | 150 | text = titlecase('this is something listed on del.icio.us') 151 | result = 'This Is Something Listed on del.icio.us' 152 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 153 | 154 | def test_itunes(self): 155 | """Testing: iTunes should be unmolested""" 156 | 157 | text = titlecase('iTunes should be unmolested') 158 | result = 'iTunes Should Be Unmolested' 159 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 160 | 161 | def test_thoughts_on_music(self): 162 | """Testing: Reading Between the Lines of Steve Jobs’s...""" 163 | 164 | text = titlecase( 165 | 'Reading between the lines of steve jobs’s ‘thoughts on music’' 166 | ) 167 | result = 'Reading Between the Lines of Steve Jobs’s ‘Thoughts on '\ 168 | 'Music’' 169 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 170 | 171 | def test_repair_perms(self): 172 | """Testing: Seriously, ‘Repair Permissions’ Is Voodoo""" 173 | 174 | text = titlecase('seriously, ‘repair permissions’ is voodoo') 175 | result = 'Seriously, ‘Repair Permissions’ Is Voodoo' 176 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 177 | 178 | def test_generalissimo(self): 179 | """Testing: Generalissimo Francisco Franco...""" 180 | 181 | text = titlecase( 182 | 'generalissimo francisco franco: still dead; kieren McCarthy: ' 183 | 'still a jackass' 184 | ) 185 | result = 'Generalissimo Francisco Franco: Still Dead; Kieren '\ 186 | 'McCarthy: Still a Jackass' 187 | self.assertEqual(text, result, "%s should be: %s" % (text, result, )) 188 | 189 | 190 | if __name__ == '__main__': 191 | suite = unittest.TestLoader().loadTestsFromTestCase(TestTitlecase) 192 | unittest.TextTestRunner(verbosity=2).run(suite) 193 | -------------------------------------------------------------------------------- /typogrify/titlecase.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # from __future__ import print_function 3 | 4 | # https://github.com/chrisdrackett/django-typogrify/blob/master/typogrify/titlecase.py 5 | # https://daringfireball.net/2008/05/title_case 6 | 7 | import re 8 | import sys 9 | 10 | SMALL = 'a|an|and|as|at|but|by|en|for|if|in|of|on|or|the|to|v\.?|via|vs\.?' 11 | PUNCT = "[!\"#$%&'‘()*+,-./:;?@[\\\\\\]_`{|}~]" 12 | 13 | SMALL_WORDS = re.compile(r'^(%s)$' % SMALL, re.I) 14 | INLINE_PERIOD = re.compile(r'[a-zA-Z][.][a-zA-Z]') 15 | UC_ELSEWHERE = re.compile(r'%s*?[a-zA-Z]+[A-Z]+?' % PUNCT) 16 | CAPFIRST = re.compile(r"^%s*?([A-Za-z])" % PUNCT) 17 | SMALL_FIRST = re.compile(r'^(%s*)(%s)\b' % (PUNCT, SMALL), re.I) 18 | SMALL_LAST = re.compile(r'\b(%s)%s?$' % (SMALL, PUNCT), re.I) 19 | SUBPHRASE = re.compile(r'([:.;?!][ ])(%s)' % SMALL) 20 | 21 | 22 | def titlecase(text): 23 | """ 24 | Titlecases input text 25 | 26 | This filter changes all words to Title Caps, and attempts to be clever 27 | about *un*capitalizing SMALL words like a/an/the in the input. 28 | 29 | The list of "SMALL words" which are not capped comes from 30 | the New York Times Manual of Style, plus 'vs' and 'v'. 31 | 32 | """ 33 | 34 | words = re.split('\s', text) 35 | line = [] 36 | for word in words: 37 | if word.startswith('#') or \ 38 | INLINE_PERIOD.search(word) or \ 39 | UC_ELSEWHERE.match(word): 40 | line.append(word) 41 | continue 42 | if SMALL_WORDS.match(word): 43 | line.append(word.lower()) 44 | continue 45 | line.append(CAPFIRST.sub(lambda m: m.group(0).upper(), word)) 46 | 47 | line = " ".join(line) 48 | 49 | line = SMALL_FIRST.sub(lambda m: '%s%s' % ( 50 | m.group(1), 51 | m.group(2).capitalize() 52 | ), line) 53 | 54 | line = SMALL_LAST.sub(lambda m: m.group(0).capitalize(), line) 55 | 56 | line = SUBPHRASE.sub(lambda m: '%s%s' % ( 57 | m.group(1), 58 | m.group(2).capitalize() 59 | ), line) 60 | 61 | return line 62 | 63 | 64 | if __name__ == '__main__': 65 | if not sys.stdin.isatty(): 66 | for line in sys.stdin: 67 | print(titlecase(line)) 68 | --------------------------------------------------------------------------------