flexablelib 2.0.6__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.6 → flexablelib-2.0.7}/CHANGELOG.md +13 -0
- {flexablelib-2.0.6/flexablelib.egg-info → flexablelib-2.0.7}/PKG-INFO +1 -1
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/merges.py +142 -76
- {flexablelib-2.0.6 → flexablelib-2.0.7/flexablelib.egg-info}/PKG-INFO +1 -1
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib.egg-info/SOURCES.txt +1 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/package.json +1 -1
- flexablelib-2.0.7/tests/test_merge_edge_matrix.py +252 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_merges.py +147 -3
- {flexablelib-2.0.6 → flexablelib-2.0.7}/LICENSE +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/MANIFEST.in +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/README.md +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/__init__.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/_speedups.c +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/_width_tables.h +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/cells.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/colors.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/columns.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/fast.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/options.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/readers.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/style_compositions.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/table.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/table_strings.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/text_width.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/utils.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/writers.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib.egg-info/dependency_links.txt +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib.egg-info/requires.txt +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib.egg-info/top_level.txt +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/setup.cfg +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/setup.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_cells.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_columns.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_excel.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_features.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_issues.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_readme_examples.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_styles.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_text_width.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_two_pass_measurement.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_upstream_issues.py +0 -0
- {flexablelib-2.0.6 → flexablelib-2.0.7}/tools/benchmark.py +0 -0
- {flexablelib-2.0.6 → 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
|
|
@@ -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,13 +400,13 @@ 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
412
|
def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=None, missing_north=None,
|
|
@@ -385,6 +426,7 @@ def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=Non
|
|
|
385
426
|
if v is None:
|
|
386
427
|
return line
|
|
387
428
|
h_glyph = h_glyph or '─'
|
|
429
|
+
round_corners = any(ch in line for ch in _ROUND_CORNERS)
|
|
388
430
|
rewritten = line
|
|
389
431
|
for x, v_glyph in _junction_positions(layout, composition):
|
|
390
432
|
if visible_width(rewritten) <= x:
|
|
@@ -399,8 +441,10 @@ def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=Non
|
|
|
399
441
|
east = _is_h(right)
|
|
400
442
|
north = (not no_north) and (x not in missing_north)
|
|
401
443
|
south = (not no_south) and (x not in missing_south)
|
|
402
|
-
|
|
403
|
-
|
|
444
|
+
glyph = _pick_junction(
|
|
445
|
+
v_glyph, h_glyph, north, south, east, west,
|
|
446
|
+
round_corners=round_corners,
|
|
447
|
+
)
|
|
404
448
|
rewritten = _repaint(rewritten, x, x + 1, glyph)
|
|
405
449
|
return rewritten
|
|
406
450
|
|
|
@@ -434,9 +478,11 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
434
478
|
touched_seps = set()
|
|
435
479
|
# Horizontal (or 2D) merges: verticals removed between their columns on
|
|
436
480
|
# their data lines; caps above/below need ┴/┬ repair.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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 = {}
|
|
440
486
|
|
|
441
487
|
synthesized = _synthesize_body_rule(composition)
|
|
442
488
|
col_only = (
|
|
@@ -445,6 +491,21 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
445
491
|
and composition.vertical_table_body_lines is not None
|
|
446
492
|
and composition.vertical_table_body_lines.middle not in (None, '', ' ')
|
|
447
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
|
|
448
509
|
|
|
449
510
|
for region in regions:
|
|
450
511
|
bounds = _region_bounds(region, layout, index_offset)
|
|
@@ -504,37 +565,30 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
504
565
|
if region.column_count >= 2 and inner_seps:
|
|
505
566
|
above = min(target_lines) - 1
|
|
506
567
|
below = max(target_lines) + 1
|
|
507
|
-
|
|
508
|
-
|
|
568
|
+
horiz_caps.append(
|
|
569
|
+
(above, below, set(inner_seps), region.first_row, region.last_row)
|
|
570
|
+
)
|
|
509
571
|
|
|
510
|
-
if col_only:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
# rule already. Duplicating a header/end rule looks broken;
|
|
517
|
-
# those are repaired in pass 3 instead.
|
|
518
|
-
above_is_rule = (
|
|
519
|
-
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)}
|
|
520
578
|
)
|
|
521
|
-
|
|
522
|
-
|
|
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}
|
|
523
588
|
)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
synthesized.middle,
|
|
528
|
-
missing_south=set(inner_seps),
|
|
529
|
-
)
|
|
530
|
-
col_only_inserts.append((min(target_lines), capped))
|
|
531
|
-
if not below_is_rule:
|
|
532
|
-
capped_below = _repair_rule_junctions(
|
|
533
|
-
rule_line, layout, composition,
|
|
534
|
-
synthesized.middle,
|
|
535
|
-
missing_north=set(inner_seps),
|
|
536
|
-
)
|
|
537
|
-
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)
|
|
538
592
|
|
|
539
593
|
# --- Pass 2: repair junctions on body rules touched by vertical blanks ---
|
|
540
594
|
if h_glyph:
|
|
@@ -546,11 +600,10 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
546
600
|
line, layout, composition, h_glyph
|
|
547
601
|
)
|
|
548
602
|
|
|
549
|
-
# --- Pass 3: ┴ / ┬ on rules above and below
|
|
550
|
-
# Combine missing arms when several merges share a cap line.
|
|
603
|
+
# --- Pass 3: ┴ / ┬ on existing rules above and below H / 2D merges -------
|
|
551
604
|
missing_south_by_line = {}
|
|
552
605
|
missing_north_by_line = {}
|
|
553
|
-
for above, below, xs in horiz_caps:
|
|
606
|
+
for above, below, xs, _fr, _lr in horiz_caps:
|
|
554
607
|
if above >= 0:
|
|
555
608
|
missing_south_by_line.setdefault(above, set()).update(xs)
|
|
556
609
|
if below < len(lines):
|
|
@@ -559,12 +612,9 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
559
612
|
cap_lines = set(missing_south_by_line) | set(missing_north_by_line)
|
|
560
613
|
for line_index in cap_lines:
|
|
561
614
|
line = lines[line_index]
|
|
562
|
-
|
|
563
|
-
if not any(_is_h(ch) for ch in line):
|
|
615
|
+
if not _looks_like_rule(line, h_glyph):
|
|
564
616
|
continue
|
|
565
|
-
# Guess h glyph from the line itself.
|
|
566
617
|
line_h = next((ch for ch in line if _is_h(ch)), h_glyph or '─')
|
|
567
|
-
# Outer borders never grow an arm past the table.
|
|
568
618
|
lines[line_index] = _repair_rule_junctions(
|
|
569
619
|
line, layout, composition, line_h,
|
|
570
620
|
missing_south=missing_south_by_line.get(line_index),
|
|
@@ -573,30 +623,46 @@ def apply(lines, row_line_map, regions, column_widths, composition,
|
|
|
573
623
|
no_north=(line_index == 0),
|
|
574
624
|
)
|
|
575
625
|
|
|
576
|
-
# --- Pass 4:
|
|
577
|
-
#
|
|
578
|
-
#
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
|
|
593
656
|
rule = _build_rule_line(
|
|
594
657
|
layout, composition, column_widths, hidden_columns, synthesized
|
|
595
658
|
)
|
|
659
|
+
if rule is None:
|
|
660
|
+
continue
|
|
661
|
+
rule = _blank_crossings(rule, g)
|
|
596
662
|
rule = _repair_rule_junctions(
|
|
597
|
-
rule, layout, composition,
|
|
598
|
-
missing_south=
|
|
599
|
-
missing_north=
|
|
663
|
+
rule, layout, composition, syn_h,
|
|
664
|
+
missing_south=plan['s'] or None,
|
|
665
|
+
missing_north=plan['n'] or None,
|
|
600
666
|
)
|
|
601
667
|
lines.insert(max(0, min(at, len(lines))), rule)
|
|
602
668
|
|
|
@@ -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
|
|
@@ -638,3 +643,142 @@ def test_stress_last_row_merge_bottom_border_is_end_line():
|
|
|
638
643
|
assert '╤' not in bottom and '╪' not in bottom, bottom
|
|
639
644
|
# Continuous run under H14 (C2–C5): three inner junctions → ═.
|
|
640
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
|
|
File without changes
|