flexablelib 2.0.3__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.3 → flexablelib-2.0.4}/CHANGELOG.md +8 -0
- {flexablelib-2.0.3/flexablelib.egg-info → flexablelib-2.0.4}/PKG-INFO +1 -1
- flexablelib-2.0.4/flexablelib/merges.py +596 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4/flexablelib.egg-info}/PKG-INFO +1 -1
- {flexablelib-2.0.3 → flexablelib-2.0.4}/package.json +1 -1
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_merges.py +114 -5
- flexablelib-2.0.3/flexablelib/merges.py +0 -317
- {flexablelib-2.0.3 → flexablelib-2.0.4}/LICENSE +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/MANIFEST.in +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/README.md +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/__init__.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/_speedups.c +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/_width_tables.h +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/cells.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/colors.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/columns.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/fast.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/options.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/readers.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/style_compositions.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/table.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/table_strings.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/text_width.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/utils.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/writers.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib.egg-info/SOURCES.txt +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib.egg-info/dependency_links.txt +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib.egg-info/requires.txt +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib.egg-info/top_level.txt +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/setup.cfg +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/setup.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_cells.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_columns.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_excel.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_features.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_issues.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_readme_examples.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_styles.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_text_width.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_two_pass_measurement.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_upstream_issues.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tools/benchmark.py +0 -0
- {flexablelib-2.0.3 → flexablelib-2.0.4}/tools/generate_width_tables.py +0 -0
|
@@ -2,6 +2,14 @@
|
|
|
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
|
+
|
|
5
13
|
## [2.0.3](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.2...v2.0.3) (2026-10-28)
|
|
6
14
|
|
|
7
15
|
### Features
|
|
@@ -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
|
|
@@ -104,10 +104,12 @@ def test_vertical_merge_vertices_follow_bold_borderline_style():
|
|
|
104
104
|
table = build_table('bold_borderline')
|
|
105
105
|
table.merge_cells(0, 0, last_row=1, value='Norte')
|
|
106
106
|
|
|
107
|
-
sep = next(line for line in str(table).splitlines() if '
|
|
107
|
+
sep = next(line for line in str(table).splitlines() if '├───────┼────────╢' in line)
|
|
108
108
|
|
|
109
109
|
assert sep.startswith('║')
|
|
110
|
-
|
|
110
|
+
# Middle verticals are single │, so the tee opening the remaining columns
|
|
111
|
+
# is ├ — not the double-left ╟ reserved for the table's left border.
|
|
112
|
+
assert '║ ├───────┼────────╢' in sep
|
|
111
113
|
|
|
112
114
|
|
|
113
115
|
def test_a_block_merge_covers_both_directions():
|
|
@@ -293,7 +295,11 @@ def test_a_merge_works_across_styles(style):
|
|
|
293
295
|
rendered = str(table)
|
|
294
296
|
|
|
295
297
|
assert 'span' in rendered
|
|
296
|
-
|
|
298
|
+
# Column-only styles (pretty_columns, grid_eheader, …) grow by one synthetic
|
|
299
|
+
# row rule per multi-column merge; every line must still share the width.
|
|
300
|
+
after = [len(line) for line in rendered.splitlines()]
|
|
301
|
+
assert set(after) == set(before)
|
|
302
|
+
assert len(after) >= len(before)
|
|
297
303
|
|
|
298
304
|
|
|
299
305
|
# +-------------------------------------------------------------------------+
|
|
@@ -440,13 +446,14 @@ def test_the_reported_case_renders_square():
|
|
|
440
446
|
table.merge_cells(0, 0, last_column=1)
|
|
441
447
|
|
|
442
448
|
rendered = str(table)
|
|
443
|
-
|
|
449
|
+
lines = rendered.splitlines()
|
|
450
|
+
merged_line = next(line for line in lines if 'John' in strip_ansi(line))
|
|
451
|
+
unmerged_line = next(line for line in lines if 'Jane' in strip_ansi(line))
|
|
444
452
|
|
|
445
453
|
assert len(visible_widths(rendered)) == 1
|
|
446
454
|
assert 'John' in strip_ansi(merged_line)
|
|
447
455
|
# Name and Age are now one cell: the rule that used to sit between them is
|
|
448
456
|
# gone, and every other rule on the row survives.
|
|
449
|
-
unmerged_line = rendered.splitlines()[4]
|
|
450
457
|
assert merged_line.count('|') == unmerged_line.count('|') - 1
|
|
451
458
|
|
|
452
459
|
|
|
@@ -479,3 +486,105 @@ def test_two_merges_in_one_table_both_land():
|
|
|
479
486
|
assert 'TOP' in rendered
|
|
480
487
|
assert 'DOWN' in rendered
|
|
481
488
|
assert len(visible_widths(rendered)) == 1
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# +-------------------------------------------------------------------------+
|
|
492
|
+
# Junction repair: horizontal + vertical + column-only styles
|
|
493
|
+
# +-------------------------------------------------------------------------+
|
|
494
|
+
|
|
495
|
+
def _ops_report():
|
|
496
|
+
"""Golden table from complex-merges/03_ops_report (H+V merges)."""
|
|
497
|
+
table = Table(style_name='bold_borderline')
|
|
498
|
+
table.title = 'Weekly Ops Report'
|
|
499
|
+
shifts, tickets, sla = [], [], []
|
|
500
|
+
n = 0
|
|
501
|
+
for _day in ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']:
|
|
502
|
+
for sh in ['AM', 'PM']:
|
|
503
|
+
shifts.append(sh)
|
|
504
|
+
tickets.append(40 + n * 3)
|
|
505
|
+
sla.append(round(0.92 + (n % 5) * 0.01, 2))
|
|
506
|
+
n += 1
|
|
507
|
+
table.add_column('Site', [''] * 14)
|
|
508
|
+
table.add_column('Day', [''] * 14)
|
|
509
|
+
table.add_column('Shift', shifts)
|
|
510
|
+
table.add_column('Tickets', tickets)
|
|
511
|
+
table.add_column('SLA', sla)
|
|
512
|
+
table.merge_cells(0, 0, last_row=13, value='MX-GDL')
|
|
513
|
+
for i, day in enumerate(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']):
|
|
514
|
+
table.merge_cells(i * 2, 1, last_row=i * 2 + 1, value=day)
|
|
515
|
+
table.merge_cells(10, 2, last_column=4, value='Weekend AM rollup')
|
|
516
|
+
table.merge_cells(11, 2, last_column=4, value='Weekend PM rollup')
|
|
517
|
+
return table
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def test_ops_report_adjacent_vertical_merges_use_middle_tees_not_border_glyphs():
|
|
521
|
+
"""Adjacent vertical merges must not leave ╢/╟ on middle verticals."""
|
|
522
|
+
rendered = str(_ops_report())
|
|
523
|
+
# Internal AM/PM separators inside Site+Day merges.
|
|
524
|
+
internal = [
|
|
525
|
+
line for line in rendered.splitlines()
|
|
526
|
+
if line.startswith('║') and '├───────┼─────────┼──────╢' in line
|
|
527
|
+
]
|
|
528
|
+
assert internal, rendered
|
|
529
|
+
for line in internal:
|
|
530
|
+
assert '╢ ╟' not in line
|
|
531
|
+
assert '╟───────┼─────────┼──────╢' not in line or not line.startswith('╟')
|
|
532
|
+
# Shared Site|Day junction is a bare vertical; Day|Shift opens with ├.
|
|
533
|
+
assert '│ ├' in line or '│ ├' in line or '├───────┼─────────┼──────╢' in line
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def test_ops_report_site_only_separator_uses_single_tee():
|
|
537
|
+
rendered = str(_ops_report())
|
|
538
|
+
between_days = [
|
|
539
|
+
line for line in rendered.splitlines()
|
|
540
|
+
if '├─────┼───────┼─────────┼──────╢' in line
|
|
541
|
+
]
|
|
542
|
+
assert between_days, rendered
|
|
543
|
+
for line in between_days:
|
|
544
|
+
assert line.startswith('║')
|
|
545
|
+
assert not line.startswith('╟')
|
|
546
|
+
# Must not reuse the double-left border glyph on a middle vertical.
|
|
547
|
+
assert '║ ╟' not in line
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def test_horizontal_merge_caps_become_tees_above_and_below():
|
|
551
|
+
table = Table(style_name='pretty_grid')
|
|
552
|
+
table.add_column('A', ['a1', 'a2', 'a3'])
|
|
553
|
+
table.add_column('B', ['b1', 'b2', 'b3'])
|
|
554
|
+
table.add_column('C', ['c1', 'c2', 'c3'])
|
|
555
|
+
table.merge_cells(1, 0, last_column=1, value='AB')
|
|
556
|
+
|
|
557
|
+
lines = str(table).splitlines()
|
|
558
|
+
above = next(line for line in lines if '┴' in line)
|
|
559
|
+
below = next(line for line in lines if '┬' in line)
|
|
560
|
+
assert '├────┴────┼────┤' in above
|
|
561
|
+
assert '├────┬────┼────┤' in below
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def test_column_only_style_inserts_horizontal_break_under_multi_column_merge():
|
|
565
|
+
"""pretty_columns has column lines but no row lines; a multi-column merge
|
|
566
|
+
must insert a row rule so the grid does not break oddly."""
|
|
567
|
+
table = Table(style_name='pretty_columns')
|
|
568
|
+
table.add_column('A', ['a1', 'a2', 'a3'])
|
|
569
|
+
table.add_column('B', ['b1', 'b2', 'b3'])
|
|
570
|
+
table.add_column('C', ['c1', 'c2', 'c3'])
|
|
571
|
+
table.merge_cells(1, 0, last_column=1, value='AB')
|
|
572
|
+
|
|
573
|
+
lines = str(table).splitlines()
|
|
574
|
+
assert any('┴' in line for line in lines)
|
|
575
|
+
assert any('┬' in line for line in lines)
|
|
576
|
+
assert any('AB' in line for line in lines)
|
|
577
|
+
# The synthetic rules keep the table square.
|
|
578
|
+
assert len({visible_width(line) for line in lines}) == 1
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def test_block_merge_header_and_footer_caps():
|
|
582
|
+
table = Table(style_name='pretty_grid')
|
|
583
|
+
table.add_column('A', ['a1', 'a2', 'a3'])
|
|
584
|
+
table.add_column('B', ['b1', 'b2', 'b3'])
|
|
585
|
+
table.add_column('C', ['c1', 'c2', 'c3'])
|
|
586
|
+
table.merge_cells(0, 0, 1, 1, value='BLOCK')
|
|
587
|
+
|
|
588
|
+
rendered = str(table)
|
|
589
|
+
assert '╧' in rendered # header intersection loses south into the block
|
|
590
|
+
assert '┬' in rendered # body rule below gains north-less tee
|
|
@@ -1,317 +0,0 @@
|
|
|
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
|
-
def apply(lines, row_line_map, regions, column_widths, composition,
|
|
216
|
-
index_offset, hidden_columns=(), filler=' '):
|
|
217
|
-
"""
|
|
218
|
-
Rewrite assembled lines so each region renders as one cell.
|
|
219
|
-
|
|
220
|
-
``lines`` is the rendered table split into lines. ``row_line_map`` maps
|
|
221
|
-
each logical row index to the indexes of the physical lines it occupies,
|
|
222
|
-
which differ once wrapping has turned one row into several. Separator
|
|
223
|
-
lines between rows are not in the map; they are found between the mapped
|
|
224
|
-
lines and painted over where a region spans them vertically.
|
|
225
|
-
|
|
226
|
-
``index_offset`` is 1 when the index column is displayed, since region
|
|
227
|
-
coordinates ignore it. ``hidden_columns`` lists the columns the table is
|
|
228
|
-
not drawing, which take up no room in a line.
|
|
229
|
-
|
|
230
|
-
Returns the rewritten lines.
|
|
231
|
-
"""
|
|
232
|
-
if not regions:
|
|
233
|
-
return lines
|
|
234
|
-
|
|
235
|
-
layout = compute_layout(column_widths, composition, hidden_columns)
|
|
236
|
-
lines = list(lines)
|
|
237
|
-
margin = 1 if composition.margin else 0
|
|
238
|
-
|
|
239
|
-
for region in regions:
|
|
240
|
-
bounds = _region_bounds(region, layout, index_offset)
|
|
241
|
-
if bounds is None:
|
|
242
|
-
continue
|
|
243
|
-
(start, end), (cell_start, cell_end), first_span, last_span = bounds
|
|
244
|
-
span_width = end - start - margin * 2
|
|
245
|
-
if span_width < 1:
|
|
246
|
-
continue
|
|
247
|
-
|
|
248
|
-
target_lines = []
|
|
249
|
-
for row in range(region.first_row, region.last_row + 1):
|
|
250
|
-
target_lines.extend(row_line_map.get(row, []))
|
|
251
|
-
if not target_lines:
|
|
252
|
-
continue
|
|
253
|
-
|
|
254
|
-
text = region.value
|
|
255
|
-
if text is None:
|
|
256
|
-
# No replacement text given: keep what the top-left cell holds.
|
|
257
|
-
# Read that cell alone. Reading the whole span instead used to pull
|
|
258
|
-
# in the next column and the rule between them, and since that text
|
|
259
|
-
# already filled the span exactly, painting it back changed nothing
|
|
260
|
-
# -- the merge silently did not happen.
|
|
261
|
-
first_line = lines[target_lines[0]]
|
|
262
|
-
text = strip_by_width(slice_by_width(first_line, cell_start, cell_end))
|
|
263
|
-
text = str(text)
|
|
264
|
-
|
|
265
|
-
if visible_width(text) > span_width:
|
|
266
|
-
text = truncate_to_width(text, span_width)
|
|
267
|
-
painted = (filler * margin
|
|
268
|
-
+ pad_to_width(text, span_width, region.align, filler)
|
|
269
|
-
+ filler * margin)
|
|
270
|
-
blank = filler * (end - start)
|
|
271
|
-
|
|
272
|
-
# The text sits on the middle line of the block, so a tall merge reads
|
|
273
|
-
# as one cell rather than a label with empty space under it.
|
|
274
|
-
text_line = target_lines[len(target_lines) // 2]
|
|
275
|
-
for line_index in target_lines:
|
|
276
|
-
line = lines[line_index]
|
|
277
|
-
if visible_width(line) < end:
|
|
278
|
-
continue
|
|
279
|
-
replacement = painted if line_index == text_line else blank
|
|
280
|
-
lines[line_index] = _repaint(line, start, end, replacement)
|
|
281
|
-
|
|
282
|
-
# Paint over the separator lines that fall inside the block, so the
|
|
283
|
-
# merged region reads as one cell top to bottom.
|
|
284
|
-
#
|
|
285
|
-
# Blanking the horizontal run removes the arms that fed each vertex.
|
|
286
|
-
# Rebuild those vertices from the style's own characters:
|
|
287
|
-
# - right edge, more columns: body left ('├' where '┼' was)
|
|
288
|
-
# - right edge, table border: vertical right ('│' where '┤' was)
|
|
289
|
-
# - left edge, prior columns: body right ('┤' where '┼' was)
|
|
290
|
-
# - left edge, table border: vertical left ('│' where '├' was)
|
|
291
|
-
body_rule = composition.table_body_line
|
|
292
|
-
v_lines = composition.vertical_table_body_lines
|
|
293
|
-
joins_more_columns = last_span + 1 < len(layout.spans)
|
|
294
|
-
joins_prev_columns = first_span > 0
|
|
295
|
-
|
|
296
|
-
covered = set(target_lines)
|
|
297
|
-
for line_index in range(min(target_lines), max(target_lines)):
|
|
298
|
-
if line_index in covered:
|
|
299
|
-
continue
|
|
300
|
-
line = lines[line_index]
|
|
301
|
-
if visible_width(line) < end:
|
|
302
|
-
continue
|
|
303
|
-
rewritten = _repaint(line, start, end, blank)
|
|
304
|
-
if body_rule is not None or v_lines is not None:
|
|
305
|
-
if visible_width(rewritten) > end:
|
|
306
|
-
if joins_more_columns and body_rule is not None and body_rule.left:
|
|
307
|
-
rewritten = _repaint(rewritten, end, end + 1, body_rule.left)
|
|
308
|
-
elif not joins_more_columns and v_lines is not None and v_lines.right:
|
|
309
|
-
rewritten = _repaint(rewritten, end, end + 1, v_lines.right)
|
|
310
|
-
if start > 0:
|
|
311
|
-
if joins_prev_columns and body_rule is not None and body_rule.right:
|
|
312
|
-
rewritten = _repaint(rewritten, start - 1, start, body_rule.right)
|
|
313
|
-
elif not joins_prev_columns and v_lines is not None and v_lines.left:
|
|
314
|
-
rewritten = _repaint(rewritten, start - 1, start, v_lines.left)
|
|
315
|
-
lines[line_index] = rewritten
|
|
316
|
-
|
|
317
|
-
return lines
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|