flexablelib 2.0.3__tar.gz → 2.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {flexablelib-2.0.3 → flexablelib-2.0.6}/CHANGELOG.md +8 -0
  2. {flexablelib-2.0.3/flexablelib.egg-info → flexablelib-2.0.6}/PKG-INFO +13 -1
  3. {flexablelib-2.0.3 → flexablelib-2.0.6}/README.md +12 -0
  4. flexablelib-2.0.6/flexablelib/merges.py +603 -0
  5. {flexablelib-2.0.3 → flexablelib-2.0.6/flexablelib.egg-info}/PKG-INFO +13 -1
  6. {flexablelib-2.0.3 → flexablelib-2.0.6}/package.json +1 -1
  7. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_merges.py +164 -5
  8. flexablelib-2.0.3/flexablelib/merges.py +0 -317
  9. {flexablelib-2.0.3 → flexablelib-2.0.6}/LICENSE +0 -0
  10. {flexablelib-2.0.3 → flexablelib-2.0.6}/MANIFEST.in +0 -0
  11. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/__init__.py +0 -0
  12. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/_speedups.c +0 -0
  13. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/_width_tables.h +0 -0
  14. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/cells.py +0 -0
  15. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/colors.py +0 -0
  16. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/columns.py +0 -0
  17. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/fast.py +0 -0
  18. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/options.py +0 -0
  19. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/readers.py +0 -0
  20. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/style_compositions.py +0 -0
  21. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/table.py +0 -0
  22. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/table_strings.py +0 -0
  23. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/text_width.py +0 -0
  24. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/utils.py +0 -0
  25. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib/writers.py +0 -0
  26. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib.egg-info/SOURCES.txt +0 -0
  27. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib.egg-info/dependency_links.txt +0 -0
  28. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib.egg-info/requires.txt +0 -0
  29. {flexablelib-2.0.3 → flexablelib-2.0.6}/flexablelib.egg-info/top_level.txt +0 -0
  30. {flexablelib-2.0.3 → flexablelib-2.0.6}/setup.cfg +0 -0
  31. {flexablelib-2.0.3 → flexablelib-2.0.6}/setup.py +0 -0
  32. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_cells.py +0 -0
  33. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_columns.py +0 -0
  34. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_excel.py +0 -0
  35. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_features.py +0 -0
  36. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_issues.py +0 -0
  37. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_readme_examples.py +0 -0
  38. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_styles.py +0 -0
  39. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_text_width.py +0 -0
  40. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_two_pass_measurement.py +0 -0
  41. {flexablelib-2.0.3 → flexablelib-2.0.6}/tests/test_upstream_issues.py +0 -0
  42. {flexablelib-2.0.3 → flexablelib-2.0.6}/tools/benchmark.py +0 -0
  43. {flexablelib-2.0.3 → flexablelib-2.0.6}/tools/generate_width_tables.py +0 -0
@@ -2,6 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.0.4](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.3...v2.0.4) (2026-10-28)
6
+
7
+ ### Bug Fixes
8
+
9
+ * repair merge junctions on both axes (vertical + horizontal) from surviving arms
10
+ * use middle-vertical tees (├/┤) instead of border glyphs (╟/╢) inside the grid
11
+ * insert synthetic row rules for column-only styles around multi-column merges
12
+
5
13
  ## [2.0.3](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.2...v2.0.3) (2026-10-28)
6
14
 
