Various updates, including a new text renderer

- Add a new text file renderer
- Update the HTML renderer to include some basic formatting options
- Expand the CLI to specify a renderer and pass parameters to the selected renderer
- Fix a bug where trailing chords were being swallowed
This commit is contained in:
Raoul Snyman 2021-07-28 11:41:15 -07:00
parent 41394d559c
commit 161dbf9864
Signed by: raoul
GPG Key ID: F55BCED79626AE9C
4 changed files with 118 additions and 12 deletions

View File

@ -1,17 +1,33 @@
from argparse import ArgumentParser from argparse import ArgumentParser
from chordpro.base import Song 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__': if __name__ == '__main__':
parser = ArgumentParser() parser = ArgumentParser()
parser.add_argument('input', metavar='FILENAME', help='Input ChordPro file') 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('-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() 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) 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: if args.output:
with open(args.output, 'w') as html_file: with open(args.output, 'w') as html_file:
html_file.write(output) html_file.write(output)

View File

@ -7,6 +7,7 @@ from chordpro.constants import KNOWN_DIRECTIVES, KNOWN_VERSE_TYPES
DIRECTIVE = re.compile(r'\{(.*?): *(.*?)\}') DIRECTIVE = re.compile(r'\{(.*?): *(.*?)\}')
START_OF = re.compile(r'\{start_of_(' + '|'.join(KNOWN_VERSE_TYPES) + 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')\}') END_OF = re.compile(r'\{end_of_(' + '|'.join(KNOWN_VERSE_TYPES) + r')\}')
CHORUS_MARKER = re.compile(r'\{chorus(: *(.*?))?\}')
CHORD_WORD = re.compile(r'(.*?)\[(.*?)\]') CHORD_WORD = re.compile(r'(.*?)\[(.*?)\]')
HYPHEN_CACHE = {} HYPHEN_CACHE = {}
SYLLABLE_EXCEPTIONS = { SYLLABLE_EXCEPTIONS = {
@ -71,7 +72,7 @@ class Word(object):
4. Add chord to syllable 4. Add chord to syllable
""" """
word_parts = [] 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) match = CHORD_WORD.match(word)
while match: while match:
word_parts.append(match.group(1)) word_parts.append(match.group(1))
@ -100,18 +101,41 @@ class Word(object):
sylls = [whole_word] sylls = [whole_word]
for syll in sylls: for syll in sylls:
syllable = Syllable(syll) syllable = Syllable(syll)
for i, part in enumerate(word_parts): can_consume = False
for idx, part in enumerate(word_parts):
if part.startswith(syll): if part.startswith(syll):
syllable.chord = chords[i] can_consume = True
syllable.chord = chords[idx]
break break
self.syllables.append(syllable) 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): class Line(object):
def __init__(self, line=None): def __init__(self, line=None):
if line: 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: else:
self.words = [] self.words = []
@ -132,7 +156,7 @@ class Verse(object):
match = START_OF.match(line) match = START_OF.match(line)
verse = cls(match.group(1)) verse = cls(match.group(1))
if len(match.groups()) > 1: if len(match.groups()) > 1:
verse.title = match.group(3) verse.title = match.group(3) or match.group(1).title()
return verse return verse
@staticmethod @staticmethod
@ -146,6 +170,18 @@ class Verse(object):
return match is not None return match is not None
return match.group(1) == type_ 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): class Metadata(object):
@ -170,6 +206,7 @@ class Song(object):
def parse(self, filename): def parse(self, filename):
self.metadata = Metadata() self.metadata = Metadata()
self.verses = [] self.verses = []
self.verse_order = []
with open(filename) as song_file: with open(filename) as song_file:
is_verse = False is_verse = False
current_verse = None current_verse = None
@ -179,6 +216,7 @@ class Song(object):
elif Verse.is_start_of_verse(line): elif Verse.is_start_of_verse(line):
is_verse = True is_verse = True
current_verse = Verse.parse(line) current_verse = Verse.parse(line)
self.verse_order.append(current_verse)
elif Verse.is_end_of_verse(line): elif Verse.is_end_of_verse(line):
if not Verse.is_end_of_verse(line, current_verse.type_): if not Verse.is_end_of_verse(line, current_verse.type_):
raise MismatchedVerseType(line_number, current_verse.type_) raise MismatchedVerseType(line_number, current_verse.type_)
@ -187,3 +225,9 @@ class Song(object):
current_verse = None current_verse = None
elif is_verse: elif is_verse:
current_verse.add_line(line.strip()) 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

View File

@ -13,21 +13,28 @@ HTML = '''<html>
<head> <head>
<title>{title}</title> <title>{title}</title>
<style> <style>
.verse-name {{ font-weight: bold; margin-bottom: 0; text-transform: uppercase; }} h4.verse-name {{ font-weight: normal; }}
.verse-name {{ margin-bottom: 0; }}
.verse-body {{ padding-left: 2rem; }} .verse-body {{ padding-left: 2rem; }}
.chord {{ font-weight: bold; text-align: center; }} .chord {{ text-align: center; }}
{styles}
</style> </style>
</head> </head>
<body> <body>
{body} {body}
</body> </body>
</html>''' </html>'''
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""" """Render a song to HTML"""
rendered_verses = [] rendered_verses = []
for verse in song.verses: for verse in song.verse_order:
rendered_lines = [] rendered_lines = []
for line in verse.lines: for line in verse.lines:
rendered_chords = [] rendered_chords = []
@ -45,4 +52,11 @@ def render(song):
title = song.metadata.get('title') or 'Song' title = song.metadata.get('title') or 'Song'
metadata = TITLE.format(title=title, artist=song.metadata.get('artist') or song.metadata.get('composer') or '') metadata = TITLE.format(title=title, artist=song.metadata.get('artist') or song.metadata.get('composer') or '')
body = metadata + '\n' + '\n'.join(rendered_verses) 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)

View File

@ -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