span-table 0.1.0__tar.gz
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-0.1.0/.gitignore +218 -0
- span_table-0.1.0/.python-version +1 -0
- span_table-0.1.0/LICENSE +21 -0
- span_table-0.1.0/PKG-INFO +137 -0
- span_table-0.1.0/README.md +116 -0
- span_table-0.1.0/main.py +6 -0
- span_table-0.1.0/pyproject.toml +33 -0
- span_table-0.1.0/src/span_table/__init__.py +3 -0
- span_table-0.1.0/src/span_table/cell.py +265 -0
- span_table-0.1.0/src/span_table/markup_text.py +62 -0
- span_table-0.1.0/src/span_table/span.py +128 -0
- span_table-0.1.0/src/span_table/table.py +54 -0
- span_table-0.1.0/src/span_table/types.py +5 -0
- span_table-0.1.0/uv.lock +80 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
span_table-0.1.0/LICENSE
ADDED
|
@@ -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.
|
|
@@ -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,116 @@
|
|
|
1
|
+
# span-table
|
|
2
|
+
|
|
3
|
+
`span-table` is a lightweight Python helper for rendering ASCII tables with merged cells using Rich-style markup.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Render table layouts with custom merged spans
|
|
8
|
+
- Support multi-line cell content
|
|
9
|
+
- Uses `rich` box styles for flexible borders
|
|
10
|
+
- Compatible with Python `>=3.14`
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install span-table
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from span_table import span_table
|
|
22
|
+
from rich import print
|
|
23
|
+
|
|
24
|
+
table = [
|
|
25
|
+
["Header 1", "Header 2", "Header3", "Header 4"],
|
|
26
|
+
["row 1", "column 2", "column 3", "column 4"],
|
|
27
|
+
["row 2", "Cells span columns.", "", ""],
|
|
28
|
+
["row 3", "Cells\nspan rows.", "- Cells\n- contain\n- blocks", ""],
|
|
29
|
+
["row 4", "", "", ""]
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# Spans are defined as ((start_row, start_col), (end_row, end_col)) with zero-based indexes
|
|
33
|
+
my_spans = [
|
|
34
|
+
((2, 1), (2, 3)),
|
|
35
|
+
((3, 1), (4, 1)),
|
|
36
|
+
((3, 2), (4, 3)),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
print(span_table(table, my_spans))
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This renders:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
┌──────────┬────────────┬──────────┬──────────┐
|
|
46
|
+
│ Header 1 │ Header 2 │ Header3 │ Header 4 │
|
|
47
|
+
├──────────┼────────────┼──────────┼──────────┤
|
|
48
|
+
│ row 1 │ column 2 │ column 3 │ column 4 │
|
|
49
|
+
├──────────┼────────────┴──────────┴──────────┤
|
|
50
|
+
│ row 2 │ Cells span columns. │
|
|
51
|
+
├──────────┼────────────┬─────────────────────┤
|
|
52
|
+
│ row 3 │ Cells │ - Cells │
|
|
53
|
+
├──────────┤ span rows. │ - contain │
|
|
54
|
+
│ row 4 │ │ - blocks │
|
|
55
|
+
└──────────┴────────────┴─────────────────────┘
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Advanced Usage
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from span_table import span_table
|
|
62
|
+
from rich import print
|
|
63
|
+
|
|
64
|
+
table = [
|
|
65
|
+
["Time", "[on red]Height[/]", "", "[on green]Weight[/]", ""],
|
|
66
|
+
["", "Total", "Change", "Total", "Change"],
|
|
67
|
+
["15:30", "1", "2", "3", "4"]
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
spans = [
|
|
71
|
+
((0, 0), (1, 0)),
|
|
72
|
+
((0, 1), (0, 2)),
|
|
73
|
+
((0, 3), (0, 4))
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
print(span_table(table, spans))
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This renders:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
┌───────┬────────────────┬────────────────┐
|
|
83
|
+
│ Time │<red bg> Height │<Green BG>Weight│
|
|
84
|
+
│ ├───────┬────────┼───────┬────────┤
|
|
85
|
+
│ │ Total │ Change │ Total │ Change │
|
|
86
|
+
├───────┼───────┼────────┼───────┼────────┤
|
|
87
|
+
│ 15:30 │ 1 │ 2 │ 3 │ 4 │
|
|
88
|
+
└───────┴───────┴────────┴───────┴────────┘
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## API
|
|
92
|
+
|
|
93
|
+
### `span_table(data, spans, box=rich_box.SQUARE) -> str`
|
|
94
|
+
|
|
95
|
+
Render a text table with optional merged cells.
|
|
96
|
+
|
|
97
|
+
- `data`: `list[list[str]]` — table rows and columns
|
|
98
|
+
- `spans`: `list[tuple[tuple[int, int], tuple[int, int]]]` — list of merge regions
|
|
99
|
+
- `box`: `rich.box.Box` — optional Rich box style such as `box.SQUARE`
|
|
100
|
+
|
|
101
|
+
### Span rules
|
|
102
|
+
|
|
103
|
+
- Each span is a rectangular region defined by a start and end coordinate
|
|
104
|
+
- Coordinates are zero-based: `(row_index, column_index)`
|
|
105
|
+
- Spans can cover a single cell or multiple cells
|
|
106
|
+
- Spans must be within the table bounds
|
|
107
|
+
|
|
108
|
+
## Rich markup support
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
## Notes
|
|
113
|
+
|
|
114
|
+
- Ensure each row has the same number of columns
|
|
115
|
+
- Non-merged cells may be omitted from `spans`
|
|
116
|
+
- The library automatically fills any cells not included in spans as standard individual cells
|
span_table-0.1.0/main.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "span-table"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "span-table is a lightweight Python helper for rendering ASCII tables with merged cells using Rich-style markup."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"rich>=15.0.0",
|
|
9
|
+
]
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Soami Charan", email = "charansoami@gmail.com" }]
|
|
13
|
+
maintainers = [{ name = "Soami Charan", email = "charansoami@gmail.com" }]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
]
|
|
21
|
+
keywords = ["table", "ascii", "rich", "span", "merged cells"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/soamicharan/span-table"
|
|
25
|
+
Homepage = "https://github.com/soamicharan/span-table"
|
|
26
|
+
Issues = "https://github.com/soamicharan/span-table/issues"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["hatchling"]
|
|
30
|
+
build-backend = "hatchling.build"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/span_table"]
|
|
@@ -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))
|
|
@@ -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
|
|
@@ -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-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.9"
|
|
4
|
+
resolution-markers = [
|
|
5
|
+
"python_full_version >= '3.10'",
|
|
6
|
+
"python_full_version < '3.10'",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
[[package]]
|
|
10
|
+
name = "markdown-it-py"
|
|
11
|
+
version = "3.0.0"
|
|
12
|
+
source = { registry = "https://pypi.org/simple" }
|
|
13
|
+
resolution-markers = [
|
|
14
|
+
"python_full_version < '3.10'",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
{ name = "mdurl", marker = "python_full_version < '3.10'" },
|
|
18
|
+
]
|
|
19
|
+
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
|
|
20
|
+
wheels = [
|
|
21
|
+
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[[package]]
|
|
25
|
+
name = "markdown-it-py"
|
|
26
|
+
version = "4.2.0"
|
|
27
|
+
source = { registry = "https://pypi.org/simple" }
|
|
28
|
+
resolution-markers = [
|
|
29
|
+
"python_full_version >= '3.10'",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
{ name = "mdurl", marker = "python_full_version >= '3.10'" },
|
|
33
|
+
]
|
|
34
|
+
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
|
35
|
+
wheels = [
|
|
36
|
+
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[[package]]
|
|
40
|
+
name = "mdurl"
|
|
41
|
+
version = "0.1.2"
|
|
42
|
+
source = { registry = "https://pypi.org/simple" }
|
|
43
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
|
44
|
+
wheels = [
|
|
45
|
+
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[[package]]
|
|
49
|
+
name = "pygments"
|
|
50
|
+
version = "2.20.0"
|
|
51
|
+
source = { registry = "https://pypi.org/simple" }
|
|
52
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
|
53
|
+
wheels = [
|
|
54
|
+
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[[package]]
|
|
58
|
+
name = "rich"
|
|
59
|
+
version = "15.0.0"
|
|
60
|
+
source = { registry = "https://pypi.org/simple" }
|
|
61
|
+
dependencies = [
|
|
62
|
+
{ name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
|
63
|
+
{ name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
|
64
|
+
{ name = "pygments" },
|
|
65
|
+
]
|
|
66
|
+
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
|
67
|
+
wheels = [
|
|
68
|
+
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[[package]]
|
|
72
|
+
name = "span-table"
|
|
73
|
+
version = "0.1.0"
|
|
74
|
+
source = { editable = "." }
|
|
75
|
+
dependencies = [
|
|
76
|
+
{ name = "rich" },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[package.metadata]
|
|
80
|
+
requires-dist = [{ name = "rich", specifier = ">=15.0.0" }]
|