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.
Files changed (43) hide show
  1. {flexablelib-2.0.3 → flexablelib-2.0.4}/CHANGELOG.md +8 -0
  2. {flexablelib-2.0.3/flexablelib.egg-info → flexablelib-2.0.4}/PKG-INFO +1 -1
  3. flexablelib-2.0.4/flexablelib/merges.py +596 -0
  4. {flexablelib-2.0.3 → flexablelib-2.0.4/flexablelib.egg-info}/PKG-INFO +1 -1
  5. {flexablelib-2.0.3 → flexablelib-2.0.4}/package.json +1 -1
  6. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_merges.py +114 -5
  7. flexablelib-2.0.3/flexablelib/merges.py +0 -317
  8. {flexablelib-2.0.3 → flexablelib-2.0.4}/LICENSE +0 -0
  9. {flexablelib-2.0.3 → flexablelib-2.0.4}/MANIFEST.in +0 -0
  10. {flexablelib-2.0.3 → flexablelib-2.0.4}/README.md +0 -0
  11. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/__init__.py +0 -0
  12. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/_speedups.c +0 -0
  13. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/_width_tables.h +0 -0
  14. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/cells.py +0 -0
  15. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/colors.py +0 -0
  16. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/columns.py +0 -0
  17. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/fast.py +0 -0
  18. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/options.py +0 -0
  19. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/readers.py +0 -0
  20. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/style_compositions.py +0 -0
  21. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/table.py +0 -0
  22. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/table_strings.py +0 -0
  23. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/text_width.py +0 -0
  24. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/utils.py +0 -0
  25. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib/writers.py +0 -0
  26. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib.egg-info/SOURCES.txt +0 -0
  27. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib.egg-info/dependency_links.txt +0 -0
  28. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib.egg-info/requires.txt +0 -0
  29. {flexablelib-2.0.3 → flexablelib-2.0.4}/flexablelib.egg-info/top_level.txt +0 -0
  30. {flexablelib-2.0.3 → flexablelib-2.0.4}/setup.cfg +0 -0
  31. {flexablelib-2.0.3 → flexablelib-2.0.4}/setup.py +0 -0
  32. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_cells.py +0 -0
  33. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_columns.py +0 -0
  34. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_excel.py +0 -0
  35. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_features.py +0 -0
  36. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_issues.py +0 -0
  37. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_readme_examples.py +0 -0
  38. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_styles.py +0 -0
  39. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_text_width.py +0 -0
  40. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_two_pass_measurement.py +0 -0
  41. {flexablelib-2.0.3 → flexablelib-2.0.4}/tests/test_upstream_issues.py +0 -0
  42. {flexablelib-2.0.3 → flexablelib-2.0.4}/tools/benchmark.py +0 -0
  43. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.3
3
+ Version: 2.0.4
4
4
  Summary: Flexible console and HTML tables for Python
5
5
  Home-page: https://github.com/Opus-Perpetuus/flexablelib
6
6
  Download-URL:
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.3
3
+ Version: 2.0.4
4
4
  Summary: Flexible console and HTML tables for Python
5
5
  Home-page: https://github.com/Opus-Perpetuus/flexablelib
6
6
  Download-URL:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexablelib",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Flexible console and HTML tables for Python: styles, merges, colors, widths, decimal alignment.",
5
5
  "main": "__init__.py",
6
6
  "scripts": {
@@ -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 '╟───────┼────────╢' in line)
107
+ sep = next(line for line in str(table).splitlines() if '├───────┼────────╢' in line)
108
108
 
109
109
  assert sep.startswith('║')
110
- assert '║ ╟───────┼────────╢' in sep
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
- assert [len(line) for line in rendered.splitlines()] == before
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
- merged_line = rendered.splitlines()[3]
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