Merge pull request #5559 from JulienPalard/text-colspan-rowspan

Working on text colspan and rowspan.
This commit is contained in:
Takeshi KOMIYA 2018-11-01 00:42:02 +09:00 committed by GitHub
commit 08aadcffcd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 371 additions and 73 deletions

View File

@ -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

View File

@ -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)

View File

@ -1,7 +1,7 @@
+-----+-----+
| XXX | XXX |
+-----+-----+
| | XXX |
+-----+-----+
| XXX | |
+-----+-----+
+-----+-----+
| XXX | XXX |
+-----+-----+
| | XXX |
+-----+-----+
| XXX | |
+-----+-----+

View File

@ -0,0 +1,7 @@
+-----+-----+
| XXX | XXX |
+-----+-----+
| | XXX |
+-----+ |
| XXX | |
+-----+-----+

View File

@ -0,0 +1,7 @@
+-----------+-----+
| AAA | BBB |
+-----+-----+ |
| | XXX | |
| +-----+-----+
| DDD | CCC |
+-----+-----------+

View File

@ -0,0 +1,7 @@
+-----+-----+
| XXX | XXX |
+-----+-----+
| | XXX |
| +-----+
| XXX | |
+-----+-----+

View File

@ -0,0 +1,7 @@
+-----+-----+
| XXXXXXXXX |
+-----+-----+
| | XXX |
+-----+-----+
| XXX | |
+-----+-----+

View File

@ -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()
@ -101,6 +136,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()