root/relatorio/templates/opendocument.py @ 100:33521906fedb

Revision 100:33521906fedb, 25.8 kB (checked in by Ga?tan de Menten <ged@…>, 4 years ago)

- enable compression in the resulting OO document
- preserve file permissions of files inside the OO document

Line 
1###############################################################################
2#
3# Copyright (c) 2007, 2008 OpenHex SPRL. (http://openhex.com) All Rights
4# Reserved.
5#
6# This program is free software; you can redistribute it and/or modify it under
7# the terms of the GNU General Public License as published by the Free Software
8# Foundation; either version 2 of the License, or (at your option) any later
9# version.
10#
11# This program is distributed in the hope that it will be useful, but WITHOUT
12# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
13# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
14# details.
15#
16# You should have received a copy of the GNU General Public License along with
17# this program.  If not, see <http://www.gnu.org/licenses/>.
18#
19###############################################################################
20
21__metaclass__ = type
22
23import re
24import md5
25import time
26import urllib
27import zipfile
28from cStringIO import StringIO
29from copy import deepcopy
30
31
32import warnings
33warnings.filterwarnings('always', module='relatorio.templates.opendocument')
34
35import lxml.etree
36import genshi
37import genshi.output
38from genshi.template import MarkupTemplate
39from genshi.filters import Transformer
40from genshi.filters.transform import ENTER, EXIT
41from genshi.core import Stream
42
43
44from relatorio.templates.base import RelatorioStream
45from relatorio.reporting import Report, MIMETemplateLoader
46try:
47    from relatorio.templates.chart import Template as ChartTemplate
48except ImportError:
49    ChartTemplate = type(None)
50
51GENSHI_EXPR = re.compile(r'''
52        (/)?                                 # is this a closing tag?
53        (for|if|choose|when|otherwise|with)  # tag directive
54        \s*
55        (?:\s(\w+)=["'](.*)["']|$)           # match a single attr & its value
56        |
57        .*                                   # or anything else
58        ''', re.VERBOSE)
59
60EXTENSIONS = {'image/png': 'png',
61              'image/jpeg': 'jpg',
62              'image/bmp': 'bmp',
63              'image/gif': 'gif',
64              'image/tiff': 'tif',
65              'image/xbm': 'xbm',
66             }
67
68RELATORIO_URI = 'http://relatorio.openhex.org/'
69output_encode = genshi.output.encode
70EtreeElement = lxml.etree.Element
71
72def guess_type(val):
73    if isinstance(val, (str, unicode)):
74        return 'string'
75    elif isinstance(val, (int, float)):
76        return 'float'
77
78class OOTemplateError(genshi.template.base.TemplateSyntaxError):
79    "Error to raise when there is a SyntaxError in the genshi template"
80
81
82class ImageHref:
83    "A class used to add images in the odf zipfile"
84
85    def __init__(self, zfile, context):
86        self.zip = zfile
87        self.context = context.copy()
88
89    def __call__(self, expr):
90        bitstream, mimetype = expr
91        if isinstance(bitstream, Report):
92            bitstream = bitstream(**self.context).render()
93        elif isinstance(bitstream, ChartTemplate):
94            bitstream = bitstream.generate(**self.context).render()
95        bitstream.seek(0)
96        file_content = bitstream.read()
97        name = md5.new(file_content).hexdigest()
98        path = 'Pictures/%s.%s' % (name, EXTENSIONS[mimetype])
99        if path not in self.zip.namelist():
100            self.zip.writestr(path, file_content)
101        return {'{http://www.w3.org/1999/xlink}href': path}
102
103
104class ColumnCounter:
105    """A class used to count the actual maximum number of cells (and thus
106    columns) a table contains accross its rows.
107    """
108    def __init__(self):
109        self.temp_counters = {}
110        self.counters = {}
111
112    def reset(self, loop_id):
113        self.temp_counters[loop_id] = 0
114
115    def inc(self, loop_id):
116        self.temp_counters[loop_id] += 1
117
118    def store(self, loop_id, table_name):
119        self.counters[table_name] = max(self.temp_counters.pop(loop_id),
120                                        self.counters.get(table_name, 0))
121
122
123def wrap_nodes_between(first, last, new_parent):
124    """An helper function to move all nodes between two nodes to a new node
125    and add that new node to their former parent. The boundary nodes are
126    removed in the process.
127    """
128    old_parent = first.getparent()
129    for node in first.itersiblings():
130        if node is last:
131            break
132        # appending a node to a new parent also
133        # remove it from its previous parent
134        new_parent.append(node)
135    old_parent.replace(first, new_parent)
136    old_parent.remove(last)
137
138
139class Template(MarkupTemplate):
140
141    def __init__(self, source, filepath=None, filename=None, loader=None,
142                 encoding=None, lookup='strict', allow_exec=True):
143        self.namespaces = {}
144        self.inner_docs = []
145        self.has_col_loop = False
146        super(Template, self).__init__(source, filepath, filename, loader,
147                                       encoding, lookup, allow_exec)
148
149    def _parse(self, source, encoding):
150        """parses the odf file.
151
152        It adds genshi directives and finds the inner docs.
153        """
154        zf = zipfile.ZipFile(self.filepath)
155        content = zf.read('content.xml')
156        styles = zf.read('styles.xml')
157
158        template = super(Template, self)
159        content = template._parse(self.insert_directives(content), encoding)
160        styles = template._parse(self.insert_directives(styles), encoding)
161        content_files = [('content.xml', content)]
162        styles_files = [('styles.xml', styles)]
163
164        while self.inner_docs:
165            doc = self.inner_docs.pop()
166            c_path, s_path = doc + '/content.xml', doc + '/styles.xml'
167            content = zf.read(c_path)
168            styles = zf.read(s_path)
169
170            c_parsed = template._parse(self.insert_directives(zf.read(c_path)),
171                                       encoding)
172            s_parsed = template._parse(self.insert_directives(zf.read(s_path)),
173                                       encoding)
174            content_files.append((c_path, c_parsed))
175            styles_files.append((s_path, s_parsed))
176
177        zf.close()
178        parsed = []
179        for fpath, fparsed in content_files + styles_files:
180            parsed.append((genshi.core.PI, ('relatorio', fpath), None))
181            parsed += fparsed
182
183        return parsed
184
185    def insert_directives(self, content):
186        """adds the genshi directives, handle the images and the innerdocs.
187        """
188        tree = lxml.etree.parse(StringIO(content))
189        root = tree.getroot()
190        self.namespaces = root.nsmap.copy()
191        self.namespaces['py'] = 'http://genshi.edgewall.org/'
192        self.namespaces['relatorio'] = RELATORIO_URI
193
194        self._invert_style(tree)
195        self._handle_relatorio_tags(tree)
196        self._handle_images(tree)
197        self._handle_innerdocs(tree)
198        return StringIO(lxml.etree.tostring(tree))
199
200    def _invert_style(self, tree):
201        "inverts the text:a and text:span"
202        xpath_expr = "//text:a[starts-with(@xlink:href, 'relatorio://')]" \
203                     "/text:span"
204        for span in tree.xpath(xpath_expr, namespaces=self.namespaces):
205            text_a = span.getparent()
206            outer = text_a.getparent()
207            text_a.text = span.text
208            span.text = ''
209            text_a.remove(span)
210            outer.replace(text_a, span)
211            span.append(text_a)
212
213    def _relatorio_statements(self, tree):
214        "finds the relatorio statements (text:a/text:placeholder)"
215        # If this node href matches the relatorio URL it is kept.
216        # If this node href matches a genshi directive it is kept for further
217        # processing.
218        xlink_href_attrib = '{%s}href' % self.namespaces['xlink']
219        text_a = '{%s}a' % self.namespaces['text']
220        placeholder = '{%s}placeholder' % self.namespaces['text']
221        s_xpath = "//text:a[starts-with(@xlink:href, 'relatorio://')]" \
222                  "| //text:placeholder"
223
224        r_statements = []
225        opened_tags = []
226        # We map each opening tag with its closing tag
227        closing_tags = {}
228        for statement in tree.xpath(s_xpath, namespaces=self.namespaces):
229            if statement.tag == placeholder:
230                expr = statement.text[1:-1]
231            elif statement.tag == text_a:
232                expr = urllib.unquote(statement.attrib[xlink_href_attrib][12:])
233
234            if not expr:
235                raise OOTemplateError("No expression in the tag",
236                                      self.filepath)
237            closing, directive, attr, attr_val = \
238                    GENSHI_EXPR.match(expr).groups()
239            is_opening = closing != '/'
240
241            warn_msg = None
242            if not statement.text:
243                warn_msg = "No statement text in '%s' for '%s'" \
244                           % (self.filepath, expr)
245            elif expr != statement.text and statement.tag == text_a:
246                warn_msg = "url and text do not match in %s: %s != %s" \
247                           % (self.filepath, expr,
248                              statement.text.encode('utf-8'))
249            if warn_msg:
250                if directive is not None and not is_opening:
251                    warn_msg += " corresponding to opening tag '%s'" \
252                                % opened_tags[-1].text
253                warnings.warn(warn_msg)
254
255            if directive is not None:
256                # map closing tags with their opening tag
257                if is_opening:
258                    opened_tags.append(statement)
259                else:
260                    closing_tags[id(opened_tags.pop())] = statement
261            # - we only need to return opening statements
262            if is_opening:
263                r_statements.append((statement,
264                                     (expr, directive, attr, attr_val))
265                                   )
266        assert not opened_tags
267        return r_statements, closing_tags
268
269    def _handle_relatorio_tags(self, tree):
270        """
271        Will treat all relatorio tag (py:if/for/choose/when/otherwise)
272        tags
273        """
274        # Some tag/attribute name constants
275        table_namespace = self.namespaces['table']
276        table_row_tag = '{%s}table-row' % table_namespace
277        table_cell_tag = '{%s}table-cell' % table_namespace
278
279        office_name = '{%s}value' % self.namespaces['office']
280        office_valuetype = '{%s}value-type' % self.namespaces['office']
281
282        py_namespace = self.namespaces['py']
283        py_attrs_attr = '{%s}attrs' % py_namespace
284        py_replace = '{%s}replace' % py_namespace
285
286        r_statements, closing_tags = self._relatorio_statements(tree)
287
288        for r_node, parsed in r_statements:
289            expr, directive, attr, a_val = parsed
290
291            # If the node is a genshi directive statement:
292            if directive is not None:
293                opening = r_node
294                closing = closing_tags[id(r_node)]
295
296                # - we find the nearest common ancestor of the closing and
297                #   opening statements
298                o_ancestors = []
299                c_ancestors = list(closing.iterancestors())
300                ancestor = None
301                for node in opening.iterancestors():
302                    try:
303                        idx = c_ancestors.index(node)
304                        assert c_ancestors[idx] == node
305                        # we only need ancestors up to the common one
306                        del c_ancestors[idx:]
307                        ancestor = node
308                        break
309                    except ValueError:
310                        # c_ancestors.index(node) raise ValueError if node is
311                        # not a child of c_ancestors
312                        pass
313                    o_ancestors.append(node)
314                assert ancestor is not None, \
315                       "No common ancestor found for opening and closing tag"
316                outermost_o_ancestor = o_ancestors[-1]
317                outermost_c_ancestor = c_ancestors[-1]
318
319                # handle horizontal repetitions (over columns)
320                if directive == "for" and ancestor.tag == table_row_tag:
321                    a_val = self._handle_column_loops(parsed, ancestor,
322                                                      opening,
323                                                      outermost_o_ancestor,
324                                                      outermost_c_ancestor)
325
326                # - we create a <py:xxx> node
327                genshi_node = EtreeElement('{%s}%s' % (py_namespace,
328                                                       directive),
329                                           attrib={attr: a_val},
330                                           nsmap=self.namespaces)
331
332                # - we move all the nodes between the opening and closing
333                #   statements to this new node (append also removes from old
334                #   parent)
335                # - we replace the opening statement by the <py:xxx> node
336                # - we delete the closing statement (and its ancestors)
337                wrap_nodes_between(outermost_o_ancestor, outermost_c_ancestor,
338                                   genshi_node)
339            else:
340                # It's not a genshi statement it's a python expression
341                r_node.attrib[py_replace] = expr
342                parent = r_node.getparent().getparent()
343                if parent is None or parent.tag != table_cell_tag:
344                    continue
345
346                # The grand-parent tag is a table cell we should set the
347                # correct value and type for this cell.
348                dico = "{'%s': %s, '%s': __relatorio_guess_type(%s)}"
349                parent.attrib[py_attrs_attr] = dico % (office_name, expr,
350                                                       office_valuetype, expr)
351                parent.attrib.pop(office_valuetype, None)
352                parent.attrib.pop(office_name, None)
353
354    def _handle_column_loops(self, statement, ancestor, opening,
355                             outer_o_node, outer_c_node):
356        _, directive, attr, a_val = statement
357
358        self.has_col_loop = True
359
360        table_namespace = self.namespaces['table']
361        table_col_tag = '{%s}table-column' % table_namespace
362        table_num_col_attr = '{%s}number-columns-repeated' % table_namespace
363
364        py_namespace = self.namespaces['py']
365        py_attrs_attr = '{%s}attrs' % py_namespace
366
367        repeat_tag = '{%s}repeat' % self.namespaces['relatorio']
368
369        # table node (it is not necessarily the direct parent of ancestor)
370        table_node = ancestor.iterancestors('{%s}table' % table_namespace) \
371                             .next()
372        table_name = table_node.attrib['{%s}name' % table_namespace]
373
374        # add counting instructions
375        loop_id = id(opening)
376
377        # 1) add reset counter code on the row opening tag
378        #    (through a py:attrs attribute).
379        # Note that table_name is not needed in the first two
380        # operations, but a unique id within the table is required
381        # to support nested column repetition
382        ancestor.attrib[py_attrs_attr] = \
383            "__relatorio_reset_col_count(%d)" % loop_id
384
385        # 2) add increment code (through a py:attrs attribute) on
386        #    the first cell node after the opening (cell node)
387        #    ancestor
388        enclosed_cell = outer_o_node.getnext()
389        assert enclosed_cell.tag == '{%s}table-cell' % table_namespace
390        enclosed_cell.attrib[py_attrs_attr] = \
391            "__relatorio_inc_col_count(%d)" % loop_id
392
393        # 3) add "store count" code as a py:replace node, as the
394        #    last child of the row
395        attr_value = "__relatorio_store_col_count(%d, %r)" \
396                     % (loop_id, table_name)
397        replace_node = EtreeElement('{%s}replace' % py_namespace,
398                                    attrib={'value': attr_value},
399                                    nsmap=self.namespaces)
400        ancestor.append(replace_node)
401
402        # find the position in the row of the cells holding the
403        # <for> and </for> instructions
404        # We use "*" so as to count both normal cells and covered/hidden cells
405        position_xpath_expr = 'count(preceding-sibling::*)'
406        opening_pos = \
407            int(outer_o_node.xpath(position_xpath_expr,
408                                   namespaces=self.namespaces))
409        closing_pos = \
410            int(outer_c_node.xpath(position_xpath_expr,
411                                   namespaces=self.namespaces))
412
413        # check whether or not the opening tag spans several rows
414        a_val = self._handle_row_spanned_column_loops(
415                    statement, outer_o_node, opening_pos, closing_pos)
416
417        # check if this table's headers were already processed
418        repeat_node = table_node.find(repeat_tag)
419        if repeat_node is not None:
420            prev_pos = (int(repeat_node.attrib['opening']),
421                        int(repeat_node.attrib['closing']))
422            if (opening_pos, closing_pos) != prev_pos:
423                raise Exception(
424                    'Incoherent column repetition found! '
425                    'If a table has several lines with repeated '
426                    'columns, the repetition need to be on the '
427                    'same columns across all lines.')
428        else:
429            # compute splits: oo collapses the headers of adjacent
430            # columns which use the same style. We need to split
431            # any column header which is repeated so many times
432            # that it encompasses any of the column headers that
433            # we need to repeat
434            to_split = []
435            idx = 0
436            childs = list(table_node.iterchildren(table_col_tag))
437            for tag in childs:
438                inc = int(tag.attrib.get(table_num_col_attr, 1))
439                oldidx = idx
440                idx += inc
441                if oldidx < opening_pos < idx or \
442                   oldidx < closing_pos < idx:
443                    to_split.append(tag)
444
445            # split tags
446            for tag in to_split:
447                tag_pos = table_node.index(tag)
448                num = int(tag.attrib.pop(table_num_col_attr))
449                new_tags = [deepcopy(tag) for _ in range(num)]
450                table_node[tag_pos:tag_pos+1] = new_tags
451
452            # recompute the list of column headers as it could
453            # have changed.
454            coldefs = list(table_node.iterchildren(table_col_tag))
455
456            # compute the column header nodes corresponding to
457            # the opening and closing tags.
458            first = table_node[opening_pos]
459            last = table_node[closing_pos]
460
461            # add a <relatorio:repeat> node around the column
462            # definitions nodes
463            attribs = {
464               "opening": str(opening_pos),
465               "closing": str(closing_pos),
466               "table": table_name
467            }
468            repeat_node = EtreeElement(repeat_tag, attrib=attribs,
469                                       nsmap=self.namespaces)
470            wrap_nodes_between(first, last, repeat_node)
471        return a_val
472
473    def _handle_row_spanned_column_loops(self, statement, outer_o_node,
474                                         opening_pos, closing_pos):
475        """handles column repetitions which span several rows, by duplicating
476        the py:for node for each row, and make the loops work on a copy of the
477        original iterable as to not exhaust generators."""
478
479        _, directive, attr, a_val = statement
480        table_rowspan_attr = '{%s}number-rows-spanned' \
481                             % self.namespaces['table']
482
483        # checks wether there is a (meaningful) rowspan
484        rows_spanned = int(outer_o_node.attrib.get(table_rowspan_attr, 1))
485        if rows_spanned == 1:
486            return a_val
487
488        py_namespace = self.namespaces['py']
489        table_namespace = self.namespaces['table']
490        table_row_tag = '{%s}table-row' % table_namespace
491        table_cov_cell_tag = '{%s}covered-table-cell' % table_namespace
492
493        # if so, we need to:
494
495        # 1) create a with node to define a temporary variable
496        temp_var = "__relatorio_temp%d" % id(outer_o_node)
497        # a_val == "target in iterable"
498        target, iterable = a_val.split(' in ', 1)
499        vars = "%s = list(%s)" % (temp_var, iterable.strip())
500        with_node = EtreeElement('{%s}with' % py_namespace,
501                                 attrib={"vars": vars},
502                                 nsmap=self.namespaces)
503
504        # 2) transform a_val to use that temporary variable
505        a_val = "%s in %s" % (target, temp_var)
506
507        # 3) wrap the corresponding cells on the next row(s)
508        #    (those should be covered-table-cell) inside a
509        #    duplicate py:for node (looping on the temporary
510        #    variable).
511        row_node = outer_o_node.getparent()
512        row_node.addprevious(with_node)
513        rows_to_wrap = [row_node]
514        assert row_node.tag == table_row_tag
515        next_rows = row_node.itersiblings(table_row_tag)
516        for row_idx in range(rows_spanned-1):
517            next_row_node = next_rows.next()
518            rows_to_wrap.append(next_row_node)
519            # compute the start and end nodes
520            first = next_row_node[opening_pos]
521            last = next_row_node[closing_pos]
522            assert first.tag == table_cov_cell_tag
523            assert last.tag == table_cov_cell_tag
524            # wrap them
525            tag = '{%s}%s' % (py_namespace, directive)
526            for_node = EtreeElement(tag,
527                                    attrib={attr: a_val},
528                                    nsmap=self.namespaces)
529            wrap_nodes_between(first, last, for_node)
530
531        # 4) wrap all the corresponding rows indide the "with"
532        #    node
533        for node in rows_to_wrap:
534            with_node.append(node)
535        return a_val
536
537    def _handle_images(self, tree):
538        "replaces all draw:frame named 'image: ...' by draw:image nodes"
539        draw_name = '{%s}name' % self.namespaces['draw']
540        draw_image = '{%s}image' % self.namespaces['draw']
541        py_attrs = '{%s}attrs' % self.namespaces['py']
542        xpath_expr = "//draw:frame[starts-with(@draw:name, 'image:')]"
543        for draw in tree.xpath(xpath_expr, namespaces=self.namespaces):
544            d_name = draw.attrib[draw_name]
545            attr_expr = "__relatorio_make_href(%s)" % d_name[7:]
546            image_node = EtreeElement(draw_image,
547                                      attrib={py_attrs: attr_expr},
548                                      nsmap=self.namespaces)
549            draw.replace(draw[0], image_node)
550
551    def _handle_innerdocs(self, tree):
552        "finds inner_docs and adds them to the processing stack."
553        href_attrib = '{%s}href' % self.namespaces['xlink']
554        xpath_expr = "//draw:object[starts-with(@xlink:href, './')" \
555                     "and @xlink:show='embed']"
556        for draw in tree.xpath(xpath_expr, namespaces=self.namespaces):
557            self.inner_docs.append(draw.attrib[href_attrib][2:])
558
559    def generate(self, *args, **kwargs):
560        "creates the RelatorioStream."
561        serializer = OOSerializer(self.filepath)
562        kwargs['__relatorio_make_href'] = ImageHref(serializer.outzip, kwargs)
563        kwargs['__relatorio_guess_type'] = guess_type
564
565        counter = ColumnCounter()
566        kwargs['__relatorio_reset_col_count'] = counter.reset
567        kwargs['__relatorio_inc_col_count'] = counter.inc
568        kwargs['__relatorio_store_col_count'] = counter.store
569
570        stream = super(Template, self).generate(*args, **kwargs)
571        if self.has_col_loop:
572            transformation = DuplicateColumnHeaders(counter)
573            col_filter = Transformer('//repeat[namespace-uri()="%s"]'
574                                     % RELATORIO_URI)
575            col_filter = col_filter.apply(transformation)
576            stream = Stream(list(stream), self.serializer) | col_filter
577        return RelatorioStream(stream, serializer)
578
579
580class DuplicateColumnHeaders(object):
581    def __init__(self, counter):
582        self.counter = counter
583
584    def __call__(self, stream):
585        for mark, (kind, data, pos) in stream:
586            # for each repeat tag found
587            if mark is ENTER:
588                # get the number of columns for that table
589                attrs = data[1]
590                table = attrs.get('table')
591                col_count = self.counter.counters[table]
592
593                # collect events (column header tags) to repeat
594                events = []
595                for submark, event in stream:
596                    if submark is EXIT:
597                        break
598                    events.append(event)
599
600                # repeat them
601                for _ in range(col_count):
602                    for event in events:
603                        yield None, event
604            else:
605                yield mark, (kind, data, pos)
606
607
608class OOSerializer:
609
610    def __init__(self, oo_path):
611        self.inzip = zipfile.ZipFile(oo_path)
612        self.new_oo = StringIO()
613        self.outzip = zipfile.ZipFile(self.new_oo, 'w')
614        self.xml_serializer = genshi.output.XMLSerializer()
615
616    def __call__(self, stream):
617        files = {}
618        for kind, data, pos in stream:
619            if kind == genshi.core.PI and data[0] == 'relatorio':
620                stream_for = data[1]
621                continue
622            files.setdefault(stream_for, []).append((kind, data, pos))
623
624        now = time.localtime()[:6]
625        for f_info in self.inzip.infolist():
626            if f_info.filename.startswith('ObjectReplacements'):
627                continue
628            elif f_info.filename in files:
629                stream = files[f_info.filename]
630                # create a new file descriptor, copying some attributes from
631                # the original file
632                new_info = zipfile.ZipInfo(f_info.filename, now)
633                for attr in ('compress_type', 'flag_bits', 'create_system'):
634                    setattr(new_info, attr, getattr(f_info, attr))
635                serialized_stream = output_encode(self.xml_serializer(stream))
636                self.outzip.writestr(new_info, serialized_stream)
637            else:
638                self.outzip.writestr(f_info, self.inzip.read(f_info.filename))
639        self.inzip.close()
640        self.outzip.close()
641
642        return self.new_oo
643
644MIMETemplateLoader.add_factory('oo.org', Template)
Note: See TracBrowser for help on using the browser.