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.
Files changed (43) hide show
  1. {flexablelib-2.0.4 → flexablelib-2.0.7}/CHANGELOG.md +13 -0
  2. {flexablelib-2.0.4/flexablelib.egg-info → flexablelib-2.0.7}/PKG-INFO +13 -1
  3. {flexablelib-2.0.4 → flexablelib-2.0.7}/README.md +12 -0
  4. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/merges.py +151 -78
  5. {flexablelib-2.0.4 → flexablelib-2.0.7/flexablelib.egg-info}/PKG-INFO +13 -1
  6. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib.egg-info/SOURCES.txt +1 -0
  7. {flexablelib-2.0.4 → flexablelib-2.0.7}/package.json +1 -1
  8. flexablelib-2.0.7/tests/test_merge_edge_matrix.py +252 -0
  9. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_merges.py +197 -3
  10. {flexablelib-2.0.4 → flexablelib-2.0.7}/LICENSE +0 -0
  11. {flexablelib-2.0.4 → flexablelib-2.0.7}/MANIFEST.in +0 -0
  12. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/__init__.py +0 -0
  13. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/_speedups.c +0 -0
  14. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/_width_tables.h +0 -0
  15. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/cells.py +0 -0
  16. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/colors.py +0 -0
  17. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/columns.py +0 -0
  18. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/fast.py +0 -0
  19. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/options.py +0 -0
  20. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/readers.py +0 -0
  21. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/style_compositions.py +0 -0
  22. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/table.py +0 -0
  23. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/table_strings.py +0 -0
  24. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/text_width.py +0 -0
  25. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/utils.py +0 -0
  26. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib/writers.py +0 -0
  27. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib.egg-info/dependency_links.txt +0 -0
  28. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib.egg-info/requires.txt +0 -0
  29. {flexablelib-2.0.4 → flexablelib-2.0.7}/flexablelib.egg-info/top_level.txt +0 -0
  30. {flexablelib-2.0.4 → flexablelib-2.0.7}/setup.cfg +0 -0
  31. {flexablelib-2.0.4 → flexablelib-2.0.7}/setup.py +0 -0
  32. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_cells.py +0 -0
  33. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_columns.py +0 -0
  34. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_excel.py +0 -0
  35. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_features.py +0 -0
  36. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_issues.py +0 -0
  37. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_readme_examples.py +0 -0
  38. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_styles.py +0 -0
  39. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_text_width.py +0 -0
  40. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_two_pass_measurement.py +0 -0
  41. {flexablelib-2.0.4 → flexablelib-2.0.7}/tests/test_upstream_issues.py +0 -0
  42. {flexablelib-2.0.4 → flexablelib-2.0.7}/tools/benchmark.py +0 -0
  43. {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.4
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
- # Replacement for apply() and helpers — pasted into merges.py below Layout helpers.
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) booleans.
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) -> str:
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
- if v_glyph in '|+' or h_glyph in '-=':
290
- # ASCII grid / pipes family
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
- # Unicode column style (pretty_columns, round_edges, …)
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(left=left, middle='─', intersection='┼', right=right)
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
- plain = line
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 any(ch in plain for ch in _H_CHARS)
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
- # A junction with no arms at all becomes the bare vertical.
399
- glyph = _pick_junction(v_glyph, h_glyph, north, south, east, west)
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
- 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
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
- # Existing rules (body / header / end) get ┴/┬ via horiz_caps.
504
- horiz_caps.append((above, below, set(inner_seps)))
568
+ horiz_caps.append(
569
+ (above, below, set(inner_seps), region.first_row, region.last_row)
570
+ )
505
571
 
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])
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
- below_is_rule = (
518
- below < len(lines) and any(_is_h(ch) for ch in lines[below])
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
- 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))
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 horizontal / 2D merges -----
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
- # Header / body / end rules all use horizontal glyphs.
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: 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()])
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, synthesized.middle,
591
- missing_south=miss_s or None,
592
- missing_north=miss_n or None,
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.4
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
@@ -31,6 +31,7 @@ tests/test_columns.py
31
31
  tests/test_excel.py
32
32
  tests/test_features.py
33
33
  tests/test_issues.py
34
+ tests/test_merge_edge_matrix.py
34
35
  tests/test_merges.py
35
36
  tests/test_readme_examples.py
36
37
  tests/test_styles.py
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexablelib",
3
- "version": "2.0.4",
3
+ "version": "2.0.7",
4
4
  "description": "Flexible console and HTML tables for Python: styles, merges, colors, widths, decimal alignment.",
5
5
  "main": "__init__.py",
6
6
  "scripts": {
@@ -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
- assert any('┴' in line for line in lines)
575
- assert any('┬' in line for line in lines)
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