# book_snippets.py # -*- coding: utf-8 -*- # # This file is part of LilyPond, the GNU music typesetter. # # Copyright (C) 2010--2022 Reinhold Kainhofer # # LilyPond is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # LilyPond is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with LilyPond. If not, see . import copy import hashlib import os import re import shutil import subprocess import sys import book_base import lilylib as ly #################################################################### # Snippet option handling #################################################################### # # Is this pythonic? Personally, I find this rather #define-nesque. --hwn # # Global definitions: AFTER = 'after' ALT = 'alt' BEFORE = 'before' DOCTITLE = 'doctitle' EXAMPLEINDENT = 'exampleindent' FILENAME = 'filename' FILTER = 'filter' FRAGMENT = 'fragment' LAYOUT = 'layout' LINE_WIDTH = 'line-width' NOFRAGMENT = 'nofragment' NOGETTEXT = 'nogettext' NOINDENT = 'noindent' INDENT = 'indent' INLINE = 'inline' NORAGGED_RIGHT = 'noragged-right' NOTES = 'body' NOTIME = 'notime' OUTPUT = 'output' OUTPUTIMAGE = 'outputimage' PAPER = 'paper' PAPER_HEIGHT = 'paper-height' PAPERSIZE = 'papersize' PAPER_WIDTH = 'paper-width' PARA = 'para' PREAMBLE = 'preamble' PRINTFILENAME = 'printfilename' QUOTE = 'quote' RAGGED_RIGHT = 'ragged-right' RELATIVE = 'relative' STAFFSIZE = 'staffsize' TEXIDOC = 'texidoc' VERBATIM = 'verbatim' VERSION = 'lilypondversion' # NOTIME and NOGETTEXT have no opposite so they aren't part of this # dictionary. no_options = { NOFRAGMENT: FRAGMENT, NOINDENT: INDENT, } # Options that have no impact on processing by lilypond (or --process # argument) PROCESSING_INDEPENDENT_OPTIONS = ( ALT, NOGETTEXT, VERBATIM, TEXIDOC, DOCTITLE, VERSION, PRINTFILENAME) # Options without a pattern in snippet_options. simple_options = [ EXAMPLEINDENT, FRAGMENT, INLINE, NOFRAGMENT, NOGETTEXT, NOINDENT, PAPER_HEIGHT, PAPER_WIDTH, PRINTFILENAME, DOCTITLE, TEXIDOC, VERBATIM, FILENAME, ALT ] #################################################################### # LilyPond templates for the snippets #################################################################### snippet_options = { ## NOTES: { RELATIVE: r'''\relative c%(relative_quotes)s''', }, ## # TODO: Remove the 1mm additional padding in the line-width # once lilypond creates tighter cropped images! PAPER: { PAPERSIZE: r'''#(set-paper-size %(papersize)s)''', INDENT: r'''indent = %(indent)s''', LINE_WIDTH: r'''line-width = %(line-width)s %% offset the left padding, also add 1mm as lilypond creates cropped %% images with a little space on the right line-width = #(- line-width (* mm %(padding_mm)f) (* mm 1))''', QUOTE: r'''line-width = %(line-width)s - 2.0 * %(exampleindent)s %% offset the left padding, also add 1mm as lilypond creates cropped %% images with a little space on the right line-width = #(- line-width (* mm %(padding_mm)f) (* mm 1))''', RAGGED_RIGHT: r'''ragged-right = ##t''', NORAGGED_RIGHT: r'''ragged-right = ##f''', }, ## LAYOUT: { NOTIME: r''' \context { \Score timing = ##f } \context { \Staff \remove Time_signature_engraver }''', }, ## PREAMBLE: { STAFFSIZE: r'''#(set-global-staff-size %(staffsize)s)''', }, } def classic_lilypond_book_compatibility(key, value): if key == 'lilyquote': return (QUOTE, value) if key == 'singleline' and value is None: return (RAGGED_RIGHT, None) m = re.search(r'relative\s*([-0-9])', key) if m: return ('relative', m.group(1)) m = re.match('([0-9]+)pt', key) if m: return ('staffsize', m.group(1)) if key == 'indent' or key == 'line-width': m = re.match('([-.0-9]+)(cm|in|mm|pt|bp|staffspace)', value) if m: f = float(m.group(1)) return (key, '%f\\%s' % (f, m.group(2))) return (None, None) PREAMBLE_LY = r'''%%%% Generated by lilypond-book %%%% Options: [%(option_string)s] \include "lilypond-book-preamble.ly" %% **************************************************************** %% Start cut-&-pastable-section %% **************************************************************** %(padding_mm_string)s %(preamble_string)s \paper { %(paper_string)s } \layout { %(layout_string)s } ''' FULL_LY = ''' %% **************************************************************** %% ly snippet: %% **************************************************************** %(code)s %% **************************************************************** %% end ly snippet %% **************************************************************** ''' FRAGMENT_LY = r''' %(notes_string)s { %% **************************************************************** %% ly snippet contents follows: %% **************************************************************** %(code)s %% **************************************************************** %% end ly snippet %% **************************************************************** } ''' #################################################################### # Helper functions #################################################################### def ps_page_count(ps_name): # Open .ps file in binary mode, it might contain embedded fonts. header = open(ps_name, 'rb').read(1024) m = re.search(b'\n%%Pages: ([0-9]+)', header) if m: return int(m.group(1)) return 0 ly_var_def_re = re.compile(r'^([a-zA-Z]+)[\t ]*=', re.M) ly_comment_re = re.compile(r'(%+[\t ]*)(.*)$', re.M) ly_context_id_re = re.compile('\\\\(?:new|context)\\s+(?:[a-zA-Z]*?(?:Staff\ (?:Group)?|Voice|FiguredBass|FretBoards|Names|Devnull))\\s+=\\s+"?([a-zA-Z]+)"?\\s+') ly_dimen_re = re.compile(r'^([0-9]+\.?[0-9]*|\.[0-9]+)\s*\\(cm|mm|in|pt|bp)$') def ly_comment_gettext(t, m): return m.group(1) + t(m.group(2)) class CompileError(Exception): pass #################################################################### # Snippet classes #################################################################### class Chunk: def replacement_text(self): return '' def filter_text(self): return self.replacement_text() def is_plain(self): return False def __init__(self): self._output_fullpath = '' def set_output_fullpath(self, out_fp: str): self._output_fullpath = out_fp def output_fullpath(self) -> str: """The output file path that this chunk belongs to.""" return self._output_fullpath class Substring (Chunk): """A string that does not require extra memory.""" def __init__(self, source, start, end, line_number): self.source = source self.start = start self.end = end self.line_number = line_number self.override_text = None def is_plain(self): return True def replacement_text(self): if self.override_text: return self.override_text else: return self.source[self.start:self.end] class Snippet (Chunk): def __init__(self, type, match, formatter, line_number, global_options): self.type = type self.match = match self.checksum = 0 self.option_dict = {} self.formatter = formatter self.line_number = line_number self.global_options = global_options self.replacements = {'program_version': global_options.information["program_version"], 'program_name': ly.program_name} # return a shallow copy of the replacements, so the caller can modify # it locally without interfering with other snippet operations def get_replacements(self): return copy.copy(self.replacements) def replacement_text(self): return self.match.group('match') def substring(self, s): return self.match.group(s) def __repr__(self): return repr(self.__class__) + ' type = ' + self.type class IncludeSnippet (Snippet): def processed_filename(self): f = self.substring('filename') return os.path.splitext(f)[0] + self.formatter.default_extension def replacement_text(self): s = self.match.group('match') f = self.substring('filename') return re.sub(f, self.processed_filename(), s) class LilypondSnippet (Snippet): def __init__(self, type, match, formatter, line_number, global_options): Snippet.__init__(self, type, match, formatter, line_number, global_options) self.filename = '' self.ext = '.ly' os = match.group('options') self.parse_snippet_options(os, self.type) def snippet_options(self): return [] def verb_ly_gettext(self, s): lang = self.formatter.document_language if not lang: return s try: t = langdefs.translation[lang] except: return s # TODO: this part is flawed. langdefs is not imported, # so the line under `try:` raises a NameError, which is # catched by the too broad `except:` that was likely meant # only to except KeyError. As a result, this function # always returns `s` and the below code is never executed. # Investigate what the intent was and change the code accordingly # if possible. --jas s = ly_comment_re.sub(lambda m: ly_comment_gettext(t, m), s) if langdefs.LANGDICT[lang].enable_ly_identifier_l10n: for v in ly_var_def_re.findall(s): s = re.sub(r"(?m)(? 0: relative_quotes += "'" * relative if INLINE in override: # For inline images, try to make left and right padding equal, # ignoring the `--left-padding` value. # # URGH Value 0 makes LilyPond apply no left padding at all, but # still having some right padding. This is a bug (#6116). override['padding_mm'] = 0.0001 # put paper-size first, if it exists for i, elem in enumerate(compose_dict[PAPER]): if elem.startswith("#(set-paper-size"): compose_dict[PAPER].insert(0, compose_dict[PAPER].pop(i)) break paper_string = '\n '.join(compose_dict[PAPER]) % override layout_string = '\n '.join(compose_dict[LAYOUT]) % override notes_string = '\n '.join(compose_dict[NOTES]) % vars() preamble_string = '\n '.join(compose_dict[PREAMBLE]) % override padding_mm = override['padding_mm'] if padding_mm != 0: padding_mm_string = \ "#(ly:set-option 'eps-box-padding %f)" % padding_mm else: padding_mm_string = "" d = globals().copy() d.update(locals()) d.update(self.global_options.information) if FRAGMENT in self.option_dict: body = FRAGMENT_LY else: body = FULL_LY return (PREAMBLE_LY + body) % d def get_checksum(self): if not self.checksum: # We only want to calculate the hash based on the snippet # code plus fragment options relevant to processing by # lilypond, not the snippet + preamble hash = hashlib.md5(self.relevant_contents( self.ly()).encode('utf-8')) for option in self.get_outputrelevant_option_strings(): hash.update(option.encode('utf-8')) # let's not create too long names. self.checksum = hash.hexdigest()[:10] return self.checksum def basename(self): cs = self.get_checksum() name = os.path.join(cs[:2], 'lily-%s' % cs[2:]) return name final_basename = basename def write_ly(self): base = self.basename() path = os.path.join(self.global_options.lily_output_dir, base) directory = os.path.split(path)[0] os.makedirs(directory, exist_ok=True) filename = path + '.ly' if os.path.exists(filename): existing = open(filename, 'r', encoding='utf-8').read() if self.relevant_contents(existing) != self.relevant_contents(self.full_ly()): ly.warning("%s: duplicate filename but different contents of original file,\n\ printing diff against existing file." % filename) encoded = self.full_ly().encode('utf-8') cmd = 'diff -u %s -' % filename sys.stderr.write(self.filter_pipe( encoded, cmd).decode('utf-8')) else: out = open(filename, 'w', encoding='utf-8') out.write(self.full_ly()) def relevant_contents(self, ly): return re.sub(r'\\(version|sourcefileline|sourcefilename)[^\n]*\n', '', ly) def link_all_output_files(self, output_dir, destination): existing, missing = self.all_output_files(output_dir) if missing: ly.error(_('Missing files: %s') % ', '.join(missing)) raise CompileError(self.basename()) for name in existing: if (self.global_options.use_source_file_names and isinstance(self, LilypondFileSnippet)): base, ext = os.path.splitext(name) components = base.split('-') # ugh, assume filenames with prefix with one dash (lily-xxxx) if len(components) > 2: base_suffix = '-' + components[-1] else: base_suffix = '' final_name = self.final_basename() + base_suffix + ext else: final_name = name try: os.unlink(os.path.join(destination, final_name)) except OSError: pass src = os.path.join(output_dir, name) dst = os.path.join(destination, final_name) dst_path = os.path.split(dst)[0] os.makedirs(dst_path, exist_ok=True) try: if (self.global_options.use_source_file_names and isinstance(self, LilypondFileSnippet)): content = open(src, 'rb').read() basename = self.basename().encode('utf-8') final_basename = self.final_basename().encode('utf-8') content = content.replace(basename, final_basename) open(dst, 'wb').write(content) else: try: os.link(src, dst) except AttributeError: shutil.copyfile(src, dst) except (IOError, OSError): ly.error(_('Could not overwrite file %s') % dst) raise CompileError(self.basename()) def additional_files_to_consider(self, base, full): return [] def additional_files_required(self, base, full): result = [] if self.ext != '.ly': result.append(base + self.ext) return result def all_output_files(self, output_dir): """Return all files generated in lily_output_dir, a set. output_dir_files is the list of files in the output directory. """ result = set() missing = set() base = self.basename() full = os.path.join(output_dir, base) def consider_file(name): if os.path.isfile(os.path.join(output_dir, name)): result.add(name) def require_file(name): if os.path.isfile(os.path.join(output_dir, name)): result.add(name) else: missing.add(name) # UGH - junk self.global_options skip_lily = self.global_options.skip_lilypond_run require_file(base + '.ly') if not skip_lily: require_file(base + '-systems.count') if 'dseparate-log-file' in self.global_options.process_cmd: require_file(base + '.log') for f in [base + '.tex', base + '.eps', base + '.pdf', base + '.texidoc', base + '.doctitle', base + '-systems.texi', base + '-systems.tex', base + '-systems.pdftexi']: consider_file(f) if self.formatter.document_language: for f in [base + '.texidoc' + self.formatter.document_language, base + '.doctitle' + self.formatter.document_language]: consider_file(f) required_files = self.formatter.required_files( self, base, full, result) for f in required_files: require_file(f) system_count = 0 if not skip_lily and not missing: system_count = int(open(full + '-systems.count', encoding="utf8").read()) for number in range(1, system_count + 1): systemfile = '%s-%d' % (base, number) require_file(systemfile + '.eps') consider_file(systemfile + '.pdf') consider_file(systemfile + '.png') for f in self.additional_files_to_consider(base, full): consider_file(f) for f in self.additional_files_required(base, full): require_file(f) return (result, missing) def is_outdated(self, output_dir): found, missing = self.all_output_files(output_dir) return missing def filter_pipe(self, input: bytes, cmd: str) -> bytes: """Pass input through cmd, and return the result. Args: input: the input cmd: a shell command Returns: the filtered result """ ly.debug_output(_("Running through filter `%s'") % cmd, True) closefds = True if sys.platform == "mingw32": closefds = False p = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=closefds) (stdin, stdout, stderr) = (p.stdin, p.stdout, p.stderr) stdin.write(input) status = stdin.close() if not status: status = 0 output = stdout.read() status = stdout.close() # assume stderr always is text err = stderr.read().decode('utf-8') if not status: status = 0 signal = 0x0f & status if status or (not output and err): exit_status = status >> 8 ly.error(_("`%s' failed (%d)") % (cmd, exit_status)) ly.error(_("The ly.error log is as follows:")) sys.stderr.write(err) exit(status) ly.debug_output('\n') return output def get_snippet_code(self) -> str: return self.substring('code') def filter_text(self): """Run snippet bodies through a command (say: convert-ly). """ code = self.get_snippet_code().encode('utf-8') output = self.filter_pipe(code, self.global_options.filter_cmd) options = self.match.group('options') if options is None: options = '' d = { 'code': output.decode('utf-8'), 'options': options, } return self.formatter.output_simple_replacements(FILTER, d) def replacement_text(self): base = self.final_basename() return self.formatter.snippet_output(base, self) def get_images(self): base = self.final_basename() outdir = self.global_options.lily_output_dir single_base= '%s.png' % base single = os.path.join(outdir, single_base) multiple = os.path.join(outdir, '%s-page1.png' % base) images = (single_base,) if (os.path.exists(multiple) and (not os.path.exists(single) or (os.stat(multiple)[stat.ST_MTIME] > os.stat(single)[stat.ST_MTIME]))): count = ps_page_count(os.path.join(outdir, '%s.eps' % base)) images = ['%s-page%d.png' % (base, page) for page in range(1, count+1)] images = tuple(images) return images re_begin_verbatim = re.compile(r'\s+%.*?begin verbatim.*\n*', re.M) re_end_verbatim = re.compile(r'\s+%.*?end verbatim.*$', re.M) class LilypondFileSnippet (LilypondSnippet): def __init__(self, type, match, formatter, line_number, global_options): LilypondSnippet.__init__( self, type, match, formatter, line_number, global_options) self.filename = self.substring('filename') self.contents = None def get_contents(self) -> bytes: if not self.contents: path = book_base.find_file(self.filename, self.global_options.include_path) self.contents = open(path, 'rb').read() return self.contents def get_snippet_code(self) -> str: return self.get_contents().decode('utf-8') def verb_ly(self): s = self.get_snippet_code() s = re_begin_verbatim.split(s)[-1] s = re_end_verbatim.split(s)[0] if not NOGETTEXT in self.option_dict: s = self.verb_ly_gettext(s) if not s.endswith('\n'): s += '\n' return s def ly(self): name = self.filename return ('\\sourcefilename \"%s\"\n\\sourcefileline 0\n%s' % (name, self.get_snippet_code())) def final_basename(self): if self.global_options.use_source_file_names: base = os.path.splitext(os.path.basename(self.filename))[0] return base else: return self.basename() class MusicXMLFileSnippet (LilypondFileSnippet): def __init__(self, type, match, formatter, line_number, global_options): LilypondFileSnippet.__init__( self, type, match, formatter, line_number, global_options) self.compressed = False self.converted_ly = None self.ext = os.path.splitext(os.path.basename(self.filename))[1] self.musicxml_options_dict = { 'verbose': '--verbose', 'lxml': '--lxml', 'compressed': '--compressed', 'relative': '--relative', 'absolute': '--absolute', 'no-articulation-directions': '--no-articulation-directions', 'no-rest-positions': '--no-rest-positions', 'no-page-layout': '--no-page-layout', 'no-beaming': '--no-beaming', 'language': '--language', } def snippet_options(self): return list(self.musicxml_options_dict.keys()) def convert_from_musicxml(self): name = self.filename xml2ly_option_list = [] for (key, value) in list(self.option_dict.items()): cmd_key = self.musicxml_options_dict.get(key, None) if cmd_key is None: continue if value is None: xml2ly_option_list.append(cmd_key) else: xml2ly_option_list.append(cmd_key + '=' + value) if ('.mxl' in name) and ('--compressed' not in xml2ly_option_list): xml2ly_option_list.append('--compressed') self.compressed = True opts = " ".join(xml2ly_option_list) ly.progress(_("Converting MusicXML file `%s'...") % self.filename) cmd = 'musicxml2ly %s --out=- - ' % opts ly_code = self.filter_pipe(self.get_contents(), cmd).decode('utf-8') return ly_code def ly(self): if self.converted_ly is None: self.converted_ly = self.convert_from_musicxml() name = self.filename return ('\\sourcefilename \"%s\"\n\\sourcefileline 0\n%s' % (name, self.converted_ly)) def write_ly(self): base = self.basename() path = os.path.join(self.global_options.lily_output_dir, base) directory = os.path.split(path)[0] os.makedirs(directory, exist_ok=True) # First write the XML to a file (so we can link it!) if self.compressed: xmlfilename = path + '.mxl' else: xmlfilename = path + '.xml' if os.path.exists(xmlfilename): diff_against_existing = self.filter_pipe( self.get_contents(), 'diff -u %s - ' % xmlfilename) if diff_against_existing: ly.warning(_("%s: duplicate filename but different contents of original file,\n\ printing diff against existing file.") % xmlfilename) sys.stderr.write(diff_against_existing.decode('utf-8')) else: out = open(xmlfilename, 'wb') out.write(self.get_contents()) out.close() # also write the converted lilypond filename = path + '.ly' if os.path.exists(filename): encoded = self.full_ly().encode('utf-8') cmd = 'diff -u %s -' % filename diff_against_existing = self.filter_pipe( encoded, cmd).decode('utf-8') if diff_against_existing: ly.warning(_("%s: duplicate filename but different contents of converted lilypond file,\n\ printing diff against existing file.") % filename) sys.stderr.write(diff_against_existing.decode('utf-8')) else: out = open(filename, 'w', encoding='utf-8') out.write(self.full_ly()) out.close() class LilyPondVersionString (Snippet): """A string that does not require extra memory.""" def __init__(self, type, match, formatter, line_number, global_options): Snippet.__init__(self, type, match, formatter, line_number, global_options) def replacement_text(self): return self.formatter.output_simple(self.type, self) snippet_type_to_class = { 'lilypond_file': LilypondFileSnippet, 'lilypond_block': LilypondSnippet, 'lilypond': LilypondSnippet, 'include': IncludeSnippet, 'lilypondversion': LilyPondVersionString, 'musicxml_file': MusicXMLFileSnippet, }