flexablelib 2.0.1__tar.gz → 2.0.4__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.
- {flexablelib-2.0.1 → flexablelib-2.0.4}/CHANGELOG.md +20 -0
- {flexablelib-2.0.1/flexablelib.egg-info → flexablelib-2.0.4}/PKG-INFO +1 -1
- flexablelib-2.0.4/flexablelib/merges.py +596 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/readers.py +37 -6
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/table.py +3 -1
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/writers.py +22 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4/flexablelib.egg-info}/PKG-INFO +1 -1
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib.egg-info/SOURCES.txt +1 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/package.json +1 -1
- flexablelib-2.0.4/tests/test_excel.py +110 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_merges.py +171 -3
- flexablelib-2.0.1/flexablelib/merges.py +0 -300
- {flexablelib-2.0.1 → flexablelib-2.0.4}/LICENSE +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/MANIFEST.in +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/README.md +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/__init__.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/_speedups.c +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/_width_tables.h +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/cells.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/colors.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/columns.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/fast.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/options.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/style_compositions.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/table_strings.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/text_width.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/utils.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib.egg-info/dependency_links.txt +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib.egg-info/requires.txt +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib.egg-info/top_level.txt +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/setup.cfg +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/setup.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_cells.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_columns.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_features.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_issues.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_readme_examples.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_styles.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_text_width.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_two_pass_measurement.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_upstream_issues.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tools/benchmark.py +0 -0
- {flexablelib-2.0.1 → flexablelib-2.0.4}/tools/generate_width_tables.py +0 -0
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [2.0.4](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.3...v2.0.4) (2026-10-28)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* repair merge junctions on both axes (vertical + horizontal) from surviving arms
|
|
10
|
+
* use middle-vertical tees (├/┤) instead of border glyphs (╟/╢) inside the grid
|
|
11
|
+
* insert synthetic row rules for column-only styles around multi-column merges
|
|
12
|
+
|
|
13
|
+
## [2.0.3](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.2...v2.0.3) (2026-10-28)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* ``to_excel`` / ``from_excel`` preserve merged cell ranges (openpyxl)
|
|
18
|
+
|
|
19
|
+
## [2.0.2](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.1...v2.0.2) (2026-10-28)
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* redraw vertical-merge vertices as edges/tees instead of leftover corners
|
|
24
|
+
|
|
5
25
|
## [2.0.1](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.0...v2.0.1) (2026-10-28)
|
|
6
26
|
|
|
7
27
|
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MERGED CELLS
|
|
3
|
+
|
|
4
|
+
A merge makes one rectangular block of cells render as a single cell, spanning
|
|
5
|
+
columns, rows, or both.
|
|
6
|
+
|
|
7
|
+
The merge is applied to the finished table string rather than threaded through
|
|
8
|
+
the measuring pipeline. That pipeline sizes each column independently, which is
|
|
9
|
+
what makes decimal-point alignment and terminal fitting work; making a column's
|
|
10
|
+
width depend on a cell that belongs to several columns at once would put a
|
|
11
|
+
cycle in it. Rewriting the assembled lines keeps the measurement honest -- the
|
|
12
|
+
columns are still sized by their own unmerged content -- and the merge becomes
|
|
13
|
+
a presentation step that cannot affect layout correctness.
|
|
14
|
+
|
|
15
|
+
The consequence is that a merged region never widens the table. Content longer
|
|
16
|
+
than the span it was given is truncated, not accommodated.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import List, NamedTuple, Optional
|
|
20
|
+
|
|
21
|
+
from .fast import (
|
|
22
|
+
has_ansi,
|
|
23
|
+
pad_to_width,
|
|
24
|
+
partition_by_width,
|
|
25
|
+
slice_by_width,
|
|
26
|
+
strip_by_width,
|
|
27
|
+
truncate_to_width,
|
|
28
|
+
visible_width,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Painting over a stretch of line can remove the escape that closed a colour --
|
|
32
|
+
# the reset ending a coloured border, say, when the merge starts just after it.
|
|
33
|
+
# The replacement then inherits a colour nobody asked for. Opening the painted
|
|
34
|
+
# span with a reset of its own costs nothing on a plain table, because it is
|
|
35
|
+
# only added where escapes were actually removed.
|
|
36
|
+
_RESET = '\x1b[0m'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _repaint(line, start, end, replacement):
|
|
40
|
+
"""Replace the visible columns [start, end) of a line, ending any colour."""
|
|
41
|
+
before, removed, after = partition_by_width(line, start, end)
|
|
42
|
+
if has_ansi(removed) and not replacement.startswith(_RESET):
|
|
43
|
+
replacement = _RESET + replacement
|
|
44
|
+
return before + replacement + after
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MergedRegion(NamedTuple):
|
|
48
|
+
"""
|
|
49
|
+
One rectangular block of cells rendered as a single cell.
|
|
50
|
+
|
|
51
|
+
Coordinates are inclusive and zero-based, in the table's own row and
|
|
52
|
+
column numbering -- the index column, when shown, is not counted.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
first_row: int
|
|
56
|
+
first_column: int
|
|
57
|
+
last_row: int
|
|
58
|
+
last_column: int
|
|
59
|
+
value: Optional[str]
|
|
60
|
+
align: str
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def row_count(self) -> int:
|
|
64
|
+
return self.last_row - self.first_row + 1
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def column_count(self) -> int:
|
|
68
|
+
return self.last_column - self.first_column + 1
|
|
69
|
+
|
|
70
|
+
def covers(self, row: int, column: int) -> bool:
|
|
71
|
+
return (self.first_row <= row <= self.last_row
|
|
72
|
+
and self.first_column <= column <= self.last_column)
|
|
73
|
+
|
|
74
|
+
def overlaps(self, other: 'MergedRegion') -> bool:
|
|
75
|
+
return not (
|
|
76
|
+
self.last_row < other.first_row
|
|
77
|
+
or other.last_row < self.first_row
|
|
78
|
+
or self.last_column < other.first_column
|
|
79
|
+
or other.last_column < self.first_column
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def normalise(first_row, first_column, last_row, last_column, value, align,
|
|
84
|
+
row_count, column_count, existing):
|
|
85
|
+
"""
|
|
86
|
+
Validate a merge request and turn it into a MergedRegion.
|
|
87
|
+
|
|
88
|
+
Accepts the corners in either order, so callers may give them the way they
|
|
89
|
+
think of them. Raises ValueError on anything that cannot be rendered: a
|
|
90
|
+
single cell, coordinates outside the table, an unknown alignment, or an
|
|
91
|
+
overlap with a merge already in place.
|
|
92
|
+
"""
|
|
93
|
+
first_row, last_row = sorted((first_row, last_row))
|
|
94
|
+
first_column, last_column = sorted((first_column, last_column))
|
|
95
|
+
|
|
96
|
+
if first_row < 0 or first_column < 0:
|
|
97
|
+
raise ValueError('merge coordinates cannot be negative')
|
|
98
|
+
if last_row >= row_count:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f'last_row {last_row} is outside the table, which has '
|
|
101
|
+
f'{row_count} row(s)'
|
|
102
|
+
)
|
|
103
|
+
if last_column >= column_count:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f'last_column {last_column} is outside the table, which has '
|
|
106
|
+
f'{column_count} column(s)'
|
|
107
|
+
)
|
|
108
|
+
if first_row == last_row and first_column == last_column:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
'a merge must cover more than one cell; '
|
|
111
|
+
'give a different last_row or last_column'
|
|
112
|
+
)
|
|
113
|
+
if align not in ('l', 'r', 'c'):
|
|
114
|
+
raise ValueError(f"align must be 'l', 'r' or 'c', not {align!r}")
|
|
115
|
+
|
|
116
|
+
region = MergedRegion(first_row, first_column, last_row, last_column,
|
|
117
|
+
value, align)
|
|
118
|
+
|
|
119
|
+
for other in existing:
|
|
120
|
+
if region.overlaps(other):
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f'this merge overlaps the one covering rows '
|
|
123
|
+
f'{other.first_row}-{other.last_row}, columns '
|
|
124
|
+
f'{other.first_column}-{other.last_column}'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return region
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Layout(NamedTuple):
|
|
131
|
+
"""
|
|
132
|
+
Where each column sits inside a rendered line.
|
|
133
|
+
|
|
134
|
+
``spans`` holds one (start, end) pair per *drawn* column, as visible-column
|
|
135
|
+
bounds within the line, covering the cell text and its margins but not the
|
|
136
|
+
separators around it. ``separators`` holds the offset of each vertical rule
|
|
137
|
+
between columns, so a horizontal merge knows which characters to paint
|
|
138
|
+
over. ``span_of_column`` maps a table column index to its entry in
|
|
139
|
+
``spans``, and has no entry for a column the table is not drawing.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
spans: List[tuple]
|
|
143
|
+
separators: List[int]
|
|
144
|
+
span_of_column: dict
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def compute_layout(column_widths, composition, hidden=()) -> Layout:
|
|
148
|
+
"""
|
|
149
|
+
Work out the column offsets for a style and set of widths.
|
|
150
|
+
|
|
151
|
+
Mirrors how table_strings assembles a row: an optional left rule, then for
|
|
152
|
+
each column its margins and content, with a rule between neighbours and an
|
|
153
|
+
optional one at the end.
|
|
154
|
+
|
|
155
|
+
``hidden`` lists the columns the table is leaving out -- empty ones, when
|
|
156
|
+
``show_empty_columns`` is off. They take up no room in the rendered line,
|
|
157
|
+
so counting them here would put every offset after them too far right.
|
|
158
|
+
"""
|
|
159
|
+
body = composition.vertical_table_body_lines
|
|
160
|
+
margin = 1 if composition.margin else 0
|
|
161
|
+
hidden = set(hidden)
|
|
162
|
+
|
|
163
|
+
offset = len(body.left) if body.left is not None else 0
|
|
164
|
+
spans = []
|
|
165
|
+
separators = []
|
|
166
|
+
span_of_column = {}
|
|
167
|
+
|
|
168
|
+
for index, width in enumerate(column_widths):
|
|
169
|
+
if index in hidden:
|
|
170
|
+
continue
|
|
171
|
+
if spans:
|
|
172
|
+
if body.middle is not None:
|
|
173
|
+
separators.append(offset)
|
|
174
|
+
offset += len(body.middle)
|
|
175
|
+
cell_width = width + margin * 2
|
|
176
|
+
span_of_column[index] = len(spans)
|
|
177
|
+
spans.append((offset, offset + cell_width))
|
|
178
|
+
offset += cell_width
|
|
179
|
+
|
|
180
|
+
return Layout(spans, separators, span_of_column)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _region_bounds(region, layout, index_offset):
|
|
184
|
+
"""
|
|
185
|
+
Where a region sits in a line, as visible-column offsets.
|
|
186
|
+
|
|
187
|
+
Returns the span covering the whole block, the span of its top-left cell,
|
|
188
|
+
and the index of its rightmost drawn column. A merge given no replacement
|
|
189
|
+
text reads its content from that top-left cell -- not from the strip of
|
|
190
|
+
table under the whole block, which is the neighbouring columns and the
|
|
191
|
+
rules between them.
|
|
192
|
+
|
|
193
|
+
Columns the table is not drawing are skipped over, so a merge that reaches
|
|
194
|
+
across a hidden empty column still lands on the columns either side of it.
|
|
195
|
+
Returns None when the region is drawn nowhere at all.
|
|
196
|
+
"""
|
|
197
|
+
drawn = [
|
|
198
|
+
layout.span_of_column[column]
|
|
199
|
+
for column in range(region.first_column + index_offset,
|
|
200
|
+
region.last_column + index_offset + 1)
|
|
201
|
+
if column in layout.span_of_column
|
|
202
|
+
]
|
|
203
|
+
if not drawn:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
first, last = drawn[0], drawn[-1]
|
|
207
|
+
return (
|
|
208
|
+
(layout.spans[first][0], layout.spans[last][1]),
|
|
209
|
+
layout.spans[first],
|
|
210
|
+
first,
|
|
211
|
+
last,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Replacement for apply() and helpers — pasted into merges.py below Layout helpers.
|
|
216
|
+
|
|
217
|
+
# Horizontal dash characters used by the shipped styles (and ASCII grids).
|
|
218
|
+
_H_CHARS = set('─━═┄┅┈┉╌╍▬−' '-~=')
|
|
219
|
+
|
|
220
|
+
# Light / heavy / double / ascii junction tables keyed by (N, S, E, W) booleans.
|
|
221
|
+
_LIGHT = {
|
|
222
|
+
(1, 1, 1, 1): '┼', (1, 1, 1, 0): '├', (1, 1, 0, 1): '┤',
|
|
223
|
+
(1, 0, 1, 1): '┴', (0, 1, 1, 1): '┬', (1, 1, 0, 0): '│',
|
|
224
|
+
(0, 0, 1, 1): '─', (1, 0, 1, 0): '└', (1, 0, 0, 1): '┘',
|
|
225
|
+
(0, 1, 1, 0): '┌', (0, 1, 0, 1): '┐', (1, 0, 0, 0): '│',
|
|
226
|
+
(0, 1, 0, 0): '│', (0, 0, 1, 0): '─', (0, 0, 0, 1): '─',
|
|
227
|
+
}
|
|
228
|
+
_DOUBLE_V_SINGLE_H = {
|
|
229
|
+
(1, 1, 1, 1): '╫', (1, 1, 1, 0): '╟', (1, 1, 0, 1): '╢',
|
|
230
|
+
(1, 0, 1, 1): '╨', (0, 1, 1, 1): '╥', (1, 1, 0, 0): '║',
|
|
231
|
+
(0, 0, 1, 1): '─', (1, 0, 0, 0): '║', (0, 1, 0, 0): '║',
|
|
232
|
+
(1, 0, 1, 0): '╙', (1, 0, 0, 1): '╜', (0, 1, 1, 0): '╓',
|
|
233
|
+
(0, 1, 0, 1): '╖',
|
|
234
|
+
}
|
|
235
|
+
_DOUBLE = {
|
|
236
|
+
(1, 1, 1, 1): '╬', (1, 1, 1, 0): '╠', (1, 1, 0, 1): '╣',
|
|
237
|
+
(1, 0, 1, 1): '╩', (0, 1, 1, 1): '╦', (1, 1, 0, 0): '║',
|
|
238
|
+
(0, 0, 1, 1): '═', (1, 0, 0, 0): '║', (0, 1, 0, 0): '║',
|
|
239
|
+
(1, 0, 1, 0): '╚', (1, 0, 0, 1): '╝', (0, 1, 1, 0): '╔',
|
|
240
|
+
(0, 1, 0, 1): '╗',
|
|
241
|
+
}
|
|
242
|
+
_SINGLE_V_DOUBLE_H = {
|
|
243
|
+
(1, 1, 1, 1): '╪', (1, 1, 1, 0): '╞', (1, 1, 0, 1): '╡',
|
|
244
|
+
(1, 0, 1, 1): '╧', (0, 1, 1, 1): '╤', (1, 1, 0, 0): '│',
|
|
245
|
+
(0, 0, 1, 1): '═', (1, 0, 0, 0): '│', (0, 1, 0, 0): '│',
|
|
246
|
+
(1, 0, 1, 0): '╘', (1, 0, 0, 1): '╛', (0, 1, 1, 0): '╒',
|
|
247
|
+
(0, 1, 0, 1): '╕',
|
|
248
|
+
}
|
|
249
|
+
_ASCII = {
|
|
250
|
+
(1, 1, 1, 1): '+', (1, 1, 1, 0): '+', (1, 1, 0, 1): '+',
|
|
251
|
+
(1, 0, 1, 1): '+', (0, 1, 1, 1): '+', (1, 1, 0, 0): '|',
|
|
252
|
+
(0, 0, 1, 1): '-', (1, 0, 0, 0): '|', (0, 1, 0, 0): '|',
|
|
253
|
+
(1, 0, 1, 0): '+', (1, 0, 0, 1): '+', (0, 1, 1, 0): '+',
|
|
254
|
+
(0, 1, 0, 1): '+', (0, 0, 1, 0): '-', (0, 0, 0, 1): '-',
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _is_h(ch: str) -> bool:
|
|
259
|
+
return ch in _H_CHARS
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _char_at(line: str, index: int) -> str:
|
|
263
|
+
if index < 0 or index >= visible_width(line):
|
|
264
|
+
return ''
|
|
265
|
+
return slice_by_width(line, index, index + 1)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _junction_positions(layout, composition):
|
|
270
|
+
"""(x, vertical_glyph) for every vertical rule that can host a junction."""
|
|
271
|
+
v = composition.vertical_table_body_lines
|
|
272
|
+
if v is None:
|
|
273
|
+
return []
|
|
274
|
+
out = []
|
|
275
|
+
if layout.spans and v.left:
|
|
276
|
+
out.append((layout.spans[0][0] - len(v.left), v.left))
|
|
277
|
+
for sep_x in layout.separators:
|
|
278
|
+
if v.middle:
|
|
279
|
+
out.append((sep_x, v.middle))
|
|
280
|
+
if layout.spans and v.right:
|
|
281
|
+
out.append((layout.spans[-1][1], v.right))
|
|
282
|
+
return out
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _pick_junction(v_glyph: str, h_glyph: str, n: bool, s: bool, e: bool, w: bool) -> str:
|
|
286
|
+
key = (1 if n else 0, 1 if s else 0, 1 if e else 0, 1 if w else 0)
|
|
287
|
+
if key == (0, 0, 0, 0):
|
|
288
|
+
return ' '
|
|
289
|
+
if v_glyph in '|+' or h_glyph in '-=':
|
|
290
|
+
# ASCII grid / pipes family
|
|
291
|
+
if v_glyph in '|+' and h_glyph in '-=+':
|
|
292
|
+
return _ASCII.get(key, '+')
|
|
293
|
+
if v_glyph == '║' and h_glyph == '═':
|
|
294
|
+
return _DOUBLE.get(key, '║')
|
|
295
|
+
if v_glyph == '║':
|
|
296
|
+
return _DOUBLE_V_SINGLE_H.get(key, '║')
|
|
297
|
+
if h_glyph == '═' and v_glyph in ('│', '┃'):
|
|
298
|
+
return _SINGLE_V_DOUBLE_H.get(key, '│')
|
|
299
|
+
return _LIGHT.get(key, v_glyph or '│')
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _synthesize_body_rule(composition):
|
|
303
|
+
"""Body SeparatorLine for styles that draw column lines but no row lines."""
|
|
304
|
+
from .style_compositions import SeparatorLine
|
|
305
|
+
existing = composition.table_body_line
|
|
306
|
+
if existing is not None and existing.middle:
|
|
307
|
+
return existing
|
|
308
|
+
v = composition.vertical_table_body_lines
|
|
309
|
+
if v is None or not v.middle or v.middle == ' ':
|
|
310
|
+
return None
|
|
311
|
+
if v.middle in '|+':
|
|
312
|
+
return SeparatorLine(
|
|
313
|
+
left='+' if v.left else None,
|
|
314
|
+
middle='-',
|
|
315
|
+
intersection='+',
|
|
316
|
+
right='+' if v.right else None,
|
|
317
|
+
)
|
|
318
|
+
# Unicode column style (pretty_columns, round_edges, …)
|
|
319
|
+
left = '├' if v.left else None
|
|
320
|
+
right = '┤' if v.right else None
|
|
321
|
+
if v.left == '║':
|
|
322
|
+
left = '╟'
|
|
323
|
+
if v.right == '║':
|
|
324
|
+
right = '╢'
|
|
325
|
+
return SeparatorLine(left=left, middle='─', intersection='┼', right=right)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _build_rule_line(layout, composition, column_widths, hidden, rule=None):
|
|
329
|
+
"""Assemble one full horizontal rule matching the column layout."""
|
|
330
|
+
rule = rule or _synthesize_body_rule(composition)
|
|
331
|
+
if rule is None:
|
|
332
|
+
return None
|
|
333
|
+
v = composition.vertical_table_body_lines
|
|
334
|
+
margin = 1 if composition.margin else 0
|
|
335
|
+
hidden = set(hidden or ())
|
|
336
|
+
chunks = []
|
|
337
|
+
first = True
|
|
338
|
+
for index, width in enumerate(column_widths):
|
|
339
|
+
if index in hidden:
|
|
340
|
+
continue
|
|
341
|
+
if not first:
|
|
342
|
+
chunks.append(rule.intersection or '')
|
|
343
|
+
cell = (rule.middle or ' ') * (width + margin * 2)
|
|
344
|
+
chunks.append(cell)
|
|
345
|
+
first = False
|
|
346
|
+
center = ''.join(chunks)
|
|
347
|
+
if rule.left is not None:
|
|
348
|
+
left = rule.left
|
|
349
|
+
elif v and v.left:
|
|
350
|
+
left = v.left
|
|
351
|
+
else:
|
|
352
|
+
left = ''
|
|
353
|
+
if rule.right is not None:
|
|
354
|
+
right = rule.right
|
|
355
|
+
elif v and v.right:
|
|
356
|
+
right = v.right
|
|
357
|
+
else:
|
|
358
|
+
right = ''
|
|
359
|
+
return left + center + right
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _looks_like_rule(line: str, h_glyph: str) -> bool:
|
|
363
|
+
if not line:
|
|
364
|
+
return False
|
|
365
|
+
plain = line
|
|
366
|
+
if h_glyph and h_glyph in plain:
|
|
367
|
+
return True
|
|
368
|
+
return any(ch in plain for ch in _H_CHARS)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=None, missing_north=None):
|
|
372
|
+
"""
|
|
373
|
+
Rebuild every junction on a horizontal rule from the arms that remain.
|
|
374
|
+
|
|
375
|
+
``missing_south`` / ``missing_north`` are sets of visible x positions where
|
|
376
|
+
a vertical arm was removed by a horizontal (or 2D) merge on that side.
|
|
377
|
+
"""
|
|
378
|
+
missing_south = missing_south or set()
|
|
379
|
+
missing_north = missing_north or set()
|
|
380
|
+
v = composition.vertical_table_body_lines
|
|
381
|
+
if v is None:
|
|
382
|
+
return line
|
|
383
|
+
h_glyph = h_glyph or '─'
|
|
384
|
+
rewritten = line
|
|
385
|
+
for x, v_glyph in _junction_positions(layout, composition):
|
|
386
|
+
if visible_width(rewritten) <= x:
|
|
387
|
+
continue
|
|
388
|
+
here = _char_at(rewritten, x)
|
|
389
|
+
# Interior of a multi-column vertical blank: junction was wiped to space.
|
|
390
|
+
if here == ' ' or here == '':
|
|
391
|
+
continue
|
|
392
|
+
left = _char_at(rewritten, x - 1)
|
|
393
|
+
right = _char_at(rewritten, x + 1)
|
|
394
|
+
west = _is_h(left)
|
|
395
|
+
east = _is_h(right)
|
|
396
|
+
north = x not in missing_north
|
|
397
|
+
south = x not in missing_south
|
|
398
|
+
# A junction with no arms at all becomes the bare vertical.
|
|
399
|
+
glyph = _pick_junction(v_glyph, h_glyph, north, south, east, west)
|
|
400
|
+
rewritten = _repaint(rewritten, x, x + 1, glyph)
|
|
401
|
+
return rewritten
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def apply(lines, row_line_map, regions, column_widths, composition,
|
|
405
|
+
index_offset, hidden_columns=(), filler=' '):
|
|
406
|
+
"""
|
|
407
|
+
Rewrite assembled lines so each region renders as one cell.
|
|
408
|
+
|
|
409
|
+
``lines`` is the rendered table split into lines. ``row_line_map`` maps
|
|
410
|
+
each logical row index to the indexes of the physical lines it occupies,
|
|
411
|
+
which differ once wrapping has turned one row into several. Separator
|
|
412
|
+
lines between rows are not in the map; they are found between the mapped
|
|
413
|
+
lines and painted over where a region spans them vertically.
|
|
414
|
+
|
|
415
|
+
``index_offset`` is 1 when the index column is displayed, since region
|
|
416
|
+
coordinates ignore it. ``hidden_columns`` lists the columns the table is
|
|
417
|
+
not drawing, which take up no room in a line.
|
|
418
|
+
|
|
419
|
+
Returns the rewritten lines.
|
|
420
|
+
"""
|
|
421
|
+
if not regions:
|
|
422
|
+
return lines
|
|
423
|
+
|
|
424
|
+
layout = compute_layout(column_widths, composition, hidden_columns)
|
|
425
|
+
lines = list(lines)
|
|
426
|
+
margin = 1 if composition.margin else 0
|
|
427
|
+
body_rule = composition.table_body_line
|
|
428
|
+
h_glyph = body_rule.middle if body_rule is not None else None
|
|
429
|
+
# Separator lines whose horizontal run was blanked by a vertical span.
|
|
430
|
+
touched_seps = set()
|
|
431
|
+
# Horizontal (or 2D) merges: verticals removed between their columns on
|
|
432
|
+
# their data lines; caps above/below need ┴/┬ repair.
|
|
433
|
+
horiz_caps = [] # (above_line, below_line, inner_separator_xs)
|
|
434
|
+
# Column-only styles: insert a synthetic row rule around multi-column merges.
|
|
435
|
+
col_only_inserts = [] # (after_index, rule_line) insert AT after_index+1
|
|
436
|
+
|
|
437
|
+
synthesized = _synthesize_body_rule(composition)
|
|
438
|
+
col_only = (
|
|
439
|
+
composition.table_body_line is None
|
|
440
|
+
and synthesized is not None
|
|
441
|
+
and composition.vertical_table_body_lines is not None
|
|
442
|
+
and composition.vertical_table_body_lines.middle not in (None, '', ' ')
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
for region in regions:
|
|
446
|
+
bounds = _region_bounds(region, layout, index_offset)
|
|
447
|
+
if bounds is None:
|
|
448
|
+
continue
|
|
449
|
+
(start, end), (cell_start, cell_end), first_span, last_span = bounds
|
|
450
|
+
span_width = end - start - margin * 2
|
|
451
|
+
if span_width < 1:
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
target_lines = []
|
|
455
|
+
for row in range(region.first_row, region.last_row + 1):
|
|
456
|
+
target_lines.extend(row_line_map.get(row, []))
|
|
457
|
+
if not target_lines:
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
text = region.value
|
|
461
|
+
if text is None:
|
|
462
|
+
first_line = lines[target_lines[0]]
|
|
463
|
+
text = strip_by_width(slice_by_width(first_line, cell_start, cell_end))
|
|
464
|
+
text = str(text)
|
|
465
|
+
|
|
466
|
+
if visible_width(text) > span_width:
|
|
467
|
+
text = truncate_to_width(text, span_width)
|
|
468
|
+
painted = (filler * margin
|
|
469
|
+
+ pad_to_width(text, span_width, region.align, filler)
|
|
470
|
+
+ filler * margin)
|
|
471
|
+
blank = filler * (end - start)
|
|
472
|
+
|
|
473
|
+
text_line = target_lines[len(target_lines) // 2]
|
|
474
|
+
for line_index in target_lines:
|
|
475
|
+
line = lines[line_index]
|
|
476
|
+
if visible_width(line) < end:
|
|
477
|
+
continue
|
|
478
|
+
replacement = painted if line_index == text_line else blank
|
|
479
|
+
lines[line_index] = _repaint(line, start, end, replacement)
|
|
480
|
+
|
|
481
|
+
# Blank separator lines inside a vertical (or 2D) span. Junctions are
|
|
482
|
+
# repaired in a second pass once every region has painted, so adjacent
|
|
483
|
+
# merges share one consistent reading of the surviving arms.
|
|
484
|
+
covered = set(target_lines)
|
|
485
|
+
for line_index in range(min(target_lines), max(target_lines)):
|
|
486
|
+
if line_index in covered:
|
|
487
|
+
continue
|
|
488
|
+
line = lines[line_index]
|
|
489
|
+
if visible_width(line) < end:
|
|
490
|
+
continue
|
|
491
|
+
lines[line_index] = _repaint(line, start, end, blank)
|
|
492
|
+
touched_seps.add(line_index)
|
|
493
|
+
|
|
494
|
+
inner_seps = [
|
|
495
|
+
layout.separators[i]
|
|
496
|
+
for i in range(first_span, last_span)
|
|
497
|
+
if i < len(layout.separators)
|
|
498
|
+
]
|
|
499
|
+
|
|
500
|
+
if region.column_count >= 2 and inner_seps:
|
|
501
|
+
above = min(target_lines) - 1
|
|
502
|
+
below = max(target_lines) + 1
|
|
503
|
+
# Existing rules (body / header / end) get ┴/┬ via horiz_caps.
|
|
504
|
+
horiz_caps.append((above, below, set(inner_seps)))
|
|
505
|
+
|
|
506
|
+
if col_only:
|
|
507
|
+
rule_line = _build_rule_line(
|
|
508
|
+
layout, composition, column_widths, hidden_columns, synthesized
|
|
509
|
+
)
|
|
510
|
+
if rule_line is not None:
|
|
511
|
+
# Only insert a synthetic row rule where the style has no
|
|
512
|
+
# rule already. Duplicating a header/end rule looks broken;
|
|
513
|
+
# those are repaired in pass 3 instead.
|
|
514
|
+
above_is_rule = (
|
|
515
|
+
above >= 0 and any(_is_h(ch) for ch in lines[above])
|
|
516
|
+
)
|
|
517
|
+
below_is_rule = (
|
|
518
|
+
below < len(lines) and any(_is_h(ch) for ch in lines[below])
|
|
519
|
+
)
|
|
520
|
+
if not above_is_rule:
|
|
521
|
+
capped = _repair_rule_junctions(
|
|
522
|
+
rule_line, layout, composition,
|
|
523
|
+
synthesized.middle,
|
|
524
|
+
missing_south=set(inner_seps),
|
|
525
|
+
)
|
|
526
|
+
col_only_inserts.append((min(target_lines), capped))
|
|
527
|
+
if not below_is_rule:
|
|
528
|
+
capped_below = _repair_rule_junctions(
|
|
529
|
+
rule_line, layout, composition,
|
|
530
|
+
synthesized.middle,
|
|
531
|
+
missing_north=set(inner_seps),
|
|
532
|
+
)
|
|
533
|
+
col_only_inserts.append((max(target_lines) + 1, capped_below))
|
|
534
|
+
|
|
535
|
+
# --- Pass 2: repair junctions on body rules touched by vertical blanks ---
|
|
536
|
+
if h_glyph:
|
|
537
|
+
for sep_index in sorted(touched_seps):
|
|
538
|
+
line = lines[sep_index]
|
|
539
|
+
if not _looks_like_rule(line, h_glyph):
|
|
540
|
+
continue
|
|
541
|
+
lines[sep_index] = _repair_rule_junctions(
|
|
542
|
+
line, layout, composition, h_glyph
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# --- Pass 3: ┴ / ┬ on rules above and below horizontal / 2D merges -----
|
|
546
|
+
# Combine missing arms when several merges share a cap line.
|
|
547
|
+
missing_south_by_line = {}
|
|
548
|
+
missing_north_by_line = {}
|
|
549
|
+
for above, below, xs in horiz_caps:
|
|
550
|
+
if above >= 0:
|
|
551
|
+
missing_south_by_line.setdefault(above, set()).update(xs)
|
|
552
|
+
if below < len(lines):
|
|
553
|
+
missing_north_by_line.setdefault(below, set()).update(xs)
|
|
554
|
+
|
|
555
|
+
cap_lines = set(missing_south_by_line) | set(missing_north_by_line)
|
|
556
|
+
for line_index in cap_lines:
|
|
557
|
+
line = lines[line_index]
|
|
558
|
+
# Header / body / end rules all use horizontal glyphs.
|
|
559
|
+
if not any(_is_h(ch) for ch in line):
|
|
560
|
+
continue
|
|
561
|
+
# Guess h glyph from the line itself.
|
|
562
|
+
line_h = next((ch for ch in line if _is_h(ch)), h_glyph or '─')
|
|
563
|
+
lines[line_index] = _repair_rule_junctions(
|
|
564
|
+
line, layout, composition, line_h,
|
|
565
|
+
missing_south=missing_south_by_line.get(line_index),
|
|
566
|
+
missing_north=missing_north_by_line.get(line_index),
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# --- Pass 4: insert synthetic row rules for column-only styles ----------
|
|
570
|
+
# Adjacent multi-column merges on neighbouring rows both want a rule in
|
|
571
|
+
# the same gap; coalesce those into one line with every missing arm.
|
|
572
|
+
if col_only_inserts and synthesized is not None:
|
|
573
|
+
# Map insert index → (missing_south xs, missing_north xs)
|
|
574
|
+
gap_arms = {}
|
|
575
|
+
for above, below, xs in horiz_caps:
|
|
576
|
+
# insert above merge lands at above+1 (== min target_lines)
|
|
577
|
+
if above + 1 >= 0:
|
|
578
|
+
arms = gap_arms.setdefault(above + 1, [set(), set()])
|
|
579
|
+
arms[0] |= set(xs) # missing south into the merge below
|
|
580
|
+
arms = gap_arms.setdefault(below, [set(), set()])
|
|
581
|
+
arms[1] |= set(xs) # missing north into the merge above
|
|
582
|
+
# Only keep gaps we actually planned to insert (skip header/end).
|
|
583
|
+
planned = {at for at, _ in col_only_inserts}
|
|
584
|
+
for at in sorted(planned, reverse=True):
|
|
585
|
+
miss_s, miss_n = gap_arms.get(at, [set(), set()])
|
|
586
|
+
rule = _build_rule_line(
|
|
587
|
+
layout, composition, column_widths, hidden_columns, synthesized
|
|
588
|
+
)
|
|
589
|
+
rule = _repair_rule_junctions(
|
|
590
|
+
rule, layout, composition, synthesized.middle,
|
|
591
|
+
missing_south=miss_s or None,
|
|
592
|
+
missing_north=miss_n or None,
|
|
593
|
+
)
|
|
594
|
+
lines.insert(max(0, min(at, len(lines))), rule)
|
|
595
|
+
|
|
596
|
+
return lines
|
|
@@ -344,7 +344,8 @@ def from_excel(table_class, path, sheet=None, has_header: bool = True,
|
|
|
344
344
|
'Install it with: pip install flexablelib[excel]'
|
|
345
345
|
)
|
|
346
346
|
|
|
347
|
-
|
|
347
|
+
# read_only hides merged cell ranges; we need them to rebuild merges.
|
|
348
|
+
workbook = load_workbook(filename=path, read_only=False, data_only=True)
|
|
348
349
|
try:
|
|
349
350
|
if sheet is None:
|
|
350
351
|
worksheet = workbook.active
|
|
@@ -357,14 +358,44 @@ def from_excel(table_class, path, sheet=None, has_header: bool = True,
|
|
|
357
358
|
['' if cell is None else cell for cell in row]
|
|
358
359
|
for row in worksheet.iter_rows(values_only=True)
|
|
359
360
|
]
|
|
361
|
+
merge_ranges = [
|
|
362
|
+
(merged.min_row, merged.min_col, merged.max_row, merged.max_col)
|
|
363
|
+
for merged in worksheet.merged_cells.ranges
|
|
364
|
+
]
|
|
360
365
|
finally:
|
|
361
366
|
workbook.close()
|
|
362
367
|
|
|
363
368
|
if not rows:
|
|
364
369
|
return table_class(**options)
|
|
365
370
|
if has_header:
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
+
table = _new_table(table_class, rows[0], rows[1:], parse_numbers,
|
|
372
|
+
**options)
|
|
373
|
+
header_rows = 1
|
|
374
|
+
else:
|
|
375
|
+
width = max(len(row) for row in rows)
|
|
376
|
+
headers = [f'column {i + 1}' for i in range(width)]
|
|
377
|
+
table = _new_table(table_class, headers, rows, parse_numbers, **options)
|
|
378
|
+
header_rows = 0
|
|
379
|
+
|
|
380
|
+
# Excel rows/cols are 1-based. Body rows start after the header row when
|
|
381
|
+
# has_header is set. Skip merges that only cover the header.
|
|
382
|
+
for min_row, min_col, max_row, max_col in merge_ranges:
|
|
383
|
+
# Header-only or header-spanning merges are not table body merges.
|
|
384
|
+
if min_row <= header_rows:
|
|
385
|
+
continue
|
|
386
|
+
first_row = min_row - header_rows - 1
|
|
387
|
+
last_row = max_row - header_rows - 1
|
|
388
|
+
first_column = min_col - 1
|
|
389
|
+
last_column = max_col - 1
|
|
390
|
+
if first_row == last_row and first_column == last_column:
|
|
391
|
+
continue
|
|
392
|
+
if last_row < 0 or last_column < 0:
|
|
393
|
+
continue
|
|
394
|
+
if last_row >= table.row_count or last_column >= table.column_count:
|
|
395
|
+
continue
|
|
396
|
+
try:
|
|
397
|
+
table.merge_cells(first_row, first_column, last_row, last_column)
|
|
398
|
+
except ValueError:
|
|
399
|
+
# Overlaps or single cells from odd sheets: skip rather than fail.
|
|
400
|
+
continue
|
|
401
|
+
return table
|