flexablelib 2.0.4__tar.gz → 2.0.7__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.4 → flexablelib-2.0.7}/CHANGELOG.md +13 -0
- {flexablelib-2.0.4/flexablelib.egg-info → flexablelib-2.0.7}/PKG-INFO +13 -1
- {flexablelib-2.0.4 → flexablelib-2.0.7}/README.md +12 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/merges.py +151 -78
- {flexablelib-2.0.4 → flexablelib-2.0.7/flexablelib.egg-info}/PKG-INFO +13 -1
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib.egg-info/SOURCES.txt +1 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/package.json +1 -1
- flexablelib-2.0.7/tests/test_merge_edge_matrix.py +252 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_merges.py +197 -3
- {flexablelib-2.0.4 → flexablelib-2.0.7}/LICENSE +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/MANIFEST.in +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/__init__.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/_speedups.c +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/_width_tables.h +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/cells.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/colors.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/columns.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/fast.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/options.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/readers.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/style_compositions.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/table.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/table_strings.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/text_width.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/utils.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/writers.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib.egg-info/dependency_links.txt +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib.egg-info/requires.txt +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib.egg-info/top_level.txt +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/setup.cfg +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/setup.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_cells.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_columns.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_excel.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_features.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_issues.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_readme_examples.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_styles.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_text_width.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_two_pass_measurement.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_upstream_issues.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tools/benchmark.py +0 -0
- {flexablelib-2.0.4 → flexablelib-2.0.7}/tools/generate_width_tables.py +0 -0
|
@@ -2,6 +2,19 @@
|
|
|
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.7] - 2026-10-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Generic merge junction algorithm: rule detection no longer treats a lone
|
|
9
|
+
hyphen inside cell text (e.g. `Beta-1`) as a separator line, so column-only
|
|
10
|
+
styles still get synthetic caps around multi-column merges.
|
|
11
|
+
- Pure-horizontal junctions keep the rule's own dash glyph (`┄`/`═`/`─`/`-`)
|
|
12
|
+
instead of substituting a foreign `─`.
|
|
13
|
+
- Synthetic caps blank through taller vertical/2D merges that cross the gap
|
|
14
|
+
(no ghost `┼` inside a continuing merge).
|
|
15
|
+
- Synthetic caps use the table's outer horizontal style so they match top/bottom.
|
|
16
|
+
- Round-edge end corners (`╰`/`╯`) survive last-row horizontal merge repair.
|
|
17
|
+
|
|
5
18
|
## [2.0.4](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.3...v2.0.4) (2026-10-28)
|
|
6
19
|
|
|
7
20
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flexablelib
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.7
|
|
4
4
|
Summary: Flexible console and HTML tables for Python
|
|
5
5
|
Home-page: https://github.com/Opus-Perpetuus/flexablelib
|
|
6
6
|
Download-URL:
|
|
@@ -76,6 +76,18 @@ Python package for formatting tabular data as console and HTML tables: border st
|
|
|
76
76
|
|
|
77
77
|
``pip3 install flexablelib`` on Linux
|
|
78
78
|
|
|
79
|
+
Prebuilt wheels (with the C speedups) are published for Linux (manylinux/musllinux), Windows (x64) and macOS (arm64); other platforms build from the sdist or fall back to pure Python.
|
|
80
|
+
|
|
81
|
+
### Windows console encoding
|
|
82
|
+
Box-drawing styles (`┌─┐`, `╔═╗`, ...) need a UTF-8 console. Legacy `cp1252` consoles raise `UnicodeEncodeError` for them, while ASCII styles work everywhere. Enable UTF-8 with either:
|
|
83
|
+
|
|
84
|
+
```bat
|
|
85
|
+
chcp 65001
|
|
86
|
+
set PYTHONUTF8=1
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
(PowerShell: `$env:PYTHONUTF8=1`.) Windows Terminal with Python 3.8+ is usually UTF-8 already.
|
|
90
|
+
|
|
79
91
|
# Usage
|
|
80
92
|
Creating a table is simple.
|
|
81
93
|
### Code Example
|
|
@@ -27,6 +27,18 @@ Python package for formatting tabular data as console and HTML tables: border st
|
|
|
27
27
|
|
|
28
28
|
``pip3 install flexablelib`` on Linux
|
|
29
29
|
|
|
30
|
+
Prebuilt wheels (with the C speedups) are published for Linux (manylinux/musllinux), Windows (x64) and macOS (arm64); other platforms build from the sdist or fall back to pure Python.
|
|
31
|
+
|
|
32
|
+
### Windows console encoding
|
|
33
|
+
Box-drawing styles (`┌─┐`, `╔═╗`, ...) need a UTF-8 console. Legacy `cp1252` consoles raise `UnicodeEncodeError` for them, while ASCII styles work everywhere. Enable UTF-8 with either:
|
|
34
|
+
|
|
35
|
+
```bat
|
|
36
|
+
chcp 65001
|
|
37
|
+
set PYTHONUTF8=1
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
(PowerShell: `$env:PYTHONUTF8=1`.) Windows Terminal with Python 3.8+ is usually UTF-8 already.
|
|
41
|
+
|
|
30
42
|
# Usage
|
|
31
43
|
Creating a table is simple.
|
|
32
44
|
### Code Example
|
|
@@ -16,6 +16,7 @@ The consequence is that a merged region never widens the table. Content longer
|
|
|
16
16
|
than the span it was given is truncated, not accommodated.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
+
import re
|
|
19
20
|
from typing import List, NamedTuple, Optional
|
|
20
21
|
|
|
21
22
|
from .fast import (
|
|
@@ -212,12 +213,16 @@ def _region_bounds(region, layout, index_offset):
|
|
|
212
213
|
)
|
|
213
214
|
|
|
214
215
|
|
|
215
|
-
#
|
|
216
|
+
# Junction / rule helpers for merge painting.
|
|
216
217
|
|
|
217
218
|
# Horizontal dash characters used by the shipped styles (and ASCII grids).
|
|
218
219
|
_H_CHARS = set('─━═┄┅┈┉╌╍▬−' '-~=')
|
|
220
|
+
# A real rule has a *run* of dashes. A lone '-' inside "Beta-1" is not a rule.
|
|
221
|
+
_H_RUN = re.compile(r'[─━═┄┅┈┉╌╍▬−\-~=]{2,}')
|
|
222
|
+
_PURE_H = {(0, 0, 1, 1), (0, 0, 1, 0), (0, 0, 0, 1)}
|
|
223
|
+
_ROUND_CORNERS = set('╭╮╰╯')
|
|
219
224
|
|
|
220
|
-
# Light / heavy / double / ascii junction tables keyed by (N, S, E, W)
|
|
225
|
+
# Light / heavy / double / ascii / round junction tables keyed by (N, S, E, W).
|
|
221
226
|
_LIGHT = {
|
|
222
227
|
(1, 1, 1, 1): '┼', (1, 1, 1, 0): '├', (1, 1, 0, 1): '┤',
|
|
223
228
|
(1, 0, 1, 1): '┴', (0, 1, 1, 1): '┬', (1, 1, 0, 0): '│',
|
|
@@ -225,6 +230,13 @@ _LIGHT = {
|
|
|
225
230
|
(0, 1, 1, 0): '┌', (0, 1, 0, 1): '┐', (1, 0, 0, 0): '│',
|
|
226
231
|
(0, 1, 0, 0): '│', (0, 0, 1, 0): '─', (0, 0, 0, 1): '─',
|
|
227
232
|
}
|
|
233
|
+
_ROUND = {
|
|
234
|
+
(1, 1, 1, 1): '┼', (1, 1, 1, 0): '├', (1, 1, 0, 1): '┤',
|
|
235
|
+
(1, 0, 1, 1): '┴', (0, 1, 1, 1): '┬', (1, 1, 0, 0): '│',
|
|
236
|
+
(0, 0, 1, 1): '─', (1, 0, 1, 0): '╰', (1, 0, 0, 1): '╯',
|
|
237
|
+
(0, 1, 1, 0): '╭', (0, 1, 0, 1): '╮', (1, 0, 0, 0): '│',
|
|
238
|
+
(0, 1, 0, 0): '│', (0, 0, 1, 0): '─', (0, 0, 0, 1): '─',
|
|
239
|
+
}
|
|
228
240
|
_DOUBLE_V_SINGLE_H = {
|
|
229
241
|
(1, 1, 1, 1): '╫', (1, 1, 1, 0): '╟', (1, 1, 0, 1): '╢',
|
|
230
242
|
(1, 0, 1, 1): '╨', (0, 1, 1, 1): '╥', (1, 1, 0, 0): '║',
|
|
@@ -265,7 +277,6 @@ def _char_at(line: str, index: int) -> str:
|
|
|
265
277
|
return slice_by_width(line, index, index + 1)
|
|
266
278
|
|
|
267
279
|
|
|
268
|
-
|
|
269
280
|
def _junction_positions(layout, composition):
|
|
270
281
|
"""(x, vertical_glyph) for every vertical rule that can host a junction."""
|
|
271
282
|
v = composition.vertical_table_body_lines
|
|
@@ -282,25 +293,36 @@ def _junction_positions(layout, composition):
|
|
|
282
293
|
return out
|
|
283
294
|
|
|
284
295
|
|
|
285
|
-
def _pick_junction(v_glyph: str, h_glyph: str, n: bool, s: bool, e: bool, w: bool
|
|
296
|
+
def _pick_junction(v_glyph: str, h_glyph: str, n: bool, s: bool, e: bool, w: bool,
|
|
297
|
+
round_corners: bool = False) -> str:
|
|
286
298
|
key = (1 if n else 0, 1 if s else 0, 1 if e else 0, 1 if w else 0)
|
|
287
299
|
if key == (0, 0, 0, 0):
|
|
288
300
|
return ' '
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if v_glyph in '|+' and h_glyph in '
|
|
301
|
+
# Pure horizontal: keep THIS rule's dash (┄/═/─/-), never a foreign glyph.
|
|
302
|
+
if key in _PURE_H:
|
|
303
|
+
if v_glyph in '|+' or (h_glyph and h_glyph in '-=~'):
|
|
304
|
+
return h_glyph if h_glyph in '-=~' else '-'
|
|
305
|
+
return h_glyph or '─'
|
|
306
|
+
if v_glyph in '|+' or (h_glyph and h_glyph in '-=') :
|
|
307
|
+
if v_glyph in '|+' and (h_glyph in '-=+' or not h_glyph):
|
|
292
308
|
return _ASCII.get(key, '+')
|
|
293
309
|
if v_glyph == '║' and h_glyph == '═':
|
|
294
310
|
return _DOUBLE.get(key, '║')
|
|
295
311
|
if v_glyph == '║':
|
|
296
312
|
return _DOUBLE_V_SINGLE_H.get(key, '║')
|
|
297
|
-
if h_glyph == '═' and v_glyph in ('│', '┃'):
|
|
313
|
+
if h_glyph == '═' and v_glyph in ('│', '┃', '┊'):
|
|
298
314
|
return _SINGLE_V_DOUBLE_H.get(key, '│')
|
|
315
|
+
if round_corners:
|
|
316
|
+
return _ROUND.get(key, v_glyph or '│')
|
|
299
317
|
return _LIGHT.get(key, v_glyph or '│')
|
|
300
318
|
|
|
301
319
|
|
|
302
320
|
def _synthesize_body_rule(composition):
|
|
303
|
-
"""Body SeparatorLine for styles that draw column lines but no row lines.
|
|
321
|
+
"""Body SeparatorLine for styles that draw column lines but no row lines.
|
|
322
|
+
|
|
323
|
+
Uses the table's outer (end/header) horizontal glyph so synthetic caps
|
|
324
|
+
match the top/bottom border style.
|
|
325
|
+
"""
|
|
304
326
|
from .style_compositions import SeparatorLine
|
|
305
327
|
existing = composition.table_body_line
|
|
306
328
|
if existing is not None and existing.middle:
|
|
@@ -308,21 +330,40 @@ def _synthesize_body_rule(composition):
|
|
|
308
330
|
v = composition.vertical_table_body_lines
|
|
309
331
|
if v is None or not v.middle or v.middle == ' ':
|
|
310
332
|
return None
|
|
333
|
+
end = composition.table_end_line
|
|
334
|
+
header = composition.inferior_header_line
|
|
335
|
+
h = None
|
|
336
|
+
if end is not None and end.middle:
|
|
337
|
+
h = end.middle
|
|
338
|
+
elif header is not None and header.middle:
|
|
339
|
+
h = header.middle
|
|
311
340
|
if v.middle in '|+':
|
|
312
341
|
return SeparatorLine(
|
|
313
342
|
left='+' if v.left else None,
|
|
314
|
-
middle='-',
|
|
343
|
+
middle=h if h in '-=~' else '-',
|
|
315
344
|
intersection='+',
|
|
316
345
|
right='+' if v.right else None,
|
|
317
346
|
)
|
|
318
|
-
|
|
347
|
+
if h == '═':
|
|
348
|
+
return SeparatorLine(
|
|
349
|
+
left='╞' if v.left else None,
|
|
350
|
+
middle='═',
|
|
351
|
+
intersection='╪',
|
|
352
|
+
right='╡' if v.right else None,
|
|
353
|
+
)
|
|
354
|
+
# Light / dotted / round outer borders → matching light body rule.
|
|
319
355
|
left = '├' if v.left else None
|
|
320
356
|
right = '┤' if v.right else None
|
|
321
357
|
if v.left == '║':
|
|
322
358
|
left = '╟'
|
|
323
359
|
if v.right == '║':
|
|
324
360
|
right = '╢'
|
|
325
|
-
return SeparatorLine(
|
|
361
|
+
return SeparatorLine(
|
|
362
|
+
left=left,
|
|
363
|
+
middle=h or '─',
|
|
364
|
+
intersection='┼',
|
|
365
|
+
right=right,
|
|
366
|
+
)
|
|
326
367
|
|
|
327
368
|
|
|
328
369
|
def _build_rule_line(layout, composition, column_widths, hidden, rule=None):
|
|
@@ -359,21 +400,25 @@ def _build_rule_line(layout, composition, column_widths, hidden, rule=None):
|
|
|
359
400
|
return left + center + right
|
|
360
401
|
|
|
361
402
|
|
|
362
|
-
def _looks_like_rule(line: str, h_glyph: str) -> bool:
|
|
403
|
+
def _looks_like_rule(line: str, h_glyph: str = None) -> bool:
|
|
404
|
+
"""True only for real separator lines, not cell text that contains '-'."""
|
|
363
405
|
if not line:
|
|
364
406
|
return False
|
|
365
|
-
|
|
366
|
-
if h_glyph and h_glyph in plain:
|
|
407
|
+
if h_glyph and len(h_glyph) >= 1 and (h_glyph * 2) in line:
|
|
367
408
|
return True
|
|
368
|
-
return
|
|
409
|
+
return _H_RUN.search(line) is not None
|
|
369
410
|
|
|
370
411
|
|
|
371
|
-
def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=None, missing_north=None
|
|
412
|
+
def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=None, missing_north=None,
|
|
413
|
+
no_south=False, no_north=False):
|
|
372
414
|
"""
|
|
373
415
|
Rebuild every junction on a horizontal rule from the arms that remain.
|
|
374
416
|
|
|
375
417
|
``missing_south`` / ``missing_north`` are sets of visible x positions where
|
|
376
418
|
a vertical arm was removed by a horizontal (or 2D) merge on that side.
|
|
419
|
+
|
|
420
|
+
``no_south`` / ``no_north`` force every junction to drop that arm — used for
|
|
421
|
+
the table's outer end/start rules, which never continue past the border.
|
|
377
422
|
"""
|
|
378
423
|
missing_south = missing_south or set()
|
|
379
424
|
missing_north = missing_north or set()
|
|
@@ -381,6 +426,7 @@ def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=Non
|
|
|
381
426
|
if v is None:
|
|
382
427
|
return line
|
|
383
428
|
h_glyph = h_glyph or '─'
|
|
429
|
+
round_corners = any(ch in line for ch in _ROUND_CORNERS)
|
|
384
430
|
rewritten = line
|
|
385
431
|
for x, v_glyph in _junction_positions(layout, composition):
|
|
386
432
|
if visible_width(rewritten) <= x:
|
|
@@ -393,10 +439,12 @@ def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=Non
|
|
|
393
439
|
right = _char_at(rewritten, x + 1)
|
|
394
440
|
west = _is_h(left)
|
|
395
441
|
east = _is_h(right)
|
|
396
|
-
north = x not in missing_north
|
|
397
|
-
south = x not in missing_south
|
|
398
|
-
|
|
399
|
-
|
|
442
|
+
north = (not no_north) and (x not in missing_north)
|
|
443
|
+
south = (not no_south) and (x not in missing_south)
|
|
444
|
+
glyph = _pick_junction(
|
|
445
|
+
v_glyph, h_glyph, north, south, east, west,
|
|
446
|
+
round_corners=round_corners,
|
|
447
|
+
)
|
|
400
448
|
rewritten = _repaint(rewritten, x, x + 1, glyph)
|
|
401
449
|
return rewritten
|
|
402
450
|
|
|
@@ -430,9 +478,11 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
430
478
|
touched_seps = set()
|
|
431
479
|
# Horizontal (or 2D) merges: verticals removed between their columns on
|
|
432
480
|
# their data lines; caps above/below need ┴/┬ repair.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
481
|
+
# (above_line, below_line, inner_separator_xs, first_row, last_row)
|
|
482
|
+
horiz_caps = []
|
|
483
|
+
# Column-only gaps keyed by logical row *after which* a rule sits
|
|
484
|
+
# (gap after row G = between G and G+1). Values: miss_s, miss_n, insert_at.
|
|
485
|
+
gap_plans = {}
|
|
436
486
|
|
|
437
487
|
synthesized = _synthesize_body_rule(composition)
|
|
438
488
|
col_only = (
|
|
@@ -441,6 +491,21 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
441
491
|
and composition.vertical_table_body_lines is not None
|
|
442
492
|
and composition.vertical_table_body_lines.middle not in (None, '', ' ')
|
|
443
493
|
)
|
|
494
|
+
syn_h = synthesized.middle if synthesized is not None else None
|
|
495
|
+
|
|
496
|
+
def _blank_crossings(rule_line, gap_after_row):
|
|
497
|
+
"""Erase horizontal through merges that continue across this gap."""
|
|
498
|
+
for region in regions:
|
|
499
|
+
if region.first_row <= gap_after_row and region.last_row >= gap_after_row + 1:
|
|
500
|
+
bounds = _region_bounds(region, layout, index_offset)
|
|
501
|
+
if bounds is None:
|
|
502
|
+
continue
|
|
503
|
+
(start, end), _, _, _ = bounds
|
|
504
|
+
if end > start:
|
|
505
|
+
rule_line = _repaint(
|
|
506
|
+
rule_line, start, end, filler * (end - start)
|
|
507
|
+
)
|
|
508
|
+
return rule_line
|
|
444
509
|
|
|
445
510
|
for region in regions:
|
|
446
511
|
bounds = _region_bounds(region, layout, index_offset)
|
|
@@ -500,37 +565,30 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
500
565
|
if region.column_count >= 2 and inner_seps:
|
|
501
566
|
above = min(target_lines) - 1
|
|
502
567
|
below = max(target_lines) + 1
|
|
503
|
-
|
|
504
|
-
|
|
568
|
+
horiz_caps.append(
|
|
569
|
+
(above, below, set(inner_seps), region.first_row, region.last_row)
|
|
570
|
+
)
|
|
505
571
|
|
|
506
|
-
if col_only:
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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])
|
|
572
|
+
if col_only and synthesized is not None:
|
|
573
|
+
# Cap above: gap after first_row-1 (if any).
|
|
574
|
+
if region.first_row > 0:
|
|
575
|
+
g = region.first_row - 1
|
|
576
|
+
plan = gap_plans.setdefault(
|
|
577
|
+
g, {'s': set(), 'n': set(), 'at': min(target_lines)}
|
|
516
578
|
)
|
|
517
|
-
|
|
518
|
-
|
|
579
|
+
plan['s'] |= set(inner_seps)
|
|
580
|
+
plan['at'] = min(plan['at'], min(target_lines))
|
|
581
|
+
# Cap below: gap after last_row (unless last table row — end
|
|
582
|
+
# line is repaired in pass 3).
|
|
583
|
+
max_row = max(row_line_map) if row_line_map else region.last_row
|
|
584
|
+
if region.last_row < max_row:
|
|
585
|
+
g = region.last_row
|
|
586
|
+
plan = gap_plans.setdefault(
|
|
587
|
+
g, {'s': set(), 'n': set(), 'at': max(target_lines) + 1}
|
|
519
588
|
)
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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))
|
|
589
|
+
plan['n'] |= set(inner_seps)
|
|
590
|
+
plan['at'] = min(plan.get('at', max(target_lines) + 1),
|
|
591
|
+
max(target_lines) + 1)
|
|
534
592
|
|
|
535
593
|
# --- Pass 2: repair junctions on body rules touched by vertical blanks ---
|
|
536
594
|
if h_glyph:
|
|
@@ -542,11 +600,10 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
542
600
|
line, layout, composition, h_glyph
|
|
543
601
|
)
|
|
544
602
|
|
|
545
|
-
# --- Pass 3: ┴ / ┬ on rules above and below
|
|
546
|
-
# Combine missing arms when several merges share a cap line.
|
|
603
|
+
# --- Pass 3: ┴ / ┬ on existing rules above and below H / 2D merges -------
|
|
547
604
|
missing_south_by_line = {}
|
|
548
605
|
missing_north_by_line = {}
|
|
549
|
-
for above, below, xs in horiz_caps:
|
|
606
|
+
for above, below, xs, _fr, _lr in horiz_caps:
|
|
550
607
|
if above >= 0:
|
|
551
608
|
missing_south_by_line.setdefault(above, set()).update(xs)
|
|
552
609
|
if below < len(lines):
|
|
@@ -555,41 +612,57 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
555
612
|
cap_lines = set(missing_south_by_line) | set(missing_north_by_line)
|
|
556
613
|
for line_index in cap_lines:
|
|
557
614
|
line = lines[line_index]
|
|
558
|
-
|
|
559
|
-
if not any(_is_h(ch) for ch in line):
|
|
615
|
+
if not _looks_like_rule(line, h_glyph):
|
|
560
616
|
continue
|
|
561
|
-
# Guess h glyph from the line itself.
|
|
562
617
|
line_h = next((ch for ch in line if _is_h(ch)), h_glyph or '─')
|
|
563
618
|
lines[line_index] = _repair_rule_junctions(
|
|
564
619
|
line, layout, composition, line_h,
|
|
565
620
|
missing_south=missing_south_by_line.get(line_index),
|
|
566
621
|
missing_north=missing_north_by_line.get(line_index),
|
|
622
|
+
no_south=(line_index >= len(lines) - 1),
|
|
623
|
+
no_north=(line_index == 0),
|
|
567
624
|
)
|
|
568
625
|
|
|
569
|
-
# --- Pass 4:
|
|
570
|
-
#
|
|
571
|
-
#
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
626
|
+
# --- Pass 4: synthetic caps for column-only styles ----------------------
|
|
627
|
+
# Insert a rule in each planned gap that does not already hold a real
|
|
628
|
+
# separator. Blank any merge that continues across the gap so taller
|
|
629
|
+
# vertical/2D merges are not crossed by ghost edges.
|
|
630
|
+
if col_only and gap_plans and synthesized is not None:
|
|
631
|
+
for g in sorted(gap_plans.keys(), reverse=True):
|
|
632
|
+
plan = gap_plans[g]
|
|
633
|
+
at = plan['at']
|
|
634
|
+
# If the line already at `at` (or just above the lower row) is a
|
|
635
|
+
# real rule, repair it in place instead of inserting.
|
|
636
|
+
existing = None
|
|
637
|
+
if 0 <= at < len(lines) and _looks_like_rule(lines[at], syn_h):
|
|
638
|
+
existing = at
|
|
639
|
+
elif 0 <= at - 1 < len(lines) and _looks_like_rule(lines[at - 1], syn_h):
|
|
640
|
+
# Header/end sitting where we wanted a gap.
|
|
641
|
+
existing = at - 1
|
|
642
|
+
|
|
643
|
+
if existing is not None:
|
|
644
|
+
line = lines[existing]
|
|
645
|
+
line = _blank_crossings(line, g)
|
|
646
|
+
line_h = next((ch for ch in line if _is_h(ch)), syn_h or '─')
|
|
647
|
+
lines[existing] = _repair_rule_junctions(
|
|
648
|
+
line, layout, composition, line_h,
|
|
649
|
+
missing_south=plan['s'] or None,
|
|
650
|
+
missing_north=plan['n'] or None,
|
|
651
|
+
no_south=(existing >= len(lines) - 1),
|
|
652
|
+
no_north=(existing == 0),
|
|
653
|
+
)
|
|
654
|
+
continue
|
|
655
|
+
|
|
586
656
|
rule = _build_rule_line(
|
|
587
657
|
layout, composition, column_widths, hidden_columns, synthesized
|
|
588
658
|
)
|
|
659
|
+
if rule is None:
|
|
660
|
+
continue
|
|
661
|
+
rule = _blank_crossings(rule, g)
|
|
589
662
|
rule = _repair_rule_junctions(
|
|
590
|
-
rule, layout, composition,
|
|
591
|
-
missing_south=
|
|
592
|
-
missing_north=
|
|
663
|
+
rule, layout, composition, syn_h,
|
|
664
|
+
missing_south=plan['s'] or None,
|
|
665
|
+
missing_north=plan['n'] or None,
|
|
593
666
|
)
|
|
594
667
|
lines.insert(max(0, min(at, len(lines))), rule)
|
|
595
668
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flexablelib
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.7
|
|
4
4
|
Summary: Flexible console and HTML tables for Python
|
|
5
5
|
Home-page: https://github.com/Opus-Perpetuus/flexablelib
|
|
6
6
|
Download-URL:
|
|
@@ -76,6 +76,18 @@ Python package for formatting tabular data as console and HTML tables: border st
|
|
|
76
76
|
|
|
77
77
|
``pip3 install flexablelib`` on Linux
|
|
78
78
|
|
|
79
|
+
Prebuilt wheels (with the C speedups) are published for Linux (manylinux/musllinux), Windows (x64) and macOS (arm64); other platforms build from the sdist or fall back to pure Python.
|
|
80
|
+
|
|
81
|
+
### Windows console encoding
|
|
82
|
+
Box-drawing styles (`┌─┐`, `╔═╗`, ...) need a UTF-8 console. Legacy `cp1252` consoles raise `UnicodeEncodeError` for them, while ASCII styles work everywhere. Enable UTF-8 with either:
|
|
83
|
+
|
|
84
|
+
```bat
|
|
85
|
+
chcp 65001
|
|
86
|
+
set PYTHONUTF8=1
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
(PowerShell: `$env:PYTHONUTF8=1`.) Windows Terminal with Python 3.8+ is usually UTF-8 already.
|
|
90
|
+
|
|
79
91
|
# Usage
|
|
80
92
|
Creating a table is simple.
|
|
81
93
|
### Code Example
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Rare merge edge cases: all border styles × call-order permutations.
|
|
2
|
+
|
|
3
|
+
These assert the generic junction algorithm — never demo names. Each case is a
|
|
4
|
+
small structural table chosen to stress caps, crossings, bottoms, and hyphens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import itertools
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from flexablelib import Table
|
|
11
|
+
from flexablelib.fast import visible_width
|
|
12
|
+
|
|
13
|
+
# Styles that ship with vertical column rules (the ones merges must paint).
|
|
14
|
+
STYLES_WITH_VERTICALS = [
|
|
15
|
+
'pretty_grid',
|
|
16
|
+
'pretty_columns',
|
|
17
|
+
'round_edges',
|
|
18
|
+
'dashes',
|
|
19
|
+
'grid',
|
|
20
|
+
'bold_borderline',
|
|
21
|
+
'thin_borderline',
|
|
22
|
+
'double_grid',
|
|
23
|
+
'grid_eheader',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _square(rendered: str) -> None:
|
|
28
|
+
widths = {visible_width(line) for line in rendered.splitlines()}
|
|
29
|
+
# Title lines may be shorter; body shares one width.
|
|
30
|
+
assert len(widths) <= 2, widths
|
|
31
|
+
assert max(widths) >= 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _has_h_run(line: str) -> bool:
|
|
35
|
+
import re
|
|
36
|
+
return re.search(r'[─━═┄┅┈┉╌╍▬−\-~=]{2,}', line) is not None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_base(style: str) -> Table:
|
|
40
|
+
table = Table(style_name=style)
|
|
41
|
+
table.add_column('A', ['a0', 'a1', 'a2', 'a3'])
|
|
42
|
+
table.add_column('B', ['b0', 'b1', 'b2', 'b3'])
|
|
43
|
+
table.add_column('C', ['c0', 'c1', 'c2', 'c3'])
|
|
44
|
+
table.add_column('D', ['d0', 'd1', 'd2', 'd3'])
|
|
45
|
+
return table
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize('style', STYLES_WITH_VERTICALS)
|
|
49
|
+
def test_horizontal_merge_mid_table_keeps_square(style):
|
|
50
|
+
table = _build_base(style)
|
|
51
|
+
table.merge_cells(1, 1, last_column=2, value='BC')
|
|
52
|
+
rendered = str(table)
|
|
53
|
+
assert 'BC' in rendered
|
|
54
|
+
_square(rendered)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize('style', STYLES_WITH_VERTICALS)
|
|
58
|
+
def test_vertical_merge_mid_table_keeps_square(style):
|
|
59
|
+
table = _build_base(style)
|
|
60
|
+
table.merge_cells(1, 0, last_row=2, value='AS')
|
|
61
|
+
rendered = str(table)
|
|
62
|
+
assert 'AS' in rendered or 'AS' in rendered
|
|
63
|
+
_square(rendered)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize('style', STYLES_WITH_VERTICALS)
|
|
67
|
+
def test_block_merge_keeps_square(style):
|
|
68
|
+
table = _build_base(style)
|
|
69
|
+
table.merge_cells(1, 1, 2, 2, value='BLOCK')
|
|
70
|
+
rendered = str(table)
|
|
71
|
+
assert 'BLOCK' in rendered
|
|
72
|
+
_square(rendered)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize('style', STYLES_WITH_VERTICALS)
|
|
76
|
+
def test_last_row_horizontal_merge_bottom_is_not_header_tee(style):
|
|
77
|
+
table = _build_base(style)
|
|
78
|
+
table.merge_cells(3, 1, last_column=2, value='BC-end')
|
|
79
|
+
bottom = str(table).splitlines()[-1]
|
|
80
|
+
# Header-only tees must not appear on the end line.
|
|
81
|
+
for bad in ('╞', '╡', '╤', '╪'):
|
|
82
|
+
# double_grid / pretty_columns end lines use ╘═╧╛ — ╞ would be wrong.
|
|
83
|
+
if bad in bottom and not bottom.startswith(('╘', '╚', '╙', '╰', '└', '+', '┗')):
|
|
84
|
+
pytest.fail(f'header tee {bad!r} on bottom: {bottom!r}')
|
|
85
|
+
_square(str(table))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize('style', ['pretty_columns', 'round_edges', 'dashes'])
|
|
89
|
+
def test_column_only_multi_col_merge_inserts_cap(style):
|
|
90
|
+
table = _build_base(style)
|
|
91
|
+
table.merge_cells(1, 0, last_column=1, value='AB')
|
|
92
|
+
lines = str(table).splitlines()
|
|
93
|
+
assert any(_has_h_run(line) and '═' not in line or _has_h_run(line)
|
|
94
|
+
for line in lines)
|
|
95
|
+
# There is a body-ish rule that is not only the header double rule.
|
|
96
|
+
bodyish = [line for line in lines if _has_h_run(line)]
|
|
97
|
+
assert len(bodyish) >= 2, lines # header + at least one cap or end
|
|
98
|
+
_square(str(table))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize('style', ['pretty_columns', 'round_edges', 'dashes'])
|
|
102
|
+
def test_short_merge_does_not_ghost_through_taller_block(style):
|
|
103
|
+
table = Table(style_name=style)
|
|
104
|
+
for c in range(4):
|
|
105
|
+
table.add_column(f'C{c}', [f'r{r}c{c}' for r in range(4)])
|
|
106
|
+
table.merge_cells(0, 0, 2, 1, value='TALL')
|
|
107
|
+
table.merge_cells(0, 2, 1, 3, value='SHORT')
|
|
108
|
+
rendered = str(table)
|
|
109
|
+
# Classic ghost: ┼──────┼ under TALL on the SHORT-below rule.
|
|
110
|
+
assert '┼──────┼──────┼' not in rendered
|
|
111
|
+
assert '╪══════╪══════╪' not in rendered
|
|
112
|
+
_square(rendered)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.parametrize('style', ['round_edges', 'dashes', 'pretty_columns'])
|
|
116
|
+
def test_hyphenated_labels_still_get_caps(style):
|
|
117
|
+
table = Table(style_name=style)
|
|
118
|
+
table.add_column('Cat', ['', '', '', ''])
|
|
119
|
+
table.add_column('Item', ['X-1', 'X-2', 'Y-1', 'Y-2'])
|
|
120
|
+
table.add_column('V', [1, 2, 3, 4])
|
|
121
|
+
table.add_column('N', ['n-0', 'n-1', 'n-2', 'n-3'])
|
|
122
|
+
table.merge_cells(0, 0, last_row=1, value='X')
|
|
123
|
+
table.merge_cells(2, 0, last_row=3, value='Y')
|
|
124
|
+
table.merge_cells(2, 2, last_row=3, last_column=3, value='BOX')
|
|
125
|
+
rendered = str(table)
|
|
126
|
+
assert 'BOX' in rendered
|
|
127
|
+
_square(rendered)
|
|
128
|
+
# Must have a horizontal run flanking BOX (cap or end), not only header.
|
|
129
|
+
lines = rendered.splitlines()
|
|
130
|
+
box_i = next(i for i, line in enumerate(lines) if 'BOX' in line)
|
|
131
|
+
nearby = lines[max(0, box_i - 3): box_i + 4]
|
|
132
|
+
assert any(_has_h_run(line) for line in nearby), rendered
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize('style', ['dashes'])
|
|
136
|
+
def test_dashes_bottom_merge_has_no_solid_em_dash(style):
|
|
137
|
+
table = _build_base(style)
|
|
138
|
+
table.merge_cells(3, 2, last_column=3, value='CD')
|
|
139
|
+
bottom = str(table).splitlines()[-1]
|
|
140
|
+
assert '─' not in bottom, bottom
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.mark.parametrize('style', ['round_edges'])
|
|
144
|
+
def test_round_end_corners_kept(style):
|
|
145
|
+
table = _build_base(style)
|
|
146
|
+
table.merge_cells(3, 1, last_column=2, value='BC')
|
|
147
|
+
bottom = str(table).splitlines()[-1]
|
|
148
|
+
assert bottom.startswith('╰') and bottom.endswith('╯'), bottom
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- Call-order permutations -------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _orders():
|
|
154
|
+
"""Distinct sequences of building a 2x2 block merge + a vertical stack."""
|
|
155
|
+
def style_first(style):
|
|
156
|
+
t = Table(style_name=style)
|
|
157
|
+
t.add_column('A', ['a0', 'a1', 'a2'])
|
|
158
|
+
t.add_column('B', ['b0', 'b1', 'b2'])
|
|
159
|
+
t.add_column('C', ['c0', 'c1', 'c2'])
|
|
160
|
+
t.merge_cells(0, 0, 1, 1, value='BLK')
|
|
161
|
+
t.merge_cells(0, 2, last_row=2, value='V')
|
|
162
|
+
return t
|
|
163
|
+
|
|
164
|
+
def merge_then_style(style):
|
|
165
|
+
t = Table()
|
|
166
|
+
t.add_column('A', ['a0', 'a1', 'a2'])
|
|
167
|
+
t.add_column('B', ['b0', 'b1', 'b2'])
|
|
168
|
+
t.add_column('C', ['c0', 'c1', 'c2'])
|
|
169
|
+
t.merge_cells(0, 0, 1, 1, value='BLK')
|
|
170
|
+
t.merge_cells(0, 2, last_row=2, value='V')
|
|
171
|
+
t.style_name = style
|
|
172
|
+
return t
|
|
173
|
+
|
|
174
|
+
def add_row_then_merge(style):
|
|
175
|
+
t = Table(style_name=style)
|
|
176
|
+
t.add_column('A', [])
|
|
177
|
+
t.add_column('B', [])
|
|
178
|
+
t.add_column('C', [])
|
|
179
|
+
for r in range(3):
|
|
180
|
+
t.add_row([f'a{r}', f'b{r}', f'c{r}'])
|
|
181
|
+
t.merge_cells(0, 0, 1, 1, value='BLK')
|
|
182
|
+
t.merge_cells(0, 2, last_row=2, value='V')
|
|
183
|
+
return t
|
|
184
|
+
|
|
185
|
+
def merge_between_rows(style):
|
|
186
|
+
t = Table(style_name=style)
|
|
187
|
+
t.add_column('A', ['a0'])
|
|
188
|
+
t.add_column('B', ['b0'])
|
|
189
|
+
t.add_column('C', ['c0'])
|
|
190
|
+
t.add_row(['a1', 'b1', 'c1'])
|
|
191
|
+
t.merge_cells(0, 0, 1, 1, value='BLK') # merge early
|
|
192
|
+
t.add_row(['a2', 'b2', 'c2'])
|
|
193
|
+
t.merge_cells(0, 2, last_row=2, value='V')
|
|
194
|
+
return t
|
|
195
|
+
|
|
196
|
+
return [
|
|
197
|
+
('style_first', style_first),
|
|
198
|
+
('merge_then_style', merge_then_style),
|
|
199
|
+
('add_row_then_merge', add_row_then_merge),
|
|
200
|
+
('merge_between_rows', merge_between_rows),
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.parametrize('style', ['pretty_grid', 'pretty_columns', 'round_edges', 'grid'])
|
|
205
|
+
@pytest.mark.parametrize('order_name,factory', _orders())
|
|
206
|
+
def test_call_order_permutations_render_square(style, order_name, factory):
|
|
207
|
+
table = factory(style)
|
|
208
|
+
rendered = str(table)
|
|
209
|
+
assert 'BLK' in rendered
|
|
210
|
+
_square(rendered)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@pytest.mark.parametrize('align', ['l', 'c', 'r'])
|
|
214
|
+
@pytest.mark.parametrize('style', ['pretty_grid', 'pretty_columns', 'round_edges'])
|
|
215
|
+
def test_centered_block_has_no_internal_ghost_cross(style, align):
|
|
216
|
+
table = Table(style_name=style)
|
|
217
|
+
for c in range(5):
|
|
218
|
+
table.add_column(f'C{c}', [f'r{r}c{c}' for r in range(4)])
|
|
219
|
+
table.merge_cells(0, 0, 2, 2, value='CENTER', align=align)
|
|
220
|
+
rendered = str(table)
|
|
221
|
+
# Ghost crosses under a 3-col span on a single rule.
|
|
222
|
+
assert '┼──────┼──────┼' not in rendered
|
|
223
|
+
assert '╪══════╪══════╪' not in rendered
|
|
224
|
+
_square(rendered)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.mark.parametrize('style', ['pretty_grid', 'bold_borderline', 'double_grid'])
|
|
228
|
+
def test_adjacent_vertical_then_horizontal_l_shape(style):
|
|
229
|
+
table = Table(style_name=style)
|
|
230
|
+
for c in range(5):
|
|
231
|
+
table.add_column(f'C{c}', [f'r{r}c{c}' for r in range(5)])
|
|
232
|
+
table.merge_cells(0, 0, last_row=3, value='STEM')
|
|
233
|
+
table.merge_cells(3, 1, last_column=3, value='BASE')
|
|
234
|
+
rendered = str(table)
|
|
235
|
+
assert 'STEM' in rendered and 'BASE' in rendered
|
|
236
|
+
_square(rendered)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.parametrize('style', ['pretty_grid', 'pretty_columns', 'dashes'])
|
|
240
|
+
def test_many_nonoverlapping_blocks(style):
|
|
241
|
+
table = Table(style_name=style)
|
|
242
|
+
for c in range(6):
|
|
243
|
+
table.add_column(f'C{c}', [f'r{r}c{c}' for r in range(6)])
|
|
244
|
+
table.merge_cells(0, 0, 1, 1, value='NW')
|
|
245
|
+
table.merge_cells(0, 4, 1, 5, value='NE')
|
|
246
|
+
table.merge_cells(2, 2, 3, 3, value='MID')
|
|
247
|
+
table.merge_cells(4, 0, 5, 1, value='SW')
|
|
248
|
+
table.merge_cells(4, 4, 5, 5, value='SE')
|
|
249
|
+
rendered = str(table)
|
|
250
|
+
for label in ('NW', 'NE', 'MID', 'SW', 'SE'):
|
|
251
|
+
assert label in rendered
|
|
252
|
+
_square(rendered)
|
|
@@ -563,7 +563,10 @@ def test_horizontal_merge_caps_become_tees_above_and_below():
|
|
|
563
563
|
|
|
564
564
|
def test_column_only_style_inserts_horizontal_break_under_multi_column_merge():
|
|
565
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.
|
|
566
|
+
must insert a row rule so the grid does not break oddly.
|
|
567
|
+
|
|
568
|
+
Caps use the table's outer horizontal glyph (═ for pretty_columns).
|
|
569
|
+
"""
|
|
567
570
|
table = Table(style_name='pretty_columns')
|
|
568
571
|
table.add_column('A', ['a1', 'a2', 'a3'])
|
|
569
572
|
table.add_column('B', ['b1', 'b2', 'b3'])
|
|
@@ -571,8 +574,10 @@ def test_column_only_style_inserts_horizontal_break_under_multi_column_merge():
|
|
|
571
574
|
table.merge_cells(1, 0, last_column=1, value='AB')
|
|
572
575
|
|
|
573
576
|
lines = str(table).splitlines()
|
|
574
|
-
|
|
575
|
-
|
|
577
|
+
# Light tees (┴/┬) or double-horizontal tees (╧/╤) — both valid depending
|
|
578
|
+
# on whether the synthesizer mirrors the outer ═ border.
|
|
579
|
+
assert any(('┴' in line or '╧' in line) for line in lines), lines
|
|
580
|
+
assert any(('┬' in line or '╤' in line) for line in lines), lines
|
|
576
581
|
assert any('AB' in line for line in lines)
|
|
577
582
|
# The synthetic rules keep the table square.
|
|
578
583
|
assert len({visible_width(line) for line in lines}) == 1
|
|
@@ -588,3 +593,192 @@ def test_block_merge_header_and_footer_caps():
|
|
|
588
593
|
rendered = str(table)
|
|
589
594
|
assert '╧' in rendered # header intersection loses south into the block
|
|
590
595
|
assert '┬' in rendered # body rule below gains north-less tee
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_last_row_horizontal_merge_uses_table_end_corners_not_header_tees():
|
|
599
|
+
"""A multi-column merge on the final data row must not rewrite the
|
|
600
|
+
table_end_line as if it had south arms (that yields ╞/╪/╤/╡)."""
|
|
601
|
+
table = Table(style_name='pretty_grid')
|
|
602
|
+
table.add_column('A', ['a1', 'a2', 'a3'])
|
|
603
|
+
table.add_column('B', ['b1', 'b2', 'b3'])
|
|
604
|
+
table.add_column('C', ['c1', 'c2', 'c3'])
|
|
605
|
+
table.add_column('D', ['d1', 'd2', 'd3'])
|
|
606
|
+
table.merge_cells(2, 1, last_column=2, value='BC')
|
|
607
|
+
|
|
608
|
+
lines = str(table).splitlines()
|
|
609
|
+
bottom = lines[-1]
|
|
610
|
+
assert bottom.startswith('╘'), bottom
|
|
611
|
+
assert bottom.endswith('╛'), bottom
|
|
612
|
+
assert '╞' not in bottom and '╡' not in bottom, bottom
|
|
613
|
+
assert '╪' not in bottom and '╤' not in bottom, bottom
|
|
614
|
+
# Inner sep under BC becomes a plain double horizontal (no south/north).
|
|
615
|
+
assert bottom == '╘════╧═════════╧════╛', bottom
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def test_stress_last_row_merge_bottom_border_is_end_line():
|
|
619
|
+
"""Stress table from demos: H14 on the last row must keep ╘/╧/╛."""
|
|
620
|
+
table = Table(style_name='pretty_grid')
|
|
621
|
+
headers = [f'C{c}' for c in range(8)]
|
|
622
|
+
for c, h in enumerate(headers):
|
|
623
|
+
table.add_column(h, [f'S{r}c{c}' for r in range(15)])
|
|
624
|
+
for r in range(0, 15, 3):
|
|
625
|
+
table.merge_cells(r, 0, last_row=min(r + 2, 14), value=f'R{r}')
|
|
626
|
+
for r, c0, c1, label in [
|
|
627
|
+
(0, 2, 4, 'H0'),
|
|
628
|
+
(1, 5, 7, 'H1'),
|
|
629
|
+
(4, 2, 3, 'H4'),
|
|
630
|
+
(7, 3, 6, 'H7'),
|
|
631
|
+
(10, 1, 2, 'H10'),
|
|
632
|
+
(12, 4, 7, 'H12'),
|
|
633
|
+
(14, 2, 5, 'H14'),
|
|
634
|
+
]:
|
|
635
|
+
table.merge_cells(r, c0, last_column=c1, value=label)
|
|
636
|
+
table.merge_cells(2, 5, 3, 7, value='BOX')
|
|
637
|
+
table.merge_cells(8, 1, 9, 2, value='SQ')
|
|
638
|
+
|
|
639
|
+
bottom = str(table).splitlines()[-1]
|
|
640
|
+
assert bottom.startswith('╘'), bottom
|
|
641
|
+
assert bottom.endswith('╛'), bottom
|
|
642
|
+
assert '╞' not in bottom and '╡' not in bottom, bottom
|
|
643
|
+
assert '╤' not in bottom and '╪' not in bottom, bottom
|
|
644
|
+
# Continuous run under H14 (C2–C5): three inner junctions → ═.
|
|
645
|
+
assert '╧═══════════════════════════════╧' in bottom, bottom # C2–C5: 7+1+7+1+7+1+7
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# +-------------------------------------------------------------------------+
|
|
649
|
+
# Generic junction / cap algorithm (no demo-specific hardcoding)
|
|
650
|
+
# +-------------------------------------------------------------------------+
|
|
651
|
+
|
|
652
|
+
def test_hyphen_in_cell_text_does_not_block_column_only_caps():
|
|
653
|
+
"""A lone '-' inside cell text must not be mistaken for a rule line.
|
|
654
|
+
|
|
655
|
+
Column-only styles insert synthetic caps around multi-column merges; if
|
|
656
|
+
'Beta-1' were treated as a rule, those caps would be skipped.
|
|
657
|
+
"""
|
|
658
|
+
table = Table(style_name='round_edges')
|
|
659
|
+
table.add_column('Cat', [''] * 5)
|
|
660
|
+
table.add_column('Item', ['Alpha-1', 'Alpha-2', 'Beta-1', 'Beta-2', 'Beta-3'])
|
|
661
|
+
table.add_column('V', [1, 2, 3, 4, 5])
|
|
662
|
+
table.add_column('N', ['n0', 'n1', 'n2', 'n3', 'n4'])
|
|
663
|
+
table.merge_cells(0, 0, last_row=1, value='Alpha')
|
|
664
|
+
table.merge_cells(2, 0, last_row=4, value='Beta')
|
|
665
|
+
table.merge_cells(2, 2, last_row=4, last_column=3, value='BLOCK')
|
|
666
|
+
|
|
667
|
+
lines = str(table).splitlines()
|
|
668
|
+
block_i = next(i for i, line in enumerate(lines) if 'BLOCK' in line)
|
|
669
|
+
# Cap above the block (between Alpha group and BLOCK) and below (or end).
|
|
670
|
+
above = lines[block_i - 2] # one data line of BLOCK above middle, then cap
|
|
671
|
+
# Find a synthetic/body rule that carries a tee into the V|N span.
|
|
672
|
+
rule_lines = [line for line in lines if ('┬' in line or '┴' in line or '├' in line) and '═' not in line]
|
|
673
|
+
assert rule_lines, '\n'.join(lines)
|
|
674
|
+
# At least one rule must sit next to the BLOCK rows.
|
|
675
|
+
assert any(
|
|
676
|
+
abs(i - block_i) <= 3 and ('┬' in line or '┴' in line or '─' in line)
|
|
677
|
+
for i, line in enumerate(lines)
|
|
678
|
+
if _looks_like_rule_line(line)
|
|
679
|
+
), '\n'.join(lines)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _looks_like_rule_line(line: str) -> bool:
|
|
683
|
+
import re
|
|
684
|
+
return re.search(r'[─━═┄┅┈┉╌╍▬−\-~=]{2,}', line) is not None
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def test_multi_column_merge_in_column_only_style_gets_caps_matching_outer_dash():
|
|
688
|
+
"""Caps above/below a multi-column merge use the table end horizontal glyph."""
|
|
689
|
+
table = Table(style_name='round_edges')
|
|
690
|
+
table.add_column('A', ['a1', 'a2', 'a3', 'a4'])
|
|
691
|
+
table.add_column('B', ['b1', 'b2', 'b3', 'b4'])
|
|
692
|
+
table.add_column('C', ['c1', 'c2', 'c3', 'c4'])
|
|
693
|
+
table.merge_cells(1, 1, last_row=2, last_column=2, value='BC')
|
|
694
|
+
|
|
695
|
+
rendered = str(table)
|
|
696
|
+
lines = rendered.splitlines()
|
|
697
|
+
assert any('BC' in line for line in lines)
|
|
698
|
+
# Outer end uses ─; synthetic caps must also use ─ (not a foreign dash).
|
|
699
|
+
rules = [line for line in lines if _looks_like_rule_line(line) and not line.startswith('╭') and not line.startswith('╰') and '═' not in line]
|
|
700
|
+
assert rules, rendered
|
|
701
|
+
for line in rules:
|
|
702
|
+
assert '┄' not in line and '═' not in line, line
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def test_dashes_style_bottom_under_merge_keeps_dotted_glyph_not_solid():
|
|
706
|
+
"""Suppressing a bottom junction must extend the rule's own h glyph (┄), never ─."""
|
|
707
|
+
table = Table(style_name='dashes')
|
|
708
|
+
for d, name in enumerate(['A', 'B', 'C', 'D']):
|
|
709
|
+
table.add_column(name, [f'r{r}c{d}' for r in range(3)])
|
|
710
|
+
table.merge_cells(2, 2, last_column=3, value='CD')
|
|
711
|
+
|
|
712
|
+
bottom = str(table).splitlines()[-1]
|
|
713
|
+
assert '─' not in bottom, bottom
|
|
714
|
+
assert bottom.startswith('└') and bottom.endswith('┘'), bottom
|
|
715
|
+
# Continuous dotted run under CD (one suppressed inner junction).
|
|
716
|
+
assert '┴┄┄┄┄┄┄┄┄┄┄┄┄┄┘' in bottom or bottom.count('┄') >= 10, bottom
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def test_synthetic_rule_blanks_through_taller_crossing_merge():
|
|
720
|
+
"""A short multi-column merge must not leave ghost ┼ inside a taller 2D merge."""
|
|
721
|
+
table = Table(style_name='pretty_columns')
|
|
722
|
+
for c in range(6):
|
|
723
|
+
table.add_column(f'C{c}', [f'r{r}c{c}' for r in range(5)])
|
|
724
|
+
table.merge_cells(0, 0, 2, 2, value='TALL')
|
|
725
|
+
table.merge_cells(0, 3, 1, 5, value='SHORT')
|
|
726
|
+
|
|
727
|
+
lines = str(table).splitlines()
|
|
728
|
+
# The rule just under SHORT (still inside TALL's rows) must not show ┼
|
|
729
|
+
# under TALL's columns — those junctions are ghosts.
|
|
730
|
+
short_i = next(i for i, line in enumerate(lines) if 'SHORT' in line)
|
|
731
|
+
# Scan rules after SHORT's text line while TALL still covers row 2.
|
|
732
|
+
for line in lines[short_i + 1:]:
|
|
733
|
+
if 'TALL' in line or 'r2c' in line or 'r3c' in line:
|
|
734
|
+
# data
|
|
735
|
+
if 'r3c' in line:
|
|
736
|
+
break
|
|
737
|
+
continue
|
|
738
|
+
if not _looks_like_rule_line(line):
|
|
739
|
+
continue
|
|
740
|
+
# Under TALL (first three col bands) there must be no ┼ / ╪ ghost.
|
|
741
|
+
# Left portion before the SHORT columns' reopen.
|
|
742
|
+
left = line.split('r')[0] if False else line
|
|
743
|
+
# Take from start through the junction that opens column 3.
|
|
744
|
+
# Ghost pattern was: ├──────┼──────┼──────┼
|
|
745
|
+
assert '┼──────┼──────┼' not in line, line
|
|
746
|
+
assert '╪══════╪══════╪' not in line, line
|
|
747
|
+
break
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def test_round_edges_end_corners_survive_last_row_horizontal_merge():
|
|
751
|
+
"""Repairing the end line must keep ╰/╯, not rewrite them as └/┘."""
|
|
752
|
+
table = Table(style_name='round_edges')
|
|
753
|
+
table.add_column('A', ['a1', 'a2', 'a3'])
|
|
754
|
+
table.add_column('B', ['b1', 'b2', 'b3'])
|
|
755
|
+
table.add_column('C', ['c1', 'c2', 'c3'])
|
|
756
|
+
table.merge_cells(2, 1, last_column=2, value='BC')
|
|
757
|
+
|
|
758
|
+
bottom = str(table).splitlines()[-1]
|
|
759
|
+
assert bottom.startswith('╰'), bottom
|
|
760
|
+
assert bottom.endswith('╯'), bottom
|
|
761
|
+
assert '└' not in bottom and '┘' not in bottom, bottom
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def test_crossing_vertical_merge_keeps_edge_when_cap_inserted():
|
|
765
|
+
"""When a cap is inserted under a 2D block beside a continuing vertical
|
|
766
|
+
merge, the vertical merge's column stays an edge (no horizontal through it)."""
|
|
767
|
+
table = Table(style_name='round_edges')
|
|
768
|
+
table.add_column('Cat', [''] * 6)
|
|
769
|
+
table.add_column('Item', [f'i{r}' for r in range(6)])
|
|
770
|
+
table.add_column('V', list(range(6)))
|
|
771
|
+
table.add_column('N', [f'n{r}' for r in range(6)])
|
|
772
|
+
table.merge_cells(0, 0, last_row=5, value='ALL')
|
|
773
|
+
table.merge_cells(1, 2, last_row=3, last_column=3, value='BOX')
|
|
774
|
+
|
|
775
|
+
lines = str(table).splitlines()
|
|
776
|
+
# Rules flanking BOX must blank the Cat column (ALL continues through).
|
|
777
|
+
for i, line in enumerate(lines):
|
|
778
|
+
if not _looks_like_rule_line(line):
|
|
779
|
+
continue
|
|
780
|
+
if '═' in line or line.startswith('╭') or line.startswith('╰'):
|
|
781
|
+
continue
|
|
782
|
+
# Body synthetic rule: starts with │ (edge through ALL) not ├.
|
|
783
|
+
if '┬' in line or '┴' in line:
|
|
784
|
+
assert line.startswith('│'), line
|
|
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
|