diff --git a/src/chordpro/__main__.py b/src/chordpro/__main__.py index 67a4e2d..b73329d 100644 --- a/src/chordpro/__main__.py +++ b/src/chordpro/__main__.py @@ -1,17 +1,33 @@ from argparse import ArgumentParser from chordpro.base import Song -from chordpro.render.html import render +from chordpro.renderers.html import render as render_html +from chordpro.renderers.text import render as render_text 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') + parser.add_argument('-f', '--format', metavar='FORMAT', choices=['html', 'text'], default='html', + help='Output format') + parser.add_argument('-p', '--param', metavar='KEY=VALUE', action='append', help='Parameter to send to renderer') args = parser.parse_args() + render_params = {kv.split('=')[0]: kv.split('=')[-1] for kv in args.param} if args.param else {} + # Prepare the params + for key, value in render_params.items(): + if value.lower() == "true": + render_params[key] = True + elif value.lower() == "false": + render_params[key] = False + elif value.isdigit(): + render_params[key] = int(value) song = Song(args.input) - output = render(song) + if args.format == 'text': + output = render_text(song, **render_params) + else: + output = render_html(song, **render_params) if args.output: with open(args.output, 'w') as html_file: html_file.write(output) diff --git a/src/chordpro/base.py b/src/chordpro/base.py index 7ff5cb8..d755a6c 100644 --- a/src/chordpro/base.py +++ b/src/chordpro/base.py @@ -7,6 +7,7 @@ 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')\}') +CHORUS_MARKER = re.compile(r'\{chorus(: *(.*?))?\}') CHORD_WORD = re.compile(r'(.*?)\[(.*?)\]') HYPHEN_CACHE = {} SYLLABLE_EXCEPTIONS = { @@ -71,7 +72,7 @@ class Word(object): 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 + chords = [''] match = CHORD_WORD.match(word) while match: word_parts.append(match.group(1)) @@ -100,18 +101,41 @@ class Word(object): sylls = [whole_word] for syll in sylls: syllable = Syllable(syll) - for i, part in enumerate(word_parts): + can_consume = False + for idx, part in enumerate(word_parts): if part.startswith(syll): - syllable.chord = chords[i] + can_consume = True + syllable.chord = chords[idx] break self.syllables.append(syllable) + if can_consume: + word_parts = word_parts[idx + 1:] + chords = chords[idx + 1:] + # Process any left over chords, they're trailing chords + for chord in chords: + self.syllables.append(Syllable('', chord)) class Line(object): def __init__(self, line=None): if line: - self.words = [Word(word) for word in line.split(' ')] + words = line.split(' ') + # Split trailing chords into their own "words" + last_word = words[-1] + trailing_chords = [] + for part in last_word.split('['): + if not part: + continue + if part.split(']')[-1] == '': + trailing_chords.append('[{}]'.format(part.split(']')[0])) + # remove chords from last word + for chord in trailing_chords: + last_word = last_word.replace(chord, '') + # replace last word, and append trailing chords as separate words + words[-1] = last_word + words.extend(trailing_chords) + self.words = [Word(word) for word in words] else: self.words = [] @@ -132,7 +156,7 @@ class Verse(object): match = START_OF.match(line) verse = cls(match.group(1)) if len(match.groups()) > 1: - verse.title = match.group(3) + verse.title = match.group(3) or match.group(1).title() return verse @staticmethod @@ -146,6 +170,18 @@ class Verse(object): return match is not None return match.group(1) == type_ + @staticmethod + def is_chorus_marker(line): + return line.strip().startswith('{chorus') + + @staticmethod + def get_chorus_from_marker(line): + match = CHORUS_MARKER.match(line) + if not match: + return None + if len(match.groups()) > 1: + return match.group(2) + class Metadata(object): @@ -170,6 +206,7 @@ class Song(object): def parse(self, filename): self.metadata = Metadata() self.verses = [] + self.verse_order = [] with open(filename) as song_file: is_verse = False current_verse = None @@ -179,6 +216,7 @@ class Song(object): elif Verse.is_start_of_verse(line): is_verse = True current_verse = Verse.parse(line) + self.verse_order.append(current_verse) 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_) @@ -187,3 +225,9 @@ class Song(object): current_verse = None elif is_verse: current_verse.add_line(line.strip()) + elif Verse.is_chorus_marker(line): + chorus_name = Verse.get_chorus_from_marker(line) + for verse in self.verses[::-1]: + if verse.title == chorus_name or verse.type_ == "chorus": + self.verse_order.append(verse) + break diff --git a/src/chordpro/renderers/html.py b/src/chordpro/renderers/html.py index 6ed55b9..a72335a 100644 --- a/src/chordpro/renderers/html.py +++ b/src/chordpro/renderers/html.py @@ -13,21 +13,28 @@ HTML = ''' {title} {body} ''' +STYLES = { + 'verse_name_bold': '.verse-name { font-weight: bold !important; }', + 'verse_upper': '.verse-name { text-transform: uppercase; }', + 'chord_bold': '.chord { font-weight: bold; }' +} -def render(song): +def render(song, verse_upper=False, verse_name_bold=False, chord_bold=False): """Render a song to HTML""" rendered_verses = [] - for verse in song.verses: + for verse in song.verse_order: rendered_lines = [] for line in verse.lines: rendered_chords = [] @@ -45,4 +52,11 @@ def render(song): 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) + styles = '' + if verse_name_bold: + styles += STYLES['verse_name_bold'] + '\n' + if verse_upper: + styles += STYLES['verse_upper'] + '\n' + if chord_bold: + styles += STYLES['chord_bold'] + '\n' + return HTML.format(title=title, body=body, styles=styles) diff --git a/src/chordpro/renderers/text.py b/src/chordpro/renderers/text.py new file mode 100644 index 0000000..6ce4259 --- /dev/null +++ b/src/chordpro/renderers/text.py @@ -0,0 +1,32 @@ +import os + + +def render(song, verse_upper=False): + """Render a song to a text file""" + nl = os.linesep + bl = nl + nl + rendered_verses = [] + for verse in song.verse_order: + verse_title = verse.title.upper() if verse_upper else verse.title + rendered_lines = [verse_title + ':'] + 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) + padding = max(len(syllable.syllable), len(syllable.chord or '')) + if is_last_syllable and not is_last_word: + padding += 1 + elif syllable.chord and not syllable.syllable: + padding += 1 + rendered_chords.append('{chord:^{padding}}'.format(chord=syllable.chord or '', padding=padding)) + rendered_syllables.append('{syllable:<{padding}}'.format(syllable=syllable.syllable, + padding=padding)) + rendered_lines.append(''.join(rendered_chords)) + rendered_lines.append(''.join(rendered_syllables)) + rendered_verses.append(nl.join(rendered_lines)) + title = song.metadata.get('title') or 'Song' + artist = song.metadata.get('artist') or song.metadata.get('composer') or '' + return nl.join([title, artist, '', bl.join(rendered_verses)]) + nl