'''
Functions to slice up a fixed-column-width ASCII grid.
:any:`slice_grid` splits up lines according to a header row with ``|`` separators.
:any:`merged_cells` iterates over this grid and returns merge areas.
Columns are merged if there is something different from ``|`` or space below
the separator in the header row.
Rows are merged by prefixing the cells with ``{``. The symbols must be in the
same text column.
'''
import attr
import textwrap
__all__ = [
'slice_grid',
'SlicedGrid',
'merged_cells',
'MCell',
]
# for testing
_overlapping_merge = '''
| | |
abc {de fgh
{jk {lm nop
{rstuvw xyz
'''
_adj_row_merge = '''
| |
{abc
{def
{ghi
{jkl
{mno
{pqr
'''
[docs]@attr.s
class SlicedGrid:
# text between (not including) | | splitters
column_heads = attr.ib(attr.Factory(list))
# "dumbly" splitted grid cells:
# list of lists, row - column - string
# each string being the cell text including the PRECEDING separator's column
body_lines = attr.ib(attr.Factory(list))
[docs]def slice_grid(grid_text):
'''slice a grid up by the first (nonempty) row.
Before slicing, empty lines before/after are removed,
and the text is dedented.
The first row is split by | characters.
The first column can contain a | character or not.
Returns a SlicedGrid with Properties:
* column_heads: the split up parts of the first line (not including the separators).
* body_lines: list of following lines; each item is a list of strings,
where each string is the grid "cell" including the preceding separator column.
I.e. if you join the cell list without separator, you regain the text line.
'''
grid_text = textwrap.dedent(grid_text)
lines = grid_text.splitlines()
# remove leading and trailing whitespace lines
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
if not lines:
return SlicedGrid()
column_heads = lines.pop(0).split('|')
if not column_heads[0]:
# first | is there
column_heads.pop(0)
else:
# no first |, add padding to first column
lines = [' '+line for line in lines]
widths = [len(part)+1 for part in column_heads]
# if any line is longer than the header, adjust width of last column.
maxlen = max((len(line) for line in lines), default=0)
widths[-1] = max(widths[-1], maxlen-sum(widths[:-1]))
body_lines = []
for line in lines:
body_line = []
for w in widths[:]:
cell, line = line[:w], line[w:]
if len(cell) < w:
cell = cell + ' '*(w-len(cell))
body_line.append(cell)
body_lines.append(body_line)
return SlicedGrid(column_heads=column_heads, body_lines=body_lines)
[docs]@attr.s
class MCell:
row = attr.ib()
col = attr.ib()
text = attr.ib('')
rowspan = attr.ib(1)
colspan = attr.ib(1)
[docs]def merged_cells(sliced_grid):
'''Generator: takes the sliced grid, and returns merged cells one by one.
Cells are merged by the following logic:
* If the first character of a (stripped) cell is '{', cells of the following
row(s) are merged while they also start with '{' in the same column.
* Then, columns are merged if the following (column's) cell starts neither
with space nor with '|'.
Yields MCell instances with:
* row, col: cell position (int, 0-based)
* rowspan, colspan: spanned rows/cols, at least 1
* text: merged area text, as sliced out from the text editor; not
including the leading '{'; "ragged" linebreaks retained.
Iteration order is row-wise.
Merge areas must not overlap. (However this should rarely happen on accident).
Note: If you need two row-merge ranges above each other, indent the
'{' differently.
'''
# make a deep copy first
body_lines = [ cells[:] for cells in sliced_grid.body_lines ]
for row, line in enumerate(body_lines):
for col, cell in enumerate(line):
rowspan = 1
colspan = 1
if cell is None:
# part of previously merged cell
continue
cell = cell[1:]
# calculate Row span
ofs = 0
if cell.lstrip().startswith('{'):
ofs = cell.index('{')+1
if ofs:
while True:
try:
cell_below = body_lines[row+rowspan][col][1:]
except IndexError:
break
# no aligned { or not empty before
if cell_below[ofs-1:ofs] != '{' or cell_below[:ofs-1].strip():
break
rowspan += 1
# now we talked about it, delete prefix
for row2 in range(row, row+rowspan):
body_lines[row2][col] = body_lines[row2][col][ofs:]
cell = cell[ofs:]
# calculate column_span
colspans = [] # collect for merged rows
for row2 in range(row, row+rowspan):
colspan = 1
while True:
try:
cell_right = body_lines[row2][col+colspan]
except IndexError:
break
# "not cell" also covers the "already-merged" case.
if (not cell_right) or cell_right.startswith(' ') or cell_right.startswith('|'):
break
colspan += 1
colspans.append(colspan)
colspan = max(colspans)
# All clear. Collect text.
try:
mrows = [
''.join(body_lines[row2][col:col+colspan])[1:]
for row2 in range(row, row+rowspan)
]
except TypeError as e:
# caught a None entry
raise ValueError('Overlapping merge areas') from e
text = '\n'.join(mrows)
yield MCell(row=row, col=col, text=text, rowspan=rowspan, colspan=colspan)
# white-out merge area
for row2 in range(row, row+rowspan):
for col2 in range(col, col+colspan):
try:
body_lines[row2][col2] = None
except IndexError:
pass