├── .gitignore ├── README.md ├── LICENSE ├── demo.py └── table2ascii.py /.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !*/ 3 | 4 | !*.py 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # table2ascii 2 | 3 | A simple algorithm implemented in python to easily draw complex tables. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2015-2016 Franklin "Snaipe" Mathieu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | from table2ascii import table2ascii 2 | 3 | TABLE = { 4 | 'node': 'table', 5 | 'colspec': [24, 12, 10, 10], 6 | 'rowspec': [2, 1, 1, 1, 1], 7 | 'children': [ 8 | { 9 | 'node': 'head', 10 | 'children': [ 11 | { 12 | 'node': 'row', 13 | 'children': [ 14 | {'node': 'cell', 'data': 'Header row, column 1\n(header rows optional)'}, 15 | {'node': 'cell', 'data': 'Header 2'}, 16 | {'node': 'cell', 'data': 'Header 3'}, 17 | {'node': 'cell', 'data': 'Header 4'}, 18 | ] 19 | } 20 | ] 21 | }, 22 | { 23 | 'node': 'body', 24 | 'children': [ 25 | { 26 | 'node': 'row', 27 | 'children': [ 28 | {'node': 'cell', 'data': 'body row 1, column 1'}, 29 | {'node': 'cell', 'data': 'column 2'}, 30 | {'node': 'cell', 'data': 'column 3'}, 31 | {'node': 'cell', 'data': 'column 4'}, 32 | ] 33 | }, 34 | { 35 | 'node': 'row', 36 | 'children': [ 37 | {'node': 'cell', 'data': 'body row 2'}, 38 | {'node': 'cell', 'data': 'Cells may span columns.', 'morecols': 2}, 39 | ], 40 | }, 41 | { 42 | 'node': 'row', 43 | 'children': [ 44 | {'node': 'cell', 'data': 'body row 3'}, 45 | {'node': 'cell', 'data': 'Cells may span rows.', 'morerows': 1}, 46 | {'node': 'cell', 'data': 'Cells may span both rows and columns.', 'morerows': 1, 'morecols': 1}, 47 | ], 48 | }, 49 | { 50 | 'node': 'row', 51 | 'children': [ 52 | {'node': 'cell', 'data': 'body row 4'}, 53 | ], 54 | } 55 | ] 56 | } 57 | ] 58 | } 59 | 60 | print(table2ascii(TABLE)) 61 | -------------------------------------------------------------------------------- /table2ascii.py: -------------------------------------------------------------------------------- 1 | from textwrap import wrap 2 | 3 | class SkipChildren(Exception): 4 | pass 5 | 6 | class Visitor(object): 7 | 8 | def visit(self, node): 9 | """ 10 | Visit a generic node. Calls 'visit_' + node_name on the node, 11 | then visit its children, then calls 'depart_' + node_name. 12 | """ 13 | def noop(node): 14 | pass 15 | 16 | try: 17 | visit_fn = getattr(self, 'visit_' + node['node']) 18 | except AttributeError: 19 | visit_fn = noop 20 | 21 | try: 22 | depart_fn = getattr(self, 'depart_' + node['node']) 23 | except AttributeError: 24 | depart_fn = noop 25 | 26 | try: 27 | visit_fn(node) 28 | for n in node.get('children', []): 29 | self.visit(n) 30 | except SkipChildren: 31 | return 32 | finally: 33 | depart_fn(node) 34 | 35 | class BaseTableVisitor(Visitor): 36 | 37 | def __init__(self): 38 | Visitor.__init__(self) 39 | self.lines = [''] 40 | self.line = 0 41 | self.cursor = 0 42 | self.col = 0 43 | self.row = 0 44 | self.widths = [] 45 | self.heights = [] 46 | 47 | def _rewrite_in_line(self, line_index, from_index, repl): 48 | """ 49 | Overwrites a part of the specified line, 50 | starting from the specified index, with another string. 51 | """ 52 | 53 | line = self.lines[line_index] 54 | line = line[:from_index] + repl + line[from_index + len(repl):] 55 | self.lines[line_index] = line 56 | 57 | def visit_row(self, node): 58 | self.col = 0 59 | self.cursor = 0 60 | 61 | def depart_row(self, node): 62 | self.line += self.heights[self.row] + 1 63 | self.row += 1 64 | 65 | def _get_cols(self, node, col): 66 | """ 67 | Returns the number of columns this cell spans, 68 | and its width in character columns. 69 | 70 | Args: 71 | node: the node of the cell. 72 | col: the column index of this cell. 73 | """ 74 | cols = node.get('morecols', 0) + 1 75 | width = sum(self.widths[col:col + cols]) + (cols - 1) 76 | return cols, width 77 | 78 | def _get_rows(self, node, row): 79 | """ 80 | Returns the number of rows this cell spans, 81 | and its height in lines. 82 | 83 | Args: 84 | node: the node of the cell. 85 | row: the row index of this cell. 86 | """ 87 | rows = node.get('morerows', 0) + 1 88 | height = sum(self.heights[row:row + rows]) + (rows - 1) 89 | return rows, height 90 | 91 | def _get_cell_dimensions(self, node, col, row): 92 | """ 93 | Returns the number of columns and rows this cell spans, 94 | and its width in character columns and height in lines. 95 | 96 | Args: 97 | node: the node of the cell. 98 | col: the column index of this cell. 99 | row: the row index of this cell. 100 | """ 101 | cols, width = self._get_cols(node, col) 102 | rows, height = self._get_rows(node, row) 103 | 104 | return cols, rows, width, height 105 | 106 | def visit_cell(self, node): 107 | cols, width = self._get_cols(node, self.col) 108 | 109 | self.col += cols 110 | self.cursor += width + 1 111 | 112 | # Do not recurse 113 | raise SkipChildren 114 | 115 | class TableOutliner(BaseTableVisitor): 116 | 117 | def __init__(self): 118 | BaseTableVisitor.__init__(self) 119 | self.nb_rows = 0 120 | self.local_row = 0 121 | 122 | def _draw_rule(self): 123 | """ 124 | Draw the leftmost and upmost borders of the table, 125 | and fills the defined rectangle with spaces. 126 | """ 127 | total_width = sum(self.widths) + (len(self.widths) + 1) 128 | total_height = sum(self.heights) + (len(self.heights) + 1) 129 | 130 | self.lines[self.line] += '+' + '-' * (total_width - 1) 131 | self.lines.extend(['|' + ' ' * (total_width - 1)] * (total_height - 1)) 132 | self.line += 1 133 | self.cursor = 0 134 | 135 | def visit_table(self, node): 136 | self.widths = node['colspec'] 137 | self.heights = node['rowspec'] 138 | self._draw_rule() 139 | 140 | def depart_row(self, node): 141 | BaseTableVisitor.depart_row(self, node) 142 | self.local_row += 1 143 | 144 | def visit_head(self, node): 145 | self.nb_rows = len(node['children']) 146 | self.local_row = 0 147 | 148 | visit_body = visit_head 149 | 150 | def _is_last_row(self, spanned_rows): 151 | """ 152 | Returns whenever the current row is the last row, given 153 | the span of the current cell. 154 | 155 | Args: 156 | spanned_rows: the number of rows the current cell spans. 157 | """ 158 | return self.local_row + spanned_rows - 1 == self.nb_rows - 1 159 | 160 | def visit_cell(self, node): 161 | cols, rows, width, height = self._get_cell_dimensions(node, self.col, self.row) 162 | 163 | # Draw the horizontal rule 164 | 165 | rule = '=' if self._is_last_row(rows) else '-' 166 | 167 | self._rewrite_in_line(self.line + height, self.cursor, '+' + (width * rule) + '+') 168 | 169 | # Draw the vertical rule 170 | 171 | for i in range(height): 172 | self._rewrite_in_line(self.line + i, self.cursor + width + 1, '|') 173 | self._rewrite_in_line(self.line - 1, self.cursor + width + 1, '+') 174 | 175 | BaseTableVisitor.visit_cell(self, node) 176 | 177 | class TableWriter(BaseTableVisitor): 178 | 179 | def visit_table(self, node): 180 | outliner = TableOutliner() 181 | outliner.visit(node) 182 | self.lines = outliner.lines 183 | self.widths = outliner.widths 184 | self.heights = outliner.heights 185 | 186 | def visit_cell(self, node): 187 | 188 | # Wrap the text contents of the cell 189 | 190 | width = self._get_cols(node, self.col)[1] 191 | data = wrap(node['data'], width=width-2) 192 | 193 | # Write the cell contents 194 | 195 | for i in range(len(data)): 196 | self._rewrite_in_line(self.line + i + 1, self.cursor + 2, data[i]) 197 | 198 | BaseTableVisitor.visit_cell(self, node) 199 | 200 | def table2ascii(table): 201 | v = TableWriter() 202 | v.visit(table) 203 | return '\n'.join(v.lines) 204 | --------------------------------------------------------------------------------