COPYING: Add the GNU GPLv3
[mw2txt.git] / mw2txt.py
1 #!/usr/bin/env python
2
3 # Copyright (C) 2011-2013 W. Trevor King <wking@tremily.us>
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Lesser General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13 # Lesser General Public License for more details.
14 #
15 # You should have received a copy of the GNU Lesser General Public
16 # License along with this program.  If not, see
17 # <http://www.gnu.org/licenses/>.
18
19 """View Maple worksheets (.mw) from the command line (without X).
20
21 ./mw2txt.py -c input.mw | less
22 ./mw2txt.py -m input.mw | /opt/maple15/bin/maple | less
23 """
24
25 import logging as _logging
26 import sys as _sys
27
28 import lxml.etree as _lxml_etree
29
30 try:
31     from pygments.console import colorize as _colorize
32 except ImportError as e:
33     _sys.stderr.write(str(e) + '\n')
34     def _color_string(string, color=None):
35         return string
36 else:
37     def _color_string(string, color=None):
38         color = {
39             'magenta': 'fuchsia',
40             'cyan': 'turquoise',
41             None: 'reset',
42             }.get(color, color)
43         return _colorize(color_key=color, text=string)
44
45
46 __version__ = '0.2'
47
48 LOG = _logging.getLogger(__name__)
49 LOG.addHandler(_logging.StreamHandler())
50 LOG.setLevel(_logging.ERROR)
51
52
53 def _write_color(string, color=None, stream=None):
54     if stream is None:
55         stream = _sys.stdout
56     stream.write(_color_string(string=string, color=color))
57
58
59 class Writer (object):
60     def __init__(self, color=None, stream=None, use_color=False):
61         self.color = color
62         self.stream = stream
63         self.use_color = use_color
64         self.last_char = None
65
66     def __call__(self, text, color=None):
67         if not self.use_color:
68             color = None
69         elif color is None:
70             color = self.color
71         if text == '\n' and self.last_char == '\n':
72             return  # don't add lots of blank lines
73         _write_color(string=text, color=color, stream=self.stream)
74         self.last_char = text[-1]
75
76
77 def mw2txt(path, writer, filter_math=False):
78     xml = _lxml_etree.parse(path)
79     pruned_iteration(
80         root=xml.getroot(),
81         match=lambda node: node.tag == 'Text-field',
82         match_action=lambda node: top_text_node2txt(
83             node=node, writer=writer, filter_math=filter_math),
84         match_tail=lambda node:writer(text='\n'))
85
86 def top_text_node2txt(node, writer, filter_math=False):
87     if filter_math:
88         match_action = None
89     else:
90         match_action = lambda node: other_in_text_node2txt(
91             node=node, writer=writer)
92     pruned_iteration(
93         root=node,
94         match=lambda node: node.tag not in ['Text-field', 'Font', 'Hyperlink'],
95         match_action=match_action,
96         other_action=lambda node: text_node2txt(
97             node=node, writer=writer, filter_math=filter_math),
98         match_tail=lambda node:tail_node2txt(
99             node=node, writer=writer, filter_math=filter_math),
100         other_tail=lambda node:tail_node2txt(
101             node=node, writer=writer, filter_math=filter_math))
102
103 def other_in_text_node2txt(node, writer):
104     if node.tag in ['Drawing-Root']:
105         # ignore missing content
106         pass
107     elif node.tag in ['Equation', 'Image', 'Plot']:
108         # warn about missing content
109         writer(text=node.tag, color='yellow')
110     else:
111         # warn about wierd tag
112         writer(text=node.tag, color='magenta')
113
114 def text_node2txt(node, writer, filter_math=False):
115     if node.tag not in ['Text-field', 'Font', 'Hyperlink'] and not filter_math:
116         # warn about wierd tag
117         writer(text=node.tag, color='magenta')
118     write_text(
119         node=node, text=node.text, writer=writer, filter_math=filter_math)
120
121 def tail_node2txt(node, writer, filter_math=False):
122     if node.tag != 'Text-field':
123         write_text(
124             node=node.getparent(), text=node.tail, writer=writer,
125             filter_math=filter_math)
126
127 def write_text(node, text, writer, filter_math=False):
128     if not text:
129         return
130     style = node_style(node)
131     if filter_math:
132         if style == 'Maple Input':
133             writer(text=text)
134         return
135     prompt = node.get('prompt', None)
136     if prompt:
137         t = '\n'.join(prompt+line for line in text.splitlines())
138         if text.endswith('\n'):
139             t += '\n'  # '\n'.join('a\nb\n'.splitlines()) == 'a\nb'
140         if writer.last_char not in [None, '\n']:
141             t = t[len(prompt):]  # no initial prompt
142         text = t
143     if style == 'Maple Input':
144         color = 'red'
145     else:
146         color = None
147     writer(text=text, color=color)
148
149 def node_style(node):
150     p = node
151     while p is not None:
152         style = p.get('style', None)
153         if style:
154             return style
155         p = p.getparent()
156     return None
157
158 def pruned_iteration(root, match, match_action=None, match_tail=None,
159                      other_action=None, other_tail=None):
160     LOG.debug(
161         _color_string(
162             'start pruned iteration from {}'.format(root), color='blue'))
163     line = [None]
164     stack = [root]
165     while len(stack) > 0:
166         node = stack.pop(0)
167         p = node.getparent()
168         while line[-1] != p:
169             n = line.pop()
170             if n is None:
171                 break
172             _pruned_iteration_handle_tail(
173                 node=n, match=match, match_tail=match_tail,
174                 other_tail=other_tail)
175         line.append(node)
176         LOG.debug(color_node(node, color='cyan'))
177         if match(node):
178             if match_action:
179                 match_action(node)
180         else:
181             if other_action:
182                 other_action(node)
183             stack = list(node.getchildren()) + stack
184     while len(line) > 0:
185         n = line.pop()
186         if n is None:
187             break
188         _pruned_iteration_handle_tail(
189             node=n, match=match, match_tail=match_tail, other_tail=other_tail)
190     LOG.debug(
191         _color_string(
192             'end pruned iteration from {}'.format(root), color='blue'))
193
194 def _pruned_iteration_handle_tail(node, match, match_tail, other_tail):
195     LOG.debug(color_node(node, color='magenta', tail=True))
196     if match(node):
197         if match_tail:
198             match_tail(node)
199     else:
200         if other_tail:
201             other_tail(node)
202
203 def node_depth(node):
204     depth = 0
205     p = node.getparent()
206     while p is not None:
207         depth += 1
208         p = p.getparent()
209     return depth
210
211 def color_node(node, color=None, tail=False):
212     depth = node_depth(node)
213     string = ' '*depth + node.tag
214     if tail:
215         string += ' tail'
216     return _color_string(string, color)
217
218
219 if __name__ == '__main__':
220     import argparse as _argparse
221
222     parser = _argparse.ArgumentParser(
223         description=__doc__,
224         formatter_class=_argparse.RawDescriptionHelpFormatter)
225     parser.add_argument(
226         '-v', '--version', action='version',
227         version='%(prog)s {}'.format(__version__),
228         help='print the program version and exit')
229     parser.add_argument(
230         '-V', '--verbose', action='count', default=0,
231         help='increment log verbosity')
232     parser.add_argument(
233         '-c', '--color', action='store_const', const=True,
234         help='use ANSI escape sequences to color output')
235     parser.add_argument(
236         '-m', '--maple', action='store_const', const=True,
237         help='output text suitable for piping into `maple`')
238     parser.add_argument(
239         'path', metavar='PATH',
240         help='path to a Maple worksheet (.mw)')
241
242     args = parser.parse_args()
243
244     if args.verbose:
245         LOG.setLevel(max(_logging.DEBUG, LOG.level - 10*args.verbose))
246
247     filter_math = args.maple
248     writer = Writer(use_color=args.color)
249     if args.maple:
250         if args.color:
251             raise ValueError("maple doesn't understand ANSI color")
252     mw2txt(path=args.path, writer=writer, filter_math=filter_math)