diff options
-rw-r--r-- | AUTHORS | 1 | ||||
-rw-r--r-- | sphinx/writers/text.py | 307 | ||||
-rw-r--r-- | tests/roots/test-build-text/table.txt | 14 | ||||
-rw-r--r-- | tests/roots/test-build-text/table_colspan.txt | 7 | ||||
-rw-r--r-- | tests/roots/test-build-text/table_colspan_and_rowspan.txt | 7 | ||||
-rw-r--r-- | tests/roots/test-build-text/table_colspan_left.txt | 7 | ||||
-rw-r--r-- | tests/roots/test-build-text/table_rowspan.txt | 7 | ||||
-rw-r--r-- | tests/test_build_text.py | 94 |
8 files changed, 371 insertions, 73 deletions
@@ -57,6 +57,7 @@ Other contributors, listed alphabetically, are: * Ezio Melotti -- collapsible sidebar JavaScript * Bruce Mitchener -- Minor epub improvement * Daniel Neuhäuser -- JavaScript domain, Python 3 support (GSOC) +* Julien Palard -- Colspan and rowspan in text builder * Christopher Perkins -- autosummary integration * Benjamin Peterson -- unittests * \T. Powers -- HTML output improvements diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index 912d87399..b5392c9bc 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -8,14 +8,14 @@ :copyright: Copyright 2007-2018 by the Sphinx team, see AUTHORS. :license: BSD, see LICENSE for details. """ +import math import os import re import textwrap -from itertools import groupby +from itertools import groupby, chain from docutils import nodes, writers from docutils.utils import column_width -from six.moves import zip_longest from sphinx import addnodes from sphinx.locale import admonitionlabels, _ @@ -23,12 +23,238 @@ from sphinx.util import logging if False: # For type annotation - from typing import Any, Callable, Dict, List, Tuple, Union # NOQA + from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union # NOQA from sphinx.builders.text import TextBuilder # NOQA logger = logging.getLogger(__name__) +class Cell: + """Represents a cell in a table. + It can span on multiple columns or on multiple lines. + """ + def __init__(self, text="", rowspan=1, colspan=1): + self.text = text + self.wrapped = [] # type: List[unicode] + self.rowspan = rowspan + self.colspan = colspan + self.col = None + self.row = None + + def __repr__(self): + return "<Cell {!r} {}v{}/{}>{}>".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: + """Represents a table, handling cells that can span on multiple lines + or rows, like:: + + +-----------+-----+ + | AAA | BBB | + +-----+-----+ | + | | XXX | | + | +-----+-----+ + | DDD | CCC | + +-----+-----------+ + + This class can be used in two ways: + + - Either with absolute positions: call ``table[line, col] = Cell(...)``, + this overwrite an existing cell if any. + + - Either with relative positions: call the ``add_row()`` and + ``add_cell(Cell(...))`` as needed. + + Cell spanning on multiple rows or multiple columns (having a + colspan or rowspan greater than one) are automatically referenced + by all the table cells they covers. This is a usefull + representation as we can simply check ``if self[x, y] is self[x, + y+1]`` to recognize a rowspan. + + Colwidth is not automatically computed, it has to be given, either + at construction time, either during the table construction. + + Example usage:: + + table = Table([6, 6]) + table.add_cell(Cell("foo")) + table.add_cell(Cell("bar")) + table.set_separator() + table.add_row() + table.add_cell(Cell("FOO")) + table.add_cell(Cell("BAR")) + print(str(table)) + +--------+--------+ + | foo | bar | + |========|========| + | FOO | BAR | + +--------+--------+ + + """ + def __init__(self, colwidth=None): + self.lines = [] # type: List[List[Cell]] + self.separator = 0 + self.colwidth = (colwidth if colwidth is not None + else []) # type: List[int] + self.current_line = 0 + self.current_col = 0 + + def add_row(self): + """Add a row to the table, to use with ``add_cell()``. It is not needed + to call ``add_row()`` before the first ``add_cell()``. + """ + self.current_line += 1 + self.current_col = 0 + + def set_separator(self): + """Sets the separator below the current line. + """ + self.separator = len(self.lines) + + def add_cell(self, cell): + """Add a cell to the current line, to use with ``add_row()``. To add + a cell spanning on multiple lines or rows, simply set the + ``cell.colspan`` or ``cell.rowspan`` BEFORE inserting it to + the table. + """ + 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): + return "\n".join(repr(line) for line in self.lines) + + def cell_width(self, cell, source): + """Give the cell width, according to the given source (either + ``self.colwidth`` or ``self.measured_widths``). + This take into account cells spanning on multiple columns. + """ + 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() # type: Set[Cell] + 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): + """Call ``cell.wrap()`` on all cells, and measure each column width + after wrapping (result written in ``self.measured_widths``). + """ + 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.""" @@ -189,7 +415,7 @@ class TextTranslator(nodes.NodeVisitor): self.list_counter = [] # type: List[int] self.sectionlevel = 0 self.lineblocklevel = 0 - self.table = None # type: List[Union[unicode, List[int]]] + self.table = None # type: Table def add_text(self, text): # type: (unicode) -> None @@ -582,7 +808,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"]) raise nodes.SkipNode def visit_tgroup(self, node): @@ -603,7 +829,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 +837,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 +846,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.txt b/tests/roots/test-build-text/table.txt index 84328940f..adc8b371a 100644 --- a/tests/roots/test-build-text/table.txt +++ b/tests/roots/test-build-text/table.txt @@ -1,7 +1,7 @@ - +-----+-----+ - | XXX | XXX | - +-----+-----+ - | | XXX | - +-----+-----+ - | XXX | | - +-----+-----+ ++-----+-----+ +| XXX | XXX | ++-----+-----+ +| | XXX | ++-----+-----+ +| XXX | | ++-----+-----+ 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..4ae663789 --- /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..82d36070a --- /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..dbfa324c5 --- /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..36c30eb79 --- /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..dd11427ce 100644 --- a/tests/test_build_text.py +++ b/tests/test_build_text.py @@ -12,7 +12,7 @@ import pytest from docutils.utils import column_width -from sphinx.writers.text import MAXWIDTH +from sphinx.writers.text import MAXWIDTH, Table, Cell def with_text_app(*args, **kw): @@ -87,6 +87,41 @@ def test_nonascii_maxwidth(app, status, warning): assert max(line_widths) < MAXWIDTH +def test_table_builder(): + table = Table([6, 6]) + table.add_cell(Cell("foo")) + table.add_cell(Cell("bar")) + table_str = str(table).split("\n") + assert table_str[0] == "+--------+--------+" + assert table_str[1] == "| foo | bar |" + assert table_str[2] == "+--------+--------+" + assert repr(table).count("<Cell ") == 2 + + +def test_table_separator(): + table = Table([6, 6]) + table.add_cell(Cell("foo")) + table.add_cell(Cell("bar")) + table.set_separator() + table.add_row() + table.add_cell(Cell("FOO")) + table.add_cell(Cell("BAR")) + table_str = str(table).split("\n") + assert table_str[0] == "+--------+--------+" + assert table_str[1] == "| foo | bar |" + assert table_str[2] == "|========|========|" + assert table_str[3] == "| FOO | BAR |" + assert table_str[4] == "+--------+--------+" + assert repr(table).count("<Cell ") == 4 + + +def test_table_cell(): + cell = Cell("Foo bar baz") + cell.wrap(3) + assert "Cell" in repr(cell) + assert cell.wrapped == ["Foo", "bar", "baz"] + + @with_text_app() def test_table_with_empty_cell(app, status, warning): app.builder.build_update() @@ -102,6 +137,63 @@ def test_table_with_empty_cell(app, status, warning): @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() result = (app.outdir / 'listitems.txt').text(encoding='utf-8') |