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 +68 -0
- sheetpeek/__main__.py +7 -0
- sheetpeek/_carlito.py +47 -0
- sheetpeek/_colors.py +155 -0
- sheetpeek/_excel.py +408 -0
- sheetpeek/_layout.py +691 -0
- sheetpeek/_model.py +312 -0
- sheetpeek/_package.py +60 -0
- sheetpeek/_read.py +408 -0
- sheetpeek/_rendered.py +215 -0
- sheetpeek/_rows.py +80 -0
- sheetpeek/_serve.py +175 -0
- sheetpeek/_structure.py +240 -0
- sheetpeek/backends/__init__.py +128 -0
- sheetpeek/backends/delimited.py +30 -0
- sheetpeek/backends/html.py +399 -0
- sheetpeek/backends/markdown.py +66 -0
- sheetpeek/backends/rich.py +115 -0
- sheetpeek/backends/text.py +61 -0
- sheetpeek/cli.py +128 -0
- sheetpeek/py.typed +0 -0
- sheetpeek-0.1.0.dist-info/METADATA +152 -0
- sheetpeek-0.1.0.dist-info/RECORD +26 -0
- sheetpeek-0.1.0.dist-info/WHEEL +4 -0
- sheetpeek-0.1.0.dist-info/entry_points.txt +2 -0
- sheetpeek-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|