1#!/usr/bin/env python3
2# -*- coding: utf-8; mode: python -*-
3# pylint: disable=C0330, R0903, R0912
4
5u"""
6    flat-table
7    ~~~~~~~~~~
8
9    Implementation of the ``flat-table`` reST-directive.
10
11    :copyright:  Copyright (C) 2016  Markus Heiser
12    :license:    GPL Version 2, June 1991 see linux/COPYING for details.
13
14    The ``flat-table`` (:py:class:`FlatTable`) is a double-stage list similar to
15    the ``list-table`` with some additional features:
16
17    * *column-span*: with the role ``cspan`` a cell can be extended through
18      additional columns
19
20    * *row-span*: with the role ``rspan`` a cell can be extended through
21      additional rows
22
23    * *auto span* rightmost cell of a table row over the missing cells on the
24      right side of that table-row.  With Option ``:fill-cells:`` this behavior
25      can changed from *auto span* to *auto fill*, which automaticly inserts
26      (empty) cells instead of spanning the last cell.
27
28    Options:
29
30    * header-rows:   [int] count of header rows
31    * stub-columns:  [int] count of stub columns
32    * widths:        [[int] [int] ... ] widths of columns
33    * fill-cells:    instead of autospann missing cells, insert missing cells
34
35    roles:
36
37    * cspan: [int] additionale columns (*morecols*)
38    * rspan: [int] additionale rows (*morerows*)
39"""
40
41# ==============================================================================
42# imports
43# ==============================================================================
44
45import sys
46
47from docutils import nodes
48from docutils.parsers.rst import directives, roles
49from docutils.parsers.rst.directives.tables import Table
50from docutils.utils import SystemMessagePropagation
51
52# ==============================================================================
53# common globals
54# ==============================================================================
55
56__version__  = '1.0'
57
58PY3 = sys.version_info[0] == 3
59PY2 = sys.version_info[0] == 2
60
61if PY3:
62    # pylint: disable=C0103, W0622
63    unicode     = str
64    basestring  = str
65
66# ==============================================================================
67def setup(app):
68# ==============================================================================
69
70    app.add_directive("flat-table", FlatTable)
71    roles.register_local_role('cspan', c_span)
72    roles.register_local_role('rspan', r_span)
73
74    return dict(
75        version = __version__,
76        parallel_read_safe = True,
77        parallel_write_safe = True
78    )
79
80# ==============================================================================
81def c_span(name, rawtext, text, lineno, inliner, options=None, content=None):
82# ==============================================================================
83    # pylint: disable=W0613
84
85    options  = options if options is not None else {}
86    content  = content if content is not None else []
87    nodelist = [colSpan(span=int(text))]
88    msglist  = []
89    return nodelist, msglist
90
91# ==============================================================================
92def r_span(name, rawtext, text, lineno, inliner, options=None, content=None):
93# ==============================================================================
94    # pylint: disable=W0613
95
96    options  = options if options is not None else {}
97    content  = content if content is not None else []
98    nodelist = [rowSpan(span=int(text))]
99    msglist  = []
100    return nodelist, msglist
101
102
103# ==============================================================================
104class rowSpan(nodes.General, nodes.Element): pass # pylint: disable=C0103,C0321
105class colSpan(nodes.General, nodes.Element): pass # pylint: disable=C0103,C0321
106# ==============================================================================
107
108# ==============================================================================
109class FlatTable(Table):
110# ==============================================================================
111
112    u"""FlatTable (``flat-table``) directive"""
113
114    option_spec = {
115        'name': directives.unchanged
116        , 'class': directives.class_option
117        , 'header-rows': directives.nonnegative_int
118        , 'stub-columns': directives.nonnegative_int
119        , 'widths': directives.positive_int_list
120        , 'fill-cells' : directives.flag }
121
122    def run(self):
123
124        if not self.content:
125            error = self.state_machine.reporter.error(
126                'The "%s" directive is empty; content required.' % self.name,
127                nodes.literal_block(self.block_text, self.block_text),
128                line=self.lineno)
129            return [error]
130
131        title, messages = self.make_title()
132        node = nodes.Element()          # anonymous container for parsing
133        self.state.nested_parse(self.content, self.content_offset, node)
134
135        tableBuilder = ListTableBuilder(self)
136        tableBuilder.parseFlatTableNode(node)
137        tableNode = tableBuilder.buildTableNode()
138        # SDK.CONSOLE()  # print --> tableNode.asdom().toprettyxml()
139        if title:
140            tableNode.insert(0, title)
141        return [tableNode] + messages
142
143
144# ==============================================================================
145class ListTableBuilder(object):
146# ==============================================================================
147
148    u"""Builds a table from a double-stage list"""
149
150    def __init__(self, directive):
151        self.directive = directive
152        self.rows      = []
153        self.max_cols  = 0
154
155    def buildTableNode(self):
156
157        colwidths    = self.directive.get_column_widths(self.max_cols)
158        if isinstance(colwidths, tuple):
159            # Since docutils 0.13, get_column_widths returns a (widths,
160            # colwidths) tuple, where widths is a string (i.e. 'auto').
161            # See https://sourceforge.net/p/docutils/patches/120/.
162            colwidths = colwidths[1]
163        stub_columns = self.directive.options.get('stub-columns', 0)
164        header_rows  = self.directive.options.get('header-rows', 0)
165
166        table = nodes.table()
167        tgroup = nodes.tgroup(cols=len(colwidths))
168        table += tgroup
169
170
171        for colwidth in colwidths:
172            colspec = nodes.colspec(colwidth=colwidth)
173            # FIXME: It seems, that the stub method only works well in the
174            # absence of rowspan (observed by the html buidler, the docutils-xml
175            # build seems OK).  This is not extraordinary, because there exists
176            # no table directive (except *this* flat-table) which allows to
177            # define coexistent of rowspan and stubs (there was no use-case
178            # before flat-table). This should be reviewed (later).
179            if stub_columns:
180                colspec.attributes['stub'] = 1
181                stub_columns -= 1
182            tgroup += colspec
183        stub_columns = self.directive.options.get('stub-columns', 0)
184
185        if header_rows:
186            thead = nodes.thead()
187            tgroup += thead
188            for row in self.rows[:header_rows]:
189                thead += self.buildTableRowNode(row)
190
191        tbody = nodes.tbody()
192        tgroup += tbody
193
194        for row in self.rows[header_rows:]:
195            tbody += self.buildTableRowNode(row)
196        return table
197
198    def buildTableRowNode(self, row_data, classes=None):
199        classes = [] if classes is None else classes
200        row = nodes.row()
201        for cell in row_data:
202            if cell is None:
203                continue
204            cspan, rspan, cellElements = cell
205
206            attributes = {"classes" : classes}
207            if rspan:
208                attributes['morerows'] = rspan
209            if cspan:
210                attributes['morecols'] = cspan
211            entry = nodes.entry(**attributes)
212            entry.extend(cellElements)
213            row += entry
214        return row
215
216    def raiseError(self, msg):
217        error =  self.directive.state_machine.reporter.error(
218            msg
219            , nodes.literal_block(self.directive.block_text
220                                  , self.directive.block_text)
221            , line = self.directive.lineno )
222        raise SystemMessagePropagation(error)
223
224    def parseFlatTableNode(self, node):
225        u"""parses the node from a :py:class:`FlatTable` directive's body"""
226
227        if len(node) != 1 or not isinstance(node[0], nodes.bullet_list):
228            self.raiseError(
229                'Error parsing content block for the "%s" directive: '
230                'exactly one bullet list expected.' % self.directive.name )
231
232        for rowNum, rowItem in enumerate(node[0]):
233            row = self.parseRowItem(rowItem, rowNum)
234            self.rows.append(row)
235        self.roundOffTableDefinition()
236
237    def roundOffTableDefinition(self):
238        u"""Round off the table definition.
239
240        This method rounds off the table definition in :py:member:`rows`.
241
242        * This method inserts the needed ``None`` values for the missing cells
243        arising from spanning cells over rows and/or columns.
244
245        * recount the :py:member:`max_cols`
246
247        * Autospan or fill (option ``fill-cells``) missing cells on the right
248          side of the table-row
249        """
250
251        y = 0
252        while y < len(self.rows):
253            x = 0
254
255            while x < len(self.rows[y]):
256                cell = self.rows[y][x]
257                if cell is None:
258                    x += 1
259                    continue
260                cspan, rspan = cell[:2]
261                # handle colspan in current row
262                for c in range(cspan):
263                    try:
264                        self.rows[y].insert(x+c+1, None)
265                    except: # pylint: disable=W0702
266                        # the user sets ambiguous rowspans
267                        pass # SDK.CONSOLE()
268                # handle colspan in spanned rows
269                for r in range(rspan):
270                    for c in range(cspan + 1):
271                        try:
272                            self.rows[y+r+1].insert(x+c, None)
273                        except: # pylint: disable=W0702
274                            # the user sets ambiguous rowspans
275                            pass # SDK.CONSOLE()
276                x += 1
277            y += 1
278
279        # Insert the missing cells on the right side. For this, first
280        # re-calculate the max columns.
281
282        for row in self.rows:
283            if self.max_cols < len(row):
284                self.max_cols = len(row)
285
286        # fill with empty cells or cellspan?
287
288        fill_cells = False
289        if 'fill-cells' in self.directive.options:
290            fill_cells = True
291
292        for row in self.rows:
293            x =  self.max_cols - len(row)
294            if x and not fill_cells:
295                if row[-1] is None:
296                    row.append( ( x - 1, 0, []) )
297                else:
298                    cspan, rspan, content = row[-1]
299                    row[-1] = (cspan + x, rspan, content)
300            elif x and fill_cells:
301                for i in range(x):
302                    row.append( (0, 0, nodes.comment()) )
303
304    def pprint(self):
305        # for debugging
306        retVal = "[   "
307        for row in self.rows:
308            retVal += "[ "
309            for col in row:
310                if col is None:
311                    retVal += ('%r' % col)
312                    retVal += "\n    , "
313                else:
314                    content = col[2][0].astext()
315                    if len (content) > 30:
316                        content = content[:30] + "..."
317                    retVal += ('(cspan=%s, rspan=%s, %r)'
318                               % (col[0], col[1], content))
319                    retVal += "]\n    , "
320            retVal = retVal[:-2]
321            retVal += "]\n  , "
322        retVal = retVal[:-2]
323        return retVal + "]"
324
325    def parseRowItem(self, rowItem, rowNum):
326        row = []
327        childNo = 0
328        error   = False
329        cell    = None
330        target  = None
331
332        for child in rowItem:
333            if (isinstance(child , nodes.comment)
334                or isinstance(child, nodes.system_message)):
335                pass
336            elif isinstance(child , nodes.target):
337                target = child
338            elif isinstance(child, nodes.bullet_list):
339                childNo += 1
340                cell = child
341            else:
342                error = True
343                break
344
345        if childNo != 1 or error:
346            self.raiseError(
347                'Error parsing content block for the "%s" directive: '
348                'two-level bullet list expected, but row %s does not '
349                'contain a second-level bullet list.'
350                % (self.directive.name, rowNum + 1))
351
352        for cellItem in cell:
353            cspan, rspan, cellElements = self.parseCellItem(cellItem)
354            if target is not None:
355                cellElements.insert(0, target)
356            row.append( (cspan, rspan, cellElements) )
357        return row
358
359    def parseCellItem(self, cellItem):
360        # search and remove cspan, rspan colspec from the first element in
361        # this listItem (field).
362        cspan = rspan = 0
363        if not len(cellItem):
364            return cspan, rspan, []
365        for elem in cellItem[0]:
366            if isinstance(elem, colSpan):
367                cspan = elem.get("span")
368                elem.parent.remove(elem)
369                continue
370            if isinstance(elem, rowSpan):
371                rspan = elem.get("span")
372                elem.parent.remove(elem)
373                continue
374        return cspan, rspan, cellItem[:]
375