Repository navigation
Expand file tree
/
Copy pathaddress_utils.py
More file actions
248 lines (205 loc) · 10.1 KB
/
Copy pathaddress_utils.py
File metadata and controls
248 lines (205 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# WriterAgent - AI Writing Assistant for LibreOffice
# Copyright (c) 2024 John Balis
# Copyright (c) 2026 KeithCu (modifications and relicensing)
# Copyright (c) 2026 LibreCalc AI Assistant (Calc integration features, originally MIT)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Cell address processing helper functions.
Pure utility functions with no UNO dependency. Ported from
core/calc_address_utils.py for the plugin framework.
"""
from __future__ import annotations
import re
from plugin.framework.deal_shim import DEAL_MAX_CELL_REF, DEAL_MAX_COL_INDEX, DEAL_MAX_COL_LETTERS, DEAL_MAX_ROW_INDEX, ascii_bounded, deal, inverse_ensure
# Pre must cap the int: `index >= 0` with no max lets CrossHair feed a giant
# value into `while index > 0: divmod(..., 26)`, and deep check never returns.
@deal.pre(lambda index: isinstance(index, int) and 0 <= index <= DEAL_MAX_COL_INDEX)
@deal.post(lambda result: isinstance(result, str) and 1 <= len(result) <= DEAL_MAX_COL_LETTERS)
def index_to_column(index: int) -> str:
"""Convert 0-based column index to column letter.
Args:
index: 0-based column index.
Returns:
Column letter (e.g. "A", "AB").
"""
# crosshair: off
# while+chr combinatoric leftover (cover-all 33451622787: 16405s / 59700 examples despite DEAL_MAX_COL_INDEX=25). Doable later: closed A-Z table instead of divmod/chr.
result = []
index += 1
while index > 0:
index, remainder = divmod(index - 1, 26)
result.append(chr(ord("A") + remainder))
return "".join(reversed(result))
# Cap letters so CrossHair cannot chase an unbounded inverse. Nested
# index_to_column is skipped under CrossHair (import-time inverse_ensure
# no-op); cheap @deal.post still runs so the function is analyzed.
@deal.pre(lambda col_str: ascii_bounded(col_str, DEAL_MAX_COL_LETTERS, min_len=1) and col_str.isalpha())
@deal.post(lambda result: isinstance(result, int) and result >= 0)
@inverse_ensure(lambda col_str, result: index_to_column(result) == col_str.upper())
def column_to_index(col_str: str) -> int:
"""Convert column letter to 0-based index.
Args:
col_str: Column letter (e.g. "A", "AB"); at most 3 letters (Excel/Calc max).
Returns:
0-based column index.
"""
result = 0
for char in col_str.upper():
result = result * 26 + (ord(char) - ord("A") + 1)
return result - 1
# A reference may name its sheet: Sheet1.A1, 'Sheet One'.A1:C5, Sheet1!A1.
# LibreOffice writes the dot form, Excel the bang; both are accepted, and a
# quoted name may contain spaces and dots. An apostrophe inside the name is
# doubled ('O''Brien'.A1), the same escape Calc and Excel use.
_SHEET_PREFIX = re.compile(
r"""^\s*
(?:'(?P<quoted>(?:[^']|'')+)' # 'Sheet One' or 'O''Brien'
|(?P<bare>[^.!'\s][^.!']*?)) # Sheet1
\s*[.!]\s*
(?P<rest>.+)$""",
re.VERBOSE,
)
def split_sheet_prefix(ref: str) -> tuple[str | None, str]:
"""Split a reference into ``(sheet_name, address)``.
Returns ``(None, ref)`` when there is no prefix. The sheet name keeps
its original case — only the address part is normalised later, so a
sheet called ``Summary`` is not reported back as ``SUMMARY``.
>>> split_sheet_prefix("Sheet1.A1:C5")
('Sheet1', 'A1:C5')
>>> split_sheet_prefix("'Data Sheet'!B2")
('Data Sheet', 'B2')
>>> split_sheet_prefix("'O''Brien'.A1")
("O'Brien", 'A1')
>>> split_sheet_prefix("A1:C5")
(None, 'A1:C5')
"""
# crosshair: off
# _SHEET_PREFIX regex (cover-all 33418536119: address_utils in-flight leftover, no COVER TIMING). Engine-hostile; keep off.
if not ref:
return None, ref
match = _SHEET_PREFIX.match(ref)
if not match:
return None, ref.strip()
# What was wrong: a quoted sheet name stopped at the first apostrophe, so
# 'O''Brien'.C5:D6 did not match and the sheet was dropped.
# How: the quoted group was [^']+, which cannot hold a doubled quote.
# Why: '' is one apostrophe in Calc/Excel; unescape after the match.
quoted = match.group("quoted")
if quoted is not None:
name = quoted.replace("''", "'")
else:
name = match.group("bare") or ""
return name.strip(), match.group("rest").strip()
@deal.pre(lambda address: ascii_bounded(address, DEAL_MAX_CELL_REF, min_len=1))
@deal.post(lambda result: isinstance(result, tuple) and len(result) == 2 and result[0] >= 0 and result[1] >= 0)
@deal.raises(ValueError)
def parse_address(address: str) -> tuple[int, int]:
"""Convert cell address to column and row indices.
Args:
address: Cell address (e.g. "A1", "AB10"), without a sheet prefix —
use :func:`split_sheet_prefix` first when the reference may name one.
Returns:
(column_index, row_index) tuple (0-based).
Raises:
ValueError: Invalid cell address, or a sheet prefix was left on.
"""
# Sheet-prefix + A1 regex hang under deep check.
# crosshair: off
# Reject prefixes here so callers that need the sheet go through
# split_sheet_prefix / CalcBridge.resolve instead of silently dropping it.
sheet, address = split_sheet_prefix(address)
if sheet is not None:
raise ValueError(f"Cell address '{sheet}.{address}' names a sheet, but this operation resolves the sheet separately. Pass the sheet via sheet_name, or drop the prefix.")
address = address.strip().upper()
match = re.match(r"^([A-Z]+)([0-9]+)$", address)
if not match:
raise ValueError(f"Invalid cell address: '{address}'")
col_str = match.group(1)
row_num = int(match.group(2))
if row_num < 1:
raise ValueError(f"Invalid row number in cell address: {row_num}")
col_index = column_to_index(col_str)
row_index = row_num - 1
return col_index, row_index
@deal.pre(lambda range_str: ascii_bounded(range_str, DEAL_MAX_CELL_REF, min_len=1))
@deal.post(lambda result: isinstance(result, tuple) and len(result) == 2 and all(isinstance(p, tuple) and len(p) == 2 and p[0] >= 0 and p[1] >= 0 for p in result))
@deal.raises(ValueError)
def parse_range_string(range_str: str) -> tuple[tuple[int, int], tuple[int, int]]:
"""Convert cell range string to column/row indices.
Args:
range_str: Range string in "A1:D10" or "A1" format, without a
sheet prefix — use :func:`split_sheet_prefix` first when the
reference may name one.
Returns:
((start_col, start_row), (end_col, end_row)) tuple.
Both tuples are the same for a single cell.
Raises:
ValueError: Invalid range format, or a sheet prefix was left on.
"""
# Sheet-prefix + A1 regex hang under deep check.
# crosshair: off
sheet, range_str = split_sheet_prefix(range_str)
if sheet is not None:
raise ValueError(f"Range '{sheet}.{range_str}' names a sheet, but this operation resolves the sheet separately. Pass the sheet via sheet_name, or drop the prefix.")
range_str = range_str.strip().upper()
pattern = r"^([A-Z]+)([0-9]+)(?::([A-Z]+)([0-9]+))?$"
match = re.match(pattern, range_str)
if not match:
raise ValueError(f"Invalid cell range format: '{range_str}'")
start_col = column_to_index(match.group(1))
start_row_num = int(match.group(2))
if start_row_num < 1:
raise ValueError(f"Invalid row number in start cell address: {start_row_num}")
start_row = start_row_num - 1
if match.group(3) is not None:
end_col = column_to_index(match.group(3))
end_row_num = int(match.group(4))
if end_row_num < 1:
raise ValueError(f"Invalid row number in end cell address: {end_row_num}")
end_row = end_row_num - 1
if start_col > end_col or start_row > end_row:
raise ValueError(f"Invalid cell range: start cell must be top-left and end cell bottom-right: '{range_str}'")
else:
end_col = start_col
end_row = start_row
return (start_col, start_row), (end_col, end_row)
# Nested parse_address is skipped under CrossHair (import-time inverse_ensure
# no-op); cheap @deal.post still runs so the function is analyzed.
@deal.pre(lambda col, row: isinstance(col, int) and 0 <= col <= DEAL_MAX_COL_INDEX and isinstance(row, int) and 0 <= row <= DEAL_MAX_ROW_INDEX)
@deal.post(lambda result: isinstance(result, str) and bool(result) and result[0].isalpha() and result.isascii() and result[-1].isdigit())
@inverse_ensure(lambda col, row, result: parse_address(result) == (col, row))
def format_address(col: int, row: int) -> str:
"""Create cell address from column and row indices.
Args:
col: 0-based column index.
row: 0-based row index.
Returns:
Cell address (e.g. "A1", "AB10").
"""
return f"{index_to_column(col)}{row + 1}"
def parse_output_anchor(output_range: str) -> tuple[str | None, int, int]:
"""Sheet name (if any), column, and row where a generated report should start.
What was wrong: ``output_range.rsplit(".", 1)[-1]`` treated the last dot
as the sheet separator. A quoted or dotted name (``'Q1.Sales'!B2``) was
handed to ``parse_address`` still prefixed, and a range address
(``Sheet1.A1:Sheet1.C10`` or ``$A$1:$C$5``) resolved to the end cell or
to a token ``parse_address`` rejects.
``split_sheet_prefix`` keeps quoted names, dots inside quotes, and both
``.`` and ``!``. The write starts at the first cell, with ``$`` locks
removed, then ``parse_address``.
"""
sheet_name, cell_part = split_sheet_prefix(output_range)
anchor = cell_part.replace("$", "").split(":", 1)[0].strip()
col, row = parse_address(anchor)
return sheet_name, col, row