root/relatorio/templates/opendocument.py @ 99:1efd21aa821f

Revision 99:1efd21aa821f, 25.4 kB (checked in by Ga?tan de Menten <ged@…>, 4 years ago)

- move column loop support code to its own method
- fix typos in tests

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