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:
parent
41394d559c
commit
161dbf9864
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
32
src/chordpro/renderers/text.py
Normal file
32
src/chordpro/renderers/text.py
Normal 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
|
Loading…
Reference in New Issue
Block a user