From d84b7815d07287b3e765835e655de1f755dac6eb Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Fri, 23 Jul 2021 23:31:21 -0700 Subject: [PATCH] Added a command-line runner, renamed a few things --- src/chordpro/__init__.py | 191 +----------------------------------- src/chordpro/__main__.py | 19 ++++ src/chordpro/base.py | 189 +++++++++++++++++++++++++++++++++++ src/chordpro/render/html.py | 43 ++++++-- 4 files changed, 245 insertions(+), 197 deletions(-) create mode 100644 src/chordpro/__main__.py create mode 100644 src/chordpro/base.py diff --git a/src/chordpro/__init__.py b/src/chordpro/__init__.py index 2db8158..5d169d1 100644 --- a/src/chordpro/__init__.py +++ b/src/chordpro/__init__.py @@ -1,190 +1 @@ -import re - -from hyphen import Hyphenator - -from chordpro.constants import KNOWN_DIRECTIVES, KNOWN_VERSE_TYPES - -DIRECTIVE = re.compile(r'\{(.*?): *(.*?)\}') -START_OF = re.compile(r'\{start_of_(' + '|'.join(KNOWN_VERSE_TYPES) + r')(: *(.*?))?\}') -END_OF = re.compile(r'\{end_of_(' + '|'.join(KNOWN_VERSE_TYPES) + r')\}') -CHORD_WORD = re.compile(r'(.*?)\[(.*?)\]') -HYPHEN_CACHE = {} -SYLLABLE_EXCEPTIONS = { - 'outer': ['out', 'er'] -} - - -class MismatchedVerseType(Exception): - - def __init__(self, line_number, expected_type): - super().__init__('Mismatched verse type on line {}, expected {}'.format(line_number, expected_type)) - - -class Directive(object): - - def __init__(self, line=None): - self.directive = None - self.info = None - if line: - self.parse(line) - - def parse(self, line): - """Parse a directive line and return a Directive object""" - match = DIRECTIVE.match(line) - if not match: - return None - for known_directive in KNOWN_DIRECTIVES: - if match.group(1) in known_directive: - self.directive = match.group(1) - self.info = match.group(2) - - @staticmethod - def is_directive(line): - """Check if a line in a file contains a directive""" - match = DIRECTIVE.match(line) - if match: - for known_directive in KNOWN_DIRECTIVES: - if match.group(1) in known_directive: - return True - return False - - -class Syllable(object): - def __init__(self, syllable, chord=None): - self.syllable = syllable - self.chord = chord - - -class Word(object): - - def __init__(self, word=None): - self.syllables = [] - if word: - self.parse(word) - - def parse(self, word): - """Parse a word into syllables with chords. - - 1. Split word by chords - 2. Rejoin word, split into syllables - 3. Track down syllable before chord - 4. Add chord to syllable - """ - word_parts = [] - chords = [''] # Due to regex, chords will always be 1 behind, so create an empty item to bump them along - match = CHORD_WORD.match(word) - while match: - word_parts.append(match.group(1)) - chords.append(match.group(2)) - word = word.replace(match.group(0), '') - match = CHORD_WORD.match(word) - # If there are any left over portions, just add them as the rest of the word - word_parts.append(word) - whole_word = ''.join(word_parts) - self.syllables = [] - if whole_word in SYLLABLE_EXCEPTIONS: - # words with a 2-letter ending syllable currently do not get recognised by PyHyphen - sylls = SYLLABLE_EXCEPTIONS[whole_word] - else: - if 'en_US' not in HYPHEN_CACHE: - HYPHEN_CACHE['en_US'] = Hyphenator('en_US') - if 'en_GB' not in HYPHEN_CACHE: - HYPHEN_CACHE['en_GB'] = Hyphenator('en_GB') - hyphenator = HYPHEN_CACHE['en_US'] - # Do a fallback for en_GB - if not hyphenator.pairs(whole_word): - if HYPHEN_CACHE['en_GB'].pairs(whole_word): - hyphenator = HYPHEN_CACHE['en_GB'] - sylls = hyphenator.syllables(whole_word) - if not sylls: - sylls = [whole_word] - for syll in sylls: - syllable = Syllable(syll) - for i, part in enumerate(word_parts): - if part.startswith(syll): - syllable.chord = chords[i] - break - self.syllables.append(syllable) - - -class Line(object): - - def __init__(self, line=None): - if line: - self.words = [Word(word) for word in line.split(' ')] - else: - self.words = [] - - -class Verse(object): - - def __init__(self, type_, title=None, content=None): - self.type_ = type_ - self.title = title or type_.title() - self.lines = [Line(line) for line in content.splitlines()] if content else [] - - def add_line(self, line): - self.lines.append(Line(line)) - - @classmethod - def parse(cls, line): - """Parse the line into a verse type""" - match = START_OF.match(line) - verse = cls(match.group(1)) - if len(match.groups()) > 1: - verse.title = match.group(3) - return verse - - @staticmethod - def is_start_of_verse(line): - return START_OF.match(line) is not None - - @staticmethod - def is_end_of_verse(line, type_=None): - match = END_OF.match(line) - if not type_: - return match is not None - return match.group(1) == type_ - - -class Metadata(object): - - def __init__(self): - self._directives = {} - - def add(self, directive): - self._directives[directive.directive] = directive - - @property - def title(self): - """Grab the title from the title directive""" - return self._directives.get('title') - - -class Song(object): - - def __init__(self, filename=None): - self.filename = filename - if self.filename: - self.parse(self.filename) - - def parse(self, filename): - self.metadata = Metadata() - self.verses = [] - with open(filename) as song_file: - is_verse = False - current_verse = None - for line_number, line in enumerate(song_file): - if Directive.is_directive(line): - self.metadata.add(Directive(line)) - elif Verse.is_start_of_verse(line): - is_verse = True - current_verse = Verse.parse(line) - elif Verse.is_end_of_verse(line): - if not Verse.is_end_of_verse(line, current_verse.type_): - raise MismatchedVerseType(line_number, current_verse.type_) - self.verses.append(current_verse) - is_verse = False - current_verse = None - elif is_verse: - current_verse.add_line(line.strip()) +from chordpro.base import Song # noqa diff --git a/src/chordpro/__main__.py b/src/chordpro/__main__.py new file mode 100644 index 0000000..67a4e2d --- /dev/null +++ b/src/chordpro/__main__.py @@ -0,0 +1,19 @@ +from argparse import ArgumentParser + +from chordpro.base import Song +from chordpro.render.html import render + + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('input', metavar='FILENAME', help='Input ChordPro file') + parser.add_argument('-o', '--output', metavar='FILENAME', help='Output to a file') + args = parser.parse_args() + + song = Song(args.input) + output = render(song) + if args.output: + with open(args.output, 'w') as html_file: + html_file.write(output) + else: + print(output) diff --git a/src/chordpro/base.py b/src/chordpro/base.py new file mode 100644 index 0000000..7ff5cb8 --- /dev/null +++ b/src/chordpro/base.py @@ -0,0 +1,189 @@ +import re + +from hyphen import Hyphenator + +from chordpro.constants import KNOWN_DIRECTIVES, KNOWN_VERSE_TYPES + +DIRECTIVE = re.compile(r'\{(.*?): *(.*?)\}') +START_OF = re.compile(r'\{start_of_(' + '|'.join(KNOWN_VERSE_TYPES) + r')(: *(.*?))?\}') +END_OF = re.compile(r'\{end_of_(' + '|'.join(KNOWN_VERSE_TYPES) + r')\}') +CHORD_WORD = re.compile(r'(.*?)\[(.*?)\]') +HYPHEN_CACHE = {} +SYLLABLE_EXCEPTIONS = { + 'outer': ['out', 'er'] +} + + +class MismatchedVerseType(Exception): + + def __init__(self, line_number, expected_type): + super().__init__('Mismatched verse type on line {}, expected {}'.format(line_number, expected_type)) + + +class Directive(object): + + def __init__(self, line=None): + self.directive = None + self.info = None + if line: + self.parse(line) + + def parse(self, line): + """Parse a directive line and return a Directive object""" + match = DIRECTIVE.match(line) + if not match: + return None + for known_directive in KNOWN_DIRECTIVES: + if match.group(1) in known_directive: + self.directive = match.group(1) + self.info = match.group(2) + + @staticmethod + def is_directive(line): + """Check if a line in a file contains a directive""" + match = DIRECTIVE.match(line) + if match: + for known_directive in KNOWN_DIRECTIVES: + if match.group(1) in known_directive: + return True + return False + + +class Syllable(object): + def __init__(self, syllable, chord=None): + self.syllable = syllable + self.chord = chord + + +class Word(object): + + def __init__(self, word=None): + self.syllables = [] + if word: + self.parse(word) + + def parse(self, word): + """Parse a word into syllables with chords. + + 1. Split word by chords + 2. Rejoin word, split into syllables + 3. Track down syllable before chord + 4. Add chord to syllable + """ + word_parts = [] + chords = [''] # Due to regex, chords will always be 1 behind, so create an empty item to bump them along + match = CHORD_WORD.match(word) + while match: + word_parts.append(match.group(1)) + chords.append(match.group(2)) + word = word.replace(match.group(0), '') + match = CHORD_WORD.match(word) + # If there are any left over portions, just add them as the rest of the word + word_parts.append(word) + whole_word = ''.join(word_parts) + self.syllables = [] + if whole_word in SYLLABLE_EXCEPTIONS: + # words with a 2-letter ending syllable currently do not get recognised by PyHyphen + sylls = SYLLABLE_EXCEPTIONS[whole_word] + else: + if 'en_US' not in HYPHEN_CACHE: + HYPHEN_CACHE['en_US'] = Hyphenator('en_US') + if 'en_GB' not in HYPHEN_CACHE: + HYPHEN_CACHE['en_GB'] = Hyphenator('en_GB') + hyphenator = HYPHEN_CACHE['en_US'] + # Do a fallback for en_GB + if not hyphenator.pairs(whole_word): + if HYPHEN_CACHE['en_GB'].pairs(whole_word): + hyphenator = HYPHEN_CACHE['en_GB'] + sylls = hyphenator.syllables(whole_word) + if not sylls: + sylls = [whole_word] + for syll in sylls: + syllable = Syllable(syll) + for i, part in enumerate(word_parts): + if part.startswith(syll): + syllable.chord = chords[i] + break + self.syllables.append(syllable) + + +class Line(object): + + def __init__(self, line=None): + if line: + self.words = [Word(word) for word in line.split(' ')] + else: + self.words = [] + + +class Verse(object): + + def __init__(self, type_, title=None, content=None): + self.type_ = type_ + self.title = title or type_.title() + self.lines = [Line(line) for line in content.splitlines()] if content else [] + + def add_line(self, line): + self.lines.append(Line(line)) + + @classmethod + def parse(cls, line): + """Parse the line into a verse type""" + match = START_OF.match(line) + verse = cls(match.group(1)) + if len(match.groups()) > 1: + verse.title = match.group(3) + return verse + + @staticmethod + def is_start_of_verse(line): + return START_OF.match(line) is not None + + @staticmethod + def is_end_of_verse(line, type_=None): + match = END_OF.match(line) + if not type_: + return match is not None + return match.group(1) == type_ + + +class Metadata(object): + + def __init__(self): + self._directives = {} + + def add(self, directive): + self._directives[directive.directive] = directive + + def get(self, key): + """Grab the title from the title directive""" + return self._directives[key].info if self._directives.get(key) else None + + +class Song(object): + + def __init__(self, filename=None): + self.filename = filename + if self.filename: + self.parse(self.filename) + + def parse(self, filename): + self.metadata = Metadata() + self.verses = [] + with open(filename) as song_file: + is_verse = False + current_verse = None + for line_number, line in enumerate(song_file): + if Directive.is_directive(line): + self.metadata.add(Directive(line)) + elif Verse.is_start_of_verse(line): + is_verse = True + current_verse = Verse.parse(line) + elif Verse.is_end_of_verse(line): + if not Verse.is_end_of_verse(line, current_verse.type_): + raise MismatchedVerseType(line_number, current_verse.type_) + self.verses.append(current_verse) + is_verse = False + current_verse = None + elif is_verse: + current_verse.add_line(line.strip()) diff --git a/src/chordpro/render/html.py b/src/chordpro/render/html.py index 71ebdea..6ed55b9 100644 --- a/src/chordpro/render/html.py +++ b/src/chordpro/render/html.py @@ -1,3 +1,29 @@ +CHORD = '{}' +SYLLB = '{}' +LINE = '{}{}
' +VERSE = '''
+

