tytable 0.6.0__py3-none-any.whl
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.
- tytable/__init__.py +24 -0
- tytable/_colors.py +212 -0
- tytable/_constants.py +28 -0
- tytable/_directives.py +114 -0
- tytable/_escape.py +58 -0
- tytable/_format.py +282 -0
- tytable/_groups.py +158 -0
- tytable/_images.py +270 -0
- tytable/_indices.py +226 -0
- tytable/_render_ascii.py +60 -0
- tytable/_render_html.py +227 -0
- tytable/_render_typst.py +425 -0
- tytable/_renderer.py +19 -0
- tytable/_resolve.py +375 -0
- tytable/_style_markup.py +155 -0
- tytable/_styling.py +308 -0
- tytable/_themes.py +143 -0
- tytable/_tytable.py +1074 -0
- tytable/_utils.py +21 -0
- tytable/_version.py +24 -0
- tytable/py.typed +0 -0
- tytable-0.6.0.dist-info/METADATA +115 -0
- tytable-0.6.0.dist-info/RECORD +25 -0
- tytable-0.6.0.dist-info/WHEEL +4 -0
- tytable-0.6.0.dist-info/licenses/LICENSE +595 -0
tytable/_format.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Value formatting: digits, significant figures, replace, escape, and fn transforms.
|
|
3
|
+
|
|
4
|
+
Applied during the render pipeline by :func:`tytable._resolve.build`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from ._escape import escape_typst
|
|
13
|
+
from ._indices import resolve_i, resolve_j
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ._directives import FormatDirective
|
|
17
|
+
from ._tytable import TinyTable
|
|
18
|
+
|
|
19
|
+
Cell = tuple[int, int]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_numeric_typed(val: object) -> bool:
|
|
23
|
+
"""True for ints/floats but not bools (which are technically int subclasses)."""
|
|
24
|
+
if isinstance(val, bool):
|
|
25
|
+
return False
|
|
26
|
+
return isinstance(val, (int, float))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fmt_numeric_decimal(val: Any, digits: int) -> str:
|
|
30
|
+
"""Format a number with a fixed number of decimal places."""
|
|
31
|
+
return f"{float(val):.{digits}f}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fmt_numeric_significant(val: Any, digits: int) -> str:
|
|
35
|
+
"""Format a number to a given number of significant figures."""
|
|
36
|
+
return f"{float(val):.{digits}g}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _matches(o: object, typed: object, s: str) -> bool:
|
|
40
|
+
"""Check whether a ``replace`` key matches a typed value or its string form (handles null/nan/inf)."""
|
|
41
|
+
if o is None:
|
|
42
|
+
return typed is None
|
|
43
|
+
if isinstance(o, float) and math.isnan(o):
|
|
44
|
+
return isinstance(typed, float) and math.isnan(typed)
|
|
45
|
+
if isinstance(o, float) and math.isinf(o):
|
|
46
|
+
return isinstance(typed, float) and math.isinf(typed) and (typed > 0) == (o > 0)
|
|
47
|
+
if isinstance(o, str):
|
|
48
|
+
lo = o.lower()
|
|
49
|
+
if lo == "null":
|
|
50
|
+
return typed is None
|
|
51
|
+
if lo == "nan":
|
|
52
|
+
return isinstance(typed, float) and math.isnan(typed)
|
|
53
|
+
if lo == "inf":
|
|
54
|
+
return isinstance(typed, float) and math.isinf(typed) and typed > 0
|
|
55
|
+
if lo == "-inf":
|
|
56
|
+
return isinstance(typed, float) and math.isinf(typed) and typed < 0
|
|
57
|
+
return typed == o or s == str(o)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _apply_replace(typed_val: object, current_str: str, replace: object) -> str:
|
|
61
|
+
"""Apply a ``replace`` spec (``True``, a fill string, or an ``{old: new}`` dict) to one cell."""
|
|
62
|
+
if replace is True:
|
|
63
|
+
if typed_val is None or (isinstance(typed_val, float) and math.isnan(typed_val)):
|
|
64
|
+
return " "
|
|
65
|
+
return current_str
|
|
66
|
+
if isinstance(replace, str):
|
|
67
|
+
if typed_val is None or (isinstance(typed_val, float) and math.isnan(typed_val)):
|
|
68
|
+
return replace
|
|
69
|
+
return current_str
|
|
70
|
+
if isinstance(replace, dict):
|
|
71
|
+
mapping: dict = {}
|
|
72
|
+
for k, v in replace.items():
|
|
73
|
+
if isinstance(k, list):
|
|
74
|
+
for item in k:
|
|
75
|
+
mapping[item] = v
|
|
76
|
+
else:
|
|
77
|
+
mapping[k] = v
|
|
78
|
+
for old, new in mapping.items():
|
|
79
|
+
if _matches(old, typed_val, current_str):
|
|
80
|
+
return str(new)
|
|
81
|
+
return current_str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _apply_escape(current_str: str, escape_spec: object, output: str) -> str:
|
|
85
|
+
"""Re-escape a cell string for the target backend when ``fmt(escape=...)`` is set."""
|
|
86
|
+
if escape_spec is True or escape_spec == "typst":
|
|
87
|
+
if output in ("html", "ascii"):
|
|
88
|
+
from ._escape import escape_html
|
|
89
|
+
|
|
90
|
+
return escape_html(current_str)
|
|
91
|
+
return escape_typst(current_str)
|
|
92
|
+
return current_str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_target_cells(
|
|
96
|
+
i_vals: list[int],
|
|
97
|
+
j_vals: list[int],
|
|
98
|
+
data_body: list[list[str]],
|
|
99
|
+
colnames_display: list[str],
|
|
100
|
+
) -> list[Cell]:
|
|
101
|
+
"""Translate resolved table coordinates into body/header cell coordinates."""
|
|
102
|
+
target_cells: list[Cell] = []
|
|
103
|
+
for i_idx in i_vals:
|
|
104
|
+
if i_idx == 0:
|
|
105
|
+
target_cells.extend(
|
|
106
|
+
(-1, j_idx - 1) for j_idx in j_vals if 0 < j_idx <= len(colnames_display)
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
row_idx = i_idx - 1
|
|
110
|
+
if row_idx < 0 or row_idx >= len(data_body):
|
|
111
|
+
continue
|
|
112
|
+
target_cells.extend(
|
|
113
|
+
(row_idx, j_idx - 1) for j_idx in j_vals if 0 < j_idx <= len(data_body[row_idx])
|
|
114
|
+
)
|
|
115
|
+
return target_cells
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _typed_value(cell: Cell, typed_body: list[list[Any]], colnames: list[str]) -> object | None:
|
|
119
|
+
"""Return the original typed value for a body or header cell."""
|
|
120
|
+
row_idx, col_idx = cell
|
|
121
|
+
if row_idx == -1:
|
|
122
|
+
return colnames[col_idx]
|
|
123
|
+
if row_idx < len(typed_body) and col_idx < len(typed_body[row_idx]):
|
|
124
|
+
return typed_body[row_idx][col_idx]
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cell_value(cell: Cell, data_body: list[list[str]], colnames_display: list[str]) -> str:
|
|
129
|
+
"""Read the current rendered value of a body or header cell."""
|
|
130
|
+
row_idx, col_idx = cell
|
|
131
|
+
return colnames_display[col_idx] if row_idx == -1 else data_body[row_idx][col_idx]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _set_cell_value(
|
|
135
|
+
cell: Cell,
|
|
136
|
+
value: object,
|
|
137
|
+
data_body: list[list[str]],
|
|
138
|
+
colnames_display: list[str],
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Set the rendered value of a body or header cell."""
|
|
141
|
+
row_idx, col_idx = cell
|
|
142
|
+
if row_idx == -1:
|
|
143
|
+
colnames_display[col_idx] = str(value)
|
|
144
|
+
else:
|
|
145
|
+
data_body[row_idx][col_idx] = str(value)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _apply_digits(
|
|
149
|
+
cells: list[Cell],
|
|
150
|
+
directive: FormatDirective,
|
|
151
|
+
data_body: list[list[str]],
|
|
152
|
+
typed_body: list[list[Any]],
|
|
153
|
+
colnames_display: list[str],
|
|
154
|
+
colnames: list[str],
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Apply numeric formatting to the selected cells."""
|
|
157
|
+
if directive.digits is None:
|
|
158
|
+
return
|
|
159
|
+
formatter = (
|
|
160
|
+
_fmt_numeric_significant if directive.num_fmt == "significant" else _fmt_numeric_decimal
|
|
161
|
+
)
|
|
162
|
+
for cell in cells:
|
|
163
|
+
typed_val = _typed_value(cell, typed_body, colnames)
|
|
164
|
+
if _is_numeric_typed(typed_val) and not isinstance(typed_val, int):
|
|
165
|
+
_set_cell_value(
|
|
166
|
+
cell, formatter(typed_val, directive.digits), data_body, colnames_display
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _apply_fn(
|
|
171
|
+
cells: list[Cell],
|
|
172
|
+
directive: FormatDirective,
|
|
173
|
+
data_body: list[list[str]],
|
|
174
|
+
colnames_display: list[str],
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Apply a column-wise transform to the selected cells."""
|
|
177
|
+
if directive.fn is None:
|
|
178
|
+
return
|
|
179
|
+
col_to_rows: dict[int, list[int]] = {}
|
|
180
|
+
for row_idx, col_idx in cells:
|
|
181
|
+
col_to_rows.setdefault(col_idx, []).append(row_idx)
|
|
182
|
+
for col_idx, rows in col_to_rows.items():
|
|
183
|
+
column_cells = [(row_idx, col_idx) for row_idx in sorted(rows)]
|
|
184
|
+
values = [_cell_value(cell, data_body, colnames_display) for cell in column_cells]
|
|
185
|
+
result = directive.fn(values)
|
|
186
|
+
if len(result) != len(values):
|
|
187
|
+
raise ValueError(f"fn() returned {len(result)} items, expected {len(values)}")
|
|
188
|
+
for cell, value in zip(column_cells, result, strict=True):
|
|
189
|
+
_set_cell_value(cell, value, data_body, colnames_display)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _apply_replacements(
|
|
193
|
+
cells: list[Cell],
|
|
194
|
+
directive: FormatDirective,
|
|
195
|
+
data_body: list[list[str]],
|
|
196
|
+
typed_body: list[list[Any]],
|
|
197
|
+
colnames_display: list[str],
|
|
198
|
+
colnames: list[str],
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Apply replacement rules to the selected cells."""
|
|
201
|
+
if directive.replace is None:
|
|
202
|
+
return
|
|
203
|
+
for cell in cells:
|
|
204
|
+
formatted = _apply_replace(
|
|
205
|
+
_typed_value(cell, typed_body, colnames),
|
|
206
|
+
_cell_value(cell, data_body, colnames_display),
|
|
207
|
+
directive.replace,
|
|
208
|
+
)
|
|
209
|
+
_set_cell_value(cell, formatted, data_body, colnames_display)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _apply_escapes(
|
|
213
|
+
cells: list[Cell],
|
|
214
|
+
directive: FormatDirective,
|
|
215
|
+
data_body: list[list[str]],
|
|
216
|
+
colnames_display: list[str],
|
|
217
|
+
output: str,
|
|
218
|
+
) -> set[Cell]:
|
|
219
|
+
"""Apply directive-level escaping and return the escaped cell coordinates."""
|
|
220
|
+
if not directive.escape:
|
|
221
|
+
return set()
|
|
222
|
+
for cell in cells:
|
|
223
|
+
formatted = _apply_escape(
|
|
224
|
+
_cell_value(cell, data_body, colnames_display), directive.escape, output
|
|
225
|
+
)
|
|
226
|
+
_set_cell_value(cell, formatted, data_body, colnames_display)
|
|
227
|
+
return set(cells)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def apply_formats(
|
|
231
|
+
data_body: list[list[str]],
|
|
232
|
+
typed_body: list[list[Any]],
|
|
233
|
+
colnames_display: list[str],
|
|
234
|
+
table: TinyTable,
|
|
235
|
+
*,
|
|
236
|
+
nhead: int,
|
|
237
|
+
has_header: bool,
|
|
238
|
+
n_merged_body: int,
|
|
239
|
+
group_positions: set[int],
|
|
240
|
+
output: str,
|
|
241
|
+
colnames: list[str],
|
|
242
|
+
) -> set[tuple[int, int]]:
|
|
243
|
+
"""Apply every ``FormatDirective``, returning cells that were explicitly escaped.
|
|
244
|
+
|
|
245
|
+
Body cells use their zero-based row index. Header cells use row ``-1`` so
|
|
246
|
+
callers can distinguish them when applying the table-wide escape pass.
|
|
247
|
+
"""
|
|
248
|
+
escaped_cells: set[tuple[int, int]] = set()
|
|
249
|
+
|
|
250
|
+
for d in table._format_directives:
|
|
251
|
+
if d.output is not None and output not in d.output:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
i_vals = resolve_i(
|
|
255
|
+
d.i,
|
|
256
|
+
nhead=nhead,
|
|
257
|
+
group_positions=group_positions,
|
|
258
|
+
n_merged_body=n_merged_body,
|
|
259
|
+
has_header=has_header,
|
|
260
|
+
data=table._data,
|
|
261
|
+
)
|
|
262
|
+
if i_vals is None:
|
|
263
|
+
i_vals = resolve_i(
|
|
264
|
+
"body",
|
|
265
|
+
nhead=nhead,
|
|
266
|
+
group_positions=group_positions,
|
|
267
|
+
n_merged_body=n_merged_body,
|
|
268
|
+
has_header=has_header,
|
|
269
|
+
)
|
|
270
|
+
if i_vals is None:
|
|
271
|
+
raise RuntimeError("i_vals unexpectedly None in apply_formats")
|
|
272
|
+
j_vals = resolve_j(d.j, colnames, regex=d.regex)
|
|
273
|
+
|
|
274
|
+
target_cells = _resolve_target_cells(i_vals, j_vals, data_body, colnames_display)
|
|
275
|
+
_apply_digits(target_cells, d, data_body, typed_body, colnames_display, table._colnames)
|
|
276
|
+
_apply_fn(target_cells, d, data_body, colnames_display)
|
|
277
|
+
_apply_replacements(
|
|
278
|
+
target_cells, d, data_body, typed_body, colnames_display, table._colnames
|
|
279
|
+
)
|
|
280
|
+
escaped_cells.update(_apply_escapes(target_cells, d, data_body, colnames_display, output))
|
|
281
|
+
|
|
282
|
+
return escaped_cells
|
tytable/_groups.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Row and column group registration and merging into the table body.
|
|
3
|
+
|
|
4
|
+
Called by :meth:`TinyTable.group` to record groups, and by
|
|
5
|
+
:func:`tytable._resolve.build` to merge row-group separator rows into the body.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from ._directives import RowGroup
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ._tytable import TinyTable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve_cols(col_spec: list[str | int], colnames: list[str]) -> list[int]:
|
|
19
|
+
"""Translate a list of column names/positions into 0-based integer indices."""
|
|
20
|
+
indices = []
|
|
21
|
+
for c in col_spec:
|
|
22
|
+
if isinstance(c, str):
|
|
23
|
+
try:
|
|
24
|
+
indices.append(colnames.index(c))
|
|
25
|
+
except ValueError:
|
|
26
|
+
raise ValueError(f"column {c!r} not found") from None
|
|
27
|
+
elif isinstance(c, int):
|
|
28
|
+
indices.append(c)
|
|
29
|
+
else:
|
|
30
|
+
raise TypeError(f"column spec must be str or int, got {type(c).__name__}")
|
|
31
|
+
return indices
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_col_group_row(
|
|
35
|
+
j_dict: dict[str, list[str | int]], colnames: list[str]
|
|
36
|
+
) -> list[str | None]:
|
|
37
|
+
"""Build one column-group header row (label at span start, ``""`` under the span, ``None`` elsewhere)."""
|
|
38
|
+
ncol = len(colnames)
|
|
39
|
+
row: list[str | None] = [None] * ncol
|
|
40
|
+
for label, cols in j_dict.items():
|
|
41
|
+
indices = _resolve_cols(cols, colnames)
|
|
42
|
+
if not indices:
|
|
43
|
+
continue
|
|
44
|
+
start = indices[0]
|
|
45
|
+
row[start] = label
|
|
46
|
+
for ci in indices[1:]:
|
|
47
|
+
if 0 <= ci < ncol:
|
|
48
|
+
row[ci] = ""
|
|
49
|
+
return row
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_col_group_rows_delim(delim: str, colnames: list[str]) -> list[list[str | None]]:
|
|
53
|
+
"""Split column names on ``delim`` and build one header row per hierarchical level."""
|
|
54
|
+
parts = [c.split(delim) for c in colnames]
|
|
55
|
+
nlevels = len(parts[0])
|
|
56
|
+
if any(len(p) != nlevels for p in parts):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"delimiter {delim!r} does not split all column names into the same number of parts"
|
|
59
|
+
)
|
|
60
|
+
rows: list[list[str | None]] = []
|
|
61
|
+
for level in range(nlevels):
|
|
62
|
+
ncol = len(colnames)
|
|
63
|
+
row: list[str | None] = [None] * ncol
|
|
64
|
+
i = 0
|
|
65
|
+
while i < ncol:
|
|
66
|
+
label = parts[i][level].strip()
|
|
67
|
+
start = i
|
|
68
|
+
i += 1
|
|
69
|
+
while i < ncol and parts[i][level].strip() == label:
|
|
70
|
+
i += 1
|
|
71
|
+
display = label if label else " "
|
|
72
|
+
row[start] = display
|
|
73
|
+
for ci in range(start + 1, i):
|
|
74
|
+
row[ci] = ""
|
|
75
|
+
rows.append(row)
|
|
76
|
+
return rows
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_col_group_spans(row: list[str | None]) -> list[tuple[str, int, int]]:
|
|
80
|
+
"""Return ``(label, start, span)`` entries for a column-group header row.
|
|
81
|
+
|
|
82
|
+
An empty string immediately after a label extends that label's span, while
|
|
83
|
+
``None`` and standalone empty strings each represent one blank cell.
|
|
84
|
+
"""
|
|
85
|
+
spans: list[tuple[str, int, int]] = []
|
|
86
|
+
i = 0
|
|
87
|
+
while i < len(row):
|
|
88
|
+
value = row[i]
|
|
89
|
+
label = "" if value is None else value.strip()
|
|
90
|
+
start = i
|
|
91
|
+
i += 1
|
|
92
|
+
if label:
|
|
93
|
+
while i < len(row) and row[i] is not None and (row[i] or "").strip() == "":
|
|
94
|
+
i += 1
|
|
95
|
+
spans.append((label, start, i - start))
|
|
96
|
+
return spans
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def register_row_groups(table: TinyTable, i: dict[str, int] | list[Any]) -> TinyTable:
|
|
100
|
+
"""Record row-group separators from a ``{label: row}`` dict or a run-length list."""
|
|
101
|
+
if isinstance(i, dict):
|
|
102
|
+
pairs = sorted(i.items(), key=lambda x: x[1])
|
|
103
|
+
for label, pos in pairs:
|
|
104
|
+
table._row_groups.append(RowGroup(label=str(label), position=int(pos)))
|
|
105
|
+
elif isinstance(i, list):
|
|
106
|
+
prev = None
|
|
107
|
+
pos = 0
|
|
108
|
+
for idx, val in enumerate(i):
|
|
109
|
+
if idx > 0 and val != prev:
|
|
110
|
+
table._row_groups.append(RowGroup(label=str(prev), position=pos))
|
|
111
|
+
pos = idx
|
|
112
|
+
prev = val
|
|
113
|
+
if pos < len(i) and prev is not None:
|
|
114
|
+
table._row_groups.append(RowGroup(label=str(prev), position=pos))
|
|
115
|
+
else:
|
|
116
|
+
raise TypeError("group(i=...) must be a dict or list")
|
|
117
|
+
return table
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def register_col_groups(
|
|
121
|
+
table: TinyTable, j: dict[str, list[str | int]] | str, colnames: list[str]
|
|
122
|
+
) -> TinyTable:
|
|
123
|
+
"""Record column-group header rows from a ``{label: [cols]}`` dict or a delimiter string."""
|
|
124
|
+
if isinstance(j, dict):
|
|
125
|
+
row = _build_col_group_row(j, colnames)
|
|
126
|
+
table._col_group_rows.insert(0, row)
|
|
127
|
+
elif isinstance(j, str):
|
|
128
|
+
rows = _build_col_group_rows_delim(j, colnames)
|
|
129
|
+
for row in reversed(rows):
|
|
130
|
+
table._col_group_rows.insert(0, row)
|
|
131
|
+
else:
|
|
132
|
+
raise TypeError("group(j=...) must be a dict or str")
|
|
133
|
+
return table
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def merge_row_groups(
|
|
137
|
+
data_body: list[list[str]], row_groups: list[RowGroup], ncols: int
|
|
138
|
+
) -> tuple[list[list[str]], dict[int, str]]:
|
|
139
|
+
"""Interleave row-group separator rows into the body; returns the merged body and ``{row: label}``."""
|
|
140
|
+
if not row_groups:
|
|
141
|
+
return data_body, {}
|
|
142
|
+
nrows = len(data_body)
|
|
143
|
+
ngroups = len(row_groups)
|
|
144
|
+
n_merged = nrows + ngroups
|
|
145
|
+
p = sorted(rg.position for rg in row_groups)
|
|
146
|
+
group_positions_1 = [p[k] + k + 1 for k in range(ngroups)]
|
|
147
|
+
group_positions_set = set(group_positions_1)
|
|
148
|
+
sorted_rg = sorted(row_groups, key=lambda rg: rg.position)
|
|
149
|
+
pos_to_label = dict(zip(group_positions_1, (rg.label for rg in sorted_rg), strict=True))
|
|
150
|
+
merged = []
|
|
151
|
+
data_row_idx = 0
|
|
152
|
+
for r in range(1, n_merged + 1):
|
|
153
|
+
if r in group_positions_set:
|
|
154
|
+
merged.append([pos_to_label[r]] + [""] * (ncols - 1))
|
|
155
|
+
else:
|
|
156
|
+
merged.append(data_body[data_row_idx])
|
|
157
|
+
data_row_idx += 1
|
|
158
|
+
return merged, pos_to_label
|