# file docbook_copy.py # This file is part of LyX, the document processor. # Licence details can be found in the file COPYING. # # \author Thibaut Cuvelier # # Full author contact details are available in file CREDITS # Usage: # python docbook_copy.py lilypond_book_command in.docbook out.docbook # This script copies the original DocBook file (directly produced by LyX) to the output DocBook file, # potentially applying a post-processing step. For now, the only implemented post-processing step is # LilyPond. # lilypond_book_command is either directly the binary to call OR the equivalent Python script that is # not directly executable. # /!\ The original file may be modified by this script! import subprocess import os import os.path import re import shutil import sys class DocBookCopier: def __init__(self, args): # Parse the command line. self.lilypond_command = args[1] self.in_file = args[2] self.out_file = args[3] # Compute a few things from the raw parameters. self.in_folder = os.path.split(self.in_file)[0] self.out_folder = os.path.split(self.out_file)[0] self.in_lily_file = self.in_file.replace('.xml', '.lyxml') self.has_lilypond = self.lilypond_command not in {'', 'none'} self.lilypond_folder = os.path.split(self.lilypond_command)[0] if self.has_lilypond else '' self.do_lilypond_processing = self.has_lilypond and self.in_file_needs_lilypond() # Help debugging. print('>> Given arguments:') print('>> LilyPond: ' + ('present' if self.has_lilypond else 'not found') + '.') print('>> LilyPond callable as: ' + self.lilypond_command + '.') print('>> LilyPond path: ' + self.lilypond_folder + '.') print('>> Input file: ' + self.in_file + '.') print('>> Output file: ' + self.out_file + '.') print('>> Input folder: ' + self.in_folder + '.') print('>> Output folder: ' + self.out_folder + '.') def in_file_needs_lilypond(self): # Really tailored to the kind of output lilypond.module makes (in lib/layouts). with open(self.in_file) as f: return "language='lilypond'" in f.read() def preprocess_input_for_lilypond(self): # LilyPond requires that its input file has the .lyxml extension. Due to a bug in LilyPond, # use " instead of ' to encode XML attributes. # Bug report: https://gitlab.com/lilypond/lilypond/-/issues/6185 # Typical transformation: # Fixed by 2.23.4. # FROM: language='lilypond' role='fragment verbatim staffsize=16 ragged-right relative=2' # TO: language="lilypond" role="fragment verbatim staffsize=16 ragged-right relative=2" # Another problem to fix: the output is in XML, with some characters encoded as XML # entities. For instance, this could be in a LilyPond snippet: # \new PianoStaff << # instead of: # \new PianoStaff << # (More complete example: # https://lilypond.org/doc/v2.23/Documentation/learning/piano-centered-lyrics.) # This issue must be fixed by LilyPond, as any change in this part would make the XML # file invalid. # Bug report: https://gitlab.com/lilypond/lilypond/-/issues/6204 with open(self.in_file, encoding='utf-8') as f, open(self.in_lily_file, 'w', encoding='utf-8') as f_lily: for line in f: if "language='lilypond'" in line: line = re.sub( '.*?)\')?>', '', line ) f_lily.write(line) os.unlink(self.in_file) def postprocess_output_for_lilypond(self): # Major problem: LilyPond used to output the LilyPond code outside the image, which is then always displayed # before the image (instead of only the generated image). # Bug report: https://gitlab.com/lilypond/lilypond/-/issues/6186 # No more necessary with the new version of LilyPond (2.23.4). No efficient way to decide how to post-process # for previous versions of LilyPond. Basically, it does not make sense to post-process. pass def call_lilypond(self): # LilyPond requires that its input file has the .lyxml extension (plus bugs in LilyPond). print('>> Rewriting ' + self.in_file) print('>> as ' + self.in_lily_file + '.') self.preprocess_input_for_lilypond() # Add LilyPond to the PATH. lilypond-book uses a direct call to lilypond from the PATH. if os.path.isdir(self.lilypond_folder): os.environ['PATH'] += os.pathsep + self.lilypond_folder # Make LilyPond believe it is working from the temporary LyX directory. Otherwise, it tries to find files # starting from LyX's working directory... LilyPond bug, most likely. # https://lists.gnu.org/archive/html/bug-lilypond/2021-09/msg00041.html os.chdir(self.in_folder) # Start LilyPond on the copied file. First test the binary, then check if adding Python helps. command_args = ['--format=docbook', '--output=' + self.in_folder, self.in_lily_file] command_raw = [self.lilypond_command] + command_args command_python = ['python', self.lilypond_command] + command_args print('>> Running LilyPond.') sys.stdout.flush() # So that the LilyPond output is at the right place in the logs. failed = True exceptions = [] for cmd in [command_raw, command_python]: try: subprocess.check_call(cmd, stdout=sys.stdout.fileno(), stderr=sys.stdout.fileno()) print('>> Success running LilyPond with ') print('>> ' + str(cmd)) failed = False except (subprocess.CalledProcessError, OSError) as e: exceptions.append((cmd, e)) if failed: print('>> Error from LilyPond. The successive calls were:') for (i, pair) in enumerate(exceptions): exc = pair[0] cmd = pair[1] print('>> (' + i + ') Error from trying ' + str(cmd) + ':') print('>> (' + i + ') ' + str(exc)) if failed: sys.exit(1) # LilyPond has a distressing tendency to leave the raw LilyPond code in the new file. self.postprocess_output_for_lilypond() # Now, in_file should have the clean LilyPond-processed contents. def copy_lilypond_generated_images(self): # LilyPond generates a lot of files in LyX' temporary folder, within the ff folder: source LilyPond files # for each snippet to render, images in several formats. in_generated_images_folder = os.path.join(self.in_folder, 'ff') out_generated_images_folder = os.path.join(self.out_folder, 'ff') if not os.path.isdir(out_generated_images_folder): os.mkdir(out_generated_images_folder) for img in os.listdir(in_generated_images_folder): if not img.endswith('.png') and not img.endswith('.pdf'): continue shutil.copyfile( os.path.join(in_generated_images_folder, img), os.path.join(out_generated_images_folder, img), ) def copy(self): # Apply LilyPond to the original file if available and needed. if self.do_lilypond_processing: print('>> The input file needs a LilyPond pass and LilyPond is available.') self.call_lilypond() # Perform the actual copy: both the modified XML file and the generated images, if LilyPond is used. shutil.copyfile(self.in_file, self.out_file) if self.do_lilypond_processing: self.copy_lilypond_generated_images() if __name__ == '__main__': if len(sys.argv) != 4: print(f'Exactly four arguments are expected, only {len(sys.argv)} found: {sys.argv}.') sys.exit(1) DocBookCopier(sys.argv).copy()