{verse_name}

+
+{verse_body} +
+
''' +TITLE = '

{title}

{artist}

' +HTML = ''' + + {title} + + + +{body} + +''' + + def render(song): """Render a song to HTML""" rendered_verses = [] @@ -10,10 +36,13 @@ def render(song): is_last_word = (word_counter + 1) == len(line.words) for syll_counter, syllable in enumerate(word.syllables): is_last_syllable = (syll_counter + 1) == len(word.syllables) - rendered_chords.append('{}'.format(syllable.chord or '')) - rendered_syllables.append('{}'.format(syllable.syllable + (' ' if is_last_syllable and not is_last_word else ''))) - rendered_lines.append('{}{}
'.format(''.join(rendered_chords), ''.join(rendered_syllables))) - rendered_verses.append('
{}
'.format(verse.type_ or '', '\n'.join(rendered_lines))) - title = song.metadata.title.info or 'Song' - body = '
{}'.format('\n'.join(rendered_verses)) - return '{}{}'.format(title, body) + rendered_chords.append(CHORD.format(syllable.chord or ' ')) + rendered_syllables.append(SYLLB.format( + syllable.syllable + (' ' if is_last_syllable and not is_last_word else ''))) + rendered_lines.append(LINE.format(''.join(rendered_chords), ''.join(rendered_syllables))) + rendered_verses.append(VERSE.format(verse_type=verse.type_ or '', verse_name=verse.title, + verse_body='\n'.join(rendered_lines))) + title = song.metadata.get('title') or 'Song' + metadata = TITLE.format(title=title, artist=song.metadata.get('artist') or song.metadata.get('composer') or '') + body = metadata + '\n' + '\n'.join(rendered_verses) + return HTML.format(title=title, body=body)