summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AUTHORS1
-rw-r--r--sphinx/writers/text.py307
-rw-r--r--tests/roots/test-build-text/table.txt14
-rw-r--r--tests/roots/test-build-text/table_colspan.txt7
-rw-r--r--tests/roots/test-build-text/table_colspan_and_rowspan.txt7
-rw-r--r--tests/roots/test-build-text/table_colspan_left.txt7
-rw-r--r--tests/roots/test-build-text/table_rowspan.txt7
-rw-r--r--tests/test_build_text.py94
8 files changed, 371 insertions, 73 deletions
diff --git a/AUTHORS b/AUTHORS
index 3b1609b85..d773b8f17 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -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')