]> git.lyx.org Git - lyx.git/blob - lib/lyx2lyx/LyX.py
* lib/lyx2lyx/lyx_1_5.py:
[lyx.git] / lib / lyx2lyx / LyX.py
1 # This file is part of lyx2lyx
2 # -*- coding: utf-8 -*-
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., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
19
20 from parser_tools import get_value, check_token, find_token,\
21      find_tokens, find_end_of
22 import os.path
23 import gzip
24 import locale
25 import sys
26 import re
27 import time
28
29 try:
30     import lyx2lyx_version
31     version_lyx2lyx = lyx2lyx_version.version
32 except: # we are running from build directory so assume the last version
33     version_lyx2lyx = '1.5.0svn'
34
35 default_debug_level = 2
36
37 ####################################################################
38 # Private helper functions
39
40 def find_end_of_inset(lines, i):
41     " Find beginning of inset, where lines[i] is included."
42     return find_end_of(lines, i, "\\begin_inset", "\\end_inset")
43
44 def generate_minor_versions(major, last_minor_version):
45     """ Generate minor versions, using major as prefix and minor
46     versions from 0 until last_minor_version, plus the generic version.
47
48     Example:
49
50       generate_minor_versions("1.2", 4) ->
51       [ "1.2", "1.2.0", "1.2.1", "1.2.2", "1.2.3"]
52     """
53     return [major] + [major + ".%d" % i for i in range(last_minor_version + 1)]
54
55
56 # End of helper functions
57 ####################################################################
58
59
60 # Regular expressions used
61 format_re = re.compile(r"(\d)[\.,]?(\d\d)")
62 fileformat = re.compile(r"\\lyxformat\s*(\S*)")
63 original_version = re.compile(r".*?LyX ([\d.]*)")
64
65 ##
66 # file format information:
67 #  file, supported formats, stable release versions
68 format_relation = [("0_06",    [200], generate_minor_versions("0.6" , 4)),
69                    ("0_08",    [210], generate_minor_versions("0.8" , 6) + ["0.7"]),
70                    ("0_10",    [210], generate_minor_versions("0.10", 7) + ["0.9"]),
71                    ("0_12",    [215], generate_minor_versions("0.12", 1) + ["0.11"]),
72                    ("1_0",     [215], generate_minor_versions("1.0" , 4)),
73                    ("1_1",     [215], generate_minor_versions("1.1" , 4)),
74                    ("1_1_5",   [216], ["1.1.5","1.1.5.1","1.1.5.2","1.1"]),
75                    ("1_1_6_0", [217], ["1.1.6","1.1.6.1","1.1.6.2","1.1"]),
76                    ("1_1_6_3", [218], ["1.1.6.3","1.1.6.4","1.1"]),
77                    ("1_2",     [220], generate_minor_versions("1.2" , 4)),
78                    ("1_3",     [221], generate_minor_versions("1.3" , 7)),
79                    ("1_4", range(222,246), generate_minor_versions("1.4" , 4)),
80                    ("1_5", range(246,277), generate_minor_versions("1.5" , 0))]
81
82
83 def formats_list():
84     " Returns a list with supported file formats."
85     formats = []
86     for version in format_relation:
87         for format in version[1]:
88             if format not in formats:
89                 formats.append(format)
90     return formats
91
92
93 def get_end_format():
94     " Returns the more recent file format available."
95     return format_relation[-1][1][-1]
96
97
98 def get_backend(textclass):
99     " For _textclass_ returns its backend."
100     if textclass == "linuxdoc" or textclass == "manpage":
101         return "linuxdoc"
102     if textclass[:7] == "docbook":
103         return "docbook"
104     return "latex"
105
106
107 def trim_eol(line):
108     " Remove end of line char(s)."
109     if line[-2:-1] == '\r':
110         return line[:-2]
111     else:
112         return line[:-1]
113
114
115 def get_encoding(language, inputencoding, format, cjk_encoding):
116     if format > 248:
117         return "utf8"
118     # CJK-LyX encodes files using the current locale encoding.
119     # This means that files created by CJK-LyX can only be converted using
120     # the correct locale settings unless the encoding is given as commandline
121     # argument.
122     if cjk_encoding == 'auto':
123         return locale.getpreferredencoding()
124     elif cjk_encoding != '':
125         return cjk_encoding
126     from lyx2lyx_lang import lang
127     if inputencoding == "auto" or inputencoding == "default":
128         return lang[language][3]
129     if inputencoding == "":
130         return "latin1"
131     # python does not know the alias latin9
132     if inputencoding == "latin9":
133         return "iso-8859-15"
134     return inputencoding
135
136 ##
137 # Class
138 #
139 class LyX_Base:
140     """This class carries all the information of the LyX file."""
141     
142     def __init__(self, end_format = 0, input = "", output = "", error
143                  = "", debug = default_debug_level, try_hard = 0, cjk_encoding = '',
144                  language = "english", encoding = "auto"):
145
146         """Arguments:
147         end_format: final format that the file should be converted. (integer)
148         input: the name of the input source, if empty resort to standard input.
149         output: the name of the output file, if empty use the standard output.
150         error: the name of the error file, if empty use the standard error.
151         debug: debug level, O means no debug, as its value increases be more verbose.
152         """
153         self.choose_io(input, output)
154
155         if error:
156             self.err = open(error, "w")
157         else:
158             self.err = sys.stderr
159
160         self.debug = debug
161         self.try_hard = try_hard
162         self.cjk_encoding = cjk_encoding
163
164         if end_format:
165             self.end_format = self.lyxformat(end_format)
166         else:
167             self.end_format = get_end_format()
168
169         self.backend = "latex"
170         self.textclass = "article"
171         # This is a hack: We use '' since we don't know the default
172         # layout of the text class. LyX will parse it as default layout.
173         # FIXME: Read the layout file and use the real default layout
174         self.default_layout = ''
175         self.header = []
176         self.preamble = []
177         self.body = []
178         self.status = 0
179         self.encoding = encoding
180         self.language = language
181
182
183     def warning(self, message, debug_level= default_debug_level):
184         " Emits warning to self.error, if the debug_level is less than the self.debug."
185         if debug_level <= self.debug:
186             self.err.write("Warning: " + message + "\n")
187
188
189     def error(self, message):
190         " Emits a warning and exits if not in try_hard mode."
191         self.warning(message)
192         if not self.try_hard:
193             self.warning("Quiting.")
194             sys.exit(1)
195
196         self.status = 2
197
198
199     def read(self):
200         """Reads a file into the self.header and self.body parts, from self.input."""
201
202         while 1:
203             line = self.input.readline()
204             if not line:
205                 self.error("Invalid LyX file.")
206
207             line = trim_eol(line)
208             if check_token(line, '\\begin_preamble'):
209                 while 1:
210                     line = self.input.readline()
211                     if not line:
212                         self.error("Invalid LyX file.")
213
214                     line = trim_eol(line)
215                     if check_token(line, '\\end_preamble'):
216                         break
217                     
218                     if line.split()[:0] in ("\\layout", "\\begin_layout", "\\begin_body"):
219                         self.warning("Malformed LyX file: Missing '\\end_preamble'.")
220                         self.warning("Adding it now and hoping for the best.")
221
222                     self.preamble.append(line)
223
224             if check_token(line, '\\end_preamble'):
225                 continue
226
227             line = line.strip()
228             if not line:
229                 continue
230
231             if line.split()[0] in ("\\layout", "\\begin_layout", "\\begin_body"):
232                 self.body.append(line)
233                 break
234
235             self.header.append(line)
236
237         self.textclass = get_value(self.header, "\\textclass", 0)
238         self.backend = get_backend(self.textclass)
239         self.format  = self.read_format()
240         self.language = get_value(self.header, "\\language", 0, default = "english")
241         self.inputencoding = get_value(self.header, "\\inputencoding", 0, default = "auto")
242         self.encoding = get_encoding(self.language, self.inputencoding, self.format, self.cjk_encoding)
243         self.initial_version = self.read_version()
244
245         # Second pass over header and preamble, now we know the file encoding
246         for i in range(len(self.header)):
247             self.header[i] = self.header[i].decode(self.encoding)
248         for i in range(len(self.preamble)):
249             self.preamble[i] = self.preamble[i].decode(self.encoding)
250
251         # Read document body
252         while 1:
253             line = self.input.readline().decode(self.encoding)
254             if not line:
255                 break
256             self.body.append(trim_eol(line))
257
258
259     def write(self):
260         " Writes the LyX file to self.output."
261         self.set_version()
262         self.set_format()
263         self.set_textclass()
264         if self.encoding == "auto":
265             self.encoding = get_encoding(self.language, self.encoding, self.format, self.cjk_encoding)
266
267         if self.preamble:
268             i = find_token(self.header, '\\textclass', 0) + 1
269             preamble = ['\\begin_preamble'] + self.preamble + ['\\end_preamble']
270             if i == 0:
271                 self.error("Malformed LyX file: Missing '\\textclass'.")
272             else:
273                 header = self.header[:i] + preamble + self.header[i:]
274         else:
275             header = self.header
276
277         for line in header + [''] + self.body:
278             self.output.write(line.encode(self.encoding)+"\n")
279
280
281     def choose_io(self, input, output):
282         """Choose input and output streams, dealing transparently with
283         compressed files."""
284
285         if output:
286             self.output = open(output, "wb")
287         else:
288             self.output = sys.stdout
289
290         if input and input != '-':
291             self.dir = os.path.dirname(os.path.abspath(input))
292             try:
293                 gzip.open(input).readline()
294                 self.input = gzip.open(input)
295                 self.output = gzip.GzipFile(mode="wb", fileobj=self.output) 
296             except:
297                 self.input = open(input)
298         else:
299             self.dir = ''
300             self.input = sys.stdin
301
302
303     def lyxformat(self, format):
304         " Returns the file format representation, an integer."
305         result = format_re.match(format)
306         if result:
307             format = int(result.group(1) + result.group(2))
308         elif format == '2':
309             format = 200
310         else:
311             self.error(str(format) + ": " + "Invalid LyX file.")
312
313         if format in formats_list():
314             return format
315
316         self.error(str(format) + ": " + "Format not supported.")
317         return None
318
319
320     def read_version(self):
321         """ Searchs for clues of the LyX version used to write the file, returns the
322         most likely value, or None otherwise."""
323         for line in self.header:
324             if line[0] != "#":
325                 return None
326
327             line = line.replace("fix",".")
328             result = original_version.match(line)
329             if result:
330                 # Special know cases: reLyX and KLyX
331                 if line.find("reLyX") != -1 or line.find("KLyX") != -1:
332                     return "0.12"
333
334                 res = result.group(1)
335                 if not res:
336                     self.warning(line)
337                 #self.warning("Version %s" % result.group(1))
338                 return res
339         self.warning(str(self.header[:2]))
340         return None
341
342
343     def set_version(self):
344         " Set the header with the version used."
345         self.header[0] = "#LyX %s created this file. For more info see http://www.lyx.org/" % version_lyx2lyx
346         if self.header[1][0] == '#':
347             del self.header[1]
348
349
350     def read_format(self):
351         " Read from the header the fileformat of the present LyX file."
352         for line in self.header:
353             result = fileformat.match(line)
354             if result:
355                 return self.lyxformat(result.group(1))
356         else:
357             self.error("Invalid LyX File.")
358         return None
359
360
361     def set_format(self):
362         " Set the file format of the file, in the header."
363         if self.format <= 217:
364             format = str(float(self.format)/100)
365         else:
366             format = str(self.format)
367         i = find_token(self.header, "\\lyxformat", 0)
368         self.header[i] = "\\lyxformat %s" % format
369
370
371     def set_textclass(self):
372         i = find_token(self.header, "\\textclass", 0)
373         self.header[i] = "\\textclass %s" % self.textclass
374
375
376     def set_parameter(self, param, value):
377         " Set the value of the header parameter."
378         i = find_token(self.header, '\\' + param, 0)
379         if i == -1:
380             self.warning('Parameter not found in the header: %s' % param, 3)
381             return
382         self.header[i] = '\\%s %s' % (param, str(value))
383
384
385     def is_default_layout(self, layout):
386         " Check whether a layout is the default layout of this class."
387         # FIXME: Check against the real text class default layout
388         if layout == 'Standard' or layout == self.default_layout:
389             return 1
390         return 0
391
392
393     def convert(self):
394         "Convert from current (self.format) to self.end_format."
395         mode, convertion_chain = self.chain()
396         self.warning("convertion chain: " + str(convertion_chain), 3)
397
398         for step in convertion_chain:
399             steps = getattr(__import__("lyx_" + step), mode)
400
401             self.warning("Convertion step: %s - %s" % (step, mode), default_debug_level + 1)
402             if not steps:
403                     self.error("The convertion to an older format (%s) is not implemented." % self.format)
404
405             multi_conv = len(steps) != 1
406             for version, table in steps:
407                 if multi_conv and \
408                    (self.format >= version and mode == "convert") or\
409                    (self.format <= version and mode == "revert"):
410                     continue
411
412                 for conv in table:
413                     init_t = time.time()
414                     try:
415                         conv(self)
416                     except:
417                         self.warning("An error ocurred in %s, %s" % (version, str(conv)),
418                                      default_debug_level)
419                         if not self.try_hard:
420                             raise
421                         self.status = 2
422                     else:
423                         self.warning("%lf: Elapsed time on %s"  % (time.time() - init_t, str(conv)),
424                                      default_debug_level + 1)
425
426                 self.format = version
427                 if self.end_format == self.format:
428                     return
429
430
431     def chain(self):
432         """ This is where all the decisions related with the convertion are taken.
433         It returns a list of modules needed to convert the LyX file from
434         self.format to self.end_format"""
435
436         self.start =  self.format
437         format = self.format
438         correct_version = 0
439
440         for rel in format_relation:
441             if self.initial_version in rel[2]:
442                 if format in rel[1]:
443                     initial_step = rel[0]
444                     correct_version = 1
445                     break
446
447         if not correct_version:
448             if format <= 215:
449                 self.warning("Version does not match file format, discarding it. (Version %s, format %d)" %(self.initial_version, self.format))
450             for rel in format_relation:
451                 if format in rel[1]:
452                     initial_step = rel[0]
453                     break
454             else:
455                 # This should not happen, really.
456                 self.error("Format not supported.")
457
458         # Find the final step
459         for rel in format_relation:
460             if self.end_format in rel[1]:
461                 final_step = rel[0]
462                 break
463         else:
464             self.error("Format not supported.")
465
466         # Convertion mode, back or forth
467         steps = []
468         if (initial_step, self.start) < (final_step, self.end_format):
469             mode = "convert"
470             first_step = 1
471             for step in format_relation:
472                 if  initial_step <= step[0] <= final_step:
473                     if first_step and len(step[1]) == 1:
474                         first_step = 0
475                         continue
476                     steps.append(step[0])
477         else:
478             mode = "revert"
479             relation_format = format_relation[:]
480             relation_format.reverse()
481             last_step = None
482
483             for step in relation_format:
484                 if  final_step <= step[0] <= initial_step:
485                     steps.append(step[0])
486                     last_step = step
487
488             if last_step[1][-1] == self.end_format:
489                 steps.pop()
490
491         return mode, steps
492
493
494     def get_toc(self, depth = 4):
495         " Returns the TOC of this LyX document."
496         paragraphs_filter = {'Title' : 0,'Chapter' : 1, 'Section' : 2, 'Subsection' : 3, 'Subsubsection': 4}
497         allowed_insets = ['Quotes']
498         allowed_parameters = '\\paragraph_spacing', '\\noindent', '\\align', '\\labelwidthstring', "\\start_of_appendix", "\\leftindent"
499
500         sections = []
501         for section in paragraphs_filter.keys():
502             sections.append('\\begin_layout %s' % section)
503
504         toc_par = []
505         i = 0
506         while 1:
507             i = find_tokens(self.body, sections, i)
508             if i == -1:
509                 break
510
511             j = find_end_of(self.body,  i + 1, '\\begin_layout', '\\end_layout')
512             if j == -1:
513                 self.warning('Incomplete file.', 0)
514                 break
515
516             section = self.body[i].split()[1]
517             if section[-1] == '*':
518                 section = section[:-1]
519
520             par = []
521
522             k = i + 1
523             # skip paragraph parameters
524             while not self.body[k].strip() or self.body[k].split()[0] in allowed_parameters:
525                 k = k +1
526
527             while k < j:
528                 if check_token(self.body[k], '\\begin_inset'):
529                     inset = self.body[k].split()[1]
530                     end = find_end_of_inset(self.body, k)
531                     if end == -1 or end > j:
532                         self.warning('Malformed file.', 0)
533
534                     if inset in allowed_insets:
535                         par.extend(self.body[k: end+1])
536                     k = end + 1
537                 else:
538                     par.append(self.body[k])
539                     k = k + 1
540
541             # trim empty lines in the end.
542             while par[-1].strip() == '' and par:
543                 par.pop()
544
545             toc_par.append(Paragraph(section, par))
546
547             i = j + 1
548
549         return toc_par
550
551
552 class File(LyX_Base):
553     " This class reads existing LyX files."
554     def __init__(self, end_format = 0, input = "", output = "", error = "", debug = default_debug_level, try_hard = 0, cjk_encoding = ''):
555         LyX_Base.__init__(self, end_format, input, output, error, debug, try_hard, cjk_encoding)
556         self.read()
557
558
559 class NewFile(LyX_Base):
560     " This class is to create new LyX files."
561     def set_header(self, **params):
562         # set default values
563         self.header.extend([
564             "#LyX xxxx created this file. For more info see http://www.lyx.org/",
565             "\\lyxformat xxx",
566             "\\begin_document",
567             "\\begin_header",
568             "\\textclass article",
569             "\\language english",
570             "\\inputencoding auto",
571             "\\font_roman default",
572             "\\font_sans default",
573             "\\font_typewriter default",
574             "\\font_default_family default",
575             "\\font_sc false",
576             "\\font_osf false",
577             "\\font_sf_scale 100",
578             "\\font_tt_scale 100",
579             "\\graphics default",
580             "\\paperfontsize default",
581             "\\papersize default",
582             "\\use_geometry false",
583             "\\use_amsmath 1",
584             "\\cite_engine basic",
585             "\\use_bibtopic false",
586             "\\paperorientation portrait",
587             "\\secnumdepth 3",
588             "\\tocdepth 3",
589             "\\paragraph_separation indent",
590             "\\defskip medskip",
591             "\\quotes_language english",
592             "\\papercolumns 1",
593             "\\papersides 1",
594             "\\paperpagestyle default",
595             "\\tracking_changes false",
596             "\\end_header"])
597
598         self.format = get_end_format()
599         for param in params:
600             self.set_parameter(param, params[param])
601
602
603     def set_body(self, paragraphs):
604         self.body.extend(['\\begin_body',''])
605
606         for par in paragraphs:
607             self.body.extend(par.asLines())
608
609         self.body.extend(['','\\end_body', '\\end_document'])
610
611
612 class Paragraph:
613     # unfinished implementation, it is missing the Text and Insets representation.
614     " This class represents the LyX paragraphs."
615     def __init__(self, name, body=[], settings = [], child = []):
616         """ Parameters:
617         name: paragraph name.
618         body: list of lines of body text.
619         child: list of paragraphs that descend from this paragraph.
620         """
621         self.name = name
622         self.body = body
623         self.settings = settings
624         self.child = child
625
626     def asLines(self):
627         " Converts the paragraph to a list of strings, representing it in the LyX file."
628         result = ['','\\begin_layout %s' % self.name]
629         result.extend(self.settings)
630         result.append('')
631         result.extend(self.body)
632         result.append('\\end_layout')
633
634         if not self.child:
635             return result
636
637         result.append('\\begin_deeper')
638         for node in self.child:
639             result.extend(node.asLines())
640         result.append('\\end_deeper')
641
642         return result
643
644
645 class Inset:
646     " This class represents the LyX insets."
647     pass
648
649
650 class Text:
651     " This class represents simple chuncks of text."
652     pass