Merge pull request 'Enhance the HTML renderer' (#1) from renderer-options into master

Create a get_option_groups() method to return the groups, which are now built dynamically

Reviewed-on: raoul/python-chordpro#1
This commit is contained in:
raoul 2021-08-08 01:21:50 +00:00 committed by Raoul Snyman
commit f8145b4c43
4 changed files with 532 additions and 42 deletions

View File

@ -1,7 +1,7 @@
from hyphen import Hyphenator
from chordpro.constants import DIRECTIVE, CHORD_WORD, CHORUS_MARKER, KNOWN_DIRECTIVES, \
START_OF, END_OF
from chordpro.constants import BRIDGE_MARKER, CHORD_WORD, CHORUS_MARKER, DIRECTIVE, END_OF, \
KNOWN_DIRECTIVES, START_OF, VERSE_MARKER
HYPHEN_CACHE = {}
SYLLABLE_EXCEPTIONS = {
@ -159,6 +159,18 @@ class Verse(object):
return match is not None
return match.group(1) == type_
@staticmethod
def is_verse_marker(line):
return line.strip().startswith('{verse')
@staticmethod
def get_verse_from_marker(line):
match = VERSE_MARKER.match(line)
if not match:
return None
if len(match.groups()) > 1:
return match.group(2)
@staticmethod
def is_chorus_marker(line):
return line.strip().startswith('{chorus')
@ -171,6 +183,18 @@ class Verse(object):
if len(match.groups()) > 1:
return match.group(2)
@staticmethod
def is_bridge_marker(line):
return line.strip().startswith('{bridge')
@staticmethod
def get_bridge_from_marker(line):
match = BRIDGE_MARKER.match(line)
if not match:
return None
if len(match.groups()) > 1:
return match.group(2)
class Metadata(object):
@ -214,9 +238,36 @@ class Song(object):
current_verse = None
elif is_verse:
current_verse.add_line(line.strip())
elif Verse.is_verse_marker(line):
verse_name = Verse.get_verse_from_marker(line)
for verse in self.verses[::-1]:
if verse.type_ != 'verse':
continue
if verse_name and verse.title == verse_name:
self.verse_order.append(verse)
break
elif not verse_name:
self.verse_order.append(verse)
break
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":
if verse.type_ != 'chorus':
continue
if chorus_name and verse.title == chorus_name:
self.verse_order.append(verse)
break
elif not chorus_name:
self.verse_order.append(verse)
break
elif Verse.is_bridge_marker(line):
bridge_name = Verse.get_bridge_from_marker(line)
for verse in self.verses[::-1]:
if verse.type_ != 'bridge':
continue
elif bridge_name and verse.title == bridge_name:
self.verse_order.append(verse)
break
elif not bridge_name:
self.verse_order.append(verse)
break

View File

@ -1,8 +1,8 @@
from argparse import ArgumentParser
from chordpro.base import Song
from chordpro.renderers.html import render as render_html
from chordpro.renderers.text import render as render_text
from chordpro.renderers.html import render as html_render, get_options as html_get_options
from chordpro.renderers.text import render as text_render
def get_args():
@ -31,9 +31,9 @@ def main():
song = Song(args.input)
if args.format == 'text':
output = render_text(song, **render_params)
output = text_render(song, **render_params)
else:
output = render_html(song, **render_params)
output = html_render(song, **render_params)
if args.output:
with open(args.output, 'w') as html_file:
html_file.write(output)

View File

@ -35,5 +35,6 @@ 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(: *(.*?))?\}')
VERSE_MARKER = re.compile(r'\{verse: *(.*?)\}')
BRIDGE_MARKER = re.compile(r'\{bridge: *(.*?)\}')
CHORD_WORD = re.compile(r'(.*?)\[(.*?)\]')
CHORD = re.compile(r'\[(.*?)\]')

View File

@ -1,22 +1,38 @@
CHORD = '<td class="chord">{}</td>'
SYLLB = '<td class="syllable">{}</td>'
LINE = '<table class="line" border="0" cellpadding="0" cellspacing="0"><tr class="chords-line">{}</tr><tr ' \
'class="lyrics-line">{}</tr></table>'
VERSE = '''<section class="verse">
<h4 class="verse-name {verse_type}">{verse_name}</h4>
<div class="verse-body">
import os
CHORD = '<div class="chord">{}</div>'
SYLLB = '<div class="syllable">{}</div>'
WRAPP = '<div class="wrapper">{}</div>'
LINE = '<div class="line">{}</div>'
STANZA = '''<section class="stanza-section {verse_type}-section">
<h2 class="stanza-heading {verse_type}-heading">{verse_name}</h2>
<div class="stanza {verse_type}">
{verse_body}
</div>
</section>'''
TITLE = '<section class="metadata"><h1 class="title">{title}</h1><h3 class="artist">{artist}</h3></section>'
METADATA = '''<section class="metadata">
{metadata}
</section>'''
TITLE = '<h1 class="title">{title}</h1>'
ARTIST = '<p class="artist">As performed by {artist}</p>'
COMPOSER = '<p class="composer">Written by {composer}</p>'
KEY_CAPO = '<div class="key-capo">{key_capo}</div>'
KEY = '<div class="key">Key: {key}</div>'
CAPO = '<div class="capo">Capo: {capo}</div>'
COPYRIGHT = '<p class="copyright">{copyright}</p>'
FOOTER = '<footer class="footer">{footer}</footer>'
HTML = '''<html>
<head>
<title>{title}</title>
<style>
h4.verse-name {{ font-weight: normal; }}
.verse-name {{ margin-bottom: 0; }}
.verse-body {{ padding-left: 2rem; }}
.chord {{ text-align: center; }}
h1.title, h2.stanza-heading, p.composer, p.artist {{ font-weight: normal; margin-top: 0; margin-bottom: 0; }}
.metadata, .stanza-section {{ margin-bottom: 1.8rem; page-break-inside: avoid; }}
.key-capo {{ padding: 0.5rem 1rem; border: 1px solid #000; border-radius: 2px; float: right; clear: right; }}
.key-capo div {{ margin-top: 0.5rem; margin-bottom: 0.5rem; }}
.key-capo div:first-child {{ margin-top: 0; }}
.key-capo div:last-child {{ margin-bottom: 0; }}
footer, .footer {{ position: absolute; bottom: 0; height: 2rem; }}
.wrapper {{ display: inline-block !important; }}
{styles}
</style>
</head>
@ -24,39 +40,461 @@ HTML = '''<html>
{body}
</body>
</html>'''
STYLES = {
'verse_name_bold': '.verse-name { font-weight: bold !important; }',
'verse_upper': '.verse-name { text-transform: uppercase; }',
'chord_bold': '.chord { font-weight: bold; }'
STYLE_KEYS = [
'font',
'size',
'is_bold',
'is_centered',
'is_upper',
'indent'
]
HTML_OPTIONS = {
'default_font': {
'description': 'The default font to use.',
'type': str,
'default': None,
'group': 'default'
},
'default_size': {
'description': 'The default size to use.',
'type': int,
'default': 12,
'group': 'default'
},
'title_font': {
'description': 'The font for the title',
'type': str,
'default': None,
'group': 'title'
},
'title_size': {
'description': 'The size of the title in pt',
'type': int,
'default': 14,
'group': 'title'
},
'title_is_bold': {
'description': 'Set to True to make the title bold',
'type': bool,
'default': True,
'group': 'title'
},
'title_is_centered': {
'description': 'Center the title on the page',
'type': bool,
'default': False,
'group': 'title'
},
'title_is_upper': {
'description': 'Force the title to be uppercase',
'type': bool,
'default': False,
'group': 'title'
},
'composer_font': {
'description': 'The font for the composer',
'type': str,
'default': None,
'group': 'composer'
},
'composer_size': {
'description': 'The size of the composer in pt',
'type': int,
'default': 12,
'group': 'composer'
},
'composer_is_bold': {
'description': 'Set to True to make the composer bold',
'type': bool,
'default': False,
'group': 'composer'
},
'composer_is_centered': {
'description': 'Center the composer on the page',
'type': bool,
'default': False,
'group': 'composer'
},
'composer_is_upper': {
'description': 'Force the composer to be uppercase',
'type': bool,
'default': False,
'group': 'composer'
},
'artist_font': {
'description': 'The font for the artist',
'type': str,
'default': None,
'group': 'artist'
},
'artist_size': {
'description': 'The size of the artist in pt',
'type': int,
'default': 12,
'group': 'artist'
},
'artist_is_bold': {
'description': 'Set to True to make the artist bold',
'type': bool,
'default': False,
'group': 'artist'
},
'artist_is_centered': {
'description': 'Center the artist on the page',
'type': bool,
'default': False,
'group': 'artist'
},
'artist_is_upper': {
'description': 'Force the artist to be uppercase',
'type': bool,
'default': False,
'group': 'artist'
},
'copyright_font': {
'description': 'The font for the copyright',
'type': str,
'default': None,
'group': 'copyright'
},
'copyright_size': {
'description': 'The size of the copyright in pt',
'type': int,
'default': 12,
'group': 'copyright'
},
'copyright_is_bold': {
'description': 'Set to True to make the copyright bold',
'type': bool,
'default': False,
'group': 'copyright'
},
'copyright_is_upper': {
'description': 'Force the copyright to be uppercase',
'type': bool,
'default': False,
'group': 'copyright'
},
'stanza_font': {
'description': 'The font for the stanzas',
'type': str,
'default': None,
'group': 'stanza'
},
'stanza_size': {
'description': 'The size of the stanza in pt',
'type': int,
'default': 12,
'group': 'stanza'
},
'stanza_is_bold': {
'description': 'Set to True to make the stanza bold',
'type': bool,
'default': False,
'group': 'stanza'
},
'stanza_indent': {
'description': 'How much to indent the stanza',
'type': int,
'default': 4,
'group': 'stanza'
},
'stanza_heading_font': {
'description': 'The font for the stanza headings',
'type': str,
'default': None,
'group': 'stanza_heading'
},
'stanza_heading_size': {
'description': 'The size of the stanza heading in pt',
'type': int,
'default': 12,
'group': 'stanza_heading'
},
'stanza_heading_is_bold': {
'description': 'Set to True to make the stanza heading bold',
'type': bool,
'default': True,
'group': 'stanza_heading'
},
'stanza_heading_is_upper': {
'description': 'Force the stanza heading to be uppercase',
'type': bool,
'default': True,
'group': 'stanza_heading'
},
'verse_font': {
'description': 'The font for the verses',
'type': str,
'default': None,
'group': 'verse'
},
'verse_size': {
'description': 'The size of the verse in pt',
'type': int,
'default': 12,
'group': 'verse'
},
'verse_is_bold': {
'description': 'Set to True to make the verse bold',
'type': bool,
'default': False,
'group': 'verse'
},
'verse_indent': {
'description': 'How much to indent the verse',
'type': int,
'default': 4,
'group': 'verse'
},
'verse_heading_font': {
'description': 'The font for the verse headings',
'type': str,
'default': None,
'group': 'verse_heading'
},
'verse_heading_size': {
'description': 'The size of the verse heading in pt',
'type': int,
'default': 12,
'group': 'verse_heading'
},
'verse_heading_is_bold': {
'description': 'Set to True to make the verse heading bold',
'type': bool,
'default': True,
'group': 'verse_heading'
},
'verse_heading_is_upper': {
'description': 'Force the verse heading to be uppercase',
'type': bool,
'default': True,
'group': 'verse_heading'
},
'chorus_font': {
'description': 'The font for the choruses',
'type': str,
'default': None,
'group': 'chorus'
},
'chorus_size': {
'description': 'The size of the chorus in pt',
'type': int,
'default': 12,
'group': 'chorus'
},
'chorus_is_bold': {
'description': 'Set to True to make the chorus bold',
'type': bool,
'default': False,
'group': 'chorus'
},
'chorus_indent': {
'description': 'How much to indent the chorus',
'type': int,
'default': 4,
'group': 'chorus'
},
'chorus_heading_font': {
'description': 'The font for the chorus headings',
'type': str,
'default': None,
'group': 'chorus_heading'
},
'chorus_heading_size': {
'description': 'The size of the chorus heading in pt',
'type': int,
'default': 12,
'group': 'chorus_heading'
},
'chorus_heading_is_bold': {
'description': 'Set to True to make the chorus heading bold',
'type': bool,
'default': True,
'group': 'chorus_heading'
},
'chorus_heading_is_upper': {
'description': 'Force the chorus heading to be uppercase',
'type': bool,
'default': True,
'group': 'chorus_heading'
},
'bridge_font': {
'description': 'The font for the bridge',
'type': str,
'default': None,
'group': 'bridge'
},
'bridge_size': {
'description': 'The size of the bridge in pt',
'type': int,
'default': 12,
'group': 'bridge'
},
'bridge_is_bold': {
'description': 'Set to True to make the bridge bold',
'type': bool,
'default': False,
'group': 'bridge'
},
'bridge_indent': {
'description': 'How much to indent the bridge',
'type': int,
'default': 4,
'group': 'bridge'
},
'bridge_heading_font': {
'description': 'The font for the bridge headings',
'type': str,
'default': None,
'group': 'bridge_heading'
},
'bridge_heading_size': {
'description': 'The size of the bridge heading in pt',
'type': int,
'default': 12,
'group': 'bridge_heading'
},
'bridge_heading_is_bold': {
'description': 'Set to True to make the bridge heading bold',
'type': bool,
'default': True,
'group': 'bridge_heading'
},
'bridge_heading_is_upper': {
'description': 'Force the bridge heading to be uppercase',
'type': bool,
'default': True,
'group': 'bridge_heading'
},
'chord_font': {
'description': 'The font for the chord',
'type': str,
'default': None,
'group': 'chord'
},
'chord_size': {
'description': 'The size of the chord in pt',
'type': int,
'default': 12,
'group': 'chord'
},
'chord_is_bold': {
'description': 'Set to True to make the chord bold',
'type': bool,
'default': True,
'group': 'chord'
},
'chord_is_centered': {
'description': 'Make chords centered over syllables',
'type': bool,
'default': True,
'group': 'chord'
}
}
_OPTION_GROUPS = []
def render(song, verse_upper=False, verse_name_bold=False, chord_bold=False):
def get_options(group=None):
"""Return the options supported by the HTML renderer"""
if not group:
return HTML_OPTIONS
else:
return {key: value for key, value in HTML_OPTIONS.items() if value['group'] == group}
def get_option_groups():
if not _OPTION_GROUPS:
for option in HTML_OPTIONS.values():
if option['group'] not in _OPTION_GROUPS:
_OPTION_GROUPS.append(option['group'])
return _OPTION_GROUPS
def make_style(name, font=None, size=None, is_bold=None, is_centered=None, is_upper=None, indent=None):
"""Build a CSS style"""
styles = []
if name == 'default':
styles.append('html, body, * {')
else:
styles.append('.{name} {{'.format(name=name))
if font:
styles.append(' font-family: \'{font}\';'.format(font=font))
if size:
styles.append(' font-size: {size}pt;'.format(size=size))
if is_bold:
styles.append(' font-weight: bold !important;')
if is_centered:
styles.append(' text-align: center;')
if is_upper:
styles.append(' text-transform: uppercase;')
if indent:
styles.append(' margin-left: {indent}rem;'.format(indent=indent))
if styles:
if name == 'default':
styles.insert(0, 'html, body, * {')
else:
styles.insert(0, '.{name} {{'.format(name=name))
styles.append('}')
return os.linesep.join(styles)
def generate_option_styles(options):
styles = []
for group in get_option_groups():
kwargs = {}
for key in STYLE_KEYS:
name = '{group}_{key}'.format(group=group, key=key)
if name not in HTML_OPTIONS:
continue
option = options.get(name, HTML_OPTIONS[name]['default'])
if option:
kwargs[key] = option
styles.append(make_style(group.replace('_', '-'), **kwargs))
return styles
def render(song, options=None, extra_styles=None):
"""Render a song to HTML"""
nl = os.linesep
styles = options and generate_option_styles(options) or []
if extra_styles:
styles.append(extra_styles)
rendered_verses = []
for verse in song.verse_order:
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(CHORD.format(syllable.chord or '&nbsp;'))
rendered_syllables.append(SYLLB.format(
syllable.syllable + ('&nbsp;' 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)
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)
rendered_chord = CHORD.format(syllable.chord or '&nbsp;')
rendered_syllable = SYLLB.format(
(syllable.syllable or '&nbsp;') +
('&nbsp;' if is_last_syllable and not is_last_word else ''))
rendered_syllables.append(WRAPP.format(rendered_chord + rendered_syllable))
rendered_lines.append(LINE.format(''.join(rendered_syllables)))
rendered_verses.append(STANZA.format(verse_type=verse.type_ or '', verse_name=verse.title,
verse_body=nl.join(rendered_lines)))
metadata_lines = []
if song.metadata.get('key') or song.metadata.get('capo'):
key_lines = []
if song.metadata.get('key'):
key_lines.append(KEY.format(key=song.metadata.get('key')))
if song.metadata.get('capo'):
key_lines.append(CAPO.format(capo=song.metadata.get('capo')))
if key_lines:
metadata_lines.append(KEY_CAPO.format(key_capo=''.join(key_lines)))
metadata_lines.append(TITLE.format(title=song.metadata.get('title') or 'Song'))
if song.metadata.get('composer'):
metadata_lines.append(COMPOSER.format(composer=song.metadata.get('composer')))
if song.metadata.get('artist'):
metadata_lines.append(ARTIST.format(artist=song.metadata.get('artist')))
metadata = METADATA.format(metadata=nl.join(metadata_lines))
footer = ''
# if song.metadata.get('copyright'):
# copyright = COPYRIGHT.format(copyright=song.metadata.get('copyright'))
# footer = FOOTER.format(footer=copyright)
body = metadata + nl + nl.join(rendered_verses) + nl + footer
return HTML.format(title=song.metadata.get('title') or 'Song', body=body, styles=nl.join(styles))