Working on text colspan and rowspan.

This commit is contained in:
Julien Palard 2018-10-22 01:14:19 +02:00
parent ee139e155a
commit 9b30fc938d
6 changed files with 264 additions and 62 deletions

View File

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

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

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