From a551fdf465a3373229351c82de4f5cb5c8e406a5 Mon Sep 17 00:00:00 2001 From: Raoul Snyman Date: Sun, 1 Aug 2021 22:06:02 -0700 Subject: [PATCH] Enhance the HTML renderer - Documenting rendering options in the HTML renderer - Programmatically generate the styles easily - Allow a group to be specified when getting the available options - Create a get_option_groups() method to return the groups, which are now built dynamically - Set a bunch of sane defaults - Support additional markers in the chordpro file, and move to div-based HTML - Add artist section - Add key/capo section --- src/chordpro/base.py | 57 +++- src/chordpro/cli.py | 8 +- src/chordpro/constants.py | 1 + src/chordpro/renderers/html.py | 504 ++++++++++++++++++++++++++++++--- 4 files changed, 528 insertions(+), 42 deletions(-) diff --git a/src/chordpro/base.py b/src/chordpro/base.py index 8df1bf0..b973901 100644 --- a/src/chordpro/base.py +++ b/src/chordpro/base.py @@ -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 diff --git a/src/chordpro/cli.py b/src/chordpro/cli.py index f6b54fd..6b9cc2e 100644 --- a/src/chordpro/cli.py +++ b/src/chordpro/cli.py @@ -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) diff --git a/src/chordpro/constants.py b/src/chordpro/constants.py index 3916a4f..5a02ded 100644 --- a/src/chordpro/constants.py +++ b/src/chordpro/constants.py @@ -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'\[(.*?)\]') diff --git a/src/chordpro/renderers/html.py b/src/chordpro/renderers/html.py index a72335a..6c44b20 100644 --- a/src/chordpro/renderers/html.py +++ b/src/chordpro/renderers/html.py @@ -1,22 +1,38 @@ -CHORD = '{}' -SYLLB = '{}' -LINE = '{}{}
' -VERSE = '''
-

{verse_name}

-
+import os + +CHORD = '
{}
' +SYLLB = '
{}
' +WRAPP = '
{}
' +LINE = '
{}
' +STANZA = '''
+

{verse_name}

+
{verse_body}
''' -TITLE = '' +METADATA = '''''' +TITLE = '

{title}

' +ARTIST = '

As performed by {artist}

' +COMPOSER = '

Written by {composer}

' +KEY_CAPO = '
{key_capo}
' +KEY = '
Key: {key}
' +CAPO = '
Capo: {capo}
' +COPYRIGHT = '' +FOOTER = '
{footer}
' HTML = ''' {title} @@ -24,39 +40,457 @@ HTML = ''' {body} ''' -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 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 ' ')) - rendered_syllables.append(SYLLB.format( - syllable.syllable + (' ' 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 ' ') + rendered_syllable = SYLLB.format( + (syllable.syllable or ' ') + + (' ' 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)) -- 2.45.2