diff --git a/.appveyor.yml b/.appveyor.yml index 913363b9..ed692231 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -31,12 +31,16 @@ install: # Let the binaries be directly callable - cmd: set PATH=%PATH%;C:\projects\patacrep\miktex\miktex\bin - # Update fonts - - cmd: luaotfload-tool.exe --update + # Update some packages to prevent ltluatex bug + - cmd: mpm.exe --update=miktex-bin-2.9 + - cmd: mpm.exe --update=ltxbase --update=luatexbase --update=luaotfload --update=miktex-luatex-base --update=fontspec # Manually install required texlive packages - cmd: mpm.exe --install-some texlive_packages.txt + # Update fonts + - cmd: luaotfload-tool.exe --update + build: false # Not a C# project, build stuff at the test step instead. before_test: @@ -47,4 +51,4 @@ test_script: # Cache Miktex Portable file cache: -- C:\projects\patacrep\miktex-portable.exe \ No newline at end of file +- C:\projects\patacrep\miktex-portable.exe diff --git a/examples/example-test.sb b/examples/example-test.sb index 4190c003..81b7420b 100644 --- a/examples/example-test.sb +++ b/examples/example-test.sb @@ -5,9 +5,10 @@ "lilypond", "pictures" ], +"lang": "ERROR", "booktype" : "chorded", "template" : "patacrep.tex", "encoding": "utf8", - "content": ["tests/*.sg", "tests/*.sgc"] + "content": ["tests/*.csg", "tests/*.tsg"] } diff --git a/examples/songs/tests/errors.csg b/examples/songs/tests/errors.csg index 3b682f4c..62bc7ee2 100644 --- a/examples/songs/tests/errors.csg +++ b/examples/songs/tests/errors.csg @@ -1,4 +1,4 @@ -{lang : en} +{lang : xx_XX} {columns : 2} { title : Error} {subtitle: A chordpro file with many errors} @@ -12,4 +12,5 @@ Bla [H]bla {fo#: bar} - +{image: foo} +{partition: bar} diff --git a/patacrep/build.py b/patacrep/build.py index ed24664d..94416383 100644 --- a/patacrep/build.py +++ b/patacrep/build.py @@ -1,6 +1,7 @@ """Build a songbook, according to parameters found in a .sb file.""" import codecs +import copy import glob import logging import threading @@ -32,7 +33,7 @@ GENERATED_EXTENSIONS = [ # pylint: disable=too-few-public-methods -class Songbook(object): +class Songbook: """Represent a songbook (.sb) file. - Low level: provide a Python representation of the values stored in the @@ -41,20 +42,21 @@ class Songbook(object): """ def __init__(self, raw_songbook, basename): - super().__init__() # Validate config schema = config_model('schema') utils.validate_yaml_schema(raw_songbook, schema) - self.config = raw_songbook + self._raw_config = raw_songbook self.basename = basename + self._errors = list() + self._config = dict() # Some special keys have their value processed. self._set_datadir() def _set_datadir(self): """Set the default values for datadir""" abs_datadir = [] - for path in self.config['_datadir']: + for path in self._raw_config['_datadir']: if os.path.exists(path) and os.path.isdir(path): abs_datadir.append(os.path.abspath(path)) else: @@ -62,10 +64,10 @@ class Songbook(object): "Ignoring non-existent datadir '{}'.".format(path) ) - self.config['_datadir'] = abs_datadir - self.config['_songdir'] = [ + self._raw_config['_datadir'] = abs_datadir + self._raw_config['_songdir'] = [ DataSubpath(path, 'songs') - for path in self.config['_datadir'] + for path in self._raw_config['_datadir'] ] def write_tex(self, output): @@ -74,45 +76,81 @@ class Songbook(object): Arguments: - output: a file object, in which the file will be written. """ - config = self.config.copy() + # Updating configuration + self._config = self._raw_config.copy() renderer = TexBookRenderer( - config['book']['template'], - config['_datadir'], - config['book']['lang'], - config['book']['encoding'], + self._config['book']['template'], + self._config['_datadir'], + self._config['book']['lang'], + self._config['book']['encoding'], ) + self._config['_template'] = renderer.get_all_variables(self._config.get('template', {})) - config['_template'] = renderer.get_all_variables(self.config.get('template', {})) - - config['_compiled_authwords'] = authors.compile_authwords(config['authors']) + self._config['_compiled_authwords'] = authors.compile_authwords( + copy.deepcopy(self._config['authors']) + ) # Loading custom plugins - config['_content_plugins'] = files.load_plugins( - datadirs=config['_datadir'], + self._config['_content_plugins'] = files.load_plugins( + datadirs=self._config['_datadir'], root_modules=['content'], keyword='CONTENT_PLUGINS', ) - config['_song_plugins'] = files.load_plugins( - datadirs=config['_datadir'], + self._config['_song_plugins'] = files.load_plugins( + datadirs=self._config['_datadir'], root_modules=['songs'], keyword='SONG_RENDERERS', )['tsg'] # Configuration set - config['render'] = content.render - config['content'] = content.process_content( - config.get('content', []), - config, + self._config['render'] = content.render + self._config['content'] = content.process_content( + self._config.get('content', []), + self._config, ) - config['filename'] = output.name[:-4] + self._config['filename'] = output.name[:-4] + + self._config['bookoptions'] = iter_bookoptions(self._config) - config['bookoptions'] = iter_bookoptions(config) + renderer.render_tex(output, self._config) + self._errors.extend(renderer.errors) - renderer.render_tex(output, config) + def has_errors(self): + """Return `True` iff errors have been encountered in the book. + + Note that `foo.has_errors() == False` does not means that the book has + not any errors: it does only mean that no error has been found yet. + """ + for _ in self.iter_errors(): + return True + return False + + def iter_errors(self): + """Iterate over errors of book and book content.""" + yield from self._errors + contentlist = self._config.get('content', content.ContentList()) + yield from contentlist.iter_errors() + + def iter_flat_errors(self): + """Iterate over errors, in an exportable format. + + This function does the same job as :func:`iter_errors`, exepted that + the errors are represented as dictionaries of standard python types. + + Each error (dictionary) contains the following keys: + - `type`: the error type (as the class name of the error); + - `message`: Error message, that does not include the error location (datadir, song, etc.); + - `full_message`: Error message, containing the full error location; + - depending on the error type, more keys may be present in the error. + """ + for error in self.iter_errors(): + yield vars(error) def requires_lilypond(self): """Tell if lilypond is part of the bookoptions""" - return 'lilypond' in self.config.get('bookoptions', []) + return ('chords' in self._config + and 'lilypond' in self._config['chords'] + and self._config['chords']['lilypond']) def _log_pipe(pipe): """Log content from `pipe`.""" @@ -122,7 +160,7 @@ def _log_pipe(pipe): break LOGGER.debug(line.strip()) -class SongbookBuilder(object): +class SongbookBuilder: """Provide methods to compile a songbook.""" # if False, do not expect anything from stdin. diff --git a/patacrep/content/__init__.py b/patacrep/content/__init__.py index a0c84361..a2268ee7 100755 --- a/patacrep/content/__init__.py +++ b/patacrep/content/__init__.py @@ -3,8 +3,8 @@ Content that can be included in a songbook is controlled by plugins. From the user (or .sb file) point of view, each piece of content is introduced by a keyword. This keywold is associated with a plugin (a submodule of this very -module), which parses the content, and return a list of instances of the -Content class. +module), which parses the content, and return a ContentList object, which is +little more than a list of instances of the ContentItem class. # Plugin definition @@ -27,8 +27,8 @@ A parser is a function which takes as arguments: - config: the configuration object of the current songbook. Plugins can change it. -A parser returns a list of instances of the Content class, defined in -this module (or of subclasses of this class). +A parser returns a ContentList object (a list of instances of the ContentItem +class), defined in this module (or of subclasses of this class). Example: When the following piece of content is met @@ -55,13 +55,13 @@ surrounded by parenthesis. It is up to the plugin to parse this argument. For intance, keyword "foo()(( bar()" is a perfectly valid keyword, and the parser associated to "foo" will get as argument the string ")(( bar(". -# Content class +# ContentItem class -The content classes are subclasses of class Content defined in this module. -Content is a perfectly valid class, but instances of it will not generate +The content classes are subclasses of class ContentItem defined in this module. +ContentItem is a perfectly valid class, but instances of it will not generate anything in the resulting .tex. -More documentation in the docstring of Content. +More documentation in the docstring of ContentItem. """ @@ -74,13 +74,13 @@ import sys import jinja2 from patacrep import files -from patacrep.errors import SongbookError +from patacrep.errors import SharedError LOGGER = logging.getLogger(__name__) EOL = '\n' #pylint: disable=no-self-use -class Content(object): +class ContentItem: """Content item. Will render to something in the .tex file. The current jinja2.runtime.Context is passed to all function defined @@ -101,8 +101,8 @@ class Content(object): # Arguments - - __previous: the songbook.content.Content object of the previous item. - - __context: see Content() documentation. + - __previous: the songbook.content.ContentItem object of the previous item. + - __context: see ContentItem() documentation. # Return @@ -121,15 +121,81 @@ class Content(object): """Return the string to end a block.""" return "" -class ContentError(SongbookError): +class ContentError(SharedError): """Error in a content plugin.""" - def __init__(self, keyword, message): + def __init__(self, keyword=None, message=None): super().__init__() self.keyword = keyword self.message = message def __str__(self): - return "Content: {}: {}".format(self.keyword, self.message) + text = "Content" + if self.keyword is not None: + text += ": " + self.keyword + if self.message is not None: + text += ": " + self.message + return text + + @property + def __dict__(self): + parent = vars(super()) + parent.update({ + 'keyword': self.keyword, + 'message': self.message, + }) + return parent + +class ContentList: + """List of content items""" + + def __init__(self, *args, **kwargs): + self._content = list(*args, **kwargs) + self._errors = [] + + def __iter__(self): + yield from self._content + + def extend(self, iterator): + """Extend content list with an iterator. + + If the argument is of the same type, the list of errors is + also extended. + """ + self._content.extend(iterator) + if isinstance(iterator, ContentList): + self._errors.extend(iterator.iter_errors()) + + def append(self, item): + """Append an item to the content list.""" + return self._content.append(item) + + def __len__(self): + return len(self._content) + + def append_error(self, error): + """Log and append an error to the error list.""" + LOGGER.warning(error) + self._errors.append(error) + + def extend_error(self, errors): + """Extend the error list with the argument, which is logged.""" + for error in errors: + self.append_error(error) + + def iter_errors(self): + """Iterate over errors.""" + yield from self._errors + for item in self: + if not hasattr(item, "iter_errors"): + continue + yield from item.iter_errors() + +class EmptyContentList(ContentList): + """Empty content list: contain only errors.""" + def __init__(self, *, errors): + super().__init__() + for error in errors: + self.append_error(error) @jinja2.contextfunction def render(context, content): @@ -138,15 +204,15 @@ def render(context, content): Arguments: - context: the jinja2.runtime.context of the current template compilation. - - content: a list of Content() instances, as the one that was returned by + - content: a list of ContentItem() instances, as the one that was returned by process_content(). """ rendered = "" previous = None last = None for elem in content: - if not isinstance(elem, Content): - LOGGER.error("Ignoring bad content item '{}'.".format(elem)) + if not isinstance(elem, ContentItem): + LOGGER.warning("Ignoring bad content item '{}'.".format(elem)) continue last = elem @@ -157,23 +223,23 @@ def render(context, content): rendered += elem.render(context) + EOL previous = elem - if isinstance(last, Content): + if last is not None: rendered += last.end_block(context) + EOL return rendered def process_content(content, config=None): - """Process content, and return a list of Content() objects. + """Process content, and return a list of ContentItem() objects. Arguments are: - content: the content field of the .sb file, which should be a list, and describe what is to be included in the songbook; - config: the configuration dictionary of the current songbook. - Return: a list of Content objects, corresponding to the content to be + Return: a list of ContentItem objects, corresponding to the content to be included in the .tex file. """ - contentlist = [] + contentlist = ContentList() plugins = config.get('_content_plugins', {}) keyword_re = re.compile(r'^ *(?P[\w\*]*) *(\((?P.*)\))? *$') if not content: @@ -184,10 +250,12 @@ def process_content(content, config=None): try: match = keyword_re.match(elem[0]).groupdict() except AttributeError: - raise ContentError(elem[0], "Cannot parse content type.") + contentlist.append_error(ContentError(elem[0], "Cannot parse content type.")) + continue (keyword, argument) = (match['keyword'], match['argument']) if keyword not in plugins: - raise ContentError(keyword, "Unknown content type.") + contentlist.append_error(ContentError(keyword, "Unknown content type.")) + continue contentlist.extend(plugins[keyword]( keyword, argument=argument, diff --git a/patacrep/content/cwd.py b/patacrep/content/cwd.py index 9ba6fddb..07cc407e 100755 --- a/patacrep/content/cwd.py +++ b/patacrep/content/cwd.py @@ -24,7 +24,7 @@ def parse(keyword, config, argument, contentlist): """ old_songdir = config['_songdir'] config['_songdir'] = ( - [DataSubpath("", argument)] + + [DataSubpath(".", argument)] + [path.clone().join(argument) for path in config['_songdir']] ) processed_content = process_content(contentlist, config) diff --git a/patacrep/content/include.py b/patacrep/content/include.py index 0dbba32c..dbc10fce 100644 --- a/patacrep/content/include.py +++ b/patacrep/content/include.py @@ -6,10 +6,9 @@ songs in JSON format. import json import os -import sys import logging -from patacrep.content import process_content, ContentError +from patacrep.content import process_content, ContentError, ContentList from patacrep import encoding, errors, files LOGGER = logging.getLogger(__name__) @@ -19,7 +18,7 @@ def load_from_datadirs(path, datadirs): Raise an exception if it was found if none of the datadirs of 'config'. """ - for filepath in files.iter_datadirs(datadirs, path): + for filepath in files.iter_datadirs(datadirs, "songs", path): if os.path.exists(filepath): return filepath # File not found @@ -38,10 +37,14 @@ def parse(keyword, config, argument, contentlist): - argument: None; - contentlist: a list of file paths to be included. """ - new_contentlist = [] + new_contentlist = ContentList() for path in contentlist: - filepath = load_from_datadirs(path, config['_datadir']) + try: + filepath = load_from_datadirs(path, config['_datadir']) + except ContentError as error: + new_contentlist.append_error(error) + continue content_file = None try: with encoding.open_read( @@ -50,13 +53,15 @@ def parse(keyword, config, argument, contentlist): ) as content_file: new_content = json.load(content_file) except Exception as error: # pylint: disable=broad-except - LOGGER.error(error) - LOGGER.error("Error while loading file '{}'.".format(filepath)) - sys.exit(1) + new_contentlist.append_error(ContentError( + keyword="include", + message="Error while loading file '{}': {}".format(filepath, error), + )) + continue - config["_datadir"].append(os.path.abspath(os.path.dirname(filepath))) - new_contentlist += process_content(new_content, config) - config["_datadir"].pop() + config['_datadir'].append(os.path.abspath(os.path.dirname(filepath))) + new_contentlist.extend(process_content(new_content, config)) + config['_datadir'].pop() return new_contentlist diff --git a/patacrep/content/section.py b/patacrep/content/section.py index 2b56975e..6aed27c6 100755 --- a/patacrep/content/section.py +++ b/patacrep/content/section.py @@ -1,6 +1,6 @@ """Allow LaTeX sections (starred or not) as content of a songbook.""" -from patacrep.content import Content, ContentError +from patacrep.content import ContentItem, ContentError, ContentList, EmptyContentList KEYWORDS = [ "part", @@ -13,7 +13,7 @@ KEYWORDS = [ ] FULL_KEYWORDS = KEYWORDS + ["{}*".format(word) for word in KEYWORDS] -class Section(Content): +class Section(ContentItem): """A LaTeX section.""" # pylint: disable=too-few-public-methods @@ -41,14 +41,17 @@ def parse(keyword, argument, contentlist, config): and short) of the section; - config: configuration dictionary of the current songbook. """ - if (keyword not in KEYWORDS) and (len(contentlist) != 1): - raise ContentError( - keyword, - "Starred section names must have exactly one argument." - ) - if (len(contentlist) not in [1, 2]): - raise ContentError(keyword, "Section can have one or two arguments.") - return [Section(keyword, *contentlist)] + try: + if (keyword not in KEYWORDS) and (len(contentlist) != 1): + raise ContentError( + keyword, + "Starred section names must have exactly one argument." + ) + if (len(contentlist) not in [1, 2]): + raise ContentError(keyword, "Section can have one or two arguments.") + return ContentList([Section(keyword, *contentlist)]) + except ContentError as error: + return EmptyContentList(errors=[error]) CONTENT_PLUGINS = dict([ diff --git a/patacrep/content/song.py b/patacrep/content/song.py index ad193c02..8e9519dc 100755 --- a/patacrep/content/song.py +++ b/patacrep/content/song.py @@ -7,18 +7,23 @@ import textwrap import jinja2 -from patacrep.content import process_content, ContentError, Content +from patacrep.content import process_content +from patacrep.content import ContentError, ContentItem, ContentList from patacrep import files, errors LOGGER = logging.getLogger(__name__) -class SongRenderer(Content): +class SongRenderer(ContentItem): """Render a song in as a tex code.""" def __init__(self, song): super().__init__() self.song = song + def iter_errors(self): + """Iterate over song errors.""" + yield from self.song.errors + def begin_new_block(self, previous, __context): """Return a boolean stating if a new block is to be created.""" return not isinstance(previous, SongRenderer) @@ -68,7 +73,7 @@ def parse(keyword, argument, contentlist, config): plugins = config['_song_plugins'] if '_langs' not in config: config['_langs'] = set() - songlist = [] + songlist = ContentList() for songdir in config['_songdir']: if contentlist: break @@ -85,20 +90,24 @@ def parse(keyword, argument, contentlist, config): LOGGER.debug('Parsing file "{}"…'.format(filename)) extension = filename.split(".")[-1] if extension not in plugins: - LOGGER.warning( + LOGGER.info( ( - 'I do not know how to parse "{}": name does ' - 'not end with one of {}. Ignored.' - ).format( - os.path.join(songdir.datadir, filename), - ", ".join(["'.{}'".format(key) for key in plugins.keys()]), - )) + 'Cannot parse "%s": name does not end with one ' + 'of %s. Ignored.' + ), + os.path.join(songdir.datadir, filename), + ", ".join(["'.{}'".format(key) for key in plugins.keys()]) + ) + continue + try: + renderer = SongRenderer(plugins[extension]( + filename, + config, + datadir=songdir.datadir, + )) + except ContentError as error: + songlist.append_error(error) continue - renderer = SongRenderer(plugins[extension]( - filename, - config, - datadir=songdir.datadir, - )) songlist.append(renderer) config["_langs"].add(renderer.song.lang) if len(songlist) > before: diff --git a/patacrep/content/songsection.py b/patacrep/content/songsection.py index fc185b57..c5fea4ab 100755 --- a/patacrep/content/songsection.py +++ b/patacrep/content/songsection.py @@ -1,13 +1,13 @@ """Allow 'songchapter' and 'songsection' as content of a songbook.""" -from patacrep.content import Content, ContentError +from patacrep.content import ContentItem, ContentError, ContentList, EmptyContentList KEYWORDS = [ "songchapter", "songsection", ] -class SongSection(Content): +class SongSection(ContentItem): """A songsection or songchapter.""" # pylint: disable=too-few-public-methods @@ -29,16 +29,19 @@ def parse(keyword, argument, contentlist, config): - contentlist: a list of one string, which is the name of the section; - config: configuration dictionary of the current songbook. """ - if (keyword not in KEYWORDS) and (len(contentlist) != 1): - raise ContentError( - keyword, - "Starred section names must have exactly one argument.", - ) - return [SongSection(keyword, contentlist[0])] + try: + if (keyword not in KEYWORDS) and (len(contentlist) != 1): + raise ContentError( + keyword, + "Starred section names must have exactly one argument.", + ) + return ContentList([SongSection(keyword, contentlist[0])]) + except ContentError as error: + return EmptyContentList(errors=[error]) CONTENT_PLUGINS = dict([ - (word, parse) - for word + (keyword, parse) + for keyword in KEYWORDS ]) diff --git a/patacrep/content/sorted.py b/patacrep/content/sorted.py index 95a1a07f..9f9fded9 100755 --- a/patacrep/content/sorted.py +++ b/patacrep/content/sorted.py @@ -9,7 +9,8 @@ import logging import unidecode from patacrep import files -from patacrep.content import ContentError, process_content +from patacrep.content import ContentError, EmptyContentList +from patacrep.content import process_content from patacrep.content.song import OnlySongsError LOGGER = logging.getLogger(__name__) @@ -85,11 +86,11 @@ def parse(keyword, config, argument, contentlist): try: songlist = process_content(contentlist, config) except OnlySongsError as error: - raise ContentError(keyword, ( + return EmptyContentList(errors=[ContentError(keyword, ( "Content list of this keyword can be only songs (or content " "that result into songs), and the following are not:" + str(error.not_songs) - )) + ))]) return sorted(songlist, key=key_generator(sort)) CONTENT_PLUGINS = {'sorted': parse} diff --git a/patacrep/content/tex.py b/patacrep/content/tex.py index 651eeba2..201dd67a 100755 --- a/patacrep/content/tex.py +++ b/patacrep/content/tex.py @@ -5,11 +5,11 @@ import logging import os from patacrep import files, errors -from patacrep.content import Content +from patacrep.content import ContentItem, ContentList, ContentError LOGGER = logging.getLogger(__name__) -class LaTeX(Content): +class LaTeX(ContentItem): """Inclusion of LaTeX code""" def __init__(self, filename): @@ -35,7 +35,7 @@ def parse(keyword, argument, contentlist, config): LOGGER.warning( "Useless 'tex' content: list of files to include is empty." ) - filelist = [] + filelist = ContentList() basefolders = itertools.chain( (path.fullpath for path in config['_songdir']), files.iter_datadirs(config['_datadir']), @@ -51,11 +51,12 @@ def parse(keyword, argument, contentlist, config): )) break if not checked_file: - LOGGER.warning( - "{} Compilation may fail later.".format( - errors.notfound(filename, basefolders) + filelist.append_error( + ContentError( + keyword="tex", + message=errors.notfound(filename, basefolders), + ) ) - ) continue filelist.append(LaTeX(checked_file)) diff --git a/patacrep/errors.py b/patacrep/errors.py index 9a5785cb..08eebc6a 100644 --- a/patacrep/errors.py +++ b/patacrep/errors.py @@ -104,6 +104,20 @@ class ParsingError(SongbookError): def __str__(self): return self.message +class SharedError(SongbookError): + """Error that is meant to be shared to third party tools using patacrep.""" + + def __str__(self): + raise NotImplementedError() + + @property + def __dict__(self): + return { + 'type': self.__class__.__name__, + 'message': str(self), + 'full_message': str(self), + } + def notfound(filename, paths, message=None): """Return a string saying that file was not found in paths.""" if message is None: diff --git a/patacrep/index.py b/patacrep/index.py index c29b88ab..d572f872 100644 --- a/patacrep/index.py +++ b/patacrep/index.py @@ -46,7 +46,7 @@ def process_sxd(filename): return idx -class Index(object): +class Index: """Title, author or scripture Index representation.""" def __init__(self, indextype): diff --git a/patacrep/latex/__init__.py b/patacrep/latex/__init__.py index d596abae..d2a57fc8 100644 --- a/patacrep/latex/__init__.py +++ b/patacrep/latex/__init__.py @@ -8,10 +8,13 @@ will work on simple cases, but not on complex ones. import logging from collections import OrderedDict +from patacrep import errors from patacrep.latex.syntax import tex2plain, parse_song LOGGER = logging.getLogger(__name__) +DEFAULT_LANGUAGE = "en_us" + BABEL_LANGUAGES = OrderedDict(( ('de_de', 'german'), ('de_at', 'austrian'), @@ -77,32 +80,76 @@ BABEL_LANGUAGES = OrderedDict(( # ('??_??', 'welsh'), )) -def lang2babel(lang): - """Return the language used by babel, corresponding to the language code""" +class UnknownLanguage(errors.SharedError): + """Error: Unknown language.""" + + def __init__(self, *, original, fallback, message): + super().__init__() + self.original = original + self.fallback = fallback + self.message = message + + @property + def babel(self): + """Return the fallback babel language.""" + return BABEL_LANGUAGES[self.fallback] + + def __str__(self): + return self.message + + @property + def __dict__(self): + parent = vars(super()) + parent.update({ + 'fallback': self.fallback, + 'original': self.original, + }) + return parent + +def checklanguage(lang): + """Check that `lang` is a known language. + + Raise an :class:`UnknownLanguage` exception if not. + """ # Exact match if lang.lower() in BABEL_LANGUAGES: - return BABEL_LANGUAGES[lang.lower()] + return lang.lower() # Only language code is provided (e.g. 'fr') for babel in BABEL_LANGUAGES: if babel.startswith(lang.lower()): - return BABEL_LANGUAGES[babel] + return babel # A non existent country code is provided (e.g. 'fr_CD'). language = lang.lower().split("_")[0] for babel in BABEL_LANGUAGES: if babel.startswith(language): - LOGGER.error( - "Unknown country code '{}'. Using default '{}' instead.".format( + raise UnknownLanguage( + original=lang, + fallback=babel, + message="Unknown country code '{}'. Using default '{}' instead.".format( lang, babel ) ) - return BABEL_LANGUAGES[babel] # Error: no (exact or approximate) match found available = ", ".join(BABEL_LANGUAGES.keys()) - LOGGER.error( - "Unknown language code '{}' (supported: {}). Using default 'english' instead.".format( - lang, - available - ) + raise UnknownLanguage( + original=lang, + fallback=DEFAULT_LANGUAGE, + message=( + "Unknown language code '{}' (supported: {}). Using " + "default '{}' instead." + ).format( + lang, + available, + DEFAULT_LANGUAGE, + ) ) - return 'english' + +def lang2babel(lang): + """Return the language used by babel, corresponding to the language code + + Raises an `UnknownLanguage` exception if the `lang` argument is not known, + the :attr:`fallback` attribute of the exception being the existing + alternative language that can be used instead. + """ + return BABEL_LANGUAGES[checklanguage(lang)] diff --git a/patacrep/latex/lexer.py b/patacrep/latex/lexer.py index 81679255..0b5c2ce7 100644 --- a/patacrep/latex/lexer.py +++ b/patacrep/latex/lexer.py @@ -72,7 +72,7 @@ class SimpleLexer: @staticmethod def t_error(token): """Manage errors""" - LOGGER.error("Illegal character '{}'".format(token.value[0])) + LOGGER.warning("Illegal character '{}'".format(token.value[0])) token.lexer.skip(1) class SongLexer(SimpleLexer): diff --git a/patacrep/latex/syntax.py b/patacrep/latex/syntax.py index 330f9eee..edf9ceb1 100644 --- a/patacrep/latex/syntax.py +++ b/patacrep/latex/syntax.py @@ -3,11 +3,10 @@ import logging import ply.yacc as yacc -from patacrep.songs.syntax import Parser -from patacrep.latex.lexer import tokens, SimpleLexer, SongLexer from patacrep.latex import ast -from patacrep.errors import ParsingError from patacrep.latex.detex import detex +from patacrep.latex.lexer import tokens, SimpleLexer, SongLexer +from patacrep.songs.syntax import Parser LOGGER = logging.getLogger() @@ -126,20 +125,22 @@ class LatexParser(Parser): else: symbols[0] = [] - @staticmethod - def p_dictionary(symbols): + def p_dictionary(self, symbols): """dictionary : identifier EQUAL braces dictionary_next | identifier EQUAL error dictionary_next | empty """ + symbols[0] = {} if len(symbols) == 2: - symbols[0] = {} + pass elif isinstance(symbols[3], ast.Expression): - symbols[0] = {} symbols[0][symbols[1]] = symbols[3] symbols[0].update(symbols[4]) else: - raise ParsingError("Do enclose arguments between braces.") + self.error( + line=symbols.lexer.lineno, + message="Argument '{}' should be enclosed between braces.".format(symbols[1]), + ) @staticmethod def p_identifier(symbols): diff --git a/patacrep/songs/__init__.py b/patacrep/songs/__init__.py index c3636bca..adf473c5 100644 --- a/patacrep/songs/__init__.py +++ b/patacrep/songs/__init__.py @@ -7,8 +7,10 @@ import os import pickle import re +from patacrep import errors as book_errors from patacrep import files, encoding from patacrep.authors import process_listauthors +from patacrep.songs import errors as song_errors LOGGER = logging.getLogger(__name__) @@ -25,7 +27,7 @@ def cached_name(datadir, filename): raise return fullpath -class DataSubpath(object): +class DataSubpath: """A path divided in two path: a datadir, and its subpath. - This object can represent either a file or directory. @@ -79,7 +81,7 @@ class Song: # Version format of cached song. Increment this number if we update # information stored in cache. - CACHE_VERSION = 3 + CACHE_VERSION = 4 # List of attributes to cache cached_attributes = [ @@ -87,6 +89,7 @@ class Song: "unprefixed_titles", "cached", "data", + "errors", "lang", "authors", "_filehash", @@ -109,8 +112,9 @@ class Song: self.subpath = subpath self._filehash = None self.encoding = config['book']["encoding"] - self.default_lang = config['book']["lang"] + self.lang = config['book']["lang"] self.config = config + self.errors = [] if self._cache_retrieved(): return @@ -118,8 +122,7 @@ class Song: # Data extraction from the latex song self.titles = [] self.data = {} - self.cached = None - self.lang = None + self.cached = {} self._parse() # Post processing of data @@ -174,16 +177,22 @@ class Song: def _write_cache(self): """If relevant, write a dumbed down version of self to the cache.""" - if self.use_cache: - cached = {attr: getattr(self, attr) for attr in self.cached_attributes} - pickle.dump( - cached, - open(self.cached_name, 'wb'), - protocol=-1 - ) + if not self.use_cache: + return + if self.errors: + # As errors are exceptions, we cannot cache them because of a Python + # bug. When this bug is fixed, we will cache errors. + # https://bugs.python.org/issue1692335 + return + cached = {attr: getattr(self, attr) for attr in self.cached_attributes} + pickle.dump( + cached, + open(self.cached_name, 'wb'), + protocol=-1 + ) - def __repr__(self): - return repr((self.titles, self.data, self.fullpath)) + def __str__(self): + return str(self.fullpath) def render(self, *args, **kwargs): """Return the code rendering this song. diff --git a/patacrep/songs/chordpro/__init__.py b/patacrep/songs/chordpro/__init__.py index a01c31f4..a7fd3e3b 100644 --- a/patacrep/songs/chordpro/__init__.py +++ b/patacrep/songs/chordpro/__init__.py @@ -4,15 +4,16 @@ import logging import operator import os -from jinja2 import Environment, FileSystemLoader, contextfunction, ChoiceLoader +from jinja2 import Environment, FileSystemLoader, ChoiceLoader +from jinja2 import contextfunction import jinja2 from patacrep import encoding, files, pkg_datapath from patacrep.songs import Song from patacrep.songs.chordpro.syntax import parse_song +from patacrep.songs.errors import FileNotFound, SongUnknownLanguage from patacrep.templates import Renderer -from patacrep.latex import lang2babel -from patacrep.files import path2posix +from patacrep.latex import lang2babel, UnknownLanguage LOGGER = logging.getLogger(__name__) @@ -20,6 +21,10 @@ def sort_directive_argument(directives): """Sort directives by their argument.""" return sorted(directives, key=operator.attrgetter("argument")) +DEFAULT_FILTERS = { + 'sortargs': sort_directive_argument, + } + class ChordproSong(Song): """Chordpro song parser""" # pylint: disable=abstract-method @@ -32,12 +37,22 @@ class ChordproSong(Song): song = parse_song(song.read(), self.fullpath) self.authors = song.authors self.titles = song.titles - self.lang = song.get_data_argument('language', self.default_lang) + self.lang = song.get_data_argument('language', self.lang) self.data = song.meta + self.errors = [error(song=self) for error in song.error_builders] self.cached = { 'song': song, } + def _filters(self): + """Return additional jinja2 filters.""" + filters = DEFAULT_FILTERS.copy() + filters.update({ + 'search_image': self.search_image, + 'search_partition': self.search_partition, + }) + return filters + def render(self, template="song"): # pylint: disable=arguments-differ context = { 'lang': self.lang, @@ -51,11 +66,7 @@ class ChordproSong(Song): jinjaenv = Environment(loader=FileSystemLoader( self.iter_datadirs("templates", "songs", "chordpro", self.output_language) )) - jinjaenv.filters['search_image'] = self.search_image - jinjaenv.filters['search_partition'] = self.search_partition - jinjaenv.filters['lang2babel'] = lang2babel - jinjaenv.filters['sortargs'] = sort_directive_argument - jinjaenv.filters['path2posix'] = path2posix + jinjaenv.filters.update(self._filters()) try: return Renderer( @@ -106,22 +117,49 @@ class Chordpro2LatexSong(ChordproSong): try: return os.path.join("scores", super().search_partition(filename)) except FileNotFoundError: - LOGGER.warning( - "Song '%s' (datadir '%s'): Score '%s' not found.", - self.subpath, self.datadir, filename, + message = "Song '{}' (datadir '{}'): Score '{}' not found.".format( + self.subpath, self.datadir, filename ) + self.errors.append(FileNotFound(self, filename)) + LOGGER.warning(message) return None def search_image(self, filename): try: return os.path.join("img", super().search_image(filename)) except FileNotFoundError: - LOGGER.warning( - "Song '%s' (datadir '%s'): Image '%s' not found.", - self.subpath, self.datadir, filename, + message = "Song '{}' (datadir '{}'): Image '{}' not found.".format( + self.subpath, self.datadir, filename ) + self.errors.append(FileNotFound(self, filename)) + LOGGER.warning(message) return None + def _filters(self): + parent = super()._filters() + parent.update({ + 'lang2babel': self.lang2babel, + }) + return parent + + def lang2babel(self, lang): + """Return the LaTeX babel code corresponding to `lang`. + + Add an error to the list of errors if argument is invalid. + """ + try: + return lang2babel(lang) + except UnknownLanguage as error: + new_error = SongUnknownLanguage( + self, + error.original, + error.fallback, + error.message, + ) + LOGGER.warning(new_error) + self.errors.append(new_error) + return error.babel + class Chordpro2ChordproSong(ChordproSong): """Render chordpro song to chordpro code""" diff --git a/patacrep/songs/chordpro/ast.py b/patacrep/songs/chordpro/ast.py index 8d287a79..769803fa 100644 --- a/patacrep/songs/chordpro/ast.py +++ b/patacrep/songs/chordpro/ast.py @@ -3,8 +3,11 @@ # 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): @@ -223,7 +226,7 @@ class Song(AST): "tag": "add_cumulative", } - def __init__(self, filename, directives): + def __init__(self, filename, directives, *, error_builders=None): super().__init__() self.content = [] self.meta = OrderedDict() @@ -231,6 +234,10 @@ class Song(AST): 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) @@ -261,7 +268,13 @@ class Song(AST): # Add a metadata directive. Some of them are added using special # methods listed in ``METADATA_ADD``. if data.keyword not in AVAILABLE_DIRECTIVES: - LOGGER.warning("Ignoring unknown directive '{}'.".format(data.keyword)) + 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: diff --git a/patacrep/songs/chordpro/lexer.py b/patacrep/songs/chordpro/lexer.py index 16f494b3..9d0f3b89 100644 --- a/patacrep/songs/chordpro/lexer.py +++ b/patacrep/songs/chordpro/lexer.py @@ -1,8 +1,12 @@ """ChordPro lexer""" +import functools import logging + import ply.lex as lex +from patacrep.songs import errors + LOGGER = logging.getLogger() #pylint: disable=invalid-name @@ -85,6 +89,7 @@ class ChordProLexer: def __init__(self, *, filename=None): self.__class__.lexer = lex.lex(module=self) + self.error_builders = [] self.filename = filename # Define a rule so we can track line numbers @@ -135,14 +140,20 @@ class ChordProLexer: def error(self, token, more=""): """Display error message, and skip illegal token.""" - message = "Line {line}: Illegal character '{char}'{more}.".format( - line=token.lexer.lineno, + message = "Illegal character '{char}'{more}.".format( char=token.value[0], more=more, ) + self.error_builders.append(functools.partial( + errors.SongSyntaxError, + line=token.lexer.lineno, + message=message, + )) if self.filename is not None: - message = "File {}: {}".format(self.filename, message) - LOGGER.error(message) + message = "Song {}, line {}: {}".format(self.filename, token.lexer.lineno, message) + else: + message = "Line {}: {}".format(token.lexer.lineno, message) + LOGGER.warning(message) token.lexer.skip(1) def t_error(self, token): diff --git a/patacrep/songs/chordpro/syntax.py b/patacrep/songs/chordpro/syntax.py index 66b9b571..04104ac6 100644 --- a/patacrep/songs/chordpro/syntax.py +++ b/patacrep/songs/chordpro/syntax.py @@ -5,9 +5,10 @@ import re import ply.yacc as yacc -from patacrep.songs.syntax import Parser +from patacrep.content import ContentError from patacrep.songs.chordpro import ast from patacrep.songs.chordpro.lexer import tokens, ChordProLexer +from patacrep.songs.syntax import Parser LOGGER = logging.getLogger() @@ -28,22 +29,28 @@ class ChordproParser(Parser): write_tables=0, ) - def parse(self, content, *, lexer): + def parse(self, content): """Parse file This is a shortcut to `yacc.yacc(...).parse()`. The arguments are transmitted to this method. """ - lexer = ChordProLexer(filename=self.filename).lexer - ast.AST.lexer = lexer - return self.parser.parse(content, lexer=lexer) + lexer = ChordProLexer(filename=self.filename) + ast.AST.lexer = lexer.lexer + parsed = self.parser.parse(content, lexer=lexer.lexer) + parsed.error_builders.extend(lexer.error_builders) + return parsed def p_song(self, symbols): """song : block song | empty """ if len(symbols) == 2: - symbols[0] = ast.Song(self.filename, self._directives) + symbols[0] = ast.Song( + self.filename, + directives=self._directives, + error_builders=self._errors, + ) else: symbols[0] = symbols[2].add(symbols[1]) @@ -187,10 +194,12 @@ class ChordproParser(Parser): else: symbols[0] = None - @staticmethod - def p_line_error(symbols): + def p_line_error(self, symbols): """line_error : error directive""" - LOGGER.error("Directive can only be preceded or followed by spaces") + self.error( + line=symbols.lexer.lineno, + message="Directive can only be preceded or followed by spaces", + ) symbols[0] = ast.Line() @staticmethod @@ -317,10 +326,7 @@ class ChordproParser(Parser): def parse_song(content, filename=None): """Parse song and return its metadata.""" parser = ChordproParser(filename) - parsed_content = parser.parse( - content, - lexer=ChordProLexer(filename=filename).lexer, - ) + parsed_content = parser.parse(content) if parsed_content is None: - raise SyntaxError('Fatal error during song parsing: {}'.format(filename)) + raise ContentError(message='Fatal error during song parsing.') return parsed_content diff --git a/patacrep/songs/errors.py b/patacrep/songs/errors.py new file mode 100644 index 00000000..3a53ba6c --- /dev/null +++ b/patacrep/songs/errors.py @@ -0,0 +1,88 @@ +"""Errors in song definition (syntax errors, and so on)""" + +from patacrep.errors import SharedError + +class SongError(SharedError): + """Generic song error""" + # pylint: disable=too-few-public-methods + + def __init__(self, song, message): + super().__init__() + self.song = song + self.message = message + + def __str__(self): + return "{}: {}".format(self._human_song(), self.message) + + def _human_song(self): + return "Datadir '{}', song '{}'".format( + self.song.datadir, + self.song.subpath, + ) + + @property + def __dict__(self): + parent = vars(super()) + parent.update({ + 'datadir': self.song.datadir, + 'subpath': self.song.subpath, + 'message': self.message, + 'full_message': str(self), + }) + return parent + +class SongSyntaxError(SongError): + """Syntax error""" + # pylint: disable=too-few-public-methods + + def __init__(self, song, line, message): + super().__init__(song, message) + #: Line of error. May be `None` if irrelevant. + self.line = line + + def __str__(self): + if self.line is not None: + return "{}, line {}: {}".format(self._human_song(), self.line, self.message) + else: + return "{}: {}".format(self._human_song(), self.message) + + @property + def __dict__(self): + parent = vars(super()) + if self.line is not None: + parent.update({ + 'line': self.line, + }) + return parent + +class FileNotFound(SongError): + """File not found error""" + + def __init__(self, song, filename): + super().__init__(song, "File '{}' not found.".format(filename)) + self.filename = filename + + @property + def __dict__(self): + parent = vars(super()) + parent.update({ + 'filename': self.filename, + }) + return parent + +class SongUnknownLanguage(SongError): + """Song language is not known.""" + + def __init__(self, song, original, fallback, message): + super().__init__(song, message) + self.original = original + self.fallback = fallback + + @property + def __dict__(self): + parent = vars(super()) + parent.update({ + 'original': self.original, + 'fallback': self.fallback, + }) + return parent diff --git a/patacrep/songs/syntax.py b/patacrep/songs/syntax.py index ee7d95da..aeeb85a1 100644 --- a/patacrep/songs/syntax.py +++ b/patacrep/songs/syntax.py @@ -1,7 +1,10 @@ """Generic parsing classes and methods""" +import functools import logging +from patacrep.songs import errors + LOGGER = logging.getLogger() class Parser: @@ -10,6 +13,7 @@ class Parser: def __init__(self): self.filename = "" # Will be overloaded + self._errors = [] @staticmethod def __find_column(token): @@ -21,7 +25,13 @@ class Parser: return column def error(self, *, line=None, column=None, message=""): - """Display an error message""" + """Record and display an error message""" + self._errors.append(functools.partial( + errors.SongSyntaxError, + line=line, + message=message, + )) + coordinates = [] if line is not None: coordinates.append("line {}".format(line)) @@ -35,18 +45,17 @@ class Parser: else: text += "." if self.filename is None: - LOGGER.error(text) + LOGGER.warning(text) else: - LOGGER.error("File {}: {}".format(self.filename, text)) + LOGGER.warning("Song {}: {}".format(self.filename, text)) def p_error(self, token): """Manage parsing errors.""" if token is None: - self.error( - message="Unexpected end of file.", - ) + self.error(message="Unexpected end of file.") else: self.error( + message="Syntax error", line=token.lineno, column=self.__find_column(token), ) diff --git a/patacrep/templates.py b/patacrep/templates.py index 370b3d88..f745a58e 100644 --- a/patacrep/templates.py +++ b/patacrep/templates.py @@ -1,5 +1,6 @@ """Template for .tex generation settings and utilities""" +import logging import re import yaml @@ -10,9 +11,11 @@ from jinja2.ext import Extension from jinja2.meta import find_referenced_templates as find_templates from patacrep import errors, files, utils -from patacrep.latex import lang2babel +from patacrep.latex import lang2babel, UnknownLanguage import patacrep.encoding +LOGGER = logging.getLogger(__name__) + _LATEX_SUBS = ( (re.compile(r'\\'), r'\\textbackslash'), (re.compile(r'([{}_#%&$])'), r'\\\1'), @@ -43,6 +46,18 @@ _VARIABLE_REGEXP = re.compile( """, re.VERBOSE|re.DOTALL) +def _escape_tex(value): + '''Escape TeX special characters''' + newval = value + for pattern, replacement in _LATEX_SUBS: + newval = pattern.sub(replacement, newval) + return newval + +DEFAULT_FILTERS = { + "escape_tex": _escape_tex, + "iter_datadirs": files.iter_datadirs, + "path2posix": files.path2posix, + } class VariablesExtension(Extension): """Extension to jinja2 to silently ignore variable block. @@ -60,19 +75,12 @@ class VariablesExtension(Extension): return nodes.Const("") # pylint: disable=no-value-for-parameter -def _escape_tex(value): - '''Escape TeX special characters''' - newval = value - for pattern, replacement in _LATEX_SUBS: - newval = pattern.sub(replacement, newval) - return newval - - class Renderer: """Render a template to a LaTeX file.""" # pylint: disable=too-few-public-methods def __init__(self, template, jinjaenv, encoding=None): + self.errors = [] self.encoding = encoding self.jinjaenv = jinjaenv self.jinjaenv.block_start_string = '(*' @@ -82,14 +90,35 @@ class Renderer: self.jinjaenv.comment_start_string = '(% comment %)' self.jinjaenv.comment_end_string = '(% endcomment %)' self.jinjaenv.line_comment_prefix = '%!' - self.jinjaenv.filters['escape_tex'] = _escape_tex self.jinjaenv.trim_blocks = True self.jinjaenv.lstrip_blocks = True - self.jinjaenv.filters["path2posix"] = files.path2posix - self.jinjaenv.filters["iter_datadirs"] = files.iter_datadirs - self.jinjaenv.filters["lang2babel"] = lang2babel + # Fill default filters + for key, value in self.filters().items(): + if key not in self.jinjaenv.filters: + self.jinjaenv.filters[key] = value + self.template = self.jinjaenv.get_template(template) + def filters(self): + """Return a dictionary of jinja2 filters.""" + filters = DEFAULT_FILTERS.copy() + filters.update({ + "lang2babel": self.lang2babel, + }) + return filters + + def lang2babel(self, lang): + """Return the LaTeX babel code corresponding to `lang`. + + Add an error to the list of errors if argument is invalid. + """ + try: + return lang2babel(lang) + except UnknownLanguage as error: + error.message = "Songbook: {}".format(error.message) + LOGGER.warning(error.message) + self.errors.append(error) + return error.babel class TexBookRenderer(Renderer): """Tex renderer for the whole songbook""" diff --git a/test/test_content/datadir/custom_list.json b/test/test_content/datadir/songs/custom_list.json similarity index 100% rename from test/test_content/datadir/custom_list.json rename to test/test_content/datadir/songs/custom_list.json diff --git a/test/test_song/errors/invalid_content.csg.source b/test/test_song/errors/invalid_content.csg.source deleted file mode 100644 index 4dba23a7..00000000 --- a/test/test_song/errors/invalid_content.csg.source +++ /dev/null @@ -1,3 +0,0 @@ -{soc} -Chorus -{eoc diff --git a/test/test_song/test_parser.py b/test/test_song/test_parser.py index 1cdfae5b..5dacf81d 100644 --- a/test/test_song/test_parser.py +++ b/test/test_song/test_parser.py @@ -11,6 +11,7 @@ from pkg_resources import resource_filename from patacrep import files from patacrep.encoding import open_read from patacrep.build import config_model +from patacrep.songs import errors from .. import logging_reduced from .. import dynamic # pylint: disable=unused-import @@ -99,14 +100,15 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest): cls._create_test(base, in_format, out_format), ) - with cls.chdir('errors'): - for source in sorted(glob.glob('*.*.source')): - [*base, in_format, _] = source.split('.') - base = '.'.join(base) - yield ( - "test_{}_{}_failure".format(base, in_format), - cls._create_failure(base, in_format), - ) + if os.path.isdir("errors"): + with cls.chdir('errors'): + for source in sorted(glob.glob('*.*.source')): + [*base, in_format, _] = source.split('.') + base = '.'.join(base) + yield ( + "test_{}_{}_failure".format(base, in_format), + cls._create_failure(base, in_format), + ) @classmethod def _create_test(cls, base, in_format, out_format): @@ -128,7 +130,7 @@ class FileTest(unittest.TestCase, metaclass=dynamic.DynamicTest): sourcename = "{}.{}.source".format(base, in_format) with self.chdir('errors'): parser = self.song_plugins[out_format][in_format] - self.assertRaises(SyntaxError, parser, sourcename, self.config) + self.assertRaises(errors.SongSyntaxError, parser, sourcename, self.config) test_parse_failure.__doc__ = ( "Test that '{base}' parsing fails." diff --git a/test/test_songbook/content.sb b/test/test_songbook/content.sb new file mode 100644 index 00000000..7e5ee2c8 --- /dev/null +++ b/test/test_songbook/content.sb @@ -0,0 +1,18 @@ +book: + pictures: yes + datadir: content_datadir + lang: en +chords: + repeatchords: no + diagramreminder: all + +content: [ + ["section", "Test of section"], + ["sorted"], + ["songsection", "Test of song section"], + ["cwd(content_datadir/content)", + "song.csg", "song.tsg", + ["tex", "foo.tex"] + ], + ["include", "include.sbc"] + ] \ No newline at end of file diff --git a/test/test_songbook/content.tex.control b/test/test_songbook/content.tex.control new file mode 100644 index 00000000..a0b501fb --- /dev/null +++ b/test/test_songbook/content.tex.control @@ -0,0 +1,159 @@ + + + + + + +%% Automatically generated document. +%% You may edit this file but all changes will be overwritten. +%% If you want to change this document, have a look at +%% the templating system. +%% +%% Generated using Songbook + +\makeatletter +\def\input@path{ % + {@TEST_FOLDER@/content_datadir/latex/} % + {@TEST_FOLDER@/latex/} % + {@DATA_FOLDER@/latex/} % +} +\makeatother + +\documentclass[ + ]{article} + +\usepackage[ +chorded, +pictures, +diagram, +guitar, + ]{patacrep} + +\usepackage{lmodern} + + +\PassOptionsToPackage{english}{babel} +\PassOptionsToPackage{english}{babel} +\usepackage[english]{babel} +\lang{english} + +\usepackage{graphicx} +\graphicspath{ % + {@TEST_FOLDER@/content_datadir/} % + {@TEST_FOLDER@/} % + {@DATA_FOLDER@/} % +} + + +\makeatletter +\@ifpackageloaded{hyperref}{}{ + \usepackage{url} + \newcommand{\phantomsection}{} + \newcommand{\hyperlink}[2]{#2} + \newcommand{\href}[2]{\expandafter\url\expandafter{#1}} +} +\makeatother + + +\usepackage{chords} + +\title{Guitar songbook} +\author{The Patacrep Team} + +\newindex{titleidx}{content_title} +\newauthorindex{authidx}{content_auth} + +\authignoreword{unknown} +\authbyword{by} +\authsepword{and} + +\notenamesout{A}{B}{C}{D}{E}{F}{G} + + +\begin{document} + +\maketitle + + +\showindex{\songindexname}{titleidx} +\showindex{\authorindexname}{authidx} + +% list of chords +\ifchorded + \ifdiagram + \phantomsection + \addcontentsline{toc}{section}{\chordlistname} + \chords + \fi +\fi + +\phantomsection +\addcontentsline{toc}{section}{\songlistname} + + +\section{Test of section} + +\begin{songs}{titleidx,authidx} +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% songs/./song.tsg + +\import{@TEST_FOLDER@/content_datadir/songs/}{song.tsg} + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% songs/./song.csg + +\selectlanguage{english} + +\beginsong{This is a song}[ + by={ + }, +] + + + + +\begin{verse} + Foo +\end{verse} + +\endsong + +\end{songs} + +\songsection{Test of song section} + +\begin{songs}{titleidx,authidx} +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% content_datadir/content/song.csg + +\selectlanguage{english} + +\beginsong{Yet another song}[ + by={ + }, +] + + +\begin{verse} + Baz +\end{verse} + +\endsong + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% content_datadir/content/song.tsg + +\import{@TEST_FOLDER@/content_datadir/content/}{song.tsg} + +\end{songs} + +\input{content_datadir/content/foo.tex} + + +\section{This is an included section} + + + + + +\end{document} diff --git a/test/test_songbook/content_datadir/content/foo.tex b/test/test_songbook/content_datadir/content/foo.tex new file mode 100644 index 00000000..38310a6c --- /dev/null +++ b/test/test_songbook/content_datadir/content/foo.tex @@ -0,0 +1 @@ +This is a \LaTeX{} file. diff --git a/test/test_songbook/content_datadir/content/inter.isg b/test/test_songbook/content_datadir/content/inter.isg new file mode 100644 index 00000000..ac68b513 --- /dev/null +++ b/test/test_songbook/content_datadir/content/inter.isg @@ -0,0 +1,5 @@ +\begin{intersong} +\sortassong{}[by={The Patacrep Team}] + +This is another example of an intersong. +\end{intersong} diff --git a/test/test_songbook/content_datadir/content/song.csg b/test/test_songbook/content_datadir/content/song.csg new file mode 100644 index 00000000..02213b60 --- /dev/null +++ b/test/test_songbook/content_datadir/content/song.csg @@ -0,0 +1,2 @@ +{title : Yet another song} +Baz diff --git a/test/test_songbook/content_datadir/content/song.tsg b/test/test_songbook/content_datadir/content/song.tsg new file mode 100644 index 00000000..f8d1de53 --- /dev/null +++ b/test/test_songbook/content_datadir/content/song.tsg @@ -0,0 +1,3 @@ +\beginsong{One last song} + Tagada +\endsong diff --git a/test/test_songbook/content_datadir/songs/include.sbc b/test/test_songbook/content_datadir/songs/include.sbc new file mode 100644 index 00000000..fefa39b1 --- /dev/null +++ b/test/test_songbook/content_datadir/songs/include.sbc @@ -0,0 +1 @@ +[["section", "This is an included section"]] diff --git a/test/test_songbook/content_datadir/songs/inter.isg b/test/test_songbook/content_datadir/songs/inter.isg new file mode 100644 index 00000000..e58fa8ed --- /dev/null +++ b/test/test_songbook/content_datadir/songs/inter.isg @@ -0,0 +1,5 @@ +\begin{intersong} +\sortassong{}[by={The Patacrep Team}] + +This is an example of an intersong. +\end{intersong} diff --git a/test/test_songbook/content_datadir/songs/song.csg b/test/test_songbook/content_datadir/songs/song.csg new file mode 100644 index 00000000..0a4f139c --- /dev/null +++ b/test/test_songbook/content_datadir/songs/song.csg @@ -0,0 +1,3 @@ +{title : This is a song} + +Foo diff --git a/test/test_songbook/content_datadir/songs/song.tsg b/test/test_songbook/content_datadir/songs/song.tsg new file mode 100644 index 00000000..e2455c31 --- /dev/null +++ b/test/test_songbook/content_datadir/songs/song.tsg @@ -0,0 +1,3 @@ +\beginsong{This is another song} + Bar. +\endsong diff --git a/texlive_packages.txt b/texlive_packages.txt index 9e354c83..7a3e79cb 100644 --- a/texlive_packages.txt +++ b/texlive_packages.txt @@ -6,6 +6,7 @@ babel-italian babel-latin babel-portuges babel-spanish +ctablestack etoolbox fancybox framed @@ -16,6 +17,7 @@ mptopdf ms pgf tipa +unicode-data url xcolor xstring