diff options
-rw-r--r-- | screenplain/richstring.py | 111 | ||||
-rw-r--r-- | tests/richstring_test.py | 122 | ||||
-rw-r--r-- | tests/spmd_test.py | 4 |
3 files changed, 233 insertions, 4 deletions
diff --git a/screenplain/richstring.py b/screenplain/richstring.py new file mode 100644 index 0000000..7543aaf --- /dev/null +++ b/screenplain/richstring.py @@ -0,0 +1,111 @@ +import re + +_emphasis = re.compile( + r'(?:' + r'(\*\*)' # two stars + r'(?=\S)' # must not be followed by space + r'(.+?[*_]*)' # inside text + r'(?<=\S)\*\*' # finishing with two stars + r'|' + r'(\*)' # one star + r'(?=\S)' # must not be followed by space + r'(.+)' # inside text + r'(?<=\S)\*' # finishing with one star + r'(?!\*)' # must not be followed by star + r'|' + r'(_)' # underline + r'(?=\S)' # must not be followed by space + r'([^_]+)' # inside text + r'(?<=\S)_' # finishing with underline + r')' +) + +class RichString(object): + def __init__(self, *segments): + self.segments = segments + + def to_html(self): + result = '' + for segment in self.segments: + if isinstance(segment, basestring): + result += segment + else: + result += segment.to_html() + return result + + def __unicode__(self): + return self.to_html() + + def __repr__(self): + return '%s(%s)' % ( + self.__class__.__name__, + ', '.join(repr(s) for s in self.segments) + ) + + def __eq__(self, other): + return ( + self.__class__ == other.__class__ and + self.segments == other.segments + ) + + def __ne__(self, other): + return ( + self.__class__ != other.__class__ or + self.segments != other.segments + ) + +class Italic(RichString): + def to_html(self): + return '<em>' + super(Italic, self).to_html() + '</em>' + +class Bold(RichString): + def to_html(self): + return '<strong>' + super(Bold, self).to_html() + '</strong>' + +class Underline(RichString): + def to_html(self): + return '<u>' + super(Underline, self).to_html() + '</u>' + +def _parse(source): + segments = [] + + scanner = _emphasis.scanner(source) + pos = 0 + while pos != len(source): + match = scanner.search() + if not match: + segments.append(source[pos:]) + break + if match.start() != pos: + segments.append(source[pos:match.start()]) + + ( + two_stars, two_stars_text, + one_star, one_star_text, + underline, underline_text + ) = match.groups() + + if two_stars: + segments.append(Bold(*_parse(two_stars_text))) + elif one_star: + segments.append(Italic(*_parse(one_star_text))) + else: + segments.append(Underline(*_parse(underline_text))) + pos = match.end() + + return segments + +def parse_emphasis(source): + """Parses emphasis markers like * and ** in a string + and returns a RichString object. + + >>> parse_emphasis(u'**hello**') + Bold(u'hello') + >>> parse_emphasis(u'plain') + RichString(u'plain') + """ + segments = _parse(source) + if len(segments) == 1 and isinstance(segments[0], RichString): + return segments[0] + else: + return RichString(*segments) diff --git a/tests/richstring_test.py b/tests/richstring_test.py new file mode 100644 index 0000000..bd7ea22 --- /dev/null +++ b/tests/richstring_test.py @@ -0,0 +1,122 @@ +import unittest2 +from screenplain.richstring import RichString, Bold, Italic, Underline +from screenplain.richstring import parse_emphasis +from screenplain.types import Slug, Action, Dialog, DualDialog, Transition + +class RichStringOperatorTests(unittest2.TestCase): + + def test_repr(self): + s = RichString(Bold('Hello'), ' there ', Bold('folks')) + self.assertEquals( + "RichString(Bold('Hello'), ' there ', Bold('folks'))", + repr(s) + ) + + def test_eq(self): + self.assertEquals(Bold('Hello'), Bold('Hello')) + self.assertNotEquals(Bold('Hello'), Bold('Foo')) + self.assertNotEquals('Hello', Bold('Hello')) + self.assertEquals( + Bold('a', Italic('b'), 'c'), + Bold('a', Italic('b'), 'c') + ) + self.assertNotEquals( + Bold('a', Italic('b'), 'c'), + Bold('a', Italic('b'), 'd') + ) + + def test_ne(self): + self.assertFalse(Bold('Hello') != Bold('Hello')) + +class RichStringTests(unittest2.TestCase): + + def test_to_html(self): + s = RichString( + Bold('bold'), + ' normal ', + Italic('italic'), + Underline('wonderline') + ) + self.assertEquals( + '<strong>bold</strong> normal <em>italic</em><u>wonderline</u>', + s.to_html() + ) + +class ParseEmphasisTests(unittest2.TestCase): + + def test_parse_without_emphasis(self): + self.assertEquals(RichString('Hello'), parse_emphasis('Hello'), + 'Expected parse_emphasis to return a string') + + def test_parse_bold(self): + self.assertEquals( + parse_emphasis('**Hello**'), + Bold('Hello') + ) + + def test_parse_pre_and_postfix_and_bold(self): + self.assertEquals( + parse_emphasis('pre**Hello**post'), + RichString('pre', Bold('Hello'), 'post'), + ) + + def test_parse_multiple_bold(self): + self.assertEquals( + parse_emphasis('x**Hello** **there**'), + RichString('x', Bold('Hello'), ' ', Bold('there')) + ) + + def test_parse_adjacent_bold(self): + self.assertEquals( + parse_emphasis('**123****456**'), + RichString(Bold('123**'), '456**') + ) + + def test_italic(self): + self.assertEquals( + parse_emphasis('*Italian style*'), + Italic('Italian style') + ) + + def test_bold_inside_italic(self): + self.assertEquals( + parse_emphasis('*Swedish **style** rules*'), + Italic('Swedish ', Bold('style'), ' rules') + ) + + def test_italic_inside_bold(self): + self.assertEquals( + parse_emphasis('**Swedish *style* rules**'), + Bold('Swedish ', Italic('style'), ' rules') + ) + + def test_italic_and_bold(self): + self.assertEquals( + parse_emphasis('***really strong***'), + Bold(Italic('really strong')) + ) + + @unittest2.expectedFailure + def test_additional_star(self): + self.assertEquals( + parse_emphasis('*foo* bar* baz'), + RichString(Italic('foo'), ' bar* baz') + ) + + def test_underline(self): + self.assertEquals( + parse_emphasis('_hello_'), + Underline('hello') + ) + + def test_bold_inside_underline(self): + self.assertEquals( + parse_emphasis('_**hello**_'), + Underline(Bold('hello')) + ) + + def test_overlapping_underscore_and_italic(self): + self.assertEquals( + parse_emphasis('_*he_llo*'), + RichString(Underline('*he'), 'llo*') + ) diff --git a/tests/spmd_test.py b/tests/spmd_test.py index caa5b20..4a04660 100644 --- a/tests/spmd_test.py +++ b/tests/spmd_test.py @@ -4,10 +4,6 @@ from screenplain.types import Slug, Action, Dialog, DualDialog, Transition class ParseTests(unittest2.TestCase): - # Without this, the @skip decorator gives - # AttributeError: 'ParseTests' object has no attribute '__name__' - __name__ = 'ParseTests' - # A Scene Heading, or "slugline," is any line that has a blank # line following it, and either begins with INT or EXT, or has # two empty lines preceding it. A Scene Heading always has at |