root/relatorio/templates/opendocument.py @ 121:90c6fb00ea5a

Revision 121:90c6fb00ea5a, 26.2 kB (checked in by Nicolas ?vrard <nicoe@…>, 4 years ago)

Merge with openhex-branch

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