flexablelib 2.0.1__tar.gz → 2.0.4__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.1 → flexablelib-2.0.4}/CHANGELOG.md +20 -0
  2. {flexablelib-2.0.1/flexablelib.egg-info → flexablelib-2.0.4}/PKG-INFO +1 -1
  3. flexablelib-2.0.4/flexablelib/merges.py +596 -0
  4. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/readers.py +37 -6
  5. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/table.py +3 -1
  6. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/writers.py +22 -0
  7. {flexablelib-2.0.1 → flexablelib-2.0.4/flexablelib.egg-info}/PKG-INFO +1 -1
  8. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib.egg-info/SOURCES.txt +1 -0
  9. {flexablelib-2.0.1 → flexablelib-2.0.4}/package.json +1 -1
  10. flexablelib-2.0.4/tests/test_excel.py +110 -0
  11. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_merges.py +171 -3
  12. flexablelib-2.0.1/flexablelib/merges.py +0 -300
  13. {flexablelib-2.0.1 → flexablelib-2.0.4}/LICENSE +0 -0
  14. {flexablelib-2.0.1 → flexablelib-2.0.4}/MANIFEST.in +0 -0
  15. {flexablelib-2.0.1 → flexablelib-2.0.4}/README.md +0 -0
  16. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/__init__.py +0 -0
  17. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/_speedups.c +0 -0
  18. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/_width_tables.h +0 -0
  19. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/cells.py +0 -0
  20. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/colors.py +0 -0
  21. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/columns.py +0 -0
  22. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/fast.py +0 -0
  23. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/options.py +0 -0
  24. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/style_compositions.py +0 -0
  25. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/table_strings.py +0 -0
  26. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/text_width.py +0 -0
  27. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib/utils.py +0 -0
  28. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib.egg-info/dependency_links.txt +0 -0
  29. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib.egg-info/requires.txt +0 -0
  30. {flexablelib-2.0.1 → flexablelib-2.0.4}/flexablelib.egg-info/top_level.txt +0 -0
  31. {flexablelib-2.0.1 → flexablelib-2.0.4}/setup.cfg +0 -0
  32. {flexablelib-2.0.1 → flexablelib-2.0.4}/setup.py +0 -0
  33. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_cells.py +0 -0
  34. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_columns.py +0 -0
  35. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_features.py +0 -0
  36. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_issues.py +0 -0
  37. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_readme_examples.py +0 -0
  38. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_styles.py +0 -0
  39. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_text_width.py +0 -0
  40. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_two_pass_measurement.py +0 -0
  41. {flexablelib-2.0.1 → flexablelib-2.0.4}/tests/test_upstream_issues.py +0 -0
  42. {flexablelib-2.0.1 → flexablelib-2.0.4}/tools/benchmark.py +0 -0
  43. {flexablelib-2.0.1 → flexablelib-2.0.4}/tools/generate_width_tables.py +0 -0
@@ -2,6 +2,26 @@
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
+
13
+ ## [2.0.3](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.2...v2.0.3) (2026-10-28)
14
+
15
+ ### Features
16
+
17
+ * ``to_excel`` / ``from_excel`` preserve merged cell ranges (openpyxl)
18
+
19
+ ## [2.0.2](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.1...v2.0.2) (2026-10-28)
20
+
21
+ ### Bug Fixes
22
+
23
+ * redraw vertical-merge vertices as edges/tees instead of leftover corners
24
+
5
25
  ## [2.0.1](https://github.com/Opus-Perpetuus/flexablelib/compare/v2.0.0...v2.0.1) (2026-10-28)
6
26
 
7
27
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexablelib
3
- Version: 2.0.1
3
+ Version: 2.0.4
4
4
  Summary: Flexible console and HTML tables for Python
5
5
  Home-page: https://github.com/Opus-Perpetuus/flexablelib
6
6
  Download-URL:
@@ -0,0 +1,596 @@
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
+ """
373
+ Rebuild every junction on a horizontal rule from the arms that remain.
374
+
375
+ ``missing_south`` / ``missing_north`` are sets of visible x positions where
376
+ a vertical arm was removed by a horizontal (or 2D) merge on that side.
377
+ """
378
+ missing_south = missing_south or set()
379
+ missing_north = missing_north or set()
380
+ v = composition.vertical_table_body_lines
381
+ if v is None:
382
+ return line
383
+ h_glyph = h_glyph or '─'
384
+ rewritten = line
385
+ for x, v_glyph in _junction_positions(layout, composition):
386
+ if visible_width(rewritten) <= x:
387
+ continue
388
+ here = _char_at(rewritten, x)
389
+ # Interior of a multi-column vertical blank: junction was wiped to space.
390
+ if here == ' ' or here == '':
391
+ continue
392
+ left = _char_at(rewritten, x - 1)
393
+ right = _char_at(rewritten, x + 1)
394
+ west = _is_h(left)
395
+ 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)
400
+ rewritten = _repaint(rewritten, x, x + 1, glyph)
401
+ return rewritten
402
+
403
+
404
+ def apply(lines, row_line_map, regions, column_widths, composition,
405
+ index_offset, hidden_columns=(), filler=' '):
406
+ """
407
+ Rewrite assembled lines so each region renders as one cell.
408
+
409
+ ``lines`` is the rendered table split into lines. ``row_line_map`` maps
410
+ each logical row index to the indexes of the physical lines it occupies,
411
+ which differ once wrapping has turned one row into several. Separator
412
+ lines between rows are not in the map; they are found between the mapped
413
+ lines and painted over where a region spans them vertically.
414
+
415
+ ``index_offset`` is 1 when the index column is displayed, since region
416
+ coordinates ignore it. ``hidden_columns`` lists the columns the table is
417
+ not drawing, which take up no room in a line.
418
+
419
+ Returns the rewritten lines.
420
+ """
421
+ if not regions:
422
+ return lines
423
+
424
+ layout = compute_layout(column_widths, composition, hidden_columns)
425
+ lines = list(lines)
426
+ margin = 1 if composition.margin else 0
427
+ body_rule = composition.table_body_line
428
+ h_glyph = body_rule.middle if body_rule is not None else None
429
+ # Separator lines whose horizontal run was blanked by a vertical span.
430
+ touched_seps = set()
431
+ # Horizontal (or 2D) merges: verticals removed between their columns on
432
+ # 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
436
+
437
+ synthesized = _synthesize_body_rule(composition)
438
+ col_only = (
439
+ composition.table_body_line is None
440
+ and synthesized is not None
441
+ and composition.vertical_table_body_lines is not None
442
+ and composition.vertical_table_body_lines.middle not in (None, '', ' ')
443
+ )
444
+
445
+ for region in regions:
446
+ bounds = _region_bounds(region, layout, index_offset)
447
+ if bounds is None:
448
+ continue
449
+ (start, end), (cell_start, cell_end), first_span, last_span = bounds
450
+ span_width = end - start - margin * 2
451
+ if span_width < 1:
452
+ continue
453
+
454
+ target_lines = []
455
+ for row in range(region.first_row, region.last_row + 1):
456
+ target_lines.extend(row_line_map.get(row, []))
457
+ if not target_lines:
458
+ continue
459
+
460
+ text = region.value
461
+ if text is None:
462
+ first_line = lines[target_lines[0]]
463
+ text = strip_by_width(slice_by_width(first_line, cell_start, cell_end))
464
+ text = str(text)
465
+
466
+ if visible_width(text) > span_width:
467
+ text = truncate_to_width(text, span_width)
468
+ painted = (filler * margin
469
+ + pad_to_width(text, span_width, region.align, filler)
470
+ + filler * margin)
471
+ blank = filler * (end - start)
472
+
473
+ text_line = target_lines[len(target_lines) // 2]
474
+ for line_index in target_lines:
475
+ line = lines[line_index]
476
+ if visible_width(line) < end:
477
+ continue
478
+ replacement = painted if line_index == text_line else blank
479
+ lines[line_index] = _repaint(line, start, end, replacement)
480
+
481
+ # Blank separator lines inside a vertical (or 2D) span. Junctions are
482
+ # repaired in a second pass once every region has painted, so adjacent
483
+ # merges share one consistent reading of the surviving arms.
484
+ covered = set(target_lines)
485
+ for line_index in range(min(target_lines), max(target_lines)):
486
+ if line_index in covered:
487
+ continue
488
+ line = lines[line_index]
489
+ if visible_width(line) < end:
490
+ continue
491
+ lines[line_index] = _repaint(line, start, end, blank)
492
+ touched_seps.add(line_index)
493
+
494
+ inner_seps = [
495
+ layout.separators[i]
496
+ for i in range(first_span, last_span)
497
+ if i < len(layout.separators)
498
+ ]
499
+
500
+ if region.column_count >= 2 and inner_seps:
501
+ above = min(target_lines) - 1
502
+ below = max(target_lines) + 1
503
+ # Existing rules (body / header / end) get ┴/┬ via horiz_caps.
504
+ horiz_caps.append((above, below, set(inner_seps)))
505
+
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])
516
+ )
517
+ below_is_rule = (
518
+ below < len(lines) and any(_is_h(ch) for ch in lines[below])
519
+ )
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))
534
+
535
+ # --- Pass 2: repair junctions on body rules touched by vertical blanks ---
536
+ if h_glyph:
537
+ for sep_index in sorted(touched_seps):
538
+ line = lines[sep_index]
539
+ if not _looks_like_rule(line, h_glyph):
540
+ continue
541
+ lines[sep_index] = _repair_rule_junctions(
542
+ line, layout, composition, h_glyph
543
+ )
544
+
545
+ # --- Pass 3: ┴ / ┬ on rules above and below horizontal / 2D merges -----
546
+ # Combine missing arms when several merges share a cap line.
547
+ missing_south_by_line = {}
548
+ missing_north_by_line = {}
549
+ for above, below, xs in horiz_caps:
550
+ if above >= 0:
551
+ missing_south_by_line.setdefault(above, set()).update(xs)
552
+ if below < len(lines):
553
+ missing_north_by_line.setdefault(below, set()).update(xs)
554
+
555
+ cap_lines = set(missing_south_by_line) | set(missing_north_by_line)
556
+ for line_index in cap_lines:
557
+ line = lines[line_index]
558
+ # Header / body / end rules all use horizontal glyphs.
559
+ if not any(_is_h(ch) for ch in line):
560
+ continue
561
+ # Guess h glyph from the line itself.
562
+ line_h = next((ch for ch in line if _is_h(ch)), h_glyph or '─')
563
+ lines[line_index] = _repair_rule_junctions(
564
+ line, layout, composition, line_h,
565
+ missing_south=missing_south_by_line.get(line_index),
566
+ missing_north=missing_north_by_line.get(line_index),
567
+ )
568
+
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()])
586
+ rule = _build_rule_line(
587
+ layout, composition, column_widths, hidden_columns, synthesized
588
+ )
589
+ rule = _repair_rule_junctions(
590
+ rule, layout, composition, synthesized.middle,
591
+ missing_south=miss_s or None,
592
+ missing_north=miss_n or None,
593
+ )
594
+ lines.insert(max(0, min(at, len(lines))), rule)
595
+
596
+ return lines
@@ -344,7 +344,8 @@ def from_excel(table_class, path, sheet=None, has_header: bool = True,
344
344
  'Install it with: pip install flexablelib[excel]'
345
345
  )
346
346
 
347
- workbook = load_workbook(filename=path, read_only=True, data_only=True)
347
+ # read_only hides merged cell ranges; we need them to rebuild merges.
348
+ workbook = load_workbook(filename=path, read_only=False, data_only=True)
348
349
  try:
349
350
  if sheet is None:
350
351
  worksheet = workbook.active
@@ -357,14 +358,44 @@ def from_excel(table_class, path, sheet=None, has_header: bool = True,
357
358
  ['' if cell is None else cell for cell in row]
358
359
  for row in worksheet.iter_rows(values_only=True)
359
360
  ]
361
+ merge_ranges = [
362
+ (merged.min_row, merged.min_col, merged.max_row, merged.max_col)
363
+ for merged in worksheet.merged_cells.ranges
364
+ ]
360
365
  finally:
361
366
  workbook.close()
362
367
 
363
368
  if not rows:
364
369
  return table_class(**options)
365
370
  if has_header:
366
- return _new_table(table_class, rows[0], rows[1:], parse_numbers,
367
- **options)
368
- width = max(len(row) for row in rows)
369
- headers = [f'column {i + 1}' for i in range(width)]
370
- return _new_table(table_class, headers, rows, parse_numbers, **options)
371
+ table = _new_table(table_class, rows[0], rows[1:], parse_numbers,
372
+ **options)
373
+ header_rows = 1
374
+ else:
375
+ width = max(len(row) for row in rows)
376
+ headers = [f'column {i + 1}' for i in range(width)]
377
+ table = _new_table(table_class, headers, rows, parse_numbers, **options)
378
+ header_rows = 0
379
+
380
+ # Excel rows/cols are 1-based. Body rows start after the header row when
381
+ # has_header is set. Skip merges that only cover the header.
382
+ for min_row, min_col, max_row, max_col in merge_ranges:
383
+ # Header-only or header-spanning merges are not table body merges.
384
+ if min_row <= header_rows:
385
+ continue
386
+ first_row = min_row - header_rows - 1
387
+ last_row = max_row - header_rows - 1
388
+ first_column = min_col - 1
389
+ last_column = max_col - 1
390
+ if first_row == last_row and first_column == last_column:
391
+ continue
392
+ if last_row < 0 or last_column < 0:
393
+ continue
394
+ if last_row >= table.row_count or last_column >= table.column_count:
395
+ continue
396
+ try:
397
+ table.merge_cells(first_row, first_column, last_row, last_column)
398
+ except ValueError:
399
+ # Overlaps or single cells from odd sheets: skip rather than fail.
400
+ continue
401
+ return table