commit d0749e07aad6b90d034fa86123b02b92f9a83b75 Author: Raoul Snyman Date: Fri Jul 23 14:57:35 2021 -0700 A working prototype diff --git a/src/chordpro/__init__.py b/src/chordpro/__init__.py new file mode 100644 index 0000000..2db8158 --- /dev/null +++ b/src/chordpro/__init__.py @@ -0,0 +1,190 @@ +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()) diff --git a/src/chordpro/chords.py b/src/chordpro/chords.py new file mode 100644 index 0000000..c73755e --- /dev/null +++ b/src/chordpro/chords.py @@ -0,0 +1,66 @@ +import re + +from chordpro.constants import CHORD_SUFFIXES, ENGLISH_NOTES, GERMAN_NOTES, NEOLATIN_NOTES + +_chord_cache = {} +_line_cache = {} + + +def _get_chord_regex(notes): + """ + Create the regex for a particular set of notes + + :param notes: The regular expression for a set of valid notes + :return: An expanded regular expression for valid chords + """ + chord = notes + CHORD_SUFFIXES + return '(' + chord + '(/' + chord + ')?)' + + +def _get_chord_match(notes): + """ + Construct chord matching regular expression object + + :param notes: The regular expression for a set of valid notes + :return: A compiled regular expression object + """ + return re.compile(r'\[' + _get_chord_regex(notes) + r'\]') + + +def _get_line_match(notes): + """ + Construct a chord line matching regular expression object + + :param notes: The regular expression for a set of valid notes + :return: A compiled regular expression object + """ + return re.compile(r'\[' + _get_chord_regex(notes) + r'\]([\u0080-\uFFFF,\w]*)' + r'([\u0080-\uFFFF\w\s\.\,\!\?\;\:\|\"\'\-\_]*)(\Z)?') + + +def _get_chords_for_notation(notation): + """ + Get the right chord_match object based on the current chord notation + """ + if notation not in _chord_cache.keys(): + if notation == 'german': + _chord_cache[notation] = _get_chord_match(GERMAN_NOTES) + elif notation == 'neo-latin': + _chord_cache[notation] = _get_chord_match(NEOLATIN_NOTES) + else: + _chord_cache[notation] = _get_chord_match(ENGLISH_NOTES) + return _chord_cache[notation] + + +def _get_line_for_notation(notation): + """ + Get the right chord line match based on the current chord notation + """ + if notation not in _line_cache.keys(): + if notation == 'german': + _line_cache[notation] = _get_line_match(GERMAN_NOTES) + elif notation == 'neo-latin': + _line_cache[notation] = _get_line_match(NEOLATIN_NOTES) + else: + _line_cache[notation] = _get_line_match(ENGLISH_NOTES) + return _line_cache[notation] diff --git a/src/chordpro/constants.py b/src/chordpro/constants.py new file mode 100644 index 0000000..cc88a39 --- /dev/null +++ b/src/chordpro/constants.py @@ -0,0 +1,29 @@ +KNOWN_DIRECTIVES = [ + ('title', 't'), + ('subtitle', 'st'), + ('artist',), + ('composer',), + ('lyricist',), + ('arranger',), + ('copyright',), + ('album',), + ('year',), + ('key',), + ('time',), + ('tempo',), + ('duration',), + ('capo',), + ('meta',) +] +KNOWN_VERSE_TYPES = [ + 'verse', + 'chorus', + 'bridge', + 'tab', + 'grid' +] +ENGLISH_NOTES = '[CDEFGAB]' +GERMAN_NOTES = '[CDEFGAH]' +NEOLATIN_NOTES = '(Do|Re|Mi|Fa|Sol|La|Si)' +CHORD_SUFFIXES = '(b|bb)?(#)?(m|maj7|maj|min7|min|sus)?(1|2|3|4|5|6|7|8|9)?' +SLIM_CHARS = 'fiíIÍjlĺľrtť.,;/ ()|"\'!:\\' diff --git a/src/chordpro/render/__init__.py b/src/chordpro/render/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/chordpro/render/html.py b/src/chordpro/render/html.py new file mode 100644 index 0000000..71ebdea --- /dev/null +++ b/src/chordpro/render/html.py @@ -0,0 +1,19 @@ +def render(song): + """Render a song to HTML""" + rendered_verses = [] + for verse in song.verses: + rendered_lines = [] + for line in verse.lines: + rendered_chords = [] + rendered_syllables = [] + for word_counter, word in enumerate(line.words): + 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)