mirror of https://github.com/patacrep/patacrep.git
Engine for LaTeX songbooks
http://www.patacrep.com
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
471 lines
13 KiB
471 lines
13 KiB
"""Abstract Syntax Tree for ChordPro code."""
|
|
|
|
# pylint: disable=too-few-public-methods
|
|
|
|
from collections import OrderedDict
|
|
import functools
|
|
import logging
|
|
|
|
from patacrep.songs import errors
|
|
|
|
LOGGER = logging.getLogger()
|
|
|
|
def _indent(string):
|
|
"""Return and indented version of argument."""
|
|
return "\n".join([" {}".format(line) for line in string.split('\n')])
|
|
|
|
#: Available directives, that is, directives that we know how to deal with
|
|
AVAILABLE_DIRECTIVES = [
|
|
"album",
|
|
"artist",
|
|
"capo",
|
|
"comment",
|
|
"copyright",
|
|
"columns",
|
|
"cover",
|
|
"define",
|
|
"guitar_comment",
|
|
"image",
|
|
"key",
|
|
"language",
|
|
"newline",
|
|
"partition",
|
|
"subtitle",
|
|
"tag",
|
|
"title",
|
|
]
|
|
|
|
#: List of properties that are to be displayed in the flow of the song (not as
|
|
#: metadata at the beginning or end of song.
|
|
INLINE_DIRECTIVES = {
|
|
"partition",
|
|
"comment",
|
|
"guitar_comment",
|
|
"image",
|
|
"newline",
|
|
"meta",
|
|
}
|
|
|
|
#: List of properties that are to be displayed in the flow of the song (not as
|
|
#: metadata at the beginning or end of song.
|
|
VERSE_DIRECTIVES = {
|
|
"repeat",
|
|
}
|
|
|
|
#: Some directive have alternative names. For instance `{title: Foo}` and `{t:
|
|
#: Foo}` are equivalent.
|
|
DIRECTIVE_SHORTCUTS = {
|
|
"t": "title",
|
|
"st": "subtitle",
|
|
"a": "album",
|
|
"by": "artist",
|
|
"c": "comment",
|
|
"gc": "guitar_comment",
|
|
"cov": "cover",
|
|
"lang": "language",
|
|
}
|
|
|
|
def directive_name(text):
|
|
"""Return name of the directive, considering eventual shortcuts."""
|
|
return DIRECTIVE_SHORTCUTS.get(text, text)
|
|
|
|
|
|
class AST:
|
|
"""Generic object representing elements of the song."""
|
|
_template = None
|
|
inline = False
|
|
lexer = None
|
|
|
|
def __init__(self):
|
|
self.lineno = self.lexer.lineno
|
|
|
|
def template(self):
|
|
"""Return the template to be used to render this object."""
|
|
if self._template is None:
|
|
LOGGER.warning("No template defined for {}.".format(self.__class__))
|
|
base = "error"
|
|
else:
|
|
base = self._template
|
|
return "content_{}".format(base)
|
|
|
|
class Error(AST):
|
|
"""Parsing error. To be ignored."""
|
|
|
|
class Line(AST):
|
|
"""A line is a sequence of (possibly truncated) words, spaces and chords."""
|
|
|
|
_template = "line"
|
|
|
|
def __init__(self, *items):
|
|
super().__init__()
|
|
self.line = list(items)
|
|
|
|
def __iter__(self):
|
|
yield from self.line
|
|
|
|
def prepend(self, data):
|
|
"""Add an object at the beginning of line.
|
|
|
|
Does nothing if argument is `None`.
|
|
"""
|
|
if data is not None:
|
|
self.line.insert(0, data)
|
|
return self
|
|
|
|
def strip(self):
|
|
"""Remove spaces at the beginning and end of line."""
|
|
while True:
|
|
if not self.line:
|
|
return self
|
|
if isinstance(self.line[0], Space) or isinstance(self.line[0], Error):
|
|
del self.line[0]
|
|
continue
|
|
if isinstance(self.line[-1], Space) or isinstance(self.line[-1], Error):
|
|
del self.line[-1]
|
|
continue
|
|
return self
|
|
|
|
def is_empty(self):
|
|
"""Return `True` iff line is empty."""
|
|
return len(self.strip().line) == 0
|
|
|
|
class Echo(AST):
|
|
"""An inline echo"""
|
|
_template = "echo"
|
|
type = 'echo'
|
|
|
|
def __init__(self, line):
|
|
super().__init__()
|
|
self.line = line
|
|
|
|
class LineElement(AST):
|
|
"""Something present on a line."""
|
|
# pylint: disable=abstract-method
|
|
pass
|
|
|
|
class Word(LineElement):
|
|
"""A chunk of word."""
|
|
_template = "word"
|
|
|
|
def __init__(self, value):
|
|
super().__init__()
|
|
self.value = value
|
|
|
|
class Space(LineElement):
|
|
"""A space between words"""
|
|
_template = "space"
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
class ChordList(LineElement):
|
|
"""A list of chords."""
|
|
_template = "chordlist"
|
|
|
|
def __init__(self, *chords):
|
|
super().__init__()
|
|
self.chords = chords
|
|
|
|
class Chord(AST):
|
|
"""A chord."""
|
|
|
|
_template = "chord"
|
|
|
|
def __init__(self, chord):
|
|
super().__init__()
|
|
self.chord = chord
|
|
|
|
@property
|
|
def pretty_chord(self):
|
|
"""Return the chord with nicer (utf8) alteration"""
|
|
return self.chord.replace('b', '♭').replace('#', '♯')
|
|
|
|
class Verse(AST):
|
|
"""A verse (or bridge, or chorus)"""
|
|
_template = "verse"
|
|
type = "verse"
|
|
inline = True
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.lines = []
|
|
|
|
def prepend(self, data):
|
|
"""Add data at the beginning of verse."""
|
|
self.lines.insert(0, data)
|
|
return self
|
|
|
|
def directive(self):
|
|
"""Return `True` iff the verse is composed only of directives."""
|
|
for line in self.lines:
|
|
for element in line:
|
|
if not isinstance(element, Directive):
|
|
return False
|
|
return True
|
|
|
|
@property
|
|
def nolyrics(self):
|
|
"""Return `True` iff verse contains only notes (no lyrics)"""
|
|
for line in self.lines:
|
|
for item in line.line:
|
|
if not (isinstance(item, Space) or isinstance(item, ChordList)):
|
|
return False
|
|
return True
|
|
|
|
class Chorus(Verse):
|
|
"""Chorus"""
|
|
type = 'chorus'
|
|
|
|
class Bridge(Verse):
|
|
"""Bridge"""
|
|
type = 'bridge'
|
|
|
|
class Song(AST):
|
|
r"""A song
|
|
|
|
Attributes:
|
|
- content: the song content, as a list of objects `foo` such that
|
|
`foo.inline` is True.
|
|
- titles: The list of titles
|
|
- lang: The language code (if set), None otherwise
|
|
- authors: The list of authors
|
|
- meta: Every other metadata.
|
|
"""
|
|
|
|
#: Some directives are added to the song using special methods.
|
|
METADATA_ADD = {
|
|
"title": "add_title",
|
|
"subtitle": "add_subtitle",
|
|
"artist": "add_author",
|
|
"key": "add_key",
|
|
"define": "add_cumulative",
|
|
"tag": "add_cumulative",
|
|
}
|
|
|
|
def __init__(self, filename, directives, *, error_builders=None):
|
|
super().__init__()
|
|
self.content = []
|
|
self.meta = OrderedDict()
|
|
self._authors = []
|
|
self._titles = []
|
|
self._subtitles = []
|
|
self.filename = filename
|
|
if error_builders is None:
|
|
self.error_builders = []
|
|
else:
|
|
self.error_builders = error_builders
|
|
for directive in directives:
|
|
self.add(directive)
|
|
|
|
def add(self, data):
|
|
"""Add an element to the song"""
|
|
# pylint: disable=too-many-branches
|
|
if isinstance(data, Error):
|
|
pass
|
|
elif data is None:
|
|
# New line
|
|
if not (self.content and isinstance(self.content[0], EndOfLine)):
|
|
self.content.insert(0, EndOfLine())
|
|
elif isinstance(data, Line):
|
|
# Add a new line, maybe in the current verse.
|
|
if not data.is_empty():
|
|
if not (self.content and isinstance(self.content[0], Verse)):
|
|
self.content.insert(0, Verse())
|
|
self.content[0].prepend(data.strip())
|
|
elif isinstance(data, Directive) and data.inline:
|
|
# Add a directive in the content of the song.
|
|
# It is useless to check if directive is in AVAILABLE_DIRECTIVES,
|
|
# since it is in INLINE_DIRECTIVES.
|
|
self.content.append(data)
|
|
elif data.inline:
|
|
# Add an object in the content of the song.
|
|
self.content.insert(0, data)
|
|
elif isinstance(data, Directive):
|
|
# Add a metadata directive. Some of them are added using special
|
|
# methods listed in ``METADATA_ADD``.
|
|
if data.keyword not in AVAILABLE_DIRECTIVES:
|
|
message = "Ignoring unknown directive '{}'.".format(data.keyword)
|
|
LOGGER.warning("Song {}, line {}: {}".format(self.filename, data.lineno, message))
|
|
self.error_builders.append(functools.partial(
|
|
errors.SongSyntaxError,
|
|
line=data.lineno,
|
|
message=message,
|
|
))
|
|
if data.keyword in self.METADATA_ADD:
|
|
getattr(self, self.METADATA_ADD[data.keyword])(data)
|
|
else:
|
|
self.meta[data.keyword] = data
|
|
else:
|
|
raise Exception()
|
|
return self
|
|
|
|
def add_title(self, data):
|
|
"""Add a title"""
|
|
self._titles.append(data.argument)
|
|
|
|
def add_cumulative(self, data):
|
|
"""Add a cumulative argument into metadata"""
|
|
if data.keyword not in self.meta:
|
|
self.meta[data.keyword] = []
|
|
self.meta[data.keyword].append(data)
|
|
|
|
def get_data_argument(self, keyword, default):
|
|
"""Return `self.meta[keyword].argument`.
|
|
|
|
Return `default` if `self.meta[keyword]` does not exist.
|
|
|
|
If `self.meta[keyword]` is a list, return the list of `item.argument`
|
|
for each item in the list.
|
|
"""
|
|
if keyword not in self.meta:
|
|
return default
|
|
if isinstance(self.meta[keyword], list):
|
|
return [item.argument for item in self.meta[keyword]]
|
|
else:
|
|
return self.meta[keyword].argument
|
|
|
|
def add_subtitle(self, data):
|
|
"""Add a subtitle"""
|
|
self._subtitles.append(data.argument)
|
|
|
|
@property
|
|
def titles(self):
|
|
"""Return the list of titles (and subtitles)."""
|
|
return self._titles + self._subtitles
|
|
|
|
def add_author(self, data):
|
|
"""Add an auhor."""
|
|
self._authors.append(data.argument)
|
|
|
|
@property
|
|
def authors(self):
|
|
"""Return the list of (raw) authors."""
|
|
return self._authors
|
|
|
|
def add_key(self, data):
|
|
"""Add a new {key: foo: bar} directive."""
|
|
key, *argument = data.argument.split(":")
|
|
if 'morekeys' not in self.meta:
|
|
self.meta['morekeys'] = []
|
|
self.meta['morekeys'].append(Directive(
|
|
key.strip(),
|
|
":".join(argument).strip(),
|
|
))
|
|
|
|
class EndOfLine(AST):
|
|
"""New line"""
|
|
_template = "endofline"
|
|
|
|
class Directive(AST):
|
|
"""A directive"""
|
|
|
|
def __init__(self, keyword, argument=None):
|
|
super().__init__()
|
|
self.keyword = directive_name(keyword.strip())
|
|
if keyword == 'meta':
|
|
argument = argument.partition(':')
|
|
self.argument = argument
|
|
|
|
@property
|
|
def _template(self):
|
|
"""Name of the template to use to render this keyword.
|
|
|
|
This only applies if ``self.inline == True``
|
|
"""
|
|
return self.keyword
|
|
|
|
def __str__(self):
|
|
return str(self.argument)
|
|
|
|
@property
|
|
def inline(self):
|
|
"""Return `True` iff `self` is an inline directive."""
|
|
return self.keyword in INLINE_DIRECTIVES
|
|
|
|
class VerseDirective(Directive):
|
|
"""A verse directive (can be inside some text)"""
|
|
|
|
def __init__(self, keyword, argument=None):
|
|
if keyword not in VERSE_DIRECTIVES:
|
|
#TODO Raise better exception
|
|
raise Exception(keyword)
|
|
super().__init__(keyword, argument)
|
|
|
|
@property
|
|
def _template(self):
|
|
"""Name of the template to use to render this keyword.
|
|
|
|
This only applies if ``self.inline == True``
|
|
"""
|
|
return self.keyword
|
|
|
|
def __str__(self):
|
|
return str(self.argument)
|
|
|
|
@property
|
|
def inline(self):
|
|
"""Return `True` iff `self` is an inline directive."""
|
|
return True
|
|
|
|
class Define(Directive):
|
|
"""A chord definition.
|
|
|
|
Attributes:
|
|
|
|
.. attribute:: key
|
|
The key, as a :class:`Chord` object.
|
|
.. attribute:: basefret
|
|
The base fret, as an integer. Can be `None` if no base fret is defined.
|
|
.. attribute:: frets
|
|
The list of frets, as a list of integers, or `None`, if this fret is not to be played.
|
|
.. attribute:: fingers
|
|
The list of fingers to use on frets, as a list of integers, or `None`
|
|
if no information is given (this string is not played, or is played
|
|
open). Can be `None` if not defined.
|
|
"""
|
|
|
|
def __init__(self, key, basefret, frets, fingers):
|
|
self.key = key
|
|
self.basefret = basefret # Can be None
|
|
self.frets = frets
|
|
self.fingers = fingers # Can be None
|
|
super().__init__("define", None)
|
|
|
|
@property
|
|
def pretty_key(self):
|
|
"""Return the key with nicer (utf8) alteration"""
|
|
return self.key.chord.replace('&', '♭').replace('#', '♯')
|
|
|
|
def __str__(self):
|
|
return None
|
|
|
|
class Image(Directive):
|
|
"""An image
|
|
|
|
.. attribute:: filename
|
|
The filename of the image.
|
|
.. attribute:: size
|
|
An iterable of tuples ``(type, float, unit)``.
|
|
"""
|
|
|
|
def __init__(self, filename, size=None):
|
|
self.filename = filename
|
|
if size is None:
|
|
size = []
|
|
self.size = size
|
|
super().__init__("image", None)
|
|
|
|
class Tab(AST):
|
|
"""Tablature"""
|
|
|
|
inline = True
|
|
_template = "tablature"
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.content = []
|
|
|
|
def prepend(self, data):
|
|
"""Add an element at the beginning of content."""
|
|
self.content.insert(0, data)
|
|
return self
|
|
|