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.
Files changed (43) hide show
  1. {flexablelib-2.0.6 → flexablelib-2.0.7}/CHANGELOG.md +13 -0
  2. {flexablelib-2.0.6/flexablelib.egg-info → flexablelib-2.0.7}/PKG-INFO +1 -1
  3. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/merges.py +142 -76
  4. {flexablelib-2.0.6 → flexablelib-2.0.7/flexablelib.egg-info}/PKG-INFO +1 -1
  5. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib.egg-info/SOURCES.txt +1 -0
  6. {flexablelib-2.0.6 → flexablelib-2.0.7}/package.json +1 -1
  7. flexablelib-2.0.7/tests/test_merge_edge_matrix.py +252 -0
  8. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_merges.py +147 -3
  9. {flexablelib-2.0.6 → flexablelib-2.0.7}/LICENSE +0 -0
  10. {flexablelib-2.0.6 → flexablelib-2.0.7}/MANIFEST.in +0 -0
  11. {flexablelib-2.0.6 → flexablelib-2.0.7}/README.md +0 -0
  12. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/__init__.py +0 -0
  13. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/_speedups.c +0 -0
  14. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/_width_tables.h +0 -0
  15. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/cells.py +0 -0
  16. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/colors.py +0 -0
  17. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/columns.py +0 -0
  18. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/fast.py +0 -0
  19. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/options.py +0 -0
  20. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/readers.py +0 -0
  21. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/style_compositions.py +0 -0
  22. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/table.py +0 -0
  23. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/table_strings.py +0 -0
  24. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/text_width.py +0 -0
  25. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/utils.py +0 -0
  26. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib/writers.py +0 -0
  27. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib.egg-info/dependency_links.txt +0 -0
  28. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib.egg-info/requires.txt +0 -0
  29. {flexablelib-2.0.6 → flexablelib-2.0.7}/flexablelib.egg-info/top_level.txt +0 -0
  30. {flexablelib-2.0.6 → flexablelib-2.0.7}/setup.cfg +0 -0
  31. {flexablelib-2.0.6 → flexablelib-2.0.7}/setup.py +0 -0
  32. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_cells.py +0 -0
  33. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_columns.py +0 -0
  34. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_excel.py +0 -0
  35. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_features.py +0 -0
  36. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_issues.py +0 -0
  37. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_readme_examples.py +0 -0
  38. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_styles.py +0 -0
  39. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_text_width.py +0 -0
  40. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_two_pass_measurement.py +0 -0
  41. {flexablelib-2.0.6 → flexablelib-2.0.7}/tests/test_upstream_issues.py +0 -0
  42. {flexablelib-2.0.6 → flexablelib-2.0.7}/tools/benchmark.py +0 -0
  43. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.6
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:
@@ -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,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
- 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
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
- # A junction with no arms at all becomes the bare vertical.
403
- glyph = _pick_junction(v_glyph, h_glyph, north, south, east, west)
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
- horiz_caps = [] # (above_line, below_line, inner_separator_xs)
438
- # Column-only styles: insert a synthetic row rule around multi-column merges.
439
- 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 = {}
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
- # Existing rules (body / header / end) get ┴/┬ via horiz_caps.
508
- 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
+ )
509
571
 
510
- if col_only:
511
- rule_line = _build_rule_line(
512
- layout, composition, column_widths, hidden_columns, synthesized
513
- )
514
- if rule_line is not None:
515
- # Only insert a synthetic row rule where the style has no
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
- below_is_rule = (
522
- 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}
523
588
  )
524
- if not above_is_rule:
525
- capped = _repair_rule_junctions(
526
- rule_line, layout, composition,
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 horizontal / 2D merges -----
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
- # Header / body / end rules all use horizontal glyphs.
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: insert synthetic row rules for column-only styles ----------
577
- # Adjacent multi-column merges on neighbouring rows both want a rule in
578
- # the same gap; coalesce those into one line with every missing arm.
579
- if col_only_inserts and synthesized is not None:
580
- # Map insert index → (missing_south xs, missing_north xs)
581
- gap_arms = {}
582
- for above, below, xs in horiz_caps:
583
- # insert above merge lands at above+1 (== min target_lines)
584
- if above + 1 >= 0:
585
- arms = gap_arms.setdefault(above + 1, [set(), set()])
586
- arms[0] |= set(xs) # missing south into the merge below
587
- arms = gap_arms.setdefault(below, [set(), set()])
588
- arms[1] |= set(xs) # missing north into the merge above
589
- # Only keep gaps we actually planned to insert (skip header/end).
590
- planned = {at for at, _ in col_only_inserts}
591
- for at in sorted(planned, reverse=True):
592
- 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
+
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, synthesized.middle,
598
- missing_south=miss_s or None,
599
- 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,
600
666
  )
601
667
  lines.insert(max(0, min(at, len(lines))), rule)
602
668
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.6
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:
@@ -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.6",
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
@@ -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