7
15
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.3
3
+ Version: 2.0.6
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
@@ -0,0 +1,603 @@
1
+ """
2
+ MERGED CELLS
3
+
4
+ A merge makes one rectangular block of cells render as a single cell, spanning
5
+ columns, rows, or both.
6
+
7
+ The merge is applied to the finished table string rather than threaded through
8
+ the measuring pipeline. That pipeline sizes each column independently, which is
9
+ what makes decimal-point alignment and terminal fitting work; making a column's
10
+ width depend on a cell that belongs to several columns at once would put a
11
+ cycle in it. Rewriting the assembled lines keeps the measurement honest -- the
12
+ columns are still sized by their own unmerged content -- and the merge becomes
13
+ a presentation step that cannot affect layout correctness.
14
+
15
+ The consequence is that a merged region never widens the table. Content longer
16
+ than the span it was given is truncated, not accommodated.
17
+ """
18
+
19
+ from typing import List, NamedTuple, Optional
20
+
21
+ from .fast import (
22
+ has_ansi,
23
+ pad_to_width,
24
+ partition_by_width,
25
+ slice_by_width,
26
+ strip_by_width,
27
+ truncate_to_width,
28
+ visible_width,
29
+ )
30
+
31
+ # Painting over a stretch of line can remove the escape that closed a colour --
32
+ # the reset ending a coloured border, say, when the merge starts just after it.
33
+ # The replacement then inherits a colour nobody asked for. Opening the painted
34
+ # span with a reset of its own costs nothing on a plain table, because it is
35
+ # only added where escapes were actually removed.
36
+ _RESET = '\x1b[0m'
37
+
38
+
39
+ def _repaint(line, start, end, replacement):
40
+ """Replace the visible columns [start, end) of a line, ending any colour."""
41
+ before, removed, after = partition_by_width(line, start, end)
42
+ if has_ansi(removed) and not replacement.startswith(_RESET):
43
+ replacement = _RESET + replacement
44
+ return before + replacement + after
45
+
46
+
47
+ class MergedRegion(NamedTuple):
48
+ """
49
+ One rectangular block of cells rendered as a single cell.
50
+
51
+ Coordinates are inclusive and zero-based, in the table's own row and
52
+ column numbering -- the index column, when shown, is not counted.
53
+ """
54
+
55
+ first_row: int
56
+ first_column: int
57
+ last_row: int
58
+ last_column: int
59
+ value: Optional[str]
60
+ align: str
61
+
62
+ @property
63
+ def row_count(self) -> int:
64
+ return self.last_row - self.first_row + 1
65
+
66
+ @property
67
+ def column_count(self) -> int:
68
+ return self.last_column - self.first_column + 1
69
+
70
+ def covers(self, row: int, column: int) -> bool:
71
+ return (self.first_row <= row <= self.last_row
72
+ and self.first_column <= column <= self.last_column)
73
+
74
+ def overlaps(self, other: 'MergedRegion') -> bool:
75
+ return not (
76
+ self.last_row < other.first_row
77
+ or other.last_row < self.first_row
78
+ or self.last_column < other.first_column
79
+ or other.last_column < self.first_column
80
+ )
81
+
82
+
83
+ def normalise(first_row, first_column, last_row, last_column, value, align,
84
+ row_count, column_count, existing):
85
+ """
86
+ Validate a merge request and turn it into a MergedRegion.
87
+
88
+ Accepts the corners in either order, so callers may give them the way they
89
+ think of them. Raises ValueError on anything that cannot be rendered: a
90
+ single cell, coordinates outside the table, an unknown alignment, or an
91
+ overlap with a merge already in place.
92
+ """
93
+ first_row, last_row = sorted((first_row, last_row))
94
+ first_column, last_column = sorted((first_column, last_column))
95
+
96
+ if first_row < 0 or first_column < 0:
97
+ raise ValueError('merge coordinates cannot be negative')
98
+ if last_row >= row_count:
99
+ raise ValueError(
100
+ f'last_row {last_row} is outside the table, which has '
101
+ f'{row_count} row(s)'
102
+ )
103
+ if last_column >= column_count:
104
+ raise ValueError(
105
+ f'last_column {last_column} is outside the table, which has '
106
+ f'{column_count} column(s)'
107
+ )
108
+ if first_row == last_row and first_column == last_column:
109
+ raise ValueError(
110
+ 'a merge must cover more than one cell; '
111
+ 'give a different last_row or last_column'
112
+ )
113
+ if align not in ('l', 'r', 'c'):
114
+ raise ValueError(f"align must be 'l', 'r' or 'c', not {align!r}")
115
+
116
+ region = MergedRegion(first_row, first_column, last_row, last_column,
117
+ value, align)
118
+
119
+ for other in existing:
120
+ if region.overlaps(other):
121
+ raise ValueError(
122
+ f'this merge overlaps the one covering rows '
123
+ f'{other.first_row}-{other.last_row}, columns '
124
+ f'{other.first_column}-{other.last_column}'
125
+ )
126
+
127
+ return region
128
+
129
+
130
+ class Layout(NamedTuple):
131
+ """
132
+ Where each column sits inside a rendered line.
133
+
134
+ ``spans`` holds one (start, end) pair per *drawn* column, as visible-column
135
+ bounds within the line, covering the cell text and its margins but not the
136
+ separators around it. ``separators`` holds the offset of each vertical rule
137
+ between columns, so a horizontal merge knows which characters to paint
138
+ over. ``span_of_column`` maps a table column index to its entry in
139
+ ``spans``, and has no entry for a column the table is not drawing.
140
+ """
141
+
142
+ spans: List[tuple]
143
+ separators: List[int]
144
+ span_of_column: dict
145
+
146
+
147
+ def compute_layout(column_widths, composition, hidden=()) -> Layout:
148
+ """
149
+ Work out the column offsets for a style and set of widths.
150
+
151
+ Mirrors how table_strings assembles a row: an optional left rule, then for
152
+ each column its margins and content, with a rule between neighbours and an
153
+ optional one at the end.
154
+
155
+ ``hidden`` lists the columns the table is leaving out -- empty ones, when
156
+ ``show_empty_columns`` is off. They take up no room in the rendered line,
157
+ so counting them here would put every offset after them too far right.
158
+ """
159
+ body = composition.vertical_table_body_lines
160
+ margin = 1 if composition.margin else 0
161
+ hidden = set(hidden)
162
+
163
+ offset = len(body.left) if body.left is not None else 0
164
+ spans = []
165
+ separators = []
166
+ span_of_column = {}
167
+
168
+ for index, width in enumerate(column_widths):
169
+ if index in hidden:
170
+ continue
171
+ if spans:
172
+ if body.middle is not None:
173
+ separators.append(offset)
174
+ offset += len(body.middle)
175
+ cell_width = width + margin * 2
176
+ span_of_column[index] = len(spans)
177
+ spans.append((offset, offset + cell_width))
178
+ offset += cell_width
179
+
180
+ return Layout(spans, separators, span_of_column)
181
+
182
+
183
+ def _region_bounds(region, layout, index_offset):
184
+ """
185
+ Where a region sits in a line, as visible-column offsets.
186
+
187
+ Returns the span covering the whole block, the span of its top-left cell,
188
+ and the index of its rightmost drawn column. A merge given no replacement
189
+ text reads its content from that top-left cell -- not from the strip of
190
+ table under the whole block, which is the neighbouring columns and the
191
+ rules between them.
192
+
193
+ Columns the table is not drawing are skipped over, so a merge that reaches
194
+ across a hidden empty column still lands on the columns either side of it.
195
+ Returns None when the region is drawn nowhere at all.
196
+ """
197
+ drawn = [
198
+ layout.span_of_column[column]
199
+ for column in range(region.first_column + index_offset,
200
+ region.last_column + index_offset + 1)
201
+ if column in layout.span_of_column
202
+ ]
203
+ if not drawn:
204
+ return None
205
+
206
+ first, last = drawn[0], drawn[-1]
207
+ return (
208
+ (layout.spans[first][0], layout.spans[last][1]),
209
+ layout.spans[first],
210
+ first,
211
+ last,
212
+ )
213
+
214
+
215
+ # Replacement for apply() and helpers — pasted into merges.py below Layout helpers.
216
+
217
+ # Horizontal dash characters used by the shipped styles (and ASCII grids).
218
+ _H_CHARS = set('─━═┄┅┈┉╌╍▬−' '-~=')
219
+
220
+ # Light / heavy / double / ascii junction tables keyed by (N, S, E, W) booleans.
221
+ _LIGHT = {
222
+ (1, 1, 1, 1): '┼', (1, 1, 1, 0): '├', (1, 1, 0, 1): '┤',
223
+ (1, 0, 1, 1): '┴', (0, 1, 1, 1): '┬', (1, 1, 0, 0): '│',
224
+ (0, 0, 1, 1): '─', (1, 0, 1, 0): '└', (1, 0, 0, 1): '┘',
225
+ (0, 1, 1, 0): '┌', (0, 1, 0, 1): '┐', (1, 0, 0, 0): '│',
226
+ (0, 1, 0, 0): '│', (0, 0, 1, 0): '─', (0, 0, 0, 1): '─',
227
+ }
228
+ _DOUBLE_V_SINGLE_H = {
229
+ (1, 1, 1, 1): '╫', (1, 1, 1, 0): '╟', (1, 1, 0, 1): '╢',
230
+ (1, 0, 1, 1): '╨', (0, 1, 1, 1): '╥', (1, 1, 0, 0): '║',
231
+ (0, 0, 1, 1): '─', (1, 0, 0, 0): '║', (0, 1, 0, 0): '║',
232
+ (1, 0, 1, 0): '╙', (1, 0, 0, 1): '╜', (0, 1, 1, 0): '╓',
233
+ (0, 1, 0, 1): '╖',
234
+ }
235
+ _DOUBLE = {
236
+ (1, 1, 1, 1): '╬', (1, 1, 1, 0): '╠', (1, 1, 0, 1): '╣',
237
+ (1, 0, 1, 1): '╩', (0, 1, 1, 1): '╦', (1, 1, 0, 0): '║',
238
+ (0, 0, 1, 1): '═', (1, 0, 0, 0): '║', (0, 1, 0, 0): '║',
239
+ (1, 0, 1, 0): '╚', (1, 0, 0, 1): '╝', (0, 1, 1, 0): '╔',
240
+ (0, 1, 0, 1): '╗',
241
+ }
242
+ _SINGLE_V_DOUBLE_H = {
243
+ (1, 1, 1, 1): '╪', (1, 1, 1, 0): '╞', (1, 1, 0, 1): '╡',
244
+ (1, 0, 1, 1): '╧', (0, 1, 1, 1): '╤', (1, 1, 0, 0): '│',
245
+ (0, 0, 1, 1): '═', (1, 0, 0, 0): '│', (0, 1, 0, 0): '│',
246
+ (1, 0, 1, 0): '╘', (1, 0, 0, 1): '╛', (0, 1, 1, 0): '╒',
247
+ (0, 1, 0, 1): '╕',
248
+ }
249
+ _ASCII = {
250
+ (1, 1, 1, 1): '+', (1, 1, 1, 0): '+', (1, 1, 0, 1): '+',
251
+ (1, 0, 1, 1): '+', (0, 1, 1, 1): '+', (1, 1, 0, 0): '|',
252
+ (0, 0, 1, 1): '-', (1, 0, 0, 0): '|', (0, 1, 0, 0): '|',
253
+ (1, 0, 1, 0): '+', (1, 0, 0, 1): '+', (0, 1, 1, 0): '+',
254
+ (0, 1, 0, 1): '+', (0, 0, 1, 0): '-', (0, 0, 0, 1): '-',
255
+ }
256
+
257
+
258
+ def _is_h(ch: str) -> bool:
259
+ return ch in _H_CHARS
260
+
261
+
262
+ def _char_at(line: str, index: int) -> str:
263
+ if index < 0 or index >= visible_width(line):
264
+ return ''
265
+ return slice_by_width(line, index, index + 1)
266
+
267
+
268
+
269
+ def _junction_positions(layout, composition):
270
+ """(x, vertical_glyph) for every vertical rule that can host a junction."""
271
+ v = composition.vertical_table_body_lines
272
+ if v is None:
273
+ return []
274
+ out = []
275
+ if layout.spans and v.left:
276
+ out.append((layout.spans[0][0] - len(v.left), v.left))
277
+ for sep_x in layout.separators:
278
+ if v.middle:
279
+ out.append((sep_x, v.middle))
280
+ if layout.spans and v.right:
281
+ out.append((layout.spans[-1][1], v.right))
282
+ return out
283
+
284
+
285
+ def _pick_junction(v_glyph: str, h_glyph: str, n: bool, s: bool, e: bool, w: bool) -> str:
286
+ key = (1 if n else 0, 1 if s else 0, 1 if e else 0, 1 if w else 0)
287
+ if key == (0, 0, 0, 0):
288
+ return ' '
289
+ if v_glyph in '|+' or h_glyph in '-=':
290
+ # ASCII grid / pipes family
291
+ if v_glyph in '|+' and h_glyph in '-=+':
292
+ return _ASCII.get(key, '+')
293
+ if v_glyph == '║' and h_glyph == '═':
294
+ return _DOUBLE.get(key, '║')
295
+ if v_glyph == '║':
296
+ return _DOUBLE_V_SINGLE_H.get(key, '║')
297
+ if h_glyph == '═' and v_glyph in ('│', '┃'):
298
+ return _SINGLE_V_DOUBLE_H.get(key, '│')
299
+ return _LIGHT.get(key, v_glyph or '│')
300
+
301
+
302
+ def _synthesize_body_rule(composition):
303
+ """Body SeparatorLine for styles that draw column lines but no row lines."""
304
+ from .style_compositions import SeparatorLine
305
+ existing = composition.table_body_line
306
+ if existing is not None and existing.middle:
307
+ return existing
308
+ v = composition.vertical_table_body_lines
309
+ if v is None or not v.middle or v.middle == ' ':
310
+ return None
311
+ if v.middle in '|+':
312
+ return SeparatorLine(
313
+ left='+' if v.left else None,
314
+ middle='-',
315
+ intersection='+',
316
+ right='+' if v.right else None,
317
+ )
318
+ # Unicode column style (pretty_columns, round_edges, …)
319
+ left = '├' if v.left else None
320
+ right = '┤' if v.right else None
321
+ if v.left == '║':
322
+ left = '╟'
323
+ if v.right == '║':
324
+ right = '╢'
325
+ return SeparatorLine(left=left, middle='─', intersection='┼', right=right)
326
+
327
+
328
+ def _build_rule_line(layout, composition, column_widths, hidden, rule=None):
329
+ """Assemble one full horizontal rule matching the column layout."""
330
+ rule = rule or _synthesize_body_rule(composition)
331
+ if rule is None:
332
+ return None
333
+ v = composition.vertical_table_body_lines
334
+ margin = 1 if composition.margin else 0
335
+ hidden = set(hidden or ())
336
+ chunks = []
337
+ first = True
338
+ for index, width in enumerate(column_widths):
339
+ if index in hidden:
340
+ continue
341
+ if not first:
342
+ chunks.append(rule.intersection or '')
343
+ cell = (rule.middle or ' ') * (width + margin * 2)
344
+ chunks.append(cell)
345
+ first = False
346
+ center = ''.join(chunks)
347
+ if rule.left is not None:
348
+ left = rule.left
349
+ elif v and v.left:
350
+ left = v.left
351
+ else:
352
+ left = ''
353
+ if rule.right is not None:
354
+ right = rule.right
355
+ elif v and v.right:
356
+ right = v.right
357
+ else:
358
+ right = ''
359
+ return left + center + right
360
+
361
+
362
+ def _looks_like_rule(line: str, h_glyph: str) -> bool:
363
+ if not line:
364
+ return False
365
+ plain = line
366
+ if h_glyph and h_glyph in plain:
367
+ return True
368
+ return any(ch in plain for ch in _H_CHARS)
369
+
370
+
371
+ def _repair_rule_junctions(line, layout, composition, h_glyph, missing_south=None, missing_north=None,
372
+ no_south=False, no_north=False):
373
+ """
374
+ Rebuild every junction on a horizontal rule from the arms that remain.
375
+
376
+ ``missing_south`` / ``missing_north`` are sets of visible x positions where
377
+ a vertical arm was removed by a horizontal (or 2D) merge on that side.
378
+
379
+ ``no_south`` / ``no_north`` force every junction to drop that arm — used for
380
+ the table's outer end/start rules, which never continue past the border.
381
+ """
382
+ missing_south = missing_south or set()
383
+ missing_north = missing_north or set()
384
+ v = composition.vertical_table_body_lines
385
+ if v is None:
386
+ return line
387
+ h_glyph = h_glyph or '─'
388
+ rewritten = line
389
+ for x, v_glyph in _junction_positions(layout, composition):
390
+ if visible_width(rewritten) <= x:
391
+ continue
392
+ here = _char_at(rewritten, x)
393
+ # Interior of a multi-column vertical blank: junction was wiped to space.
394
+ if here == ' ' or here == '':
395
+ continue
396
+ left = _char_at(rewritten, x - 1)
397
+ right = _char_at(rewritten, x + 1)
398
+ west = _is_h(left)
399
+ east = _is_h(right)
400
+ north = (not no_north) and (x not in missing_north)
401
+ 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)
404
+ rewritten = _repaint(rewritten, x, x + 1, glyph)
405
+ return rewritten
406
+
407
+
408
+ def apply(lines, row_line_map, regions, column_widths, composition,
409
+ index_offset, hidden_columns=(), filler=' '):
410
+ """
411
+ Rewrite assembled lines so each region renders as one cell.
412
+
413
+ ``lines`` is the rendered table split into lines. ``row_line_map`` maps
414
+ each logical row index to the indexes of the physical lines it occupies,
415
+ which differ once wrapping has turned one row into several. Separator
416
+ lines between rows are not in the map; they are found between the mapped
417
+ lines and painted over where a region spans them vertically.
418
+
419
+ ``index_offset`` is 1 when the index column is displayed, since region
420
+ coordinates ignore it. ``hidden_columns`` lists the columns the table is
421
+ not drawing, which take up no room in a line.
422
+
423
+ Returns the rewritten lines.
424
+ """
425
+ if not regions:
426
+ return lines
427
+
428
+ layout = compute_layout(column_widths, composition, hidden_columns)
429
+ lines = list(lines)
430
+ margin = 1 if composition.margin else 0
431
+ body_rule = composition.table_body_line
432
+ h_glyph = body_rule.middle if body_rule is not None else None
433
+ # Separator lines whose horizontal run was blanked by a vertical span.
434
+ touched_seps = set()
435
+ # Horizontal (or 2D) merges: verticals removed between their columns on
436
+ # 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
440
+
441
+ synthesized = _synthesize_body_rule(composition)
442
+ col_only = (
443
+ composition.table_body_line is None
444
+ and synthesized is not None
445
+ and composition.vertical_table_body_lines is not None
446
+ and composition.vertical_table_body_lines.middle not in (None, '', ' ')
447
+ )
448
+
449
+ for region in regions:
450
+ bounds = _region_bounds(region, layout, index_offset)
451
+ if bounds is None:
452
+ continue
453
+ (start, end), (cell_start, cell_end), first_span, last_span = bounds
454
+ span_width = end - start - margin * 2
455
+ if span_width < 1:
456
+ continue
457
+
458
+ target_lines = []
459
+ for row in range(region.first_row, region.last_row + 1):
460
+ target_lines.extend(row_line_map.get(row, []))
461
+ if not target_lines:
462
+ continue
463
+
464
+ text = region.value
465
+ if text is None:
466
+ first_line = lines[target_lines[0]]
467
+ text = strip_by_width(slice_by_width(first_line, cell_start, cell_end))
468
+ text = str(text)
469
+
470
+ if visible_width(text) > span_width:
471
+ text = truncate_to_width(text, span_width)
472
+ painted = (filler * margin
473
+ + pad_to_width(text, span_width, region.align, filler)
474
+ + filler * margin)
475
+ blank = filler * (end - start)
476
+
477
+ text_line = target_lines[len(target_lines) // 2]
478
+ for line_index in target_lines:
479
+ line = lines[line_index]
480
+ if visible_width(line) < end:
481
+ continue
482
+ replacement = painted if line_index == text_line else blank
483
+ lines[line_index] = _repaint(line, start, end, replacement)
484
+
485
+ # Blank separator lines inside a vertical (or 2D) span. Junctions are
486
+ # repaired in a second pass once every region has painted, so adjacent
487
+ # merges share one consistent reading of the surviving arms.
488
+ covered = set(target_lines)
489
+ for line_index in range(min(target_lines), max(target_lines)):
490
+ if line_index in covered:
491
+ continue
492
+ line = lines[line_index]
493
+ if visible_width(line) < end:
494
+ continue
495
+ lines[line_index] = _repaint(line, start, end, blank)
496
+ touched_seps.add(line_index)
497
+
498
+ inner_seps = [
499
+ layout.separators[i]
500
+ for i in range(first_span, last_span)
501
+ if i < len(layout.separators)
502
+ ]
503
+
504
+ if region.column_count >= 2 and inner_seps:
505
+ above = min(target_lines) - 1
506
+ below = max(target_lines) + 1
507
+ # Existing rules (body / header / end) get ┴/┬ via horiz_caps.
508
+ horiz_caps.append((above, below, set(inner_seps)))
509
+
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])
520
+ )
521
+ below_is_rule = (
522
+ below < len(lines) and any(_is_h(ch) for ch in lines[below])
523
+ )
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))
538
+
539
+ # --- Pass 2: repair junctions on body rules touched by vertical blanks ---
540
+ if h_glyph:
541
+ for sep_index in sorted(touched_seps):
542
+ line = lines[sep_index]
543
+ if not _looks_like_rule(line, h_glyph):
544
+ continue
545
+ lines[sep_index] = _repair_rule_junctions(
546
+ line, layout, composition, h_glyph
547
+ )
548
+
549
+ # --- Pass 3: ┴ / ┬ on rules above and below horizontal / 2D merges -----
550
+ # Combine missing arms when several merges share a cap line.
551
+ missing_south_by_line = {}
552
+ missing_north_by_line = {}
553
+ for above, below, xs in horiz_caps:
554
+ if above >= 0:
555
+ missing_south_by_line.setdefault(above, set()).update(xs)
556
+ if below < len(lines):
557
+ missing_north_by_line.setdefault(below, set()).update(xs)
558
+
559
+ cap_lines = set(missing_south_by_line) | set(missing_north_by_line)
560
+ for line_index in cap_lines:
561
+ line = lines[line_index]
562
+ # Header / body / end rules all use horizontal glyphs.
563
+ if not any(_is_h(ch) for ch in line):
564
+ continue
565
+ # Guess h glyph from the line itself.
566
+ 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
+ lines[line_index] = _repair_rule_junctions(
569
+ line, layout, composition, line_h,
570
+ missing_south=missing_south_by_line.get(line_index),
571
+ missing_north=missing_north_by_line.get(line_index),
572
+ no_south=(line_index >= len(lines) - 1),
573
+ no_north=(line_index == 0),
574
+ )
575
+
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()])
593
+ rule = _build_rule_line(
594
+ layout, composition, column_widths, hidden_columns, synthesized
595
+ )
596
+ rule = _repair_rule_junctions(
597
+ rule, layout, composition, synthesized.middle,
598
+ missing_south=miss_s or None,
599
+ missing_north=miss_n or None,
600
+ )
601
+ lines.insert(max(0, min(at, len(lines))), rule)
602
+
603
+ return lines
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.3
3
+ Version: 2.0.6
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