]> git.lyx.org Git - lyx.git/blob - lib/lyx2lyx/LyX.py
Modify string list in place instead of copying it
[lyx.git] / lib / lyx2lyx / LyX.py
1 # This file is part of lyx2lyx
2 # Copyright (C) 2002-2024 The LyX Team
3 # Copyright (C) 2002-2004 Dekel Tsur <dekel@lyx.org>
4 # Copyright (C) 2002-2006 José Matos <jamatos@lyx.org>
5 #
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
19
20 "The LyX module has all the rules related with different lyx file formats."
21
22 import codecs
23 import gzip
24 import io
25 import locale
26 import os.path
27 import re
28 import sys
29 import time
30
31 from parser_tools import (
32     check_token,
33     find_complete_lines,
34     find_end_of,
35     find_token,
36     get_value,
37 )
38
39 try:
40     import lyx2lyx_version
41
42     version__ = lyx2lyx_version.version
43     stable_version = True
44 except ModuleNotFoundError:
45     # we are running from the build directory so assume the last version
46     version__ = "2.5"
47     stable_version = False
48
49 default_debug__ = 2
50
51
52 ####################################################################
53 # Private helper functions
54
55
56 def find_end_of_inset(lines, i):
57     "Find beginning of inset, where lines[i] is included."
58     return find_end_of(lines, i, "\\begin_inset", "\\end_inset")
59
60
61 def minor_versions(major, last_minor_version):
62     """Generate minor versions, using major as prefix and minor
63     versions from 0 until last_minor_version, plus the generic version.
64
65     Example:
66
67       minor_versions("1.2", 4) ->
68       [ "1.2", "1.2.0", "1.2.1", "1.2.2", "1.2.3"]
69     """
70     return [major] + [major + ".%d" % i for i in range(last_minor_version + 1)]
71
72
73 # End of helper functions
74 ####################################################################
75
76
77 # Regular expressions used
78 format_re = re.compile(r"(\d)[\.,]?(\d\d)")
79 fileformat = re.compile(r"\\lyxformat\s*(\S*)")
80 original_version = re.compile(b".*?LyX ([\\d.]*)")
81 original_tex2lyx_version = re.compile(b".*?tex2lyx ([\\d.]*)")
82
83 ##
84 # file format information:
85 #  file, supported formats, stable release versions
86 format_relation = [
87     ("0_06", [200], minor_versions("0.6", 4)),
88     ("0_08", [210], minor_versions("0.8", 6) + ["0.7"]),
89     ("0_10", [210], minor_versions("0.10", 7) + ["0.9"]),
90     ("0_12", [215], minor_versions("0.12", 1) + ["0.11"]),
91     ("1_0", [215], minor_versions("1.0", 4)),
92     ("1_1", [215], minor_versions("1.1", 4)),
93     ("1_1_5", [216], ["1.1", "1.1.5", "1.1.5.1", "1.1.5.2"]),
94     ("1_1_6_0", [217], ["1.1", "1.1.6", "1.1.6.1", "1.1.6.2"]),
95     ("1_1_6_3", [218], ["1.1", "1.1.6.3", "1.1.6.4"]),
96     ("1_2", [220], minor_versions("1.2", 4)),
97     ("1_3", [221], minor_versions("1.3", 7)),
98     # Note that range(i,j) is up to j *excluded*.
99     ("1_4", list(range(222, 246)), minor_versions("1.4", 5)),
100     ("1_5", list(range(246, 277)), minor_versions("1.5", 7)),
101     ("1_6", list(range(277, 346)), minor_versions("1.6", 10)),
102     ("2_0", list(range(346, 414)), minor_versions("2.0", 8)),
103     ("2_1", list(range(414, 475)), minor_versions("2.1", 5)),
104     ("2_2", list(range(475, 509)), minor_versions("2.2", 4)),
105     ("2_3", list(range(509, 545)), minor_versions("2.3", 7)),
106     ("2_4", list(range(545, 621)), minor_versions("2.4", 0)),
107     ("2_5", (), minor_versions("2.5", 0)),
108 ]
109
110 ####################################################################
111 # This is useful just for development versions                     #
112 # if the list of supported formats is empty get it from last step  #
113 if not format_relation[-1][1]:
114     step, mode = format_relation[-1][0], "convert"
115     convert = getattr(__import__("lyx_" + step), mode)
116     format_relation[-1] = (step, [conv[0] for conv in convert], format_relation[-1][2])
117 #                                                                  #
118 ####################################################################
119
120
121 def formats_list():
122     "Returns a list with supported file formats."
123     formats = []
124     for version in format_relation:
125         for format in version[1]:
126             if format not in formats:
127                 formats.append(format)
128     return formats
129
130
131 def format_info():
132     "Returns a list with the supported file formats."
133     template = """
134 %s\tstable format:       %s
135   \tstable versions:     %s
136   \tdevelopment formats: %s
137 """
138
139     out = "version: formats and versions"
140     for version in format_relation:
141         major = str(version[2][0])
142         versions = str(version[2][1:])
143         if len(version[1]) == 1:
144             formats = str(version[1][0])
145             stable_format = str(version[1][0])
146         elif not stable_version and major == version__:
147             stable_format = "-- not yet --"
148             versions = "-- not yet --"
149             formats = f"{version[1][0]} - {version[1][-1]}"
150         else:
151             formats = f"{version[1][0]} - {version[1][-2]}"
152             stable_format = str(version[1][-1])
153
154         out += template % (major, stable_format, versions, formats)
155     return out + "\n"
156
157
158 def get_end_format():
159     "Returns the more recent file format available."
160     # this check will fail only when we have a new version
161     # and there is no format change yet.
162     if format_relation[-1][1]:
163         return format_relation[-1][1][-1]
164     return format_relation[-2][1][-1]
165
166
167 def get_backend(textclass):
168     "For _textclass_ returns its backend."
169     if textclass == "linuxdoc" or textclass == "manpage":
170         return "linuxdoc"
171     if textclass.startswith("docbook") or textclass.startswith("agu-"):
172         return "docbook"
173     return "latex"
174
175
176 def trim_eol(line):
177     "Remove end of line char(s)."
178     if line[-1] != "\n" and line[-1] != "\r":
179         # May happen for the last line of a document
180         return line
181     if line[-2:-1] == "\r":
182         return line[:-2]
183     else:
184         return line[:-1]
185
186
187 def trim_eol_binary(line):
188     "Remove end of line char(s)."
189     if line[-1] != 10 and line[-1] != 13:
190         # May happen for the last line of a document
191         return line
192     if line[-2:-1] == 13:
193         return line[:-2]
194     else:
195         return line[:-1]
196
197
198 def get_encoding(language, inputencoding, format, cjk_encoding):
199     "Returns enconding of the lyx file"
200     if format > 248:
201         return "utf8"
202     # CJK-LyX encodes files using the current locale encoding.
203     # This means that files created by CJK-LyX can only be converted using
204     # the correct locale settings unless the encoding is given as commandline
205     # argument.
206     if cjk_encoding == "auto":
207         return locale.getpreferredencoding()
208     elif cjk_encoding:
209         return cjk_encoding
210     from lyx2lyx_lang import lang
211
212     if inputencoding == "auto" or inputencoding == "default":
213         return lang[language][3]
214     if inputencoding == "":
215         return "latin1"
216     if inputencoding == "utf8x":
217         return "utf8"
218     # python does not know the alias latin9
219     if inputencoding == "latin9":
220         return "iso-8859-15"
221     return inputencoding
222
223
224 ##
225 # Class
226 #
227 class LyX_base:
228     """This class carries all the information of the LyX file."""
229
230     def __init__(
231         self,
232         end_format=0,
233         input="",
234         output="",
235         error="",
236         debug=default_debug__,
237         try_hard=0,
238         cjk_encoding="",
239         final_version="",
240         systemlyxdir="",
241         language="english",
242         encoding="auto",
243     ):
244         """Arguments:
245         end_format: final format that the file should be converted. (integer)
246         input: the name of the input source, if empty resort to standard input.
247         output: the name of the output file, if empty use the standard output.
248         error: the name of the error file, if empty use the standard error.
249         debug: debug level, O means no debug, as its value increases be more verbose.
250         """
251         self.choose_input(input)
252         self.output = output
253
254         if error:
255             self.err = open(error, "w")
256         else:
257             self.err = sys.stderr
258
259         self.debug = debug
260         self.try_hard = try_hard
261         self.cjk_encoding = cjk_encoding
262
263         if end_format:
264             self.end_format = self.lyxformat(end_format)
265
266             # In case the target version and format are both specified
267             # verify that they are compatible. If not send a warning
268             # and ignore the version.
269             if final_version:
270                 message = "Incompatible version %s for specified format %d" % (
271                     final_version,
272                     self.end_format,
273                 )
274                 for version in format_relation:
275                     if self.end_format in version[1]:
276                         if final_version not in version[2]:
277                             self.warning(message)
278                             final_version = ""
279         elif final_version:
280             for version in format_relation:
281                 if final_version in version[2]:
282                     # set the last format for that version
283                     self.end_format = version[1][-1]
284                     break
285             else:
286                 final_version = ""
287         else:
288             self.end_format = get_end_format()
289
290         if not final_version:
291             for step in format_relation:
292                 if self.end_format in step[1]:
293                     final_version = step[2][1]
294         self.final_version = final_version
295         self.warning("Final version: %s" % self.final_version, 10)
296         self.warning("Final format: %d" % self.end_format, 10)
297
298         self.backend = "latex"
299         self.textclass = "article"
300         # This is a hack: We use '' since we don't know the default
301         # layout of the text class. LyX will parse it as default layout.
302         # FIXME: Read the layout file and use the real default layout
303         self.default_layout = ""
304         self.header = []
305         self.preamble = []
306         self.body = []
307         self.status = 0
308         self.encoding = encoding
309         self.language = language
310         self.systemlyxdir = systemlyxdir
311
312     def warning(self, message, debug_level=default_debug__):
313         """Emits warning to self.error, if the debug_level is less
314         than the self.debug."""
315         if debug_level <= self.debug:
316             self.err.write("lyx2lyx warning: " + message + "\n")
317
318     def error(self, message):
319         "Emits a warning and exits if not in try_hard mode."
320         self.warning(message)
321         if not self.try_hard:
322             self.warning("Quitting.")
323             sys.exit(1)
324
325         self.status = 2
326
327     def read(self):
328         """Reads a file into the self.header and
329         self.body parts, from self.input."""
330
331         # First pass: Read header to determine file encoding
332         # If we are running under python3 then all strings are binary in this
333         # pass. In some cases we need to convert binary to unicode in order to
334         # use our parser tools. Since we do not know the true encoding yet we
335         # use latin1. This works since a) the parts we are interested in are
336         # pure ASCII (subset of latin1) and b) in contrast to pure ascii or
337         # utf8, one can decode any 8byte string using latin1.
338         first_line = True
339         while True:
340             line = self.input.readline()
341             if not line:
342                 # eof found before end of header
343                 self.error("Invalid LyX file: Missing body.")
344
345             if first_line:
346                 # Remove UTF8 BOM marker if present
347                 if line.startswith(codecs.BOM_UTF8):
348                     line = line[len(codecs.BOM_UTF8) :]
349
350                 first_line = False
351
352             line = trim_eol_binary(line)
353             decoded = line.decode("latin1")
354             if check_token(decoded, "\\begin_preamble"):
355                 while True:
356                     line = self.input.readline()
357                     if not line:
358                         # eof found before end of header
359                         self.error("Invalid LyX file: Missing body.")
360
361                     line = trim_eol_binary(line)
362                     decoded = line.decode("latin1")
363                     if check_token(decoded, "\\end_preamble"):
364                         break
365
366                     if decoded.split()[:0] in (
367                         "\\layout",
368                         "\\begin_layout",
369                         "\\begin_body",
370                     ):
371                         self.warning(
372                             "Malformed LyX file:"
373                             "Missing '\\end_preamble'."
374                             "\nAdding it now and hoping"
375                             "for the best."
376                         )
377
378                     self.preamble.append(line)
379
380             if check_token(decoded, "\\end_preamble"):
381                 continue
382
383             line = line.rstrip()
384             if not line:
385                 continue
386
387             if decoded.split()[0] in (
388                 "\\layout",
389                 "\\begin_layout",
390                 "\\begin_body",
391                 "\\begin_deeper",
392             ):
393                 self.body.append(line)
394                 break
395
396             self.header.append(line)
397
398         i = find_token(self.header, b"\\textclass", 0)
399         if i == -1:
400             self.warning("Malformed LyX file: Missing '\\textclass'.")
401             i = find_token(self.header, b"\\lyxformat", 0) + 1
402             self.header[i:i] = [b"\\textclass article"]
403
404         self.textclass = get_value(self.header, b"\\textclass", 0, default=b"")
405         self.language = get_value(self.header, b"\\language", 0, default=b"english").decode(
406             "ascii"
407         )
408         self.inputencoding = get_value(
409             self.header, b"\\inputencoding", 0, default=b"auto"
410         ).decode("ascii")
411         self.format = self.read_format()
412         self.initial_format = self.format
413         self.encoding = get_encoding(
414             self.language, self.inputencoding, self.format, self.cjk_encoding
415         )
416         self.initial_version = self.read_version()
417
418         # Second pass over header and preamble, now we know the file encoding
419         # Do not forget the textclass (Debian bug #700828)
420         self.textclass = self.textclass.decode(self.encoding)
421         self.backend = get_backend(self.textclass)
422         for i in range(len(self.header)):
423             self.header[i] = self.header[i].decode(self.encoding)
424         for i in range(len(self.preamble)):
425             self.preamble[i] = self.preamble[i].decode(self.encoding)
426         for i in range(len(self.body)):
427             self.body[i] = self.body[i].decode(self.encoding)
428
429         # Read document body
430         while True:
431             line = self.input.readline().decode(self.encoding)
432             if not line:
433                 break
434             self.body.append(trim_eol(line))
435
436     def write(self):
437         "Writes the LyX file to self.output."
438         self.choose_output(self.output)
439         self.set_version()
440         self.set_format()
441         self.set_textclass()
442         if self.encoding == "auto":
443             self.encoding = get_encoding(
444                 self.language, self.encoding, self.format, self.cjk_encoding
445             )
446         if self.preamble:
447             i = find_token(self.header, "\\textclass", 0) + 1
448             preamble = ["\\begin_preamble"] + self.preamble + ["\\end_preamble"]
449             header = self.header[:i] + preamble + self.header[i:]
450         else:
451             header = self.header
452
453         for line in header + [""] + self.body:
454             self.output.write(line + "\n")
455
456     def choose_output(self, output):
457         """Choose output streams dealing transparently with
458         compressed files."""
459
460         # This is a bit complicated, because we need to be compatible both with
461         # python 2 and python 3. Therefore we handle the encoding here and not
462         # when writing individual lines and may need up to 3 layered file like
463         # interfaces.
464         if self.compressed:
465             if output:
466                 outputfileobj = open(output, "wb")
467             else:
468                 # We cannot not use stdout directly since it needs text, not bytes in python 3
469                 outputfileobj = os.fdopen(sys.stdout.fileno(), "wb")
470             # We cannot not use gzip.open() since it is not supported by python 2
471             zipbuffer = gzip.GzipFile(mode="wb", fileobj=outputfileobj)
472             # We do not want to use different newlines on different OSes inside zipped files
473             self.output = io.TextIOWrapper(zipbuffer, encoding=self.encoding, newline="\n")
474         else:
475             if output:
476                 self.output = open(output, "w", encoding=self.encoding)
477             else:
478                 self.output = open(sys.stdout.fileno(), "w", encoding=self.encoding)
479
480     def choose_input(self, input):
481         """Choose input stream, dealing transparently with
482         compressed files."""
483
484         # Since we do not know the encoding yet we need to read the input as
485         # bytes in binary mode, and convert later to unicode.
486         if input and input != "-":
487             self.dir = os.path.dirname(os.path.abspath(input))
488             try:
489                 gzip.open(input).readline()
490                 self.input = gzip.open(input)
491                 self.compressed = True
492             except OSError:
493                 self.input = open(input, "rb")
494                 self.compressed = False
495         else:
496             self.dir = ""
497             self.input = os.fdopen(sys.stdin.fileno(), "rb")
498             self.compressed = False
499
500     def lyxformat(self, format):
501         "Returns the file format representation, an integer."
502         result = format_re.match(format)
503         if result:
504             format = int(result.group(1) + result.group(2))
505         elif format == "2":
506             format = 200
507         else:
508             self.error(str(format) + ": " + "Invalid LyX file.")
509
510         if format in formats_list():
511             return format
512
513         self.error(str(format) + ": " + "Format not supported.")
514         return None
515
516     def read_version(self):
517         """Searchs for clues of the LyX version used to write the
518         file, returns the most likely value, or None otherwise."""
519
520         for line in self.header:
521             if line[0:1] != b"#":
522                 return None
523
524             line = line.replace(b"fix", b".")
525             # need to test original_tex2lyx_version first because tex2lyx
526             # writes "#LyX file created by tex2lyx 2.2"
527             result = original_tex2lyx_version.match(line)
528             if not result:
529                 result = original_version.match(line)
530                 if result:
531                     # Special know cases: reLyX and KLyX
532                     if line.find(b"reLyX") != -1 or line.find(b"KLyX") != -1:
533                         return "0.12"
534             if result:
535                 res = result.group(1)
536                 if not res:
537                     self.warning(line)
538                 # self.warning("Version %s" % result.group(1))
539                 return res.decode("ascii")
540         self.warning(str(self.header[:2]))
541         return None
542
543     def set_version(self):
544         "Set the header with the version used."
545
546         initial_comment = " ".join(
547             [
548                 "#LyX %s created this file." % version__,
549                 "For more info see https://www.lyx.org/",
550             ]
551         )
552
553         # Simple heuristic to determine the comment that always starts
554         # a lyx file
555         if self.header[0].startswith("#"):
556             self.header[0] = initial_comment
557         else:
558             self.header.insert(0, initial_comment)
559
560         # Old lyx files had a two lines comment header:
561         # 1) the first line had the user who had created it
562         # 2) the second line had the lyx version used
563         # later we decided that 1) was a privacy risk for no gain
564         # here we remove the second line effectively erasing 1)
565         if self.header[1][0] == "#":
566             del self.header[1]
567
568     def read_format(self):
569         "Read from the header the fileformat of the present LyX file."
570         for line in self.header:
571             result = fileformat.match(line.decode("ascii"))
572             if result:
573                 return self.lyxformat(result.group(1))
574         else:
575             self.error("Invalid LyX File: Missing format.")
576         return None
577
578     def set_format(self):
579         "Set the file format of the file, in the header."
580         if self.format <= 217:
581             format = str(float(self.format) / 100)
582         else:
583             format = str(self.format)
584         i = find_token(self.header, "\\lyxformat", 0)
585         self.header[i] = "\\lyxformat %s" % format
586
587     def set_textclass(self):
588         i = find_token(self.header, "\\textclass", 0)
589         self.header[i] = "\\textclass %s" % self.textclass
590
591     # Note that the module will be added at the END of the extant ones
592     def add_module(self, module):
593         "Append module to the modules list."
594         i = find_token(self.header, "\\begin_modules", 0)
595         if i == -1:
596             # No modules yet included
597             i = find_token(self.header, "\\textclass", 0)
598             if i == -1:
599                 self.warning("Malformed LyX document: No \\textclass!!")
600                 return
601             modinfo = ["\\begin_modules", module, "\\end_modules"]
602             self.header[i + 1 : i + 1] = modinfo
603             return
604         j = find_token(self.header, "\\end_modules", i)
605         if j == -1:
606             self.warning("(add_module)Malformed LyX document: No \\end_modules.")
607             return
608         k = find_token(self.header, module, i)
609         if k != -1 and k < j:
610             return
611         self.header.insert(j, module)
612
613     def del_module(self, module):
614         "Delete `module` from module list, return success."
615         modlist = self.get_module_list()
616         if module not in modlist:
617             return False
618         self.set_module_list([line for line in modlist if line != module])
619         return True
620
621     def get_module_list(self):
622         "Return list of modules."
623         i = find_token(self.header, "\\begin_modules", 0)
624         if i == -1:
625             return []
626         j = find_token(self.header, "\\end_modules", i)
627         return self.header[i + 1 : j]
628
629     def set_module_list(self, mlist):
630         i = find_token(self.header, "\\begin_modules", 0)
631         if i == -1:
632             # No modules yet included
633             tclass = find_token(self.header, "\\textclass", 0)
634             if tclass == -1:
635                 self.warning("Malformed LyX document: No \\textclass!!")
636                 return
637             i = j = tclass + 1
638         else:
639             j = find_token(self.header, "\\end_modules", i)
640             if j == -1:
641                 self.warning("(set_module_list) Malformed LyX document: No \\end_modules.")
642                 return
643             j += 1
644         if mlist:
645             mlist = ["\\begin_modules"] + mlist + ["\\end_modules"]
646         self.header[i:j] = mlist
647
648     def set_parameter(self, param, value):
649         "Set the value of the header parameter."
650         i = find_token(self.header, "\\" + param, 0)
651         if i == -1:
652             self.warning("Parameter not found in the header: %s" % param, 3)
653             return
654         self.header[i] = f"\\{param} {str(value)}"
655
656     def is_default_layout(self, layout):
657         "Check whether a layout is the default layout of this class."
658         # FIXME: Check against the real text class default layout
659         if layout == "Standard" or layout == self.default_layout:
660             return 1
661         return 0
662
663     def convert(self):
664         "Convert from current (self.format) to self.end_format."
665         if self.format == self.end_format:
666             self.warning(
667                 "No conversion needed: Target format %s "
668                 "same as current format!" % self.format,
669                 default_debug__,
670             )
671             return
672
673         mode, conversion_chain = self.chain()
674         self.warning("conversion chain: " + str(conversion_chain), 3)
675
676         for step in conversion_chain:
677             steps = getattr(__import__("lyx_" + step), mode)
678
679             self.warning(f"Convertion step: {step} - {mode}", default_debug__ + 1)
680             if not steps:
681                 self.error(
682                     "The conversion to an older "
683                     "format (%s) is not implemented." % self.format
684                 )
685
686             multi_conv = len(steps) != 1
687             for version, table in steps:
688                 if (
689                     multi_conv
690                     and (self.format >= version and mode == "convert")
691                     or (self.format <= version and mode == "revert")
692                 ):
693                     continue
694
695                 for conv in table:
696                     init_t = time.time()
697                     try:
698                         conv(self)
699                     except Exception as exception:
700                         self.warning(
701                             f"An error occurred in {version}, {conv}",
702                             default_debug__,
703                         )
704                         if not self.try_hard:
705                             raise exception
706                         self.status = 2
707                     else:
708                         self.warning(
709                             f"{time.time() - init_t:f}: Elapsed time on {conv}",
710                             default_debug__ + 1,
711                         )
712                 self.format = version
713                 if self.end_format == self.format:
714                     return
715
716     def chain(self):
717         """This is where all the decisions related with the
718         conversion are taken.  It returns a list of modules needed to
719         convert the LyX file from self.format to self.end_format"""
720
721         format = self.format
722         correct_version = 0
723
724         for rel in format_relation:
725             if self.initial_version in rel[2]:
726                 if format in rel[1]:
727                     initial_step = rel[0]
728                     correct_version = 1
729                     break
730
731         if not correct_version:
732             if format <= 215:
733                 self.warning(
734                     "Version does not match file format, "
735                     "discarding it. (Version %s, format %d)"
736                     % (self.initial_version, self.format)
737                 )
738             for rel in format_relation:
739                 if format in rel[1]:
740                     initial_step = rel[0]
741                     break
742             else:
743                 # This should not happen, really.
744                 self.error("Format not supported.")
745
746         # Find the final step
747         for rel in format_relation:
748             if self.end_format in rel[1]:
749                 final_step = rel[0]
750                 break
751         else:
752             self.error("Format not supported.")
753
754         # Convertion mode, back or forth
755         steps = []
756         if (initial_step, self.initial_format) < (final_step, self.end_format):
757             mode = "convert"
758             full_steps = []
759             for step in format_relation:
760                 if initial_step <= step[0] <= final_step and step[2][0] <= self.final_version:
761                     full_steps.append(step)
762             if full_steps[0][1][-1] == self.format:
763                 full_steps = full_steps[1:]
764             for step in full_steps:
765                 steps.append(step[0])
766         else:
767             mode = "revert"
768             relation_format = format_relation[:]
769             relation_format.reverse()
770             last_step = None
771
772             for step in relation_format:
773                 if final_step <= step[0] <= initial_step:
774                     steps.append(step[0])
775                     last_step = step
776
777             if last_step[1][-1] == self.end_format:
778                 steps.pop()
779
780         self.warning(f"Convertion mode: {mode}\tsteps{steps}", 10)
781         return mode, steps
782
783     def append_local_layout(self, new_layout):
784         "Append `new_layout` to the local layouts."
785         # new_layout may be a string or a list of strings (lines)
786         try:
787             new_layout = new_layout.splitlines()
788         except AttributeError:
789             pass
790         i = find_token(self.header, "\\begin_local_layout", 0)
791         if i == -1:
792             k = find_token(self.header, "\\language", 0)
793             if k == -1:
794                 # this should not happen
795                 self.warning("Malformed LyX document! No \\language header found!")
796                 return
797             self.header[k:k] = ["\\begin_local_layout", "\\end_local_layout"]
798             i = k
799
800         j = find_end_of(self.header, i, "\\begin_local_layout", "\\end_local_layout")
801         if j == -1:
802             # this should not happen
803             self.warning("Malformed LyX document: Can't find end of local layout!")
804             return
805
806         self.header[i + 1 : i + 1] = new_layout
807
808     def del_local_layout(self, layout_def):
809         "Delete `layout_def` from local layouts, return success."
810         i = find_complete_lines(self.header, layout_def)
811         if i == -1:
812             return False
813         j = i + len(layout_def)
814         if (
815             self.header[i - 1] == "\\begin_local_layout"
816             and self.header[j] == "\\end_local_layout"
817         ):
818             i -= 1
819             j += 1
820         self.header[i:j] = []
821         return True
822
823     def del_from_header(self, lines):
824         "Delete `lines` from the document header, return success."
825         i = find_complete_lines(self.header, lines)
826         if i == -1:
827             return False
828         j = i + len(lines)
829         self.header[i:j] = []
830         return True
831
832
833 # Part of an unfinished attempt to make lyx2lyx gave a more
834 # structured view of the document.
835 #    def get_toc(self, depth = 4):
836 #        " Returns the TOC of this LyX document."
837 #        paragraphs_filter = {'Title' : 0,'Chapter' : 1, 'Section' : 2,
838 #                             'Subsection' : 3, 'Subsubsection': 4}
839 #        allowed_insets = ['Quotes']
840 #        allowed_parameters = ('\\paragraph_spacing', '\\noindent',
841 #                              '\\align', '\\labelwidthstring',
842 #                              "\\start_of_appendix", "\\leftindent")
843 #        sections = []
844 #        for section in paragraphs_filter.keys():
845 #            sections.append('\\begin_layout %s' % section)
846
847 #        toc_par = []
848 #        i = 0
849 #        while True:
850 #            i = find_tokens(self.body, sections, i)
851 #            if i == -1:
852 #                break
853
854 #            j = find_end_of(self.body,  i + 1, '\\begin_layout', '\\end_layout')
855 #            if j == -1:
856 #                self.warning('Incomplete file.', 0)
857 #                break
858
859 #            section = self.body[i].split()[1]
860 #            if section[-1] == '*':
861 #                section = section[:-1]
862
863 #            par = []
864
865 #            k = i + 1
866 #            # skip paragraph parameters
867 #            while not self.body[k].strip() or self.body[k].split()[0] \
868 #                      in allowed_parameters:
869 #                k += 1
870
871 #            while k < j:
872 #                if check_token(self.body[k], '\\begin_inset'):
873 #                    inset = self.body[k].split()[1]
874 #                    end = find_end_of_inset(self.body, k)
875 #                    if end == -1 or end > j:
876 #                        self.warning('Malformed file.', 0)
877
878 #                    if inset in allowed_insets:
879 #                        par.extend(self.body[k: end+1])
880 #                    k = end + 1
881 #                else:
882 #                    par.append(self.body[k])
883 #                    k += 1
884
885 #            # trim empty lines in the end.
886 #            while par and par[-1].strip() == '':
887 #                par.pop()
888
889 #            toc_par.append(Paragraph(section, par))
890
891 #            i = j + 1
892
893 #        return toc_par
894
895
896 class File(LyX_base):
897     "This class reads existing LyX files."
898
899     def __init__(
900         self,
901         end_format=0,
902         input="",
903         output="",
904         error="",
905         debug=default_debug__,
906         try_hard=0,
907         cjk_encoding="",
908         final_version="",
909         systemlyxdir="",
910     ):
911         LyX_base.__init__(
912             self,
913             end_format,
914             input,
915             output,
916             error,
917             debug,
918             try_hard,
919             cjk_encoding,
920             final_version,
921             systemlyxdir,
922         )
923         self.read()
924
925
926 # FIXME: header settings are completely outdated, don't use like this
927 # class NewFile(LyX_base):
928 #    " This class is to create new LyX files."
929 #    def set_header(self, **params):
930 #        # set default values
931 #        self.header.extend([
932 #            "#LyX xxxx created this file."
933 #            "For more info see http://www.lyx.org/",
934 #            "\\lyxformat xxx",
935 #            "\\begin_document",
936 #            "\\begin_header",
937 #            "\\textclass article",
938 #            "\\language english",
939 #            "\\inputencoding auto",
940 #            "\\font_roman default",
941 #            "\\font_sans default",
942 #            "\\font_typewriter default",
943 #            "\\font_default_family default",
944 #            "\\font_sc false",
945 #            "\\font_osf false",
946 #            "\\font_sf_scale 100",
947 #            "\\font_tt_scale 100",
948 #            "\\graphics default",
949 #            "\\paperfontsize default",
950 #            "\\papersize default",
951 #            "\\use_geometry false",
952 #            "\\use_amsmath 1",
953 #            "\\cite_engine basic",
954 #            "\\use_bibtopic false",
955 #            "\\use_indices false",
956 #            "\\paperorientation portrait",
957 #            "\\secnumdepth 3",
958 #            "\\tocdepth 3",
959 #            "\\paragraph_separation indent",
960 #            "\\defskip medskip",
961 #            "\\quotes_language english",
962 #            "\\papercolumns 1",
963 #            "\\papersides 1",
964 #            "\\paperpagestyle default",
965 #            "\\tracking_changes false",
966 #            "\\end_header"])
967
968 #        self.format = get_end_format()
969 #        for param in params:
970 #            self.set_parameter(param, params[param])
971
972
973 #    def set_body(self, paragraphs):
974 #        self.body.extend(['\\begin_body',''])
975
976 #        for par in paragraphs:
977 #            self.body.extend(par.asLines())
978
979 #        self.body.extend(['','\\end_body', '\\end_document'])
980
981
982 # Part of an unfinished attempt to make lyx2lyx gave a more
983 # structured view of the document.
984 # class Paragraph:
985 #    # unfinished implementation, it is missing the Text and Insets
986 #    # representation.
987 #    " This class represents the LyX paragraphs."
988 #    def __init__(self, name, body=[], settings = [], child = []):
989 #        """ Parameters:
990 #        name: paragraph name.
991 #        body: list of lines of body text.
992 #        child: list of paragraphs that descend from this paragraph.
993 #        """
994 #        self.name = name
995 #        self.body = body
996 #        self.settings = settings
997 #        self.child = child
998
999 #    def asLines(self):
1000 #        """ Converts the paragraph to a list of strings, representing
1001 #        it in the LyX file."""
1002
1003 #        result = ['','\\begin_layout %s' % self.name]
1004 #        result.extend(self.settings)
1005 #        result.append('')
1006 #        result.extend(self.body)
1007 #        result.append('\\end_layout')
1008
1009 #        if not self.child:
1010 #            return result
1011
1012 #        result.append('\\begin_deeper')
1013 #        for node in self.child:
1014 #            result.extend(node.asLines())
1015 #        result.append('\\end_deeper')
1016
1017 #        return result