sheetpeek 0.1.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.
sheetpeek/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """Render Excel workbooks as HTML, Markdown and terminal text, showing what Excel shows.
2
+
3
+ ``read`` gives a :class:`Sheet`: the cached values, number formats, styles, column widths,
4
+ row heights and merged ranges of one worksheet. See ``ARCHITECTURE.md``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sheetpeek._model import (
10
+ Cell,
11
+ CellKind,
12
+ CellRange,
13
+ CellStyle,
14
+ CellValue,
15
+ Column,
16
+ Font,
17
+ HorizontalAlignment,
18
+ Row,
19
+ Sheet,
20
+ SheetState,
21
+ VerticalAlignment,
22
+ )
23
+ from sheetpeek._read import Book, open, read # noqa: A004 - sheetpeek.open, like gzip.open
24
+ from sheetpeek._rendered import (
25
+ Alignment,
26
+ Gap,
27
+ Mode,
28
+ RenderedCell,
29
+ RenderedColumn,
30
+ RenderedRow,
31
+ RenderedSheet,
32
+ SpillEdge,
33
+ TextRun,
34
+ )
35
+ from sheetpeek.backends import Backend, register_backend, render
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "Alignment",
41
+ "Backend",
42
+ "Book",
43
+ "Cell",
44
+ "CellKind",
45
+ "CellRange",
46
+ "CellStyle",
47
+ "CellValue",
48
+ "Column",
49
+ "Font",
50
+ "Gap",
51
+ "HorizontalAlignment",
52
+ "Mode",
53
+ "RenderedCell",
54
+ "RenderedColumn",
55
+ "RenderedRow",
56
+ "RenderedSheet",
57
+ "Row",
58
+ "Sheet",
59
+ "SheetState",
60
+ "SpillEdge",
61
+ "TextRun",
62
+ "VerticalAlignment",
63
+ "__version__",
64
+ "open",
65
+ "read",
66
+ "register_backend",
67
+ "render",
68
+ ]
sheetpeek/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m sheetpeek``: the command line."""
2
+
3
+ import sys
4
+
5
+ from sheetpeek.cli import main
6
+
7
+ sys.exit(main())
sheetpeek/_carlito.py ADDED
@@ -0,0 +1,47 @@
1
+ """Carlito's advance widths, for spacing Calibri text in HTML as Excel for Mac does.
2
+
3
+ Generated by ``tools/carlito_widths.py`` from Carlito 1.104 (Copyright 2013 The Carlito
4
+ Project Authors, https://github.com/googlefonts/carlito), which is licensed under the SIL Open
5
+ Font License 1.1. Carlito is metric-compatible with Calibri. Only the advance widths are kept,
6
+ in font units, for printable ASCII; no font data is included.
7
+ """
8
+
9
+ UNITS_PER_EM = 2048
10
+ FIRST = 32 # the advance of chr(FIRST + k) is ADVANCES[style][k]
11
+
12
+ # fmt: off
13
+ ADVANCES: dict[str, tuple[int, ...]] = {
14
+ "regular": (
15
+ 463, 667, 821, 1020, 1038, 1464, 1397, 452, 621, 621, 1020, 1020, 511, 627, 517, 791, 1038,
16
+ 1038, 1038, 1038, 1038, 1038, 1038, 1038, 1038, 1038, 548, 548, 1020, 1020, 1020, 949, 1831,
17
+ 1185, 1114, 1092, 1260, 1000, 941, 1292, 1276, 516, 653, 1064, 861, 1751, 1322, 1356, 1058,
18
+ 1378, 1112, 941, 998, 1314, 1162, 1822, 1063, 998, 959, 628, 791, 628, 1020, 1020, 596, 981,
19
+ 1076, 866, 1076, 1019, 625, 964, 1076, 470, 490, 931, 470, 1636, 1076, 1080, 1076, 1076,
20
+ 714, 801, 686, 1076, 925, 1464, 887, 927, 809, 644, 943, 644, 1020,
21
+ ),
22
+ "bold": (
23
+ 463, 667, 898, 1020, 1038, 1493, 1443, 478, 638, 638, 1020, 1020, 528, 627, 547, 880, 1038,
24
+ 1038, 1038, 1038, 1038, 1038, 1038, 1038, 1038, 1038, 565, 565, 1020, 1020, 1020, 949, 1840,
25
+ 1241, 1148, 1084, 1291, 999, 940, 1305, 1292, 546, 678, 1120, 866, 1790, 1349, 1385, 1090,
26
+ 1405, 1153, 968, 1014, 1337, 1211, 1856, 1128, 1064, 979, 665, 880, 665, 1020, 1020, 615,
27
+ 1011, 1099, 857, 1099, 1031, 648, 971, 1099, 503, 523, 983, 503, 1666, 1099, 1101, 1099,
28
+ 1099, 728, 817, 710, 1099, 969, 1526, 941, 970, 814, 704, 973, 704, 1020,
29
+ ),
30
+ "italic": (
31
+ 463, 667, 821, 1020, 1038, 1464, 1397, 452, 621, 621, 1020, 1020, 511, 627, 517, 794, 1038,
32
+ 1038, 1038, 1038, 1038, 1038, 1038, 1038, 1038, 1038, 548, 548, 1020, 1020, 1020, 949, 1831,
33
+ 1185, 1114, 1070, 1260, 1000, 941, 1292, 1276, 516, 653, 1064, 861, 1751, 1320, 1340, 1058,
34
+ 1360, 1112, 926, 998, 1314, 1162, 1823, 1063, 998, 959, 628, 787, 628, 1020, 1020, 596,
35
+ 1053, 1053, 852, 1053, 978, 625, 1053, 1053, 470, 490, 931, 470, 1620, 1053, 1051, 1053,
36
+ 1053, 702, 797, 686, 1053, 913, 1464, 887, 916, 809, 644, 943, 644, 1020,
37
+ ),
38
+ "bold italic": (
39
+ 463, 667, 898, 1020, 1038, 1493, 1443, 478, 638, 638, 1020, 1020, 528, 627, 547, 889, 1038,
40
+ 1038, 1038, 1038, 1038, 1038, 1038, 1038, 1038, 1038, 565, 565, 1020, 1020, 1020, 949, 1840,
41
+ 1241, 1148, 1062, 1291, 999, 940, 1305, 1292, 546, 678, 1120, 866, 1790, 1344, 1369, 1090,
42
+ 1387, 1153, 953, 1014, 1337, 1211, 1857, 1128, 1064, 979, 665, 870, 665, 1020, 1020, 615,
43
+ 1081, 1081, 843, 1081, 1006, 648, 1081, 1080, 503, 523, 983, 503, 1646, 1080, 1080, 1081,
44
+ 1081, 721, 807, 710, 1080, 961, 1526, 941, 963, 814, 704, 973, 704, 1020,
45
+ ),
46
+ }
47
+ # fmt: on
sheetpeek/_colors.py ADDED
@@ -0,0 +1,155 @@
1
+ """Resolve stored colors (RGB, indexed, theme with tint, automatic) to ``RRGGBB``.
2
+
3
+ Theme colors follow ECMA-376: the theme's ``clrScheme`` gives the base color and ``tint``
4
+ lightens or darkens it in HLS space (§18.8.19). Captured (suite ``theme_colors``, 96 cells):
5
+ Excel's index order swaps the first two pairs, and the tint is applied in Windows' integer HLS
6
+ on a scale of 240 (``ColorRGBToHLS``), the lightness truncated, which reproduces every cell.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import xml.etree.ElementTree as ET
12
+ from collections.abc import Sequence
13
+
14
+ _A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
15
+ # clrScheme children in document order.
16
+ _SCHEME = (
17
+ "dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5",
18
+ "accent6", "hlink", "folHlink",
19
+ ) # fmt: skip
20
+ # Excel's theme index → clrScheme entry: the light and dark pairs are swapped.
21
+ _THEME_ORDER = (1, 0, 3, 2, 4, 5, 6, 7, 8, 9, 10, 11)
22
+ _AUTOMATIC_INDEX = (64, 65) # system foreground and background
23
+ _HLS = 240 # Windows' HLSMAX
24
+ _RGB = 255
25
+
26
+
27
+ def theme_colors(theme_xml: bytes | str | None) -> tuple[str, ...]:
28
+ """The twelve theme colors as ``RRGGBB``, in Excel's theme index order.
29
+
30
+ A workbook without a theme part, or with an unreadable one, has no theme colors.
31
+ """
32
+ if not theme_xml:
33
+ return ()
34
+ try:
35
+ root = ET.fromstring(theme_xml)
36
+ except ET.ParseError:
37
+ return ()
38
+ scheme = root.find(f".//{_A}clrScheme")
39
+ if scheme is None:
40
+ return ()
41
+ found: dict[str, str] = {}
42
+ for name in _SCHEME:
43
+ entry = scheme.find(f"{_A}{name}")
44
+ if entry is None:
45
+ return ()
46
+ srgb = entry.find(f"{_A}srgbClr")
47
+ system = entry.find(f"{_A}sysClr")
48
+ if srgb is not None:
49
+ found[name] = srgb.get("val", "000000").upper()
50
+ elif system is not None:
51
+ found[name] = system.get("lastClr", "000000").upper()
52
+ else:
53
+ return ()
54
+ return tuple(found[_SCHEME[i]] for i in _THEME_ORDER)
55
+
56
+
57
+ def resolve(
58
+ kind: str | None,
59
+ value: str | int | None,
60
+ tint: float,
61
+ theme: Sequence[str],
62
+ palette: Sequence[str],
63
+ ) -> str | None:
64
+ """A stored color as ``RRGGBB``, or ``None`` for automatic or unresolvable colors.
65
+
66
+ Args:
67
+ kind: How the file states the color: ``rgb``, ``indexed``, ``theme``, or anything else
68
+ (``auto``, missing) for automatic.
69
+ value: ``AARRGGBB`` or ``RRGGBB`` text for ``rgb``; the index for ``indexed`` and
70
+ ``theme``.
71
+ tint: Lightening (positive) or darkening (negative), from -1 to 1.
72
+ theme: Theme colors in Excel's index order (:func:`theme_colors`).
73
+ palette: The indexed palette as ``AARRGGBB`` strings (custom or default).
74
+ """
75
+ if kind == "rgb":
76
+ if not isinstance(value, str) or len(value) not in (6, 8):
77
+ return None
78
+ base = value[-6:].upper()
79
+ elif kind == "indexed":
80
+ if not isinstance(value, int) or value in _AUTOMATIC_INDEX or not 0 <= value < len(palette):
81
+ return None
82
+ base = palette[value][-6:].upper()
83
+ elif kind == "theme":
84
+ if not isinstance(value, int) or not 0 <= value < len(theme):
85
+ return None
86
+ base = theme[value]
87
+ else:
88
+ return None
89
+ return apply_tint(base, tint) if tint else base
90
+
91
+
92
+ def apply_tint(rgb: str, tint: float) -> str:
93
+ """Lighten (``tint > 0``) or darken (``tint < 0``) an ``RRGGBB`` color as Excel does.
94
+
95
+ ECMA-376's tint on the lightness, in Windows' integer HLS (scale 240): darkening multiplies
96
+ the lightness by ``1 + tint``, lightening moves it ``tint`` of the way to white, and the
97
+ result is truncated (captured, suite ``theme_colors``).
98
+ """
99
+ if not tint:
100
+ return rgb
101
+ hue, lightness, saturation = _to_hls(*(int(rgb[i : i + 2], 16) for i in (0, 2, 4)))
102
+ tinted = lightness * (1 + tint) if tint < 0 else lightness * (1 - tint) + _HLS * tint
103
+ red, green, blue = _to_rgb(hue, max(0, min(_HLS, int(tinted))), saturation)
104
+ return f"{red:02X}{green:02X}{blue:02X}"
105
+
106
+
107
+ def _to_hls(red: int, green: int, blue: int) -> tuple[int, int, int]:
108
+ """Windows' ``ColorRGBToHLS``: hue, lightness and saturation from 0 to 240."""
109
+ high, low = max(red, green, blue), min(red, green, blue)
110
+ lightness = ((high + low) * _HLS + _RGB) // (2 * _RGB)
111
+ if high == low:
112
+ return 0, lightness, 0
113
+ spread, total = high - low, high + low
114
+ if lightness <= _HLS // 2:
115
+ saturation = (spread * _HLS + total // 2) // total
116
+ else:
117
+ saturation = (spread * _HLS + (2 * _RGB - total) // 2) // (2 * _RGB - total)
118
+ sixth = _HLS // 6
119
+ r, g, b = (((high - c) * sixth + spread // 2) // spread for c in (red, green, blue))
120
+ if red == high:
121
+ hue = b - g
122
+ elif green == high:
123
+ hue = _HLS // 3 + r - b
124
+ else:
125
+ hue = 2 * _HLS // 3 + g - r
126
+ return hue % _HLS, lightness, saturation
127
+
128
+
129
+ def _to_rgb(hue: int, lightness: int, saturation: int) -> tuple[int, int, int]:
130
+ """Windows' ``ColorHLSToRGB``."""
131
+ if saturation == 0:
132
+ grey = (lightness * _RGB + _HLS // 2) // _HLS
133
+ return grey, grey, grey
134
+ if lightness <= _HLS // 2:
135
+ high = (lightness * (_HLS + saturation) + _HLS // 2) // _HLS
136
+ else:
137
+ high = lightness + saturation - (lightness * saturation + _HLS // 2) // _HLS
138
+ low = 2 * lightness - high
139
+ red, green, blue = (
140
+ (_hue(low, high, hue + shift) * _RGB + _HLS // 2) // _HLS
141
+ for shift in (_HLS // 3, 0, -_HLS // 3)
142
+ )
143
+ return red, green, blue
144
+
145
+
146
+ def _hue(low: int, high: int, hue: int) -> int:
147
+ hue %= _HLS
148
+ sixth = _HLS // 6
149
+ if hue < sixth:
150
+ return low + ((high - low) * hue + _HLS // 12) // sixth
151
+ if hue < _HLS // 2:
152
+ return high
153
+ if hue < 2 * _HLS // 3:
154
+ return low + ((high - low) * (2 * _HLS // 3 - hue) + _HLS // 12) // sixth
155
+ return low
sheetpeek/_excel.py ADDED
@@ -0,0 +1,408 @@
1
+ """``excel`` mode: a sheet laid out in points, as Excel for Mac lays it out.
2
+
3
+ Columns take xlformat's widths in points and rows the heights of ``ARCHITECTURE.md`` §4.7. Each
4
+ cell is formatted at its column's width in dialog units (a merge at its merged width), then
5
+ placed by its alignment and spilled into free neighbors by the rules of §4.1, in points. The
6
+ result is a list of :class:`~sheetpeek.TextRun` per row, with each run's clip window, and the
7
+ column boundaries text crosses. Two spills may take the same free cell; both are kept, as Excel
8
+ draws both.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from typing import Any
15
+
16
+ import xlformat
17
+
18
+ from sheetpeek._layout import (
19
+ _column_hidden,
20
+ _edge_columns,
21
+ _Fonts,
22
+ _Formatter,
23
+ _Grid,
24
+ _Origin,
25
+ _Shown,
26
+ )
27
+ from sheetpeek._model import Column, Row, Sheet
28
+ from sheetpeek._rendered import RenderedCell, RenderedColumn, RenderedRow, RenderedSheet, TextRun
29
+ from sheetpeek._rows import (
30
+ WRAPPED_LEADING,
31
+ baseline_offsets,
32
+ fits_cells,
33
+ font_row_height,
34
+ row_height,
35
+ wrapped_height,
36
+ )
37
+
38
+ _COLUMN_PADDING = 5.0 # points of a column not counted in its dialog units (xlformat)
39
+
40
+
41
+ def dialog_units(points: float, digit_width: float) -> float:
42
+ """The Column Width dialog number of a column ``points`` wide, for a merged width.
43
+
44
+ Provisional: inverts xlformat's ``column_points`` (``round(MDW * w + 5)``, below one unit
45
+ ``round(w * (MDW + 5))``) until xlformat accepts widths in points.
46
+ """
47
+ if points < digit_width + _COLUMN_PADDING:
48
+ return points / (digit_width + _COLUMN_PADDING)
49
+ return (points - _COLUMN_PADDING) / digit_width
50
+
51
+
52
+ def lay_out(
53
+ sheet: Sheet, columns: list[Column], rows: list[Row], warnings: list[str], fonts: _Fonts
54
+ ) -> RenderedSheet:
55
+ """Lay out the visible ``columns`` and ``rows`` of a sheet in points."""
56
+ normal = fonts.get(sheet.normal_font)
57
+
58
+ def metrics(column: Column) -> xlformat.ColumnMetrics:
59
+ return xlformat.file_column_metrics(
60
+ column.width,
61
+ base_col_width=sheet.base_column_width,
62
+ default_col_width=sheet.default_column_width,
63
+ font=normal, # the Normal font: xlformat names it font= here, unlike format_cell
64
+ )
65
+
66
+ base = metrics(Column(1))
67
+ if sheet.ref is None or not columns or not rows:
68
+ rendered = tuple(
69
+ RenderedColumn(c.index, None, _column_hidden(c), False, 0.0, _points(c, metrics))
70
+ for c in columns
71
+ )
72
+ return RenderedSheet(sheet, "excel", _placed(rendered), (), 0, tuple(warnings))
73
+ # Added columns are at the default width; a text spills at most twice a digit per character.
74
+ before, after = _edge_columns(sheet, max(1, int(base.points // (2 * base.digit_width))))
75
+ every = before + columns + after
76
+ widths = [_points(c, metrics) for c in every]
77
+ added = set(range(len(before))) | set(range(len(before) + len(columns), len(every)))
78
+ grid = _Grid(
79
+ sheet, rows, every, [0] * len(every), added, 0, False, _Formatter(sheet, warnings, fonts)
80
+ )
81
+
82
+ blocks: dict[tuple[int, int], float | None] = {}
83
+
84
+ def units(cols: list[int]) -> float | None:
85
+ key = (cols[0], cols[-1])
86
+ if key not in blocks:
87
+ total = sum(widths[p] for p in cols)
88
+ blocks[key] = dialog_units(total, base.digit_width) if total > 0 else None
89
+ return blocks[key]
90
+
91
+ grid.units = units
92
+ heights = [_fitted(grid, i, row, sheet, widths, base, fonts) for i, row in enumerate(rows)]
93
+ layout = _Points(grid, widths, heights, base, fonts)
94
+ rendered_rows, kept = layout.render()
95
+ x0 = layout.xs[kept[0]]
96
+ rendered_columns = tuple(
97
+ RenderedColumn(
98
+ every[p].index,
99
+ None,
100
+ _column_hidden(every[p]),
101
+ p in added,
102
+ layout.xs[p] - x0,
103
+ widths[p],
104
+ )
105
+ for p in kept
106
+ )
107
+ return RenderedSheet(sheet, "excel", rendered_columns, rendered_rows, 0, tuple(warnings))
108
+
109
+
110
+ def _fitted(
111
+ grid: _Grid,
112
+ i: int,
113
+ row: Row,
114
+ sheet: Sheet,
115
+ widths: list[float],
116
+ base: xlformat.ColumnMetrics,
117
+ fonts: _Fonts,
118
+ ) -> float:
119
+ """A row's height, fitted to its cells as Excel fits it on opening (§4.7)."""
120
+ height = row_height(row, sheet)
121
+ if not fits_cells(row, sheet):
122
+ return height
123
+ for p in range(len(grid.columns)):
124
+ if p in grid.added:
125
+ continue
126
+ cell = grid.cell(i, p)
127
+ height = max(height, font_row_height(cell.font))
128
+ # A merged cell's font counts, its wrapped lines do not (captured).
129
+ merged = (row.index, grid.columns[p].index) in grid.merge_of
130
+ if cell.kind == "text" and cell.style.wrap and widths[p] and not merged:
131
+ budget = widths[p] - base.left_inset - base.right_inset
132
+ lines = _wrap(str(cell.value), budget, fonts.get(cell.font))
133
+ height = max(height, wrapped_height(len(lines), cell.font))
134
+ return height
135
+
136
+
137
+ def _points(column: Column, metrics: Any) -> float:
138
+ return 0.0 if _column_hidden(column) else float(metrics(column).points)
139
+
140
+
141
+ def _placed(columns: tuple[RenderedColumn, ...]) -> tuple[RenderedColumn, ...]:
142
+ """Columns with their x positions as running sums of their widths."""
143
+ x, result = 0.0, []
144
+ for c in columns:
145
+ result.append(RenderedColumn(c.index, None, c.hidden, c.added, x, c.width_pt))
146
+ x += c.width_pt or 0.0
147
+ return tuple(result)
148
+
149
+
150
+ class _Points:
151
+ """Places every origin's text in points: runs, clips, spill and crossed boundaries."""
152
+
153
+ def __init__(
154
+ self,
155
+ grid: _Grid,
156
+ widths: list[float],
157
+ heights: list[float],
158
+ base: xlformat.ColumnMetrics,
159
+ fonts: _Fonts,
160
+ ) -> None:
161
+ self.grid = grid
162
+ self.widths = widths
163
+ self.heights = heights
164
+ self.left_inset = base.left_inset
165
+ self.right_inset = base.right_inset
166
+ self.digit = base.digit_width
167
+ self.fonts = fonts
168
+ self.xs = _running(widths)
169
+ self.ys = _running(heights)
170
+ self.no_claims: set[tuple[int, int]] = set() # both competing spills take a cell
171
+
172
+ def render(self) -> tuple[tuple[RenderedRow, ...], list[int]]:
173
+ grid = self.grid
174
+ origins, shown = grid.origins()
175
+ runs: dict[int, list[TextRun]] = {}
176
+ crossed: set[tuple[int, int]] = set() # (row position, p): boundary between p and p + 1
177
+ spans: list[tuple[int, int]] = []
178
+ lines_of: dict[int, int] = {}
179
+ spill_of: dict[tuple[int, int], tuple[int, int]] = {}
180
+ for origin in origins:
181
+ placed, low, high, count = self.place(origin)
182
+ if not placed:
183
+ continue
184
+ runs.setdefault(origin.rows[0], []).extend(placed)
185
+ spans.append((low, high))
186
+ for i in origin.rows:
187
+ for p in range(low, high):
188
+ crossed.add((i, p))
189
+ spill_of[(origin.cell.row, origin.cell.column)] = (
190
+ origin.cols[0] - low,
191
+ high - origin.cols[-1],
192
+ )
193
+ if len(origin.rows) == 1:
194
+ i = origin.rows[0]
195
+ lines_of[i] = max(lines_of.get(i, 1), count)
196
+ real = [p for p in range(len(grid.columns)) if p not in grid.added]
197
+ reached = [p for low, high in spans for p in (low, high) if p in grid.added]
198
+ kept = list(range(min([*reached, real[0]]), max([*reached, real[-1]]) + 1))
199
+ x0 = self.xs[kept[0]]
200
+ result = []
201
+ for i, row in enumerate(grid.rows):
202
+ cells = []
203
+ for p in kept:
204
+ cell = grid.cell(i, p)
205
+ position = (row.index, grid.columns[p].index)
206
+ merge = grid.merge_of.get(position)
207
+ show = shown.get(position, _Shown())
208
+ cells.append(
209
+ RenderedCell(
210
+ cell=cell,
211
+ text=show.text,
212
+ full_text=show.full_text,
213
+ overflow=show.overflow,
214
+ align=show.align,
215
+ format_color=show.format_color,
216
+ merge=merge,
217
+ spill=spill_of.get(position, (0, 0)),
218
+ lines=(),
219
+ source=None,
220
+ fill=grid.anchor_fill(merge) if merge else cell.fill,
221
+ font=None,
222
+ color=None,
223
+ )
224
+ )
225
+ row_runs = tuple(_shifted(run, x0) for run in runs.get(i, ()))
226
+ result.append(
227
+ RenderedRow(
228
+ row.index,
229
+ row,
230
+ lines_of.get(i, 1),
231
+ tuple(cells),
232
+ (),
233
+ self.ys[i],
234
+ self.heights[i],
235
+ row_runs,
236
+ tuple((i, p) in crossed for p in kept[:-1]),
237
+ )
238
+ )
239
+ return tuple(result), kept
240
+
241
+ def place(self, origin: _Origin) -> tuple[list[TextRun], int, int, int]:
242
+ """The runs of one origin, the columns its text spans, and its line count."""
243
+ cell, shown = origin.cell, origin.shown
244
+ first, last = origin.cols[0], origin.cols[-1]
245
+ if not shown.text:
246
+ return [], first, last, 0
247
+ x0, x1 = self.xs[first], self.xs[last] + self.widths[last]
248
+ top = self.ys[origin.rows[0]]
249
+ bottom = self.ys[origin.rows[-1]] + self.heights[origin.rows[-1]]
250
+ font = self.fonts.get(cell.font)
251
+ align = shown.align
252
+ budget = (x1 - x0) - self.left_inset - self.right_inset
253
+ wraps = cell.kind == "text" and (cell.style.wrap or align in ("justify", "distributed"))
254
+ text = shown.text
255
+ # a line break in a cell without wrapping is not drawn
256
+ lines = _wrap(text, budget, font) if wraps else [text.replace("\r", "").replace("\n", "")]
257
+ line, above, below = baseline_offsets(cell.font)
258
+ n = len(lines)
259
+ if n > 1:
260
+ line += WRAPPED_LEADING # provisional: the pitch of a fitted wrapped row
261
+ v_align = cell.style.v_align
262
+ if n * line > bottom - top:
263
+ # Lines that do not fit start at the top: a custom-height row shows its first line
264
+ # (captured, layout rows 161 to 168; unverified for centered text).
265
+ first_baseline = top + above
266
+ elif v_align == "center":
267
+ first_baseline = top + math.floor((bottom - top - n * line) / 2) + above
268
+ elif v_align == "bottom":
269
+ first_baseline = bottom - below - (n - 1) * line
270
+ else:
271
+ first_baseline = top + above
272
+ indent = cell.style.indent * self.digit
273
+ color = shown.format_color or cell.font.color
274
+ source = (cell.row, cell.column)
275
+ low, high = first, last
276
+ placed: list[tuple[str, float, str, float]] = [] # (text, x, anchor, baseline)
277
+ for k, line_text in enumerate(lines):
278
+ baseline = first_baseline + k * line
279
+ width = xlformat.text_width(line_text, font=font)
280
+ words = line_text.split()
281
+ if align == "fill":
282
+ copies = max(1, math.floor(budget / width)) if width else 1
283
+ pieces = [(line_text * copies, x0 + self.left_inset, "left")]
284
+ elif align == "distributed" or (align == "justify" and k < n - 1 and len(words) > 1):
285
+ pieces = self.spread(words, x0, x1, font, align == "distributed")
286
+ elif align == "right" and not shown.overflow:
287
+ pieces = [(line_text, x1 - self.right_inset - indent - width, "right")]
288
+ elif align == "center" or shown.overflow:
289
+ # A # run is centered whatever the alignment (captured, layout rows 130 to 136).
290
+ x = x0 + 1 + math.ceil((x1 - x0 - 1 - width) / 2)
291
+ pieces = [(line_text, x, "center")]
292
+ else:
293
+ pieces = [(line_text, x0 + self.left_inset + indent, "left")]
294
+ if origin.spills and n == 1 and align in ("left", "right", "center"):
295
+ x = pieces[0][1]
296
+ low, high = self.spill(origin, x, x + width, align)
297
+ placed.extend((piece, x, anchor, baseline) for piece, x, anchor in pieces)
298
+ clip = (self.xs[low] + 1, top + 1, self.xs[high] + self.widths[high], bottom)
299
+ runs = [
300
+ TextRun(
301
+ piece,
302
+ x,
303
+ baseline,
304
+ xlformat.text_width(piece, font=font),
305
+ clip,
306
+ anchor, # type: ignore[arg-type]
307
+ source,
308
+ cell.font,
309
+ color,
310
+ )
311
+ for piece, x, anchor, baseline in placed
312
+ if piece
313
+ ]
314
+ return runs, low, high, n
315
+
316
+ def spread(
317
+ self, words: list[str], x0: float, x1: float, font: xlformat.Font, distributed: bool
318
+ ) -> list[tuple[str, float, str]]:
319
+ """Words spread across the cell between its insets.
320
+
321
+ A single word is centered there when ``distributed`` and left-aligned otherwise.
322
+ """
323
+ lo, hi = x0 + self.left_inset, x1 - self.right_inset
324
+ widths = [xlformat.text_width(w, font=font) for w in words]
325
+ if len(words) == 1:
326
+ if distributed:
327
+ return [(words[0], lo + math.floor((hi - lo - widths[0]) / 2), "center")]
328
+ return [(words[0], lo, "left")]
329
+ gap = (hi - lo - sum(widths)) / (len(words) - 1)
330
+ result, x = [], lo
331
+ for word, width in zip(words, widths, strict=True):
332
+ result.append((word, x, "left"))
333
+ x += width + gap
334
+ return result
335
+
336
+ def spill(self, origin: _Origin, x: float, end: float, align: str) -> tuple[int, int]:
337
+ """The visible column positions a single line from ``x`` to ``end`` spans (§4.1).
338
+
339
+ Left text takes a column to the right once its end passes that column's gridline. Right
340
+ text takes one to the left once its start reaches the far edge of its own leftmost
341
+ gridline: the one-point asymmetry Excel shows (captured). Centered text takes a column
342
+ on both sides at once while it overflows either edge; once one side is blocked, the
343
+ other grows only as far as its own text needs (captured). A cell holding a value, or
344
+ merged, blocks.
345
+ """
346
+ i = origin.rows[0]
347
+ first, last = origin.cols[0], origin.cols[-1]
348
+ left = right = 0
349
+ left_open = right_open = True
350
+ while True:
351
+ lo = self.xs[first - left]
352
+ hi = self.xs[last + right] + self.widths[last + right]
353
+ need_left = x <= lo + 1 if align == "right" else x < lo + 1
354
+ need_right = end > hi
355
+ if not (need_left or need_right):
356
+ break
357
+ left_open = left_open and self.grid.free(i, first - left - 1, self.no_claims)
358
+ right_open = right_open and self.grid.free(i, last + right + 1, self.no_claims)
359
+ centered = align == "center" and left_open and right_open
360
+ grow_left = left_open and (need_left or centered)
361
+ grow_right = right_open and (need_right or centered)
362
+ if not (grow_left or grow_right):
363
+ break
364
+ left += grow_left
365
+ right += grow_right
366
+ return first - left, last + right
367
+
368
+
369
+ def _running(sizes: list[float]) -> list[float]:
370
+ positions, total = [], 0.0
371
+ for size in sizes:
372
+ positions.append(total)
373
+ total += size
374
+ return positions
375
+
376
+
377
+ def _shifted(run: TextRun, dx: float) -> TextRun:
378
+ if not dx:
379
+ return run
380
+ left, top, right, bottom = run.clip
381
+ return TextRun(
382
+ run.text, run.x - dx, run.y, run.width, (left - dx, top, right - dx, bottom),
383
+ run.align, run.source, run.font, run.color,
384
+ ) # fmt: skip
385
+
386
+
387
+ def _wrap(text: str, budget: float, font: xlformat.Font) -> list[str]:
388
+ """Lines of ``text`` broken at spaces to fit ``budget`` points; explicit breaks kept.
389
+
390
+ A word wider than the budget is broken between characters.
391
+ """
392
+ lines: list[str] = []
393
+ for paragraph in text.replace("\r", "").split("\n"):
394
+ line = ""
395
+ for word in paragraph.split(" "):
396
+ candidate = f"{line} {word}" if line else word
397
+ if line and xlformat.text_width(candidate, font=font) > budget:
398
+ lines.append(line)
399
+ candidate = word
400
+ line = candidate
401
+ while len(line) > 1 and xlformat.text_width(line, font=font) > budget:
402
+ cut = len(line) - 1
403
+ while cut > 1 and xlformat.text_width(line[:cut], font=font) > budget:
404
+ cut -= 1
405
+ lines.append(line[:cut])
406
+ line = line[cut:]
407
+ lines.append(line)
408
+ return lines