mirror of
https://github.com/sphinx-doc/sphinx.git
synced 2025-02-25 18:55:22 -06:00
Merge pull request #5559 from JulienPalard/text-colspan-rowspan
Working on text colspan and rowspan.
This commit is contained in:
commit
08aadcffcd
1
AUTHORS
1
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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
+-----+-----+
|
||||
| XXX | XXX |
|
||||
+-----+-----+
|
||||
| | XXX |
|
||||
+-----+-----+
|
||||
| XXX | |
|
||||
+-----+-----+
|
||||
+-----+-----+
|
||||
| XXX | XXX |
|
||||
+-----+-----+
|
||||
| | XXX |
|
||||
+-----+-----+
|
||||
| XXX | |
|
||||
+-----+-----+
|
||||
|
7
tests/roots/test-build-text/table_colspan.txt
Normal file
7
tests/roots/test-build-text/table_colspan.txt
Normal file
@ -0,0 +1,7 @@
|
||||
+-----+-----+
|
||||
| XXX | XXX |
|
||||
+-----+-----+
|
||||
| | XXX |
|
||||
+-----+ |
|
||||
| XXX | |
|
||||
+-----+-----+
|
@ -0,0 +1,7 @@
|
||||
+-----------+-----+
|
||||
| AAA | BBB |
|
||||
+-----+-----+ |
|
||||
| | XXX | |
|
||||
| +-----+-----+
|
||||
| DDD | CCC |
|
||||
+-----+-----------+
|
7
tests/roots/test-build-text/table_colspan_left.txt
Normal file
7
tests/roots/test-build-text/table_colspan_left.txt
Normal file
@ -0,0 +1,7 @@
|
||||
+-----+-----+
|
||||
| XXX | XXX |
|
||||
+-----+-----+
|
||||
| | XXX |
|
||||
| +-----+
|
||||
| XXX | |
|
||||
+-----+-----+
|
7
tests/roots/test-build-text/table_rowspan.txt
Normal file
7
tests/roots/test-build-text/table_rowspan.txt
Normal file
@ -0,0 +1,7 @@
|
||||
+-----+-----+
|
||||
| XXXXXXXXX |
|
||||
+-----+-----+
|
||||
| | XXX |
|
||||
+-----+-----+
|
||||
| XXX | |
|
||||
+-----+-----+
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user