Source code for ascii_designer.ascii_slice

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