root/relatorio/templates/opendocument.py @ 104:65a41313fe3a

Revision 104:65a41313fe3a, 25.8 kB (checked in by Ga?tan de Menten <ged@…>, 4 years ago)

fix the case where the opening directive and closing directive are on the same
line without any style (ie the outermost_x_ancestors are the opening and
closing tags themselves).

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(content),
171                                       encoding)
172            s_parsed = template._parse(self.insert_directives(styles),
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 = [opening]
299                c_ancestors = [closing] + 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
317                outermost_o_ancestor = o_ancestors[-1]
318                outermost_c_ancestor = c_ancestors[-1]
319
320                # handle horizontal repetitions (over columns)
321                if directive == "for" and ancestor.tag == table_row_tag:
322                    a_val = self._handle_column_loops(parsed, ancestor,
323                                                      opening,
324                                                      outermost_o_ancestor,
325                                                      outermost_c_ancestor)
326
327                # - we create a <py:xxx> node
328                genshi_node = EtreeElement('{%s}%s' % (py_namespace,
329                                                       directive),
330                                           attrib={attr: a_val},
331                                           nsmap=self.namespaces)
332
333                # - we move all the nodes between the opening and closing
334                #   statements to this new node (append also removes from old
335                #   parent)
336                # - we replace the opening statement by the <py:xxx> node
337                # - we delete the closing statement (and its ancestors)
338                wrap_nodes_between(outermost_o_ancestor, outermost_c_ancestor,
339                                   genshi_node)
340            else:
341                # It's not a genshi statement it's a python expression
342                r_node.attrib[py_replace] = expr
343                parent = r_node.getparent().getparent()
344                if parent is None or parent.tag != table_cell_tag:
345                    continue
346
347                # The grand-parent tag is a table cell we should set the
348                # correct value and type for this cell.
349                dico = "{'%s': %s, '%s': __relatorio_guess_type(%s)}"
350                parent.attrib[py_attrs_attr] = dico % (office_name, expr,
351                                                       office_valuetype, expr)
352                parent.attrib.pop(office_valuetype, None)
353                parent.attrib.pop(office_name, None)
354
355    def _handle_column_loops(self, statement, ancestor, opening,
356                             outer_o_node, outer_c_node):
357        _, directive, attr, a_val = statement
358
359        self.has_col_loop = True
360
361        table_namespace = self.namespaces['table']
362        table_col_tag = '{%s}table-column' % table_namespace
363        table_num_col_attr = '{%s}number-columns-repeated' % table_namespace
364
365        py_namespace = self.namespaces['py']
366        py_attrs_attr = '{%s}attrs' % py_namespace
367
368        repeat_tag = '{%s}repeat' % self.namespaces['relatorio']
369
370        # table node (it is not necessarily the direct parent of ancestor)
371        table_node = ancestor.iterancestors('{%s}table' % table_namespace) \
372                             .next()
373        table_name = table_node.attrib['{%s}name' % table_namespace]
374
375        # add counting instructions
376        loop_id = id(opening)
377
378        # 1) add reset counter code on the row opening tag
379        #    (through a py:attrs attribute).
380        # Note that table_name is not needed in the first two
381        # operations, but a unique id within the table is required
382        # to support nested column repetition
383        ancestor.attrib[py_attrs_attr] = \
384            "__relatorio_reset_col_count(%d)" % loop_id
385
386        # 2) add increment code (through a py:attrs attribute) on
387        #    the first cell node after the opening (cell node)
388        #    ancestor
389        enclosed_cell = outer_o_node.getnext()
390        assert enclosed_cell.tag == '{%s}table-cell' % table_namespace
391        enclosed_cell.attrib[py_attrs_attr] = \
392            "__relatorio_inc_col_count(%d)" % loop_id
393
394        # 3) add "store count" code as a py:replace node, as the
395        #    last child of the row
396        attr_value = "__relatorio_store_col_count(%d, %r)" \
397                     % (loop_id, table_name)
398        replace_node = EtreeElement('{%s}replace' % py_namespace,
399                                    attrib={'value': attr_value},
400                                    nsmap=self.namespaces)
401        ancestor.append(replace_node)
402
403        # find the position in the row of the cells holding the
404        # <for> and </for> instructions
405        # We use "*" so as to count both normal cells and covered/hidden cells
406        position_xpath_expr = 'count(preceding-sibling::*)'
407        opening_pos = \
408            int(outer_o_node.xpath(position_xpath_expr,
409                                   namespaces=self.namespaces))
410        closing_pos = \
411            int(outer_c_node.xpath(position_xpath_expr,
412                                   namespaces=self.namespaces))
413
414        # check whether or not the opening tag spans several rows
415        a_val = self._handle_row_spanned_column_loops(
416                    statement, outer_o_node, opening_pos, closing_pos)
417
418        # check if this table's headers were already processed
419        repeat_node = table_node.find(repeat_tag)
420        if repeat_node is not None:
421            prev_pos = (int(repeat_node.attrib['opening']),
422                        int(repeat_node.attrib['closing']))
423            if (opening_pos, closing_pos) != prev_pos:
424                raise Exception(
425                    'Incoherent column repetition found! '
426                    'If a table has several lines with repeated '
427                    'columns, the repetition need to be on the '
428                    'same columns across all lines.')
429        else:
430            # compute splits: oo collapses the headers of adjacent
431            # columns which use the same style. We need to split
432            # any column header which is repeated so many times
433            # that it encompasses any of the column headers that
434            # we need to repeat
435            to_split = []
436            idx = 0
437            childs = list(table_node.iterchildren(table_col_tag))
438            for tag in childs:
439                inc = int(tag.attrib.get(table_num_col_attr, 1))
440                oldidx = idx
441                idx += inc
442                if oldidx < opening_pos < idx or \
443                   oldidx < closing_pos < idx:
444                    to_split.append(tag)
445
446            # split tags
447            for tag in to_split:
448                tag_pos = table_node.index(tag)
449                num = int(tag.attrib.pop(table_num_col_attr))
450                new_tags = [deepcopy(tag) for _ in range(num)]
451                table_node[tag_pos:tag_pos+1] = new_tags
452
453            # recompute the list of column headers as it could
454            # have changed.
455            coldefs = list(table_node.iterchildren(table_col_tag))
456
457            # compute the column header nodes corresponding to
458            # the opening and closing tags.
459            first = table_node[opening_pos]
460            last = table_node[closing_pos]
461
462            # add a <relatorio:repeat> node around the column
463            # definitions nodes
464            attribs = {
465               "opening": str(opening_pos),
466               "closing": str(closing_pos),
467               "table": table_name
468            }
469            repeat_node = EtreeElement(repeat_tag, attrib=attribs,
470                                       nsmap=self.namespaces)
471            wrap_nodes_between(first, last, repeat_node)
472        return a_val
473
474    def _handle_row_spanned_column_loops(self, statement, outer_o_node,
475                                         opening_pos, closing_pos):
476        """handles column repetitions which span several rows, by duplicating
477        the py:for node for each row, and make the loops work on a copy of the
478        original iterable as to not exhaust generators."""
479
480        _, directive, attr, a_val = statement
481        table_rowspan_attr = '{%s}number-rows-spanned' \
482                             % self.namespaces['table']
483
484        # checks wether there is a (meaningful) rowspan
485        rows_spanned = int(outer_o_node.attrib.get(table_rowspan_attr, 1))
486        if rows_spanned == 1:
487            return a_val
488
489        py_namespace = self.namespaces['py']
490        table_namespace = self.namespaces['table']
491        table_row_tag = '{%s}table-row' % table_namespace
492        table_cov_cell_tag = '{%s}covered-table-cell' % table_namespace
493
494        # if so, we need to:
495
496        # 1) create a with node to define a temporary variable
497        temp_var = "__relatorio_temp%d" % id(outer_o_node)
498        # a_val == "target in iterable"
499        target, iterable = a_val.split(' in ', 1)
500        vars = "%s = list(%s)" % (temp_var, iterable.strip())
501        with_node = EtreeElement('{%s}with' % py_namespace,
502                                 attrib={"vars": vars},
503                                 nsmap=self.namespaces)
504
505        # 2) transform a_val to use that temporary variable
506        a_val = "%s in %s" % (target, temp_var)
507
508        # 3) wrap the corresponding cells on the next row(s)
509        #    (those should be covered-table-cell) inside a
510        #    duplicate py:for node (looping on the temporary
511        #    variable).
512        row_node = outer_o_node.getparent()
513        row_node.addprevious(with_node)
514        rows_to_wrap = [row_node]
515        assert row_node.tag == table_row_tag
516        next_rows = row_node.itersiblings(table_row_tag)
517        for row_idx in range(rows_spanned-1):
518            next_row_node = next_rows.next()
519            rows_to_wrap.append(next_row_node)
520            # compute the start and end nodes
521            first = next_row_node[opening_pos]
522            last = next_row_node[closing_pos]
523            assert first.tag == table_cov_cell_tag
524            assert last.tag == table_cov_cell_tag
525            # wrap them
526            tag = '{%s}%s' % (py_namespace, directive)
527            for_node = EtreeElement(tag,
528                                    attrib={attr: a_val},
529                                    nsmap=self.namespaces)
530            wrap_nodes_between(first, last, for_node)
531
532        # 4) wrap all the corresponding rows indide the "with"
533        #    node
534        for node in rows_to_wrap:
535            with_node.append(node)
536        return a_val
537
538    def _handle_images(self, tree):
539        "replaces all draw:frame named 'image: ...' by draw:image nodes"
540        draw_name = '{%s}name' % self.namespaces['draw']
541        draw_image = '{%s}image' % self.namespaces['draw']
542        py_attrs = '{%s}attrs' % self.namespaces['py']
543        xpath_expr = "//draw:frame[starts-with(@draw:name, 'image:')]"
544        for draw in tree.xpath(xpath_expr, namespaces=self.namespaces):
545            d_name = draw.attrib[draw_name]
546            attr_expr = "__relatorio_make_href(%s)" % d_name[7:]
547            image_node = EtreeElement(draw_image,
548                                      attrib={py_attrs: attr_expr},
549                                      nsmap=self.namespaces)
550            draw.replace(draw[0], image_node)
551
552    def _handle_innerdocs(self, tree):
553        "finds inner_docs and adds them to the processing stack."
554        href_attrib = '{%s}href' % self.namespaces['xlink']
555        xpath_expr = "//draw:object[starts-with(@xlink:href, './')" \
556                     "and @xlink:show='embed']"
557        for draw in tree.xpath(xpath_expr, namespaces=self.namespaces):
558            self.inner_docs.append(draw.attrib[href_attrib][2:])
559
560    def generate(self, *args, **kwargs):
561        "creates the RelatorioStream."
562        serializer = OOSerializer(self.filepath)
563        kwargs['__relatorio_make_href'] = ImageHref(serializer.outzip, kwargs)
564        kwargs['__relatorio_guess_type'] = guess_type
565
566        counter = ColumnCounter()
567        kwargs['__relatorio_reset_col_count'] = counter.reset
568        kwargs['__relatorio_inc_col_count'] = counter.inc
569        kwargs['__relatorio_store_col_count'] = counter.store
570
571        stream = super(Template, self).generate(*args, **kwargs)
572        if self.has_col_loop:
573            transformation = DuplicateColumnHeaders(counter)
574            col_filter = Transformer('//repeat[namespace-uri()="%s"]'
575                                     % RELATORIO_URI)
576            col_filter = col_filter.apply(transformation)
577            stream = Stream(list(stream), self.serializer) | col_filter
578        return RelatorioStream(stream, serializer)
579
580
581class DuplicateColumnHeaders(object):
582    def __init__(self, counter):
583        self.counter = counter
584
585    def __call__(self, stream):
586        for mark, (kind, data, pos) in stream:
587            # for each repeat tag found
588            if mark is ENTER:
589                # get the number of columns for that table
590                attrs = data[1]
591                table = attrs.get('table')
592                col_count = self.counter.counters[table]
593
594                # collect events (column header tags) to repeat
595                events = []
596                for submark, event in stream:
597                    if submark is EXIT:
598                        break
599                    events.append(event)
600
601                # repeat them
602                for _ in range(col_count):
603                    for event in events:
604                        yield None, event
605            else:
606                yield mark, (kind, data, pos)
607
608
609class OOSerializer:
610
611    def __init__(self, oo_path):
612        self.inzip = zipfile.ZipFile(oo_path)
613        self.new_oo = StringIO()
614        self.outzip = zipfile.ZipFile(self.new_oo, 'w')
615        self.xml_serializer = genshi.output.XMLSerializer()
616
617    def __call__(self, stream):
618        files = {}
619        for kind, data, pos in stream:
620            if kind == genshi.core.PI and data[0] == 'relatorio':
621                stream_for = data[1]
622                continue
623            files.setdefault(stream_for, []).append((kind, data, pos))
624
625        now = time.localtime()[:6]
626        for f_info in self.inzip.infolist():
627            if f_info.filename.startswith('ObjectReplacements'):
628                continue
629            elif f_info.filename in files:
630                stream = files[f_info.filename]
631                # create a new file descriptor, copying some attributes from
632                # the original file
633                new_info = zipfile.ZipInfo(f_info.filename, now)
634                for attr in ('compress_type', 'flag_bits', 'create_system'):
635                    setattr(new_info, attr, getattr(f_info, attr))
636                serialized_stream = output_encode(self.xml_serializer(stream))
637                self.outzip.writestr(new_info, serialized_stream)
638            else:
639                self.outzip.writestr(f_info, self.inzip.read(f_info.filename))
640        self.inzip.close()
641        self.outzip.close()
642
643        return self.new_oo
644
645MIMETemplateLoader.add_factory('oo.org', Template)
Note: See TracBrowser for help on using the browser.