span-table 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
span_table/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from table import span_table
2
+
3
+ __all__ = ["span_table"]
span_table/cell.py ADDED
@@ -0,0 +1,265 @@
1
+ from src.span_table.span import (
2
+ get_span_column_count,
3
+ get_span_row_count,
4
+ get_span_char_height,
5
+ get_span_char_width
6
+ )
7
+ from typing import Literal
8
+ from dataclasses import dataclass
9
+ from rich.box import Box
10
+ from src.span_table.types import SpanType, TableType
11
+
12
+ @dataclass
13
+ class Cell:
14
+ text: str
15
+ row: int
16
+ column: int
17
+ row_count: int
18
+ column_count: int
19
+
20
+ def __lt__(self, other):
21
+ return [self.row, self.column] < [other.row, other.column]
22
+
23
+ @property
24
+ def left_sections(self):
25
+ lines = self.text.split('\n')
26
+ sections = 0
27
+
28
+ for i in range(len(lines)):
29
+ if lines[i].startswith('+'):
30
+ sections += 1
31
+ sections -= 1
32
+
33
+ return sections
34
+
35
+ @property
36
+ def right_sections(self):
37
+ lines = self.text.split('\n')
38
+ sections = 0
39
+ for i in range(len(lines)):
40
+ if lines[i].endswith('+'):
41
+ sections += 1
42
+ return sections - 1
43
+
44
+ @property
45
+ def top_sections(self):
46
+ top_line = self.text.split('\n')[0]
47
+ sections = len(top_line.split('+')) - 2
48
+
49
+ return sections
50
+
51
+ @property
52
+ def bottom_sections(self):
53
+ bottom_line = self.text.split('\n')[-1]
54
+ sections = len(bottom_line.split('+')) - 2
55
+
56
+ return sections
57
+
58
+ def resolve_border(
59
+ box: Box,
60
+ row: int,
61
+ col: int,
62
+ total_row: int,
63
+ total_col: int,
64
+ span: SpanType,
65
+ corner: Literal['top_left', 'top_right', 'bottom_left', 'bottom_right']
66
+ ):
67
+ if corner == "top_left":
68
+ if row == 0 and col == 0:
69
+ return box.top_left
70
+ elif 0 < row and col == 0:
71
+ return box.row_left
72
+ elif row == 0 and 0 < col:
73
+ return box.top_divider
74
+ else:
75
+ return box.row_cross
76
+ elif corner == "top_right":
77
+ if len(span) > 1:
78
+ row, col = span[0][0], span[-1][1]
79
+ if row == 0 and col == (total_col - 1):
80
+ return box.top_right
81
+ elif row > 0 and col == (total_col - 1):
82
+ return box.row_right
83
+ elif row == 0 and col < (total_col - 1):
84
+ return box.top_divider
85
+ else:
86
+ return box.row_cross
87
+ elif corner == "bottom_left":
88
+ if len(span) > 1:
89
+ row, col = span[-1][0], span[0][1]
90
+ if row == (total_row - 1) and col == 0:
91
+ return box.bottom_left
92
+ elif row == (total_row - 1) and col > 0:
93
+ return box.bottom_divider
94
+ elif row < (total_row - 1) and col == 0:
95
+ return box.row_left
96
+ else:
97
+ return box.row_cross
98
+ elif corner == "bottom_right":
99
+ if len(span) > 1:
100
+ row, col = span[-1]
101
+ if row == (total_row - 1) and col == (total_col - 1):
102
+ return box.bottom_right
103
+ elif row == (total_row - 1) and col < (total_col - 1):
104
+ return box.bottom_divider
105
+ elif row < (total_row - 1) and col == (total_col - 1):
106
+ return box.row_right
107
+ else:
108
+ return box.row_cross
109
+
110
+
111
+ def make_cell(table: TableType, span: SpanType, widths: list[int], heights: list[int], box: Box) -> Cell:
112
+ width = get_span_char_width(span, widths)
113
+ height = get_span_char_height(span, heights)
114
+ text_row = span[0][0]
115
+ text_column = span[0][1]
116
+ text = table[text_row][text_column]
117
+ total_cols = len(widths)
118
+ total_rows = len(heights)
119
+
120
+ lines = text.plain.split("\n")
121
+ text.pad_width(width)
122
+
123
+ height_difference = height - len(lines)
124
+ text.append_empty_lines(height_difference, width)
125
+ output = [
126
+ ''.join([
127
+ resolve_border(box, text_row, text_column, total_rows, total_cols, span, 'top_left'),
128
+ (width * box.row_horizontal),
129
+ resolve_border(box, text_row, text_column, total_rows, total_cols, span, 'top_right')
130
+ ])
131
+ ]
132
+
133
+ for line in text.markup.split('\n'):
134
+ output.append(box.mid_vertical + line + box.mid_vertical)
135
+
136
+ symbol = box.row_horizontal
137
+
138
+ output.append(
139
+ ''.join([
140
+ resolve_border(box, text_row, text_column, total_rows, total_cols, span, 'bottom_left'),
141
+ width * symbol,
142
+ resolve_border(box, text_row, text_column, total_rows, total_cols, span, 'bottom_right')
143
+ ])
144
+ )
145
+
146
+ text = "\n".join(output)
147
+ row_count = get_span_row_count(span)
148
+ column_count = get_span_column_count(span)
149
+ return Cell(text=text, row=text_row, column=text_column, row_count=row_count, column_count=column_count)
150
+
151
+
152
+ def merge_cells(cell1: Cell, cell2: Cell, direction: Literal["RIGHT", "TOP", "BOTTOM", "LEFT"], box: Box):
153
+ cell1_lines = cell1.text.split("\n")
154
+ cell2_lines = cell2.text.split("\n")
155
+ if direction == "RIGHT":
156
+ for i in range(len(cell1_lines)):
157
+ sub = cell1_lines[i][-1]
158
+ if cell1_lines[i][-1] == box.mid_vertical and cell2_lines[i][0] == box.row_cross:
159
+ sub = box.row_left
160
+ elif cell1_lines[i][-1] == box.row_cross and cell2_lines[i][0] == box.mid_vertical:
161
+ sub = box.row_right
162
+ elif cell1_lines[i][-1] == box.row_cross and (cell2_lines[i][0] == box.top_left or cell2_lines[i][0] == box.top_divider):
163
+ sub = box.top_divider
164
+
165
+ cell1_lines[i] = cell1_lines[i][:-1] + sub + cell2_lines[i][1:]
166
+ cell1.text = "\n".join(cell1_lines)
167
+ cell1.column_count += cell2.column_count
168
+
169
+ elif direction == "TOP":
170
+ if cell1_lines[0].count('+') > cell2_lines[-1].count('+'):
171
+ cell2_lines.pop(-1)
172
+ else:
173
+ cell1_lines.pop(0)
174
+ cell2_lines.extend(cell1_lines)
175
+ cell1.text = "\n".join(cell2_lines)
176
+ cell1.row_count += cell2.row_count
177
+ cell1.row = cell2.row
178
+ cell1.column = cell2.column
179
+
180
+ elif direction == "BOTTOM":
181
+ merged_cells = ""
182
+ for top_char, bottom_char in zip(cell1_lines[-1], cell2_lines[0]):
183
+ if top_char == box.row_cross and bottom_char == box.row_horizontal:
184
+ merged_cells += box.bottom_divider
185
+ elif top_char == box.row_horizontal and bottom_char == box.row_cross:
186
+ merged_cells += box.top_divider
187
+ else:
188
+ merged_cells += top_char
189
+ cell1_lines[-1] = merged_cells
190
+ cell1_lines.extend(cell2_lines[1:])
191
+ cell1.text = "\n".join(cell1_lines)
192
+ cell1.row_count += cell2.row_count
193
+
194
+ elif direction == "LEFT":
195
+ for i in range(len(cell1_lines)):
196
+ sub = cell1_lines[i]
197
+ if cell1_lines[i][0] == box.mid_vertical and cell2_lines[i][-1] == box.row_cross:
198
+ sub = box.row_right + sub[1:]
199
+ elif cell1_lines[i][0] == box.row_cross and cell2_lines[i][-1] == box.mid_vertical:
200
+ sub = box.row_left + sub[1:]
201
+
202
+ cell1_lines[i] = cell2_lines[i][0:-1] + sub
203
+ cell1.text = "\n".join(cell1_lines)
204
+ cell1.column_count += cell2.column_count
205
+ cell1.row = cell2.row
206
+ cell1.column = cell2.column
207
+
208
+ def merge_all_cells(cells: list[Cell], box: Box) -> str:
209
+ current = 0
210
+ while len(cells) > 1:
211
+ count = 0
212
+ while count < len(cells):
213
+ cell1 = cells[current]
214
+ cell2 = cells[count]
215
+ merge_direction = get_merge_direction(cell1, cell2)
216
+ if not merge_direction == "NONE":
217
+ merge_cells(cell1, cell2, merge_direction, box)
218
+ if current > count:
219
+ current -= 1
220
+
221
+ cells.pop(count)
222
+ else:
223
+ count += 1
224
+
225
+ current += 1
226
+ if current >= len(cells):
227
+ current = 0
228
+
229
+ return cells[0].text
230
+
231
+ def get_merge_direction(cell1: Cell, cell2: Cell) -> Literal["RIGHT", "TOP", "BOTTOM", "LEFT", "NONE"]:
232
+ cell1_left = cell1.column
233
+ cell1_right = cell1.column + cell1.column_count
234
+ cell1_top = cell1.row
235
+ cell1_bottom = cell1.row + cell1.row_count
236
+ cell2_left = cell2.column
237
+ cell2_right = cell2.column + cell2.column_count
238
+ cell2_top = cell2.row
239
+ cell2_bottom = cell2.row + cell2.row_count
240
+
241
+ if (cell1_right == cell2_left and cell1_top == cell2_top and
242
+ cell1_bottom == cell2_bottom and
243
+ cell1.right_sections >= cell2.left_sections):
244
+ return "RIGHT"
245
+
246
+ elif (cell1_left == cell2_left and cell1_right == cell2_right and
247
+ cell1_top == cell2_bottom and
248
+ cell1.top_sections >= cell2.bottom_sections):
249
+ return "TOP"
250
+
251
+ elif (cell1_left == cell2_left and
252
+ cell1_right == cell2_right and
253
+ cell1_bottom == cell2_top and
254
+ cell1.bottom_sections >= cell2.top_sections):
255
+ return "BOTTOM"
256
+
257
+ elif (cell1_left == cell2_right and
258
+ cell1_top == cell2_top and
259
+ cell1_bottom == cell2_bottom and
260
+ cell1.left_sections >= cell2.right_sections):
261
+ return "LEFT"
262
+
263
+ else:
264
+ return "NONE"
265
+
@@ -0,0 +1,62 @@
1
+ from dataclasses import dataclass
2
+ from rich.markup import Tag, _parse
3
+
4
+
5
+ @dataclass
6
+ class StyleToken:
7
+ tag: Tag
8
+
9
+ @dataclass
10
+ class TextToken:
11
+ text: str
12
+
13
+ def pad_width(self, width: int):
14
+ pad = max(0, width - len(self.text))
15
+ if pad:
16
+ self.text += ' ' * pad
17
+
18
+ class MarkupText:
19
+ def __init__(self, markup: str):
20
+ self._tokens: list[StyleToken | TextToken] = []
21
+ self._parse(markup)
22
+
23
+ def _parse(self, markup: str):
24
+ for _, text, tag in _parse(markup):
25
+ if text is not None:
26
+ for line in text.split('\n'):
27
+ if line == '':
28
+ continue
29
+ self._tokens.append(TextToken(f" {line} "))
30
+ else:
31
+ self._tokens.append(StyleToken(tag))
32
+
33
+ @property
34
+ def plain(self) -> str:
35
+ return "\n".join(l.text for l in self.text_tokens)
36
+
37
+ @property
38
+ def markup(self) -> str:
39
+ markup_text = ""
40
+ for idx in range(len(self._tokens)):
41
+ if idx > 0:
42
+ if isinstance(self._tokens[idx - 1], TextToken) and isinstance(self._tokens[idx], TextToken):
43
+ markup_text += "\n"
44
+
45
+ if isinstance(self._tokens[idx], TextToken):
46
+ markup_text += self._tokens[idx].text
47
+ else:
48
+ markup_text += f"[{self._tokens[idx].tag.name}]"
49
+
50
+ return markup_text
51
+
52
+ @property
53
+ def text_tokens(self) -> list[TextToken]:
54
+ return [token for token in self._tokens if isinstance(token, TextToken)]
55
+
56
+ def pad_width(self, width: int):
57
+ for token in self.text_tokens:
58
+ token.pad_width(width)
59
+
60
+ def append_empty_lines(self, count: int, width: int):
61
+ for _ in range(count):
62
+ self._tokens.append(TextToken(" " * width))
span_table/span.py ADDED
@@ -0,0 +1,128 @@
1
+ from rich.text import Text
2
+ from src.span_table.types import TableType, SpanType
3
+ from src.span_table.markup_text import MarkupText
4
+
5
+ def check_span(data: TableType, span: SpanType):
6
+ if not len(span) == 2 or not len(span[0]) == 2 or not len(span[1]) == 2:
7
+ raise Exception("Span must be tuple of tuples in format ((start row index, start column index), (end row index, end column index))")
8
+
9
+ start_span, end_span = span
10
+ if not (start_span[0] <= end_span[0] and start_span[1] <= end_span[1]):
11
+ raise Exception('Spans must be rectangular in shape.')
12
+
13
+ if not (0 <= start_span[0] <= end_span[0] < len(data)) or not (0 <= start_span[1] <= end_span[1] < len(data[0])):
14
+ raise Exception(f"{span} is out of bounds.")
15
+
16
+ def extend_span(start: tuple[int, int], end: tuple[int, int]) -> SpanType:
17
+ extended_span = []
18
+ for row_idx in range(start[0], end[0] + 1):
19
+ for col_idx in range(start[1], end[1] + 1):
20
+ extended_span.append((row_idx, col_idx))
21
+
22
+ return extended_span
23
+
24
+ def get_span(spans: list[SpanType], row: int, column: int) -> SpanType | None:
25
+ for i in range(len(spans)):
26
+ if (row, column) in spans[i]:
27
+ return spans[i]
28
+
29
+ return None
30
+
31
+ def convert_cells_to_spans(data: TableType, spans: list[SpanType]) -> list[SpanType]:
32
+ new_spans = []
33
+ for row in range(len(data)):
34
+ for column in range(len(data[0])):
35
+ span = get_span(spans, row, column)
36
+ if not span:
37
+ new_spans.append([(row, column)])
38
+
39
+ new_spans.extend(spans)
40
+ new_spans = list(sorted(new_spans))
41
+ return new_spans
42
+
43
+ def get_span_column_count(span: SpanType) -> int:
44
+ return max((span[-1][1] - span[0][1]) + 1, 1)
45
+
46
+ def get_span_row_count(span: SpanType) -> int:
47
+ return max((span[-1][0] - span[0][0]) + 1, 1)
48
+
49
+ def get_longest_line_length(text: MarkupText) -> int:
50
+ return len(max(text.plain.split("\n"), key=lambda line: len(line)))
51
+
52
+ def get_output_column_widths(table: TableType, spans: list[SpanType]) -> list[int]:
53
+ widths = [3 for _ in range(len(table[0]))]
54
+ for row in range(len(table)):
55
+ for column in range(len(table[row])):
56
+ span = get_span(spans, row, column)
57
+ column_count = get_span_column_count(span)
58
+ text_row = span[0][0]
59
+ text_column = span[0][1]
60
+ text = table[text_row][text_column]
61
+ length = get_longest_line_length(text)
62
+ if column_count == 1:
63
+ widths[column] = max(length, widths[column])
64
+ else:
65
+ end_column = text_column + column_count
66
+ available_space = sum(widths[text_column:end_column]) + column_count - 1
67
+ while length > available_space:
68
+ for i in range(text_column, end_column):
69
+ widths[i] += 1
70
+ available_space = sum(widths[text_column:end_column]) + column_count - 1
71
+ if length <= available_space:
72
+ break
73
+ return widths
74
+
75
+ def get_output_row_heights(table: TableType, spans: list[SpanType]) -> list[int]:
76
+ heights = [-1 for _ in table]
77
+ for row in range(len(table)):
78
+ for column in range(len(table[row])):
79
+ text = table[row][column]
80
+ span = get_span(spans, row, column)
81
+ row_count = get_span_row_count(span)
82
+ height = len(text.plain.split('\n'))
83
+ if row_count == 1:
84
+ heights[row] = max(height, heights[row])
85
+
86
+ for row in range(len(table)):
87
+ for column in range(len(table[row])):
88
+ text = table[row][column]
89
+ span = get_span(spans, row, column)
90
+ row_count = get_span_row_count(span)
91
+ height = len(text.plain.split('\n'))
92
+ if row_count > 1:
93
+ text_row = span[0][0]
94
+ text_column = span[0][1]
95
+ end_row = text_row + row_count
96
+ text = table[text_row][text_column]
97
+ height = len(text.plain.split('\n')) - (row_count - 1)
98
+ add_row = 0
99
+ while height > sum(heights[text_row:end_row]):
100
+ heights[text_row + add_row] += 1
101
+ if add_row + 1 < row_count:
102
+ add_row += 1
103
+ else:
104
+ add_row = 0
105
+ return heights
106
+
107
+ def get_span_char_width(span: SpanType, column_widths: list[int]) -> int:
108
+ start_column = span[0][1]
109
+ column_count = get_span_column_count(span)
110
+ total_width = 0
111
+
112
+ for i in range(start_column, start_column + column_count):
113
+ total_width += column_widths[i]
114
+
115
+ total_width += column_count - 1
116
+
117
+ return total_width
118
+
119
+ def get_span_char_height(span: SpanType, row_heights: list[int]) -> int:
120
+ start_row = span[0][0]
121
+ row_count = get_span_row_count(span)
122
+ total_height = 0
123
+
124
+ for i in range(start_row, start_row + row_count):
125
+ total_height += row_heights[i]
126
+
127
+ total_height += row_count - 1
128
+ return total_height
span_table/table.py ADDED
@@ -0,0 +1,54 @@
1
+ from rich import box as rich_box
2
+ from rich.text import Text
3
+
4
+ from src.span_table.span import (
5
+ extend_span,
6
+ check_span,
7
+ convert_cells_to_spans,
8
+ get_output_column_widths,
9
+ get_output_row_heights,
10
+ )
11
+ from src.span_table.cell import make_cell, merge_all_cells
12
+ from src.span_table.markup_text import MarkupText
13
+ from src.span_table.types import SpanType, TableType
14
+ from typing import Annotated
15
+
16
+ TableMarkupType = Annotated[list[list[MarkupText]], 'Lists of list of markup type objects']
17
+
18
+ def check_table(data: TableType):
19
+ if not type(data) is list:
20
+ raise Exception("Table data must be a list of lists")
21
+
22
+ if len(data) == 0:
23
+ raise Exception("Table data must contain at least one row and one column")
24
+
25
+ for i in range(len(data)):
26
+ if not type(data[i]) is list:
27
+ raise Exception("Table data must be a list of lists")
28
+
29
+ if not len(data[i]) == len(data[0]):
30
+ raise Exception("Each row must have the same number of columns")
31
+
32
+ def span_table(
33
+ data: TableType,
34
+ spans: list[SpanType],
35
+ box: rich_box.Box = rich_box.SQUARE
36
+ ) -> str:
37
+ check_table(data=data)
38
+ extended_spans = []
39
+ for span in spans:
40
+ check_span(data, span)
41
+ extended_spans.append(extend_span(*span))
42
+
43
+ text_data = [[MarkupText(cell) for cell in row] for row in data]
44
+ spans = convert_cells_to_spans(text_data, extended_spans)
45
+ widths = get_output_column_widths(text_data, spans)
46
+ heights = get_output_row_heights(text_data, spans)
47
+ cells = [
48
+ make_cell(text_data, span, widths, heights, box)
49
+ for span in spans
50
+ ]
51
+ cells = list(sorted(cells))
52
+ grid_table = merge_all_cells(cells, box)
53
+
54
+ return grid_table
span_table/types.py ADDED
@@ -0,0 +1,5 @@
1
+ from typing import Annotated
2
+
3
+ SpanType = Annotated[list[tuple[int, int]], "List of tuples"]
4
+ TableType = Annotated[list[list[str]], "List of list of strings with rich markup"]
5
+
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: span-table
3
+ Version: 0.1.0
4
+ Summary: span-table is a lightweight Python helper for rendering ASCII tables with merged cells using Rich-style markup.
5
+ Project-URL: Repository, https://github.com/soamicharan/span-table
6
+ Project-URL: Homepage, https://github.com/soamicharan/span-table
7
+ Project-URL: Issues, https://github.com/soamicharan/span-table/issues
8
+ Author-email: Soami Charan <charansoami@gmail.com>
9
+ Maintainer-email: Soami Charan <charansoami@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: ascii,merged cells,rich,span,table
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: rich>=15.0.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # span-table
23
+
24
+ `span-table` is a lightweight Python helper for rendering ASCII tables with merged cells using Rich-style markup.
25
+
26
+ ## Features
27
+
28
+ - Render table layouts with custom merged spans
29
+ - Support multi-line cell content
30
+ - Uses `rich` box styles for flexible borders
31
+ - Compatible with Python `>=3.14`
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install span-table
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ```python
42
+ from span_table import span_table
43
+ from rich import print
44
+
45
+ table = [
46
+ ["Header 1", "Header 2", "Header3", "Header 4"],
47
+ ["row 1", "column 2", "column 3", "column 4"],
48
+ ["row 2", "Cells span columns.", "", ""],
49
+ ["row 3", "Cells\nspan rows.", "- Cells\n- contain\n- blocks", ""],
50
+ ["row 4", "", "", ""]
51
+ ]
52
+
53
+ # Spans are defined as ((start_row, start_col), (end_row, end_col)) with zero-based indexes
54
+ my_spans = [
55
+ ((2, 1), (2, 3)),
56
+ ((3, 1), (4, 1)),
57
+ ((3, 2), (4, 3)),
58
+ ]
59
+
60
+ print(span_table(table, my_spans))
61
+ ```
62
+
63
+ This renders:
64
+
65
+ ```text
66
+ ┌──────────┬────────────┬──────────┬──────────┐
67
+ │ Header 1 │ Header 2 │ Header3 │ Header 4 │
68
+ ├──────────┼────────────┼──────────┼──────────┤
69
+ │ row 1 │ column 2 │ column 3 │ column 4 │
70
+ ├──────────┼────────────┴──────────┴──────────┤
71
+ │ row 2 │ Cells span columns. │
72
+ ├──────────┼────────────┬─────────────────────┤
73
+ │ row 3 │ Cells │ - Cells │
74
+ ├──────────┤ span rows. │ - contain │
75
+ │ row 4 │ │ - blocks │
76
+ └──────────┴────────────┴─────────────────────┘
77
+ ```
78
+
79
+ ## Advanced Usage
80
+
81
+ ```python
82
+ from span_table import span_table
83
+ from rich import print
84
+
85
+ table = [
86
+ ["Time", "[on red]Height[/]", "", "[on green]Weight[/]", ""],
87
+ ["", "Total", "Change", "Total", "Change"],
88
+ ["15:30", "1", "2", "3", "4"]
89
+ ]
90
+
91
+ spans = [
92
+ ((0, 0), (1, 0)),
93
+ ((0, 1), (0, 2)),
94
+ ((0, 3), (0, 4))
95
+ ]
96
+
97
+ print(span_table(table, spans))
98
+ ```
99
+
100
+ This renders:
101
+
102
+ ```text
103
+ ┌───────┬────────────────┬────────────────┐
104
+ │ Time │<red bg> Height │<Green BG>Weight│
105
+ │ ├───────┬────────┼───────┬────────┤
106
+ │ │ Total │ Change │ Total │ Change │
107
+ ├───────┼───────┼────────┼───────┼────────┤
108
+ │ 15:30 │ 1 │ 2 │ 3 │ 4 │
109
+ └───────┴───────┴────────┴───────┴────────┘
110
+ ```
111
+
112
+ ## API
113
+
114
+ ### `span_table(data, spans, box=rich_box.SQUARE) -> str`
115
+
116
+ Render a text table with optional merged cells.
117
+
118
+ - `data`: `list[list[str]]` — table rows and columns
119
+ - `spans`: `list[tuple[tuple[int, int], tuple[int, int]]]` — list of merge regions
120
+ - `box`: `rich.box.Box` — optional Rich box style such as `box.SQUARE`
121
+
122
+ ### Span rules
123
+
124
+ - Each span is a rectangular region defined by a start and end coordinate
125
+ - Coordinates are zero-based: `(row_index, column_index)`
126
+ - Spans can cover a single cell or multiple cells
127
+ - Spans must be within the table bounds
128
+
129
+ ## Rich markup support
130
+
131
+ Cell values may include Rich markup tags such as `[bold]`, `[italic]`, or color tags but does not support emoji yet. The library preserves markup while measuring text layout.
132
+
133
+ ## Notes
134
+
135
+ - Ensure each row has the same number of columns
136
+ - Non-merged cells may be omitted from `spans`
137
+ - The library automatically fills any cells not included in spans as standard individual cells
@@ -0,0 +1,10 @@
1
+ span_table/__init__.py,sha256=mDKjm-AX0BmcHOESBgYga29r7SNP5UgLZ64XO-ZN16A,54
2
+ span_table/cell.py,sha256=HvQGf_mCf4vVccw2eCs59EAjwrurVDrZxl-LYaQYR44,8928
3
+ span_table/markup_text.py,sha256=wMMA2itinGryiDn1t59BFW8ZPZqEFJoHHAX-SLEViBk,1809
4
+ span_table/span.py,sha256=UKD45lw5sx7sUS12peQrdSq11XCKmD1jGcXkChImxoM,5021
5
+ span_table/table.py,sha256=2TSIad3prC_qtq648QEzkYwQlX1zLv6lfhDBRHWNuFQ,1720
6
+ span_table/types.py,sha256=c9PeMHS4Wmu71CH7OPG5uEzadFEPy08QpfizZafdt8U,177
7
+ span_table-0.1.0.dist-info/METADATA,sha256=-moRHltS5Xn4Ym66fJGSOOG9Uki4GJXDIxyWbc24HCs,4951
8
+ span_table-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ span_table-0.1.0.dist-info/licenses/LICENSE,sha256=-M09X0kv3P2rDVz4NY85k8ufAoc6ED4n5nyNJF0JvLQ,1069
10
+ span_table-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soami Charan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.