]> git.lyx.org Git - features.git/blob - lib/scripts/docbook_copy.py
Indicate see[also] refs in label and outliner
[features.git] / lib / scripts / docbook_copy.py
1 # -*- coding: utf-8 -*-
2
3 # file docbook_copy.py
4 # This file is part of LyX, the document processor.
5 # Licence details can be found in the file COPYING.
6 #
7 # \author Thibaut Cuvelier
8 #
9 # Full author contact details are available in file CREDITS
10
11 # Usage:
12 #   python docbook_copy.py lilypond_book_command in.docbook out.docbook
13 # This script copies the original DocBook file (directly produced by LyX) to the output DocBook file,
14 # potentially applying a post-processing step. For now, the only implemented post-processing step is
15 # LilyPond.
16 # lilypond_book_command is either directly the binary to call OR the equivalent Python script that is
17 # not directly executable.
18 # /!\ The original file may be modified by this script!
19
20
21 import subprocess
22 import os
23 import os.path
24 import re
25 import shutil
26 import sys
27
28
29 class DocBookCopier:
30     def __init__(self, args):
31
32         # Parse the command line.
33         self.lilypond_command = args[1]
34         self.in_file = args[2]
35         self.out_file = args[3]
36
37         # Compute a few things from the raw parameters.
38         self.in_folder = os.path.split(self.in_file)[0]
39         self.out_folder = os.path.split(self.out_file)[0]
40
41         self.in_lily_file = self.in_file.replace('.xml', '.lyxml')
42         self.has_lilypond = self.lilypond_command not in {'', 'none'}
43         self.lilypond_folder = os.path.split(self.lilypond_command)[0] if self.has_lilypond else ''
44         self.do_lilypond_processing = self.has_lilypond and self.in_file_needs_lilypond()
45
46         # Help debugging.
47         print('>> Given arguments:')
48         print('>> LilyPond: ' + ('present' if self.has_lilypond else 'not found') + '.')
49         print('>> LilyPond callable as: ' + self.lilypond_command + '.')
50         print('>> LilyPond path: ' + self.lilypond_folder + '.')
51         print('>> Input file: ' + self.in_file + '.')
52         print('>> Output file: ' + self.out_file + '.')
53         print('>> Input folder: ' + self.in_folder + '.')
54         print('>> Output folder: ' + self.out_folder + '.')
55
56     def in_file_needs_lilypond(self):
57         # Really tailored to the kind of output lilypond.module makes (in lib/layouts).
58         with open(self.in_file, 'r') as f:
59             return "language='lilypond'" in f.read()
60
61     def preprocess_input_for_lilypond(self):
62         # LilyPond requires that its input file has the .lyxml extension. Due to a bug in LilyPond,
63         # use " instead of ' to encode XML attributes.
64         # Bug report: https://gitlab.com/lilypond/lilypond/-/issues/6185
65         # Typical transformation:
66         # Fixed by 2.23.4.
67         #     FROM:  language='lilypond' role='fragment verbatim staffsize=16 ragged-right relative=2'
68         #     TO:    language="lilypond" role="fragment verbatim staffsize=16 ragged-right relative=2"
69
70         # Another problem to fix: the output is in XML, with some characters encoded as XML
71         # entities. For instance, this could be in a LilyPond snippet:
72         #     \new PianoStaff <<
73         # instead of:
74         #     \new PianoStaff <<
75         # (More complete example:
76         # https://lilypond.org/doc/v2.23/Documentation/learning/piano-centered-lyrics.)
77         # This issue must be fixed by LilyPond, as any change in this part would make the XML
78         # file invalid.
79         # Bug report: https://gitlab.com/lilypond/lilypond/-/issues/6204
80         with open(self.in_file, 'r', encoding='utf-8') as f, open(self.in_lily_file, 'w', encoding='utf-8') as f_lily:
81             for line in f:
82                 if "language='lilypond'" in line:
83                     line = re.sub(
84                         '<programlisting\\s+language=\'lilypond\'.*?(role=\'(?P<options>.*?)\')?>',
85                         '<programlisting language="lilypond" role="\\g<options>">',
86                         line
87                     )
88                 f_lily.write(line)
89         os.unlink(self.in_file)
90
91     def postprocess_output_for_lilypond(self):
92         # Major problem: LilyPond used to output the LilyPond code outside the image, which is then always displayed
93         # before the image (instead of only the generated image).
94         # Bug report: https://gitlab.com/lilypond/lilypond/-/issues/6186
95         # No more necessary with the new version of LilyPond (2.23.4). No efficient way to decide how to post-process
96         # for previous versions of LilyPond. Basically, it does not make sense to post-process.
97         pass
98
99     def call_lilypond(self):
100         # LilyPond requires that its input file has the .lyxml extension (plus bugs in LilyPond).
101         print('>> Rewriting ' + self.in_file)
102         print('>> as ' + self.in_lily_file + '.')
103         self.preprocess_input_for_lilypond()
104
105         # Add LilyPond to the PATH. lilypond-book uses a direct call to lilypond from the PATH.
106         if os.path.isdir(self.lilypond_folder):
107             os.environ['PATH'] += os.pathsep + self.lilypond_folder
108
109         # Make LilyPond believe it is working from the temporary LyX directory. Otherwise, it tries to find files
110         # starting from LyX's working directory... LilyPond bug, most likely.
111         # https://lists.gnu.org/archive/html/bug-lilypond/2021-09/msg00041.html
112         os.chdir(self.in_folder)
113
114         # Start LilyPond on the copied file. First test the binary, then check if adding Python helps.
115         command_args = ['--format=docbook', '--output=' + self.in_folder, self.in_lily_file]
116         command_raw = [self.lilypond_command] + command_args
117         command_python = ['python', self.lilypond_command] + command_args
118
119         print('>> Running LilyPond.')
120         sys.stdout.flush()  # So that the LilyPond output is at the right place in the logs.
121
122         failed = True
123         exceptions = []
124         for cmd in [command_raw, command_python]:
125             try:
126                 subprocess.check_call(cmd, stdout=sys.stdout.fileno(), stderr=sys.stdout.fileno())
127                 print('>> Success running LilyPond with ')
128                 print('>> ' + str(cmd))
129                 failed = False
130             except (subprocess.CalledProcessError, OSError) as e:
131                 exceptions.append((cmd, e))
132
133         if failed:
134             print('>> Error from LilyPond. The successive calls were:')
135             for (i, pair) in enumerate(exceptions):
136                 exc = pair[0]
137                 cmd = pair[1]
138
139                 print('>> (' + i + ') Error from trying ' + str(cmd) + ':')
140                 print('>> (' + i + ') ' + str(exc))
141
142         if failed:
143             sys.exit(1)
144
145         # LilyPond has a distressing tendency to leave the raw LilyPond code in the new file.
146         self.postprocess_output_for_lilypond()
147
148         # Now, in_file should have the clean LilyPond-processed contents.
149
150     def copy_lilypond_generated_images(self):
151         # LilyPond generates a lot of files in LyX' temporary folder, within the ff folder: source LilyPond files
152         # for each snippet to render, images in several formats.
153         in_generated_images_folder = os.path.join(self.in_folder, 'ff')
154         out_generated_images_folder = os.path.join(self.out_folder, 'ff')
155
156         if not os.path.isdir(out_generated_images_folder):
157             os.mkdir(out_generated_images_folder)
158
159         for img in os.listdir(in_generated_images_folder):
160             if not img.endswith('.png') and not img.endswith('.pdf'):
161                 continue
162
163             shutil.copyfile(
164                 os.path.join(in_generated_images_folder, img),
165                 os.path.join(out_generated_images_folder, img),
166             )
167
168     def copy(self):
169         # Apply LilyPond to the original file if available and needed.
170         if self.do_lilypond_processing:
171             print('>> The input file needs a LilyPond pass and LilyPond is available.')
172             self.call_lilypond()
173
174         # Perform the actual copy: both the modified XML file and the generated images, if LilyPond is used.
175         shutil.copyfile(self.in_file, self.out_file)
176         if self.do_lilypond_processing:
177             self.copy_lilypond_generated_images()
178
179
180 if __name__ == '__main__':
181     if len(sys.argv) != 4:
182         print('Exactly four arguments are expected, only %s found: %s.' % (len(sys.argv), sys.argv))
183         sys.exit(1)
184
185     DocBookCopier(sys.argv).copy()