python-calamine-reducto 0.7.19__tar.gz → 0.7.21__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.
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.github/workflows/release.yml +1 -1
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/Cargo.lock +2 -2
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/Cargo.toml +1 -1
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/PKG-INFO +1 -1
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/README.md +2 -1
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/python/python_calamine/__init__.py +4 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/python/python_calamine/_python_calamine.pyi +85 -8
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/lib.rs +5 -2
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/errors.rs +19 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/grid.rs +208 -22
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/mod.rs +2 -2
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/workbook.rs +1265 -234
- python_calamine_reducto-0.7.21/tests/create_hyperlink_fixture.py +30 -0
- python_calamine_reducto-0.7.21/tests/data/formula_vals.xlsb +0 -0
- python_calamine_reducto-0.7.21/tests/data/hyperlink_test.xlsx +0 -0
- python_calamine_reducto-0.7.21/tests/sparse_builders.py +47 -0
- python_calamine_reducto-0.7.21/tests/test_bounded_reads.py +519 -0
- python_calamine_reducto-0.7.21/tests/test_error_values.py +137 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_gap_compression.py +72 -2
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_images.py +83 -2
- python_calamine_reducto-0.7.21/tests/test_merge_cleanup.py +82 -0
- python_calamine_reducto-0.7.21/tests/test_shared_strings.py +169 -0
- python_calamine_reducto-0.7.21/tests/xlsb_builder.py +51 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/xlsx_builder.py +48 -1
- python_calamine_reducto-0.7.19/tests/test_bounded_reads.py +0 -195
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.github/workflows/CI.yml +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.gitignore +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.pre-commit-config.yaml +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.python-version +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/CLAUDE.md +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/LICENSE +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/build.rs +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/pyproject.toml +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/python/python_calamine/py.typed +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/renovate.json +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/cell.rs +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/sheet.rs +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/table.rs +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/__init__.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/conftest.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/create_empty_merge_fixture.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/create_hidden_col_range_fixture.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/create_richtext_fixture.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/create_test_fixture.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.ods +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xls +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xlsb +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/base.ods +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/base.xls +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/base.xlsb +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/base.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/dropdown_test.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/empty_file.ods +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/empty_file.xlsb +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/empty_file.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/empty_merge_test.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/formula_test.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/grid_test.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/hidden_col_range_test.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/image_test.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/issue139.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/multi_image_test.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/password.ods +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/password.xls +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/password.xlsb +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/password.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/richtext_test.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/table-multiple.xlsb +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/table-multiple.xlsx +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/temperature-table.xlsb +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_band.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_base.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_grid.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_pandas.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_sheet_type.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_tables.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_trim_header_only.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_uncached_formulas.py +0 -0
- {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/uv.lock +0 -0
|
@@ -80,7 +80,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
|
|
80
80
|
[[package]]
|
|
81
81
|
name = "calamine"
|
|
82
82
|
version = "0.32.0"
|
|
83
|
-
source = "git+https://github.com/reductoai/calamine.git?branch=main#
|
|
83
|
+
source = "git+https://github.com/reductoai/calamine.git?branch=main#b87b3385408cad5fd436b96d08f1d4b0ebf7664c"
|
|
84
84
|
dependencies = [
|
|
85
85
|
"atoi_simd",
|
|
86
86
|
"byteorder",
|
|
@@ -612,7 +612,7 @@ dependencies = [
|
|
|
612
612
|
|
|
613
613
|
[[package]]
|
|
614
614
|
name = "python-calamine-reducto"
|
|
615
|
-
version = "0.7.
|
|
615
|
+
version = "0.7.21"
|
|
616
616
|
dependencies = [
|
|
617
617
|
"calamine",
|
|
618
618
|
"chrono",
|
|
@@ -25,7 +25,8 @@ Our additions live on `main`.
|
|
|
25
25
|
- **`get_sheet_grid(name, rows, cols, include_styles=True)`** → `CellGrid` from the dense range.
|
|
26
26
|
- **`get_sheet_layout(name)`** → dict with row heights, column widths, defaults
|
|
27
27
|
- **`get_sheet_formulas`**, **`get_sheet_merged_ranges`**, **`get_sheet_images`**,
|
|
28
|
-
**`get_sheet_data_validations`**, **`
|
|
28
|
+
**`get_sheet_data_validations`**, **`get_sheet_hyperlinks`**, **`get_sheet_hyperlink_formulas`**,
|
|
29
|
+
**`get_sheet_freeze_panes`**,
|
|
29
30
|
**`get_sheet_filled_count`**, **`get_sheet_uncached_formula_count`**,
|
|
30
31
|
**`get_all_tables_metadata`**
|
|
31
32
|
|
{python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/python/python_calamine/__init__.py
RENAMED
|
@@ -5,6 +5,8 @@ from ._python_calamine import (
|
|
|
5
5
|
CalamineWorkbook,
|
|
6
6
|
CellGrid,
|
|
7
7
|
CellProxy,
|
|
8
|
+
FormulasTooLarge,
|
|
9
|
+
GridTooLarge,
|
|
8
10
|
PasswordError,
|
|
9
11
|
SheetMetadata,
|
|
10
12
|
SheetTypeEnum,
|
|
@@ -26,6 +28,8 @@ __all__ = (
|
|
|
26
28
|
"CalamineWorkbook",
|
|
27
29
|
"CellGrid",
|
|
28
30
|
"CellProxy",
|
|
31
|
+
"FormulasTooLarge",
|
|
32
|
+
"GridTooLarge",
|
|
29
33
|
"PasswordError",
|
|
30
34
|
"SheetMetadata",
|
|
31
35
|
"SheetTypeEnum",
|
|
@@ -204,6 +204,21 @@ class DataValidation(typing.TypedDict):
|
|
|
204
204
|
formula2: str | None
|
|
205
205
|
allow_blank: bool
|
|
206
206
|
|
|
207
|
+
@typing.type_check_only
|
|
208
|
+
class Hyperlink(typing.TypedDict):
|
|
209
|
+
ref: str
|
|
210
|
+
"""Cell or range reference, e.g. `A1` or `A1:C3`."""
|
|
211
|
+
rel_id: str | None
|
|
212
|
+
"""The `r:id` of the link, if any."""
|
|
213
|
+
target: str | None
|
|
214
|
+
"""Relationship target, e.g. a URL. `None` for a link with only a `location`, and for an
|
|
215
|
+
`r:id` that has no hyperlink relationship."""
|
|
216
|
+
location: str | None
|
|
217
|
+
"""Place inside the target or the workbook, e.g. `Sheet2!A1` or a URL fragment."""
|
|
218
|
+
display: str | None
|
|
219
|
+
external: bool
|
|
220
|
+
"""`True` when the relationship has `TargetMode="External"`."""
|
|
221
|
+
|
|
207
222
|
@typing.final
|
|
208
223
|
class CellProxy:
|
|
209
224
|
"""One cell of a `CellGrid`. Read a field with `cell["value"]`."""
|
|
@@ -226,6 +241,10 @@ class CellGrid:
|
|
|
226
241
|
def col_coords(self) -> npt.NDArray[np.int32]:
|
|
227
242
|
"""Grid column -> 0-based sheet column."""
|
|
228
243
|
|
|
244
|
+
@property
|
|
245
|
+
def gap_unpinned(self) -> bool:
|
|
246
|
+
"""Whether gap compression removed rows or columns across frozen panes or inside merges."""
|
|
247
|
+
|
|
229
248
|
@property
|
|
230
249
|
def uncached_formula_count(self) -> int | None:
|
|
231
250
|
"""Formula cells with no cached value (streaming xlsx grids), else `None`."""
|
|
@@ -259,10 +278,19 @@ class CellGrid:
|
|
|
259
278
|
value: bool,
|
|
260
279
|
) -> None: ...
|
|
261
280
|
def apply_formatting_rust(self) -> None:
|
|
262
|
-
"""Format each value with its number format (SSF, in Rust)."""
|
|
281
|
+
"""Format each value with its number format (SSF, in Rust). An error value stays as is."""
|
|
282
|
+
|
|
283
|
+
def apply_dateutil_reformat(
|
|
284
|
+
self, date_parse: Callable[[str], float | None]
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Reformat text cells that have a date format. `date_parse` gives a date serial.
|
|
263
287
|
|
|
264
|
-
|
|
265
|
-
|
|
288
|
+
Deprecated: text cells keep their text, as in Excel; the API no longer calls it.
|
|
289
|
+
It does not change an error value.
|
|
290
|
+
|
|
291
|
+
`date_parse` runs once for each distinct text. Text above 64 characters is not a
|
|
292
|
+
date, so `date_parse` does not get it.
|
|
293
|
+
"""
|
|
266
294
|
|
|
267
295
|
def apply_raw_as_formatted(self) -> None: ...
|
|
268
296
|
def apply_color_names(self, converter: Callable[[str], str | None]) -> None:
|
|
@@ -394,6 +422,7 @@ class CalamineWorkbook:
|
|
|
394
422
|
coverage: float = 0.999,
|
|
395
423
|
trim_header_only: bool = False,
|
|
396
424
|
max_empty_run: int | None = None,
|
|
425
|
+
max_grid_cells: int | None = None,
|
|
397
426
|
) -> tuple[CellGrid, int, int, int]:
|
|
398
427
|
"""Stream a grid. Returns `(grid, declared_rows, declared_cols, dropped_cells)`.
|
|
399
428
|
|
|
@@ -401,10 +430,17 @@ class CalamineWorkbook:
|
|
|
401
430
|
`max_empty_run` keeps at most that many rows (and columns) of each empty run inside the
|
|
402
431
|
trimmed bounds. It engages only above `trigger_cells`, and only when it halves the grid.
|
|
403
432
|
`coord` and `merge_parent` stay in sheet positions; `row_coords`/`col_coords` map them.
|
|
433
|
+
Frozen columns stop the row compression, frozen rows stop the column compression, and
|
|
434
|
+
a merge keeps all its rows and columns.
|
|
435
|
+
|
|
436
|
+
`max_grid_cells` bounds the grid that the reader allocates. When frozen panes or merges
|
|
437
|
+
hold the compressed grid above it, compression keeps only the frozen rows and columns
|
|
438
|
+
and the anchor of each merge (`CellGrid.gap_unpinned`). A grid still above it raises
|
|
439
|
+
`GridTooLarge` before any allocation.
|
|
404
440
|
"""
|
|
405
441
|
|
|
406
442
|
def get_sheet_filled_count(self, name: str) -> tuple[int, int, int]:
|
|
407
|
-
"""`(filled, max_filled_row, max_filled_col)` in one streaming pass.
|
|
443
|
+
"""`(filled, max_filled_row, max_filled_col)` in one streaming pass."""
|
|
408
444
|
|
|
409
445
|
def get_sheet_uncached_formula_count(self, name: str) -> int | None: ...
|
|
410
446
|
def for_each_band(
|
|
@@ -421,23 +457,60 @@ class CalamineWorkbook:
|
|
|
421
457
|
) -> tuple[int, int, int]:
|
|
422
458
|
"""Stream the sheet as row bands. Returns `(declared_rows, declared_cols, dropped_cells)`."""
|
|
423
459
|
|
|
424
|
-
def get_sheet_formulas(
|
|
425
|
-
|
|
460
|
+
def get_sheet_formulas(
|
|
461
|
+
self, name: str, max_formula_bytes: int = 268_435_456
|
|
462
|
+
) -> list[tuple[int, int, str]]:
|
|
463
|
+
"""`(row, col, formula)` for each formula cell. Each formula starts with `=`.
|
|
464
|
+
|
|
465
|
+
A formula above 8,192 characters (the Excel limit) is skipped. The reader copies a
|
|
466
|
+
shared formula into each of its cells. Every copy counts toward `max_formula_bytes`
|
|
467
|
+
(UTF-8 bytes), also a skipped copy. Above it, the read raises `FormulasTooLarge`.
|
|
468
|
+
"""
|
|
426
469
|
|
|
427
470
|
def get_sheet_images(self, name: str) -> list[tuple[bytes, str, int, int]]:
|
|
428
471
|
"""`(data, extension, anchor_row, anchor_col)` for each image."""
|
|
429
472
|
|
|
430
473
|
def get_sheet_data_validations(self, name: str) -> list[DataValidation]: ...
|
|
474
|
+
def get_sheet_hyperlinks(
|
|
475
|
+
self, name: str, limit: int | None = None
|
|
476
|
+
) -> list[Hyperlink]:
|
|
477
|
+
"""The links of the sheet's `<hyperlinks>` element, at most `limit`. Empty for xlsb, xls, and ods."""
|
|
478
|
+
|
|
479
|
+
def get_sheet_hyperlink_formulas(
|
|
480
|
+
self, name: str, limit: int | None = None, max_formula_bytes: int = 268_435_456
|
|
481
|
+
) -> list[tuple[int, int, str]]:
|
|
482
|
+
"""`(row, col, formula)` for each formula that contains `HYPERLINK`, at most `limit`. Empty for xlsb, xls, and ods.
|
|
483
|
+
|
|
484
|
+
Every formula of the sheet counts toward `max_formula_bytes`, as in `get_sheet_formulas`.
|
|
485
|
+
"""
|
|
486
|
+
|
|
431
487
|
def get_sheet_freeze_panes(self, name: str) -> tuple[int, int]:
|
|
432
488
|
"""`(frozen_rows, frozen_cols)`."""
|
|
433
489
|
|
|
434
490
|
def get_sheet_merged_ranges(self, name: str) -> list[tuple[int, int, int, int]]:
|
|
435
|
-
"""`(start_row, start_col, end_row, end_col)`, 0-based and inclusive.
|
|
491
|
+
"""`(start_row, start_col, end_row, end_col)`, 0-based and inclusive, in file order.
|
|
492
|
+
|
|
493
|
+
These are the merges that every grid build uses. The reader drops some merges, never raises:
|
|
494
|
+
|
|
495
|
+
1. A merge that repeats an earlier merge.
|
|
496
|
+
2. Above 1,048,576 merges (one for each Excel row), the merges that start farthest
|
|
497
|
+
from the top of the sheet (by start row, then start column).
|
|
498
|
+
3. A merge that overlaps an earlier kept merge. Excel does not let merges overlap.
|
|
499
|
+
|
|
500
|
+
A valid sheet keeps all of its merges. `get_sheet_dropped_merges` gives the counts.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
def get_sheet_dropped_merges(self, name: str) -> tuple[int, int, int]:
|
|
504
|
+
"""`(duplicate, over_cap, overlapping)`: the merges that `get_sheet_merged_ranges` dropped.
|
|
505
|
+
|
|
506
|
+
All zero for a valid sheet. The merges stay cached for the last sheet, so a call after a
|
|
507
|
+
grid build of the same sheet does not read the sheet again.
|
|
508
|
+
"""
|
|
436
509
|
|
|
437
510
|
def get_sheet_bounds(self, name: str) -> tuple[int, int]:
|
|
438
511
|
"""`(max_filled_row, max_filled_col)`, 1-based and exclusive.
|
|
439
512
|
|
|
440
|
-
|
|
513
|
+
The read streams the cells and does not count `""` cells, as `get_sheet_grid_streaming`.
|
|
441
514
|
"""
|
|
442
515
|
|
|
443
516
|
class CalamineError(Exception): ...
|
|
@@ -449,6 +522,8 @@ class WorkbookClosed(CalamineError): ...
|
|
|
449
522
|
class TablesNotLoaded(CalamineError): ...
|
|
450
523
|
class TablesNotSupported(CalamineError): ...
|
|
451
524
|
class TableNotFound(CalamineError): ...
|
|
525
|
+
class GridTooLarge(CalamineError): ...
|
|
526
|
+
class FormulasTooLarge(CalamineError): ...
|
|
452
527
|
|
|
453
528
|
def load_workbook(
|
|
454
529
|
path_or_filelike: str | os.PathLike | ReadBuffer, load_tables: bool = False
|
|
@@ -467,6 +542,8 @@ __all__ = [
|
|
|
467
542
|
"CalamineWorkbook",
|
|
468
543
|
"CellGrid",
|
|
469
544
|
"CellProxy",
|
|
545
|
+
"FormulasTooLarge",
|
|
546
|
+
"GridTooLarge",
|
|
470
547
|
"PasswordError",
|
|
471
548
|
"SheetMetadata",
|
|
472
549
|
"SheetTypeEnum",
|
|
@@ -3,8 +3,9 @@ use pyo3::prelude::*;
|
|
|
3
3
|
mod types;
|
|
4
4
|
use crate::types::{
|
|
5
5
|
CalamineError, CalamineSheet, CalamineTable, CalamineWorkbook, CellGrid, CellProxy, CellValue,
|
|
6
|
-
Error,
|
|
7
|
-
|
|
6
|
+
Error, FormulasTooLarge, GridTooLarge, PasswordError, SheetMetadata, SheetTypeEnum,
|
|
7
|
+
SheetVisibleEnum, TableNotFound, TablesNotLoaded, TablesNotSupported, WorkbookClosed,
|
|
8
|
+
WorksheetNotFound, XmlError, ZipError,
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
#[pyfunction]
|
|
@@ -37,5 +38,7 @@ fn _python_calamine(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
37
38
|
m.add("TablesNotLoaded", py.get_type::<TablesNotLoaded>())?;
|
|
38
39
|
m.add("TableNotFound", py.get_type::<TableNotFound>())?;
|
|
39
40
|
m.add("WorkbookClosed", py.get_type::<WorkbookClosed>())?;
|
|
41
|
+
m.add("GridTooLarge", py.get_type::<GridTooLarge>())?;
|
|
42
|
+
m.add("FormulasTooLarge", py.get_type::<FormulasTooLarge>())?;
|
|
40
43
|
Ok(())
|
|
41
44
|
}
|
|
@@ -8,6 +8,8 @@ pub enum Error {
|
|
|
8
8
|
TablesNotSupported,
|
|
9
9
|
TablesNotLoaded,
|
|
10
10
|
WorkbookClosed,
|
|
11
|
+
/// The formula text of one sheet read is above `max_formula_bytes`.
|
|
12
|
+
FormulasTooLarge(u64),
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
create_exception!(python_calamine, CalamineError, PyException);
|
|
@@ -19,6 +21,8 @@ create_exception!(python_calamine, WorkbookClosed, CalamineError);
|
|
|
19
21
|
create_exception!(python_calamine, TablesNotSupported, CalamineError);
|
|
20
22
|
create_exception!(python_calamine, TablesNotLoaded, CalamineError);
|
|
21
23
|
create_exception!(python_calamine, TableNotFound, CalamineError);
|
|
24
|
+
create_exception!(python_calamine, GridTooLarge, CalamineError);
|
|
25
|
+
create_exception!(python_calamine, FormulasTooLarge, CalamineError);
|
|
22
26
|
|
|
23
27
|
impl From<CalamineCrateError> for Error {
|
|
24
28
|
fn from(err: CalamineCrateError) -> Self {
|
|
@@ -38,6 +42,18 @@ impl From<XlsbError> for Error {
|
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
impl From<XlsError> for Error {
|
|
46
|
+
fn from(err: XlsError) -> Self {
|
|
47
|
+
Error::Calamine(err.into())
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
impl From<OdsError> for Error {
|
|
52
|
+
fn from(err: OdsError) -> Self {
|
|
53
|
+
Error::Calamine(err.into())
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
impl From<Error> for PyErr {
|
|
42
58
|
fn from(val: Error) -> Self {
|
|
43
59
|
match val {
|
|
@@ -94,6 +110,9 @@ impl From<Error> for PyErr {
|
|
|
94
110
|
Error::WorkbookClosed => WorkbookClosed::new_err("".to_string()),
|
|
95
111
|
Error::TablesNotLoaded => TablesNotLoaded::new_err("".to_string()),
|
|
96
112
|
Error::TablesNotSupported => TablesNotSupported::new_err("".to_string()),
|
|
113
|
+
Error::FormulasTooLarge(max) => FormulasTooLarge::new_err(format!(
|
|
114
|
+
"the formula text of the sheet is above max_formula_bytes={max}"
|
|
115
|
+
)),
|
|
97
116
|
}
|
|
98
117
|
}
|
|
99
118
|
}
|
|
@@ -14,6 +14,8 @@ use pyo3::exceptions::{PyIndexError, PyKeyError, PyTypeError, PyValueError};
|
|
|
14
14
|
use pyo3::prelude::*;
|
|
15
15
|
use pyo3::types::{PyAny, PyString};
|
|
16
16
|
|
|
17
|
+
use super::workbook::longer_than;
|
|
18
|
+
|
|
17
19
|
type PyObject = Py<PyAny>;
|
|
18
20
|
|
|
19
21
|
/// Compact representation of a raw cell value (stays in Rust, not Python).
|
|
@@ -22,6 +24,8 @@ pub enum RawValue {
|
|
|
22
24
|
Int(i64),
|
|
23
25
|
Float(f64),
|
|
24
26
|
String(Arc<str>),
|
|
27
|
+
/// An error value, e.g. `#DIV/0!`. Excel shows it as is, whatever the number format.
|
|
28
|
+
Error(Arc<str>),
|
|
25
29
|
Bool(bool),
|
|
26
30
|
DateTime(chrono::NaiveDateTime),
|
|
27
31
|
Date(chrono::NaiveDate),
|
|
@@ -41,7 +45,7 @@ impl RawValue {
|
|
|
41
45
|
RawValue::Empty => None,
|
|
42
46
|
RawValue::Int(v) => Some(v.to_string()),
|
|
43
47
|
RawValue::Float(v) => Some(v.to_string()),
|
|
44
|
-
RawValue::String(v) => Some(v.to_string()),
|
|
48
|
+
RawValue::String(v) | RawValue::Error(v) => Some(v.to_string()),
|
|
45
49
|
RawValue::Bool(v) => Some(if *v {
|
|
46
50
|
"TRUE".to_string()
|
|
47
51
|
} else {
|
|
@@ -219,6 +223,8 @@ pub struct CellGrid {
|
|
|
219
223
|
// `coord` and `merge_parent` use them and the offsets are 0.
|
|
220
224
|
pub(crate) row_coords: Option<Vec<i32>>,
|
|
221
225
|
pub(crate) col_coords: Option<Vec<i32>>,
|
|
226
|
+
// Gap compression removed rows or columns across frozen panes or inside merges.
|
|
227
|
+
pub(crate) gap_unpinned: bool,
|
|
222
228
|
|
|
223
229
|
// Formula cells with no cached value in the source sheet. None when unknown
|
|
224
230
|
// (non-xlsx, dense path, or a derived grid).
|
|
@@ -244,7 +250,7 @@ impl CellGrid {
|
|
|
244
250
|
.iter()
|
|
245
251
|
.enumerate()
|
|
246
252
|
.map(|(idx, value)| match value {
|
|
247
|
-
RawValue::String(s) => {
|
|
253
|
+
RawValue::String(s) | RawValue::Error(s) => {
|
|
248
254
|
let style = styles.as_ref().map(|sv| sv.get(idx));
|
|
249
255
|
style.is_some_and(|st| st.font_bold || st.font_underline) || s.ends_with(':')
|
|
250
256
|
}
|
|
@@ -271,6 +277,7 @@ impl CellGrid {
|
|
|
271
277
|
anchor_offset: (0, 0),
|
|
272
278
|
row_coords: None,
|
|
273
279
|
col_coords: None,
|
|
280
|
+
gap_unpinned: false,
|
|
274
281
|
uncached_formula_count: None,
|
|
275
282
|
}
|
|
276
283
|
}
|
|
@@ -495,7 +502,7 @@ impl CellGrid {
|
|
|
495
502
|
let column = match field.parse::<Field>()? {
|
|
496
503
|
Field::Value => self.object_column(
|
|
497
504
|
py,
|
|
498
|
-
py_strs(py, self.formatted_values.iter().map(
|
|
505
|
+
py_strs(py, self.formatted_values.iter().map(Option::as_ref)),
|
|
499
506
|
),
|
|
500
507
|
Field::Header => self.bool_column(py, self.header.clone()),
|
|
501
508
|
Field::TableMember => self.bool_column(py, self.table_member.clone()),
|
|
@@ -780,10 +787,32 @@ impl FromStr for Field {
|
|
|
780
787
|
}
|
|
781
788
|
}
|
|
782
789
|
|
|
783
|
-
///
|
|
784
|
-
|
|
790
|
+
/// No date text is longer, so `apply_dateutil_reformat` does not parse a longer string.
|
|
791
|
+
const MAX_DATE_TEXT_CHARS: usize = 64;
|
|
792
|
+
|
|
793
|
+
/// The date serial of `text` from the Python `date_parse` callback, or `None`.
|
|
794
|
+
fn date_serial(date_parse: &Bound<'_, PyAny>, text: &str) -> PyResult<Option<f64>> {
|
|
795
|
+
let result = date_parse.call1((text,))?;
|
|
796
|
+
if result.is_none() {
|
|
797
|
+
return Ok(None);
|
|
798
|
+
}
|
|
799
|
+
Ok(Some(result.extract()?))
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/// Python objects for optional strings: `str`, or `None`. The cells that share one
|
|
803
|
+
/// `Arc` share one Python string, so a long text in many cells is not copied per cell.
|
|
804
|
+
fn py_strs<'a>(
|
|
805
|
+
py: Python<'_>,
|
|
806
|
+
values: impl Iterator<Item = Option<&'a Arc<str>>>,
|
|
807
|
+
) -> Vec<PyObject> {
|
|
808
|
+
// Keyed by address: the caller holds every `Arc` until the end of the call.
|
|
809
|
+
let mut shared: HashMap<*const u8, PyObject> = HashMap::new();
|
|
785
810
|
values
|
|
786
811
|
.map(|v| match v {
|
|
812
|
+
Some(s) if Arc::strong_count(s) > 1 => shared
|
|
813
|
+
.entry(Arc::as_ptr(s).cast::<u8>())
|
|
814
|
+
.or_insert_with(|| PyString::new(py, s).into_any().unbind())
|
|
815
|
+
.clone_ref(py),
|
|
787
816
|
Some(s) => PyString::new(py, s).into_any().unbind(),
|
|
788
817
|
None => py.None(),
|
|
789
818
|
})
|
|
@@ -829,6 +858,14 @@ impl CellGrid {
|
|
|
829
858
|
)
|
|
830
859
|
}
|
|
831
860
|
|
|
861
|
+
/// Whether gap compression removed rows or columns across frozen panes or inside
|
|
862
|
+
/// merges, to keep the grid under `max_grid_cells`. The API output can then differ
|
|
863
|
+
/// from the uncompressed grid, which was too large to build.
|
|
864
|
+
#[getter]
|
|
865
|
+
fn gap_unpinned(&self) -> bool {
|
|
866
|
+
self.gap_unpinned
|
|
867
|
+
}
|
|
868
|
+
|
|
832
869
|
/// Formula cells with no cached value, counted by `get_sheet_grid_streaming`.
|
|
833
870
|
/// `None` when unknown (non-xlsx workbook or a grid from another builder).
|
|
834
871
|
#[getter]
|
|
@@ -974,6 +1011,9 @@ impl CellGrid {
|
|
|
974
1011
|
|
|
975
1012
|
// Cache parsed NumberFormat objects to avoid re-parsing the same format string
|
|
976
1013
|
let mut format_cache: HashMap<Arc<str>, Option<NumberFormat>> = HashMap::new();
|
|
1014
|
+
// The formatted text of a shared string, keyed by the addresses of the string and
|
|
1015
|
+
// its format code. Both stay in `raw_values` and `styles` for the whole call.
|
|
1016
|
+
let mut text_cache: HashMap<(*const u8, *const u8), Arc<str>> = HashMap::new();
|
|
977
1017
|
|
|
978
1018
|
for idx in 0..self.raw_values.len() {
|
|
979
1019
|
if self.raw_values[idx].is_empty() {
|
|
@@ -981,6 +1021,12 @@ impl CellGrid {
|
|
|
981
1021
|
continue;
|
|
982
1022
|
}
|
|
983
1023
|
|
|
1024
|
+
// A number format does not apply to an error value.
|
|
1025
|
+
if let RawValue::Error(e) = &self.raw_values[idx] {
|
|
1026
|
+
self.formatted_values[idx] = Some(e.clone());
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
984
1030
|
let nf: &str = self
|
|
985
1031
|
.styles
|
|
986
1032
|
.as_ref()
|
|
@@ -1035,12 +1081,31 @@ impl CellGrid {
|
|
|
1035
1081
|
let parsed = format_cache
|
|
1036
1082
|
.entry(nf_arc.clone())
|
|
1037
1083
|
.or_insert_with(|| NumberFormat::parse(nf_arc.as_ref()).ok());
|
|
1038
|
-
|
|
1039
|
-
self.formatted_values[idx] =
|
|
1040
|
-
Some(Arc::from(fmt.format_text(s.as_ref(), &opts).as_str()));
|
|
1041
|
-
} else {
|
|
1084
|
+
let Some(fmt) = parsed else {
|
|
1042
1085
|
self.formatted_values[idx] = Some(s.clone());
|
|
1043
|
-
|
|
1086
|
+
continue;
|
|
1087
|
+
};
|
|
1088
|
+
let key = (
|
|
1089
|
+
Arc::as_ptr(s).cast::<u8>(),
|
|
1090
|
+
Arc::as_ptr(&nf_arc).cast::<u8>(),
|
|
1091
|
+
);
|
|
1092
|
+
let formatted = match text_cache.get(&key) {
|
|
1093
|
+
Some(text) => text.clone(),
|
|
1094
|
+
None => {
|
|
1095
|
+
let text = fmt.format_text(s.as_ref(), &opts);
|
|
1096
|
+
// Most formats return a text cell unchanged: keep the one copy.
|
|
1097
|
+
let text: Arc<str> = if text == **s {
|
|
1098
|
+
s.clone()
|
|
1099
|
+
} else {
|
|
1100
|
+
Arc::from(text)
|
|
1101
|
+
};
|
|
1102
|
+
if Arc::strong_count(s) > 1 {
|
|
1103
|
+
text_cache.insert(key, text.clone());
|
|
1104
|
+
}
|
|
1105
|
+
text
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
self.formatted_values[idx] = Some(formatted);
|
|
1044
1109
|
continue;
|
|
1045
1110
|
}
|
|
1046
1111
|
RawValue::DateTime(dt) => {
|
|
@@ -1068,7 +1133,7 @@ impl CellGrid {
|
|
|
1068
1133
|
let nanos = td.subsec_nanos() as f64 / 1_000_000_000.0;
|
|
1069
1134
|
(secs + nanos) / 86400.0
|
|
1070
1135
|
}
|
|
1071
|
-
RawValue::Bool(_) | RawValue::Empty => unreachable!(),
|
|
1136
|
+
RawValue::Error(_) | RawValue::Bool(_) | RawValue::Empty => unreachable!(),
|
|
1072
1137
|
};
|
|
1073
1138
|
|
|
1074
1139
|
// Look up or parse the format code
|
|
@@ -1099,12 +1164,18 @@ impl CellGrid {
|
|
|
1099
1164
|
|
|
1100
1165
|
/// Reformat string cells with date format codes using a Python dateutil callback.
|
|
1101
1166
|
///
|
|
1167
|
+
/// Deprecated: text cells keep their text, as in Excel; the API no longer calls it.
|
|
1168
|
+
/// It still works for callers that pin an older API.
|
|
1169
|
+
///
|
|
1102
1170
|
/// Matches Python SSF behavior: when a string value has a date format,
|
|
1103
1171
|
/// parse the string as a date via dateutil, convert to serial number,
|
|
1104
|
-
/// and reformat with ssfmt.
|
|
1172
|
+
/// and reformat with ssfmt. An error value is not a string, so it keeps its text.
|
|
1105
1173
|
///
|
|
1106
1174
|
/// Callback signature: date_parse(string) -> Optional[float]
|
|
1107
1175
|
/// Returns a date serial number or None if parsing fails.
|
|
1176
|
+
///
|
|
1177
|
+
/// The callback runs once for each distinct string. A string above
|
|
1178
|
+
/// `MAX_DATE_TEXT_CHARS` is not a date, so the callback does not get it.
|
|
1108
1179
|
fn apply_dateutil_reformat(
|
|
1109
1180
|
&mut self,
|
|
1110
1181
|
_py: Python<'_>,
|
|
@@ -1114,11 +1185,12 @@ impl CellGrid {
|
|
|
1114
1185
|
|
|
1115
1186
|
let opts = FormatOptions::default();
|
|
1116
1187
|
let mut format_cache: HashMap<Arc<str>, Option<NumberFormat>> = HashMap::new();
|
|
1188
|
+
let mut serials: HashMap<Arc<str>, Option<f64>> = HashMap::new();
|
|
1117
1189
|
|
|
1118
1190
|
for idx in 0..self.raw_values.len() {
|
|
1119
1191
|
// Only process string values
|
|
1120
1192
|
let s = match &self.raw_values[idx] {
|
|
1121
|
-
RawValue::String(s) => s.clone(),
|
|
1193
|
+
RawValue::String(s) if !longer_than(s, MAX_DATE_TEXT_CHARS) => s.clone(),
|
|
1122
1194
|
_ => continue,
|
|
1123
1195
|
};
|
|
1124
1196
|
|
|
@@ -1144,12 +1216,17 @@ impl CellGrid {
|
|
|
1144
1216
|
continue;
|
|
1145
1217
|
}
|
|
1146
1218
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1219
|
+
let serial = match serials.get(&s) {
|
|
1220
|
+
Some(&serial) => serial,
|
|
1221
|
+
None => {
|
|
1222
|
+
let serial = date_serial(date_parse, &s)?;
|
|
1223
|
+
serials.insert(s, serial);
|
|
1224
|
+
serial
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
let Some(serial) = serial else {
|
|
1150
1228
|
continue;
|
|
1151
|
-
}
|
|
1152
|
-
let serial: f64 = result.extract()?;
|
|
1229
|
+
};
|
|
1153
1230
|
|
|
1154
1231
|
// Reformat with ssfmt
|
|
1155
1232
|
let formatted = fmt.format(serial, &opts);
|
|
@@ -1162,9 +1239,10 @@ impl CellGrid {
|
|
|
1162
1239
|
/// For large sheets: store raw values directly as display strings (no SSF).
|
|
1163
1240
|
fn apply_raw_as_formatted(&mut self) {
|
|
1164
1241
|
for idx in 0..self.raw_values.len() {
|
|
1165
|
-
self.formatted_values[idx] = self.raw_values[idx]
|
|
1166
|
-
.
|
|
1167
|
-
.map(
|
|
1242
|
+
self.formatted_values[idx] = match &self.raw_values[idx] {
|
|
1243
|
+
RawValue::String(s) | RawValue::Error(s) => Some(s.clone()),
|
|
1244
|
+
raw => raw.to_display_string().map(Arc::from),
|
|
1245
|
+
};
|
|
1168
1246
|
}
|
|
1169
1247
|
}
|
|
1170
1248
|
|
|
@@ -1295,7 +1373,7 @@ impl CellGrid {
|
|
|
1295
1373
|
// the grid has drained and arr's other columns are already filled —
|
|
1296
1374
|
// maximises headroom at the largest single allocation.
|
|
1297
1375
|
let formatted = take(&mut self.formatted_values);
|
|
1298
|
-
let objects = py_strs(py, formatted.iter().map(
|
|
1376
|
+
let objects = py_strs(py, formatted.iter().map(Option::as_ref));
|
|
1299
1377
|
drop(formatted);
|
|
1300
1378
|
set(Field::Value, self.object_column(py, objects)?)?;
|
|
1301
1379
|
|
|
@@ -1318,3 +1396,111 @@ impl CellProxy {
|
|
|
1318
1396
|
grid.cell_field(py, self.row, self.col, key)
|
|
1319
1397
|
}
|
|
1320
1398
|
}
|
|
1399
|
+
|
|
1400
|
+
#[cfg(test)]
|
|
1401
|
+
mod tests {
|
|
1402
|
+
use std::collections::HashMap;
|
|
1403
|
+
use std::sync::Arc;
|
|
1404
|
+
|
|
1405
|
+
use super::{CellGrid, CellStyle, CellStyles, RawValue};
|
|
1406
|
+
|
|
1407
|
+
const ERRORS: [&str; 8] = [
|
|
1408
|
+
"#DIV/0!",
|
|
1409
|
+
"#N/A",
|
|
1410
|
+
"#NAME?",
|
|
1411
|
+
"#NULL!",
|
|
1412
|
+
"#NUM!",
|
|
1413
|
+
"#REF!",
|
|
1414
|
+
"#VALUE!",
|
|
1415
|
+
"#GETTING_DATA",
|
|
1416
|
+
];
|
|
1417
|
+
const FORMATS: [&str; 5] = [
|
|
1418
|
+
";;;",
|
|
1419
|
+
"@\" sf\"",
|
|
1420
|
+
"0\" sf\";-0\" sf\";0\" sf\";@\" sf\"",
|
|
1421
|
+
"mm-dd-yy",
|
|
1422
|
+
"General",
|
|
1423
|
+
];
|
|
1424
|
+
|
|
1425
|
+
/// One row for each value, one column for each format in `FORMATS`.
|
|
1426
|
+
fn grid(values: &[RawValue]) -> CellGrid {
|
|
1427
|
+
let cols = FORMATS.len();
|
|
1428
|
+
let raw = values
|
|
1429
|
+
.iter()
|
|
1430
|
+
.flat_map(|v| std::iter::repeat_n(v.clone(), cols))
|
|
1431
|
+
.collect();
|
|
1432
|
+
let palette = FORMATS
|
|
1433
|
+
.iter()
|
|
1434
|
+
.map(|nf| CellStyle {
|
|
1435
|
+
number_format: Arc::from(*nf),
|
|
1436
|
+
..CellStyle::default()
|
|
1437
|
+
})
|
|
1438
|
+
.collect();
|
|
1439
|
+
let ids = (0..values.len() * cols)
|
|
1440
|
+
.map(|i| (i % cols) as u32)
|
|
1441
|
+
.collect();
|
|
1442
|
+
let styles = CellStyles { ids, palette };
|
|
1443
|
+
CellGrid::with_values(
|
|
1444
|
+
values.len(),
|
|
1445
|
+
cols,
|
|
1446
|
+
raw,
|
|
1447
|
+
Some(styles),
|
|
1448
|
+
HashMap::new(),
|
|
1449
|
+
(0, 0),
|
|
1450
|
+
)
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
fn row(grid: &CellGrid, row: usize) -> Vec<Option<&str>> {
|
|
1454
|
+
(0..grid.cols)
|
|
1455
|
+
.map(|col| grid.formatted_values[row * grid.cols + col].as_deref())
|
|
1456
|
+
.collect()
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
#[test]
|
|
1460
|
+
fn error_values_ignore_the_number_format() {
|
|
1461
|
+
let values: Vec<RawValue> = ERRORS
|
|
1462
|
+
.iter()
|
|
1463
|
+
.map(|e| RawValue::Error(Arc::from(*e)))
|
|
1464
|
+
.collect();
|
|
1465
|
+
let mut grid = grid(&values);
|
|
1466
|
+
grid.apply_formatting_rust();
|
|
1467
|
+
for (i, error) in ERRORS.iter().enumerate() {
|
|
1468
|
+
assert_eq!(row(&grid, i), vec![Some(*error); FORMATS.len()]);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
#[test]
|
|
1473
|
+
fn text_and_numbers_keep_their_formatting() {
|
|
1474
|
+
let mut grid = grid(&[RawValue::String(Arc::from("abc")), RawValue::Float(45000.5)]);
|
|
1475
|
+
grid.apply_formatting_rust();
|
|
1476
|
+
assert_eq!(
|
|
1477
|
+
row(&grid, 0),
|
|
1478
|
+
vec![
|
|
1479
|
+
Some(""),
|
|
1480
|
+
Some("abc sf"),
|
|
1481
|
+
Some("abc sf"),
|
|
1482
|
+
Some("abc"),
|
|
1483
|
+
Some("abc")
|
|
1484
|
+
]
|
|
1485
|
+
);
|
|
1486
|
+
assert_eq!(
|
|
1487
|
+
row(&grid, 1),
|
|
1488
|
+
vec![
|
|
1489
|
+
Some(""),
|
|
1490
|
+
Some("45000.5"),
|
|
1491
|
+
Some("45001 sf"),
|
|
1492
|
+
Some("03-15-23"),
|
|
1493
|
+
Some("45000.5")
|
|
1494
|
+
]
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
#[test]
|
|
1499
|
+
fn raw_display_of_an_error_is_its_text() {
|
|
1500
|
+
let error = RawValue::Error(Arc::from("#REF!"));
|
|
1501
|
+
assert_eq!(error.to_display_string().as_deref(), Some("#REF!"));
|
|
1502
|
+
let mut grid = grid(&[error]);
|
|
1503
|
+
grid.apply_raw_as_formatted();
|
|
1504
|
+
assert_eq!(row(&grid, 0), vec![Some("#REF!"); FORMATS.len()]);
|
|
1505
|
+
}
|
|
1506
|
+
}
|