From 9b30fc938da7c84545febf7f228e38f8530e29d3 Mon Sep 17 00:00:00 2001 From: Julien Palard Date: Mon, 22 Oct 2018 01:14:19 +0200 Subject: [PATCH] Working on text colspan and rowspan. --- sphinx/writers/text.py | 241 +++++++++++++----- tests/roots/test-build-text/table_colspan.txt | 7 + .../table_colspan_and_rowspan.txt | 7 + .../test-build-text/table_colspan_left.txt | 7 + tests/roots/test-build-text/table_rowspan.txt | 7 + tests/test_build_text.py | 57 +++++ 6 files changed, 264 insertions(+), 62 deletions(-) create mode 100644 tests/roots/test-build-text/table_colspan.txt create mode 100644 tests/roots/test-build-text/table_colspan_and_rowspan.txt create mode 100644 tests/roots/test-build-text/table_colspan_left.txt create mode 100644 tests/roots/test-build-text/table_rowspan.txt diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index 912d87399..5e130780e 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -8,10 +8,12 @@ :copyright: Copyright 2007-2018 by the Sphinx team, see AUTHORS. :license: BSD, see LICENSE for details. """ +from collections import namedtuple import os import re +import math import textwrap -from itertools import groupby +from itertools import groupby, chain from docutils import nodes, writers from docutils.utils import column_width @@ -29,6 +31,170 @@ if False: logger = logging.getLogger(__name__) +class Cell: + def __init__(self, text="", rowspan=1, colspan=1): + self.text = text + self.wrapped = [] + self.rowspan = rowspan + self.colspan = colspan + self.col = None + self.row = None + + def __repr__(self): + return "{}>".format( + self.text, self.row, self.rowspan, self.col, self.colspan + ) + + def __hash__(self): + return hash((self.col, self.row)) + + def wrap(self, width): + self.wrapped = my_wrap(self.text, width) + + +class Table: + def __init__(self): + self.lines = [] + self.separator = 0 + self.colwidth = [] + self.current_line = 0 + self.current_col = 0 + + def add_row(self): + self.current_line += 1 + self.current_col = 0 + + def set_separator(self): + self.separator = len(self.lines) + + def add_cell(self, cell): + while self[self.current_line, self.current_col]: + self.current_col += 1 + self[self.current_line, self.current_col] = cell + self.current_col += cell.colspan + + def __getitem__(self, pos): + line, col = pos + self._ensure_has_line(line + 1) + self._ensure_has_column(col + 1) + return self.lines[line][col] + + def __setitem__(self, pos, cell): + line, col = pos + self._ensure_has_line(line + cell.rowspan) + self._ensure_has_column(col + cell.colspan) + for dline in range(cell.rowspan): + for dcol in range(cell.colspan): + self.lines[line + dline][col + dcol] = cell + cell.row = line + cell.col = col + + def _ensure_has_line(self, line): + while len(self.lines) < line: + self.lines.append([]) + + def _ensure_has_column(self, col): + for line in self.lines: + while len(line) < col: + line.append(None) + + def __repr__(self): + out = [] + for line in self.lines: + out.append(repr(line)) + return "\n".join(out) + + def cell_width(self, cell, source): + width = 0 + for i in range(self[cell.row, cell.col].colspan): + width += source[cell.col + i] + return width + (cell.colspan - 1) * 3 + + @property + def cells(self): + seen = set() + for lineno, line in enumerate(self.lines): + for colno, cell in enumerate(line): + if cell and cell not in seen: + yield cell + seen.add(cell) + + def rewrap(self): + self.measured_widths = self.colwidth[:] + for cell in self.cells: + cell.wrap(width=self.cell_width(cell, self.colwidth)) + if not cell.wrapped: + continue + width = math.ceil(max(column_width(x) for x in cell.wrapped) / cell.colspan) + for col in range(cell.col, cell.col + cell.colspan): + self.measured_widths[col] = max(self.measured_widths[col], width) + + def physical_lines_for_line(self, line): + """From a given line, compute the number of physical lines it spans + due to text wrapping. + """ + physical_lines = 1 + for cell in line: + physical_lines = max(physical_lines, len(cell.wrapped)) + return physical_lines + + def __str__(self): + out = [] + self.rewrap() + + def writesep(char="-", lineno=None): + # type: (unicode, Optional[int]) -> unicode + """Called on the line *before* lineno. + Called with no lineno for the last sep. + """ + out = [] # type: List[unicode] + for colno, width in enumerate(self.measured_widths): + if ( + lineno is not None + and lineno > 0 + and self[lineno, colno] is self[lineno - 1, colno] + ): + out.append(" " * (width + 2)) + else: + out.append(char * (width + 2)) + head = "+" if out[0][0] == "-" else "|" + tail = "+" if out[-1][0] == "-" else "|" + glue = [ + "+" if left[0] == "-" or right[0] == "-" else "|" + for left, right in zip(out, out[1:]) + ] + glue.append(tail) + return head + "".join(chain(*zip(out, glue))) + + for lineno, line in enumerate(self.lines): + if self.separator and lineno == self.separator: + out.append(writesep("=", lineno)) + else: + out.append(writesep("-", lineno)) + for physical_line in range(self.physical_lines_for_line(line)): + linestr = ["|"] + for colno, cell in enumerate(line): + if cell.col != colno: + continue + if lineno != cell.row: + physical_text = "" + elif physical_line >= len(cell.wrapped): + physical_text = "" + else: + physical_text = cell.wrapped[physical_line] + adjust_len = len(physical_text) - column_width(physical_text) + linestr.append( + " " + + physical_text.ljust( + self.cell_width(cell, self.measured_widths) + 1 + adjust_len + ) + + "|" + ) + out.append("".join(linestr)) + out.append(writesep("-")) + return "\n".join(out) + + class TextWrapper(textwrap.TextWrapper): """Custom subclass that uses a different word separator regex.""" @@ -582,7 +748,7 @@ class TextTranslator(nodes.NodeVisitor): def visit_colspec(self, node): # type: (nodes.Node) -> None - self.table[0].append(node['colwidth']) # type: ignore + self.table.colwidth.append(node["colwidth"]) # type: ignore raise nodes.SkipNode def visit_tgroup(self, node): @@ -603,7 +769,7 @@ class TextTranslator(nodes.NodeVisitor): def visit_tbody(self, node): # type: (nodes.Node) -> None - self.table.append('sep') + self.table.set_separator() def depart_tbody(self, node): # type: (nodes.Node) -> None @@ -611,7 +777,8 @@ class TextTranslator(nodes.NodeVisitor): def visit_row(self, node): # type: (nodes.Node) -> None - self.table.append([]) + if self.table.lines: + self.table.add_row() def depart_row(self, node): # type: (nodes.Node) -> None @@ -619,79 +786,29 @@ class TextTranslator(nodes.NodeVisitor): def visit_entry(self, node): # type: (nodes.Node) -> None - if 'morerows' in node or 'morecols' in node: - raise NotImplementedError('Column or row spanning cells are ' - 'not implemented.') + self.entry = Cell( + rowspan=node.get("morerows", 0) + 1, colspan=node.get("morecols", 0) + 1 + ) self.new_state(0) def depart_entry(self, node): # type: (nodes.Node) -> None text = self.nl.join(self.nl.join(x[1]) for x in self.states.pop()) self.stateindent.pop() - self.table[-1].append(text) # type: ignore + self.entry.text = text + self.table.add_cell(self.entry) + self.entry = None def visit_table(self, node): # type: (nodes.Node) -> None if self.table: raise NotImplementedError('Nested tables are not supported.') self.new_state(0) - self.table = [[]] + self.table = Table() def depart_table(self, node): # type: (nodes.Node) -> None - lines = None # type: List[unicode] - lines = self.table[1:] # type: ignore - fmted_rows = [] # type: List[List[List[unicode]]] - colwidths = None # type: List[int] - colwidths = self.table[0] # type: ignore - realwidths = colwidths[:] - separator = 0 - # don't allow paragraphs in table cells for now - for line in lines: - if line == 'sep': - separator = len(fmted_rows) - else: - cells = [] # type: List[List[unicode]] - for i, cell in enumerate(line): - par = my_wrap(cell, width=colwidths[i]) - if par: - maxwidth = max(column_width(x) for x in par) - else: - maxwidth = 0 - realwidths[i] = max(realwidths[i], maxwidth) - cells.append(par) - fmted_rows.append(cells) - - def writesep(char='-'): - # type: (unicode) -> None - out = ['+'] # type: List[unicode] - for width in realwidths: - out.append(char * (width + 2)) - out.append('+') - self.add_text(''.join(out) + self.nl) - - def writerow(row): - # type: (List[List[unicode]]) -> None - lines = zip_longest(*row) - for line in lines: - out = ['|'] - for i, cell in enumerate(line): - if cell: - adjust_len = len(cell) - column_width(cell) - out.append(' ' + cell.ljust( - realwidths[i] + 1 + adjust_len)) - else: - out.append(' ' * (realwidths[i] + 2)) - out.append('|') - self.add_text(''.join(out) + self.nl) - - for i, row in enumerate(fmted_rows): - if separator and i == separator: - writesep('=') - else: - writesep('-') - writerow(row) - writesep('-') + self.add_text(str(self.table)) self.table = None self.end_state(wrap=False) diff --git a/tests/roots/test-build-text/table_colspan.txt b/tests/roots/test-build-text/table_colspan.txt new file mode 100644 index 000000000..9581d9c2c --- /dev/null +++ b/tests/roots/test-build-text/table_colspan.txt @@ -0,0 +1,7 @@ + +-----+-----+ + | XXX | XXX | + +-----+-----+ + | | XXX | + +-----+ | + | XXX | | + +-----+-----+ diff --git a/tests/roots/test-build-text/table_colspan_and_rowspan.txt b/tests/roots/test-build-text/table_colspan_and_rowspan.txt new file mode 100644 index 000000000..738dae43b --- /dev/null +++ b/tests/roots/test-build-text/table_colspan_and_rowspan.txt @@ -0,0 +1,7 @@ + +-----------+-----+ + | AAA | BBB | + +-----+-----+ | + | | XXX | | + | +-----+-----+ + | DDD | CCC | + +-----+-----------+ diff --git a/tests/roots/test-build-text/table_colspan_left.txt b/tests/roots/test-build-text/table_colspan_left.txt new file mode 100644 index 000000000..b2b3564d3 --- /dev/null +++ b/tests/roots/test-build-text/table_colspan_left.txt @@ -0,0 +1,7 @@ + +-----+-----+ + | XXX | XXX | + +-----+-----+ + | | XXX | + | +-----+ + | XXX | | + +-----+-----+ diff --git a/tests/roots/test-build-text/table_rowspan.txt b/tests/roots/test-build-text/table_rowspan.txt new file mode 100644 index 000000000..8a29c43c5 --- /dev/null +++ b/tests/roots/test-build-text/table_rowspan.txt @@ -0,0 +1,7 @@ + +-----+-----+ + | XXXXXXXXX | + +-----+-----+ + | | XXX | + +-----+-----+ + | XXX | | + +-----+-----+ diff --git a/tests/test_build_text.py b/tests/test_build_text.py index b9e0e61a1..864ce16b7 100644 --- a/tests/test_build_text.py +++ b/tests/test_build_text.py @@ -101,6 +101,63 @@ def test_table_with_empty_cell(app, status, warning): assert lines[6] == "+-------+-------+" +@with_text_app() +def test_table_with_rowspan(app, status, warning): + app.builder.build_update() + result = (app.outdir / 'table_rowspan.txt').text(encoding='utf-8') + lines = [line.strip() for line in result.splitlines() if line.strip()] + assert lines[0] == "+-------+-------+" + assert lines[1] == "| XXXXXXXXX |" + assert lines[2] == "+-------+-------+" + assert lines[3] == "| | XXX |" + assert lines[4] == "+-------+-------+" + assert lines[5] == "| XXX | |" + assert lines[6] == "+-------+-------+" + + +@with_text_app() +def test_table_with_colspan(app, status, warning): + app.builder.build_update() + result = (app.outdir / 'table_colspan.txt').text(encoding='utf-8') + lines = [line.strip() for line in result.splitlines() if line.strip()] + assert lines[0] == "+-------+-------+" + assert lines[1] == "| XXX | XXX |" + assert lines[2] == "+-------+-------+" + assert lines[3] == "| | XXX |" + assert lines[4] == "+-------+ |" + assert lines[5] == "| XXX | |" + assert lines[6] == "+-------+-------+" + + +@with_text_app() +def test_table_with_colspan_left(app, status, warning): + app.builder.build_update() + result = (app.outdir / 'table_colspan_left.txt').text(encoding='utf-8') + lines = [line.strip() for line in result.splitlines() if line.strip()] + assert lines[0] == "+-------+-------+" + assert lines[1] == "| XXX | XXX |" + assert lines[2] == "+-------+-------+" + assert lines[3] == "| XXX | XXX |" + assert lines[4] == "| +-------+" + assert lines[5] == "| | |" + assert lines[6] == "+-------+-------+" + + +@with_text_app() +def test_table_with_colspan_and_rowspan(app, status, warning): + app.builder.build_update() + result = (app.outdir / 'table_colspan_and_rowspan.txt').text(encoding='utf-8') + lines = [line.strip() for line in result.splitlines() if line.strip()] + assert result + assert lines[0] == "+-------+-------+-------+" + assert lines[1] == "| AAA | BBB |" + assert lines[2] == "+-------+-------+ |" + assert lines[3] == "| DDD | XXX | |" + assert lines[4] == "| +-------+-------+" + assert lines[5] == "| | CCC |" + assert lines[6] == "+-------+-------+-------+" + + @with_text_app() def test_list_items_in_admonition(app, status, warning): app.builder.build_update()