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.
Files changed (80) hide show
  1. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.github/workflows/release.yml +1 -1
  2. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/Cargo.lock +2 -2
  3. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/Cargo.toml +1 -1
  4. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/PKG-INFO +1 -1
  5. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/README.md +2 -1
  6. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/python/python_calamine/__init__.py +4 -0
  7. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/python/python_calamine/_python_calamine.pyi +85 -8
  8. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/lib.rs +5 -2
  9. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/errors.rs +19 -0
  10. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/grid.rs +208 -22
  11. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/mod.rs +2 -2
  12. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/workbook.rs +1265 -234
  13. python_calamine_reducto-0.7.21/tests/create_hyperlink_fixture.py +30 -0
  14. python_calamine_reducto-0.7.21/tests/data/formula_vals.xlsb +0 -0
  15. python_calamine_reducto-0.7.21/tests/data/hyperlink_test.xlsx +0 -0
  16. python_calamine_reducto-0.7.21/tests/sparse_builders.py +47 -0
  17. python_calamine_reducto-0.7.21/tests/test_bounded_reads.py +519 -0
  18. python_calamine_reducto-0.7.21/tests/test_error_values.py +137 -0
  19. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_gap_compression.py +72 -2
  20. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_images.py +83 -2
  21. python_calamine_reducto-0.7.21/tests/test_merge_cleanup.py +82 -0
  22. python_calamine_reducto-0.7.21/tests/test_shared_strings.py +169 -0
  23. python_calamine_reducto-0.7.21/tests/xlsb_builder.py +51 -0
  24. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/xlsx_builder.py +48 -1
  25. python_calamine_reducto-0.7.19/tests/test_bounded_reads.py +0 -195
  26. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.github/workflows/CI.yml +0 -0
  27. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.gitignore +0 -0
  28. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.pre-commit-config.yaml +0 -0
  29. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/.python-version +0 -0
  30. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/CLAUDE.md +0 -0
  31. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/LICENSE +0 -0
  32. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/build.rs +0 -0
  33. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/pyproject.toml +0 -0
  34. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/python/python_calamine/py.typed +0 -0
  35. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/renovate.json +0 -0
  36. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/cell.rs +0 -0
  37. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/sheet.rs +0 -0
  38. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/src/types/table.rs +0 -0
  39. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/__init__.py +0 -0
  40. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/conftest.py +0 -0
  41. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/create_empty_merge_fixture.py +0 -0
  42. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/create_hidden_col_range_fixture.py +0 -0
  43. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/create_richtext_fixture.py +0 -0
  44. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/create_test_fixture.py +0 -0
  45. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.ods +0 -0
  46. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xls +0 -0
  47. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xlsb +0 -0
  48. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xlsx +0 -0
  49. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/base.ods +0 -0
  50. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/base.xls +0 -0
  51. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/base.xlsb +0 -0
  52. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/base.xlsx +0 -0
  53. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/dropdown_test.xlsx +0 -0
  54. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/empty_file.ods +0 -0
  55. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/empty_file.xlsb +0 -0
  56. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/empty_file.xlsx +0 -0
  57. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/empty_merge_test.xlsx +0 -0
  58. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/formula_test.xlsx +0 -0
  59. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/grid_test.xlsx +0 -0
  60. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/hidden_col_range_test.xlsx +0 -0
  61. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/image_test.xlsx +0 -0
  62. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/issue139.xlsx +0 -0
  63. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/multi_image_test.xlsx +0 -0
  64. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/password.ods +0 -0
  65. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/password.xls +0 -0
  66. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/password.xlsb +0 -0
  67. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/password.xlsx +0 -0
  68. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/richtext_test.xlsx +0 -0
  69. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/table-multiple.xlsb +0 -0
  70. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/table-multiple.xlsx +0 -0
  71. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/data/temperature-table.xlsb +0 -0
  72. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_band.py +0 -0
  73. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_base.py +0 -0
  74. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_grid.py +0 -0
  75. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_pandas.py +0 -0
  76. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_sheet_type.py +0 -0
  77. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_tables.py +0 -0
  78. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_trim_header_only.py +0 -0
  79. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/tests/test_uncached_formulas.py +0 -0
  80. {python_calamine_reducto-0.7.19 → python_calamine_reducto-0.7.21}/uv.lock +0 -0
@@ -70,7 +70,7 @@ jobs:
70
70
 
71
71
  build-macos:
72
72
  name: wheels macos-${{ matrix.target }}
73
- runs-on: macos-14
73
+ runs-on: macos-15
74
74
  strategy:
75
75
  fail-fast: false
76
76
  matrix:
@@ -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#297ee32c41c7449e22f9fb94e93c0291b0b4d15d"
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.19"
615
+ version = "0.7.21"
616
616
  dependencies = [
617
617
  "calamine",
618
618
  "chrono",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "python-calamine-reducto"
3
- version = "0.7.19"
3
+ version = "0.7.21"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-calamine-reducto
3
- Version: 0.7.19
3
+ Version: 0.7.21
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: Programming Language :: Python :: 3.11
@@ -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`**, **`get_sheet_freeze_panes`**,
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
 
@@ -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
- def apply_dateutil_reformat(self, date_parse: Callable[[str], float | None]) -> None:
265
- """Reformat text cells that have a date format. `date_parse` gives a date serial."""
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. xlsx only."""
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(self, name: str) -> list[tuple[int, int, str]]:
425
- """`(row, col, formula)` for each formula cell. Each formula starts with `=`."""
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
- xlsx does not count `""` cells, as `get_sheet_grid_streaming`. Other formats count them.
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, PasswordError, SheetMetadata, SheetTypeEnum, SheetVisibleEnum, TableNotFound,
7
- TablesNotLoaded, TablesNotSupported, WorkbookClosed, WorksheetNotFound, XmlError, ZipError,
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(|v| v.as_deref())),
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
- /// Python objects for optional strings: `str`, or `None`.
784
- fn py_strs<'a>(py: Python<'_>, values: impl Iterator<Item = Option<&'a str>>) -> Vec<PyObject> {
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
- if let Some(fmt) = parsed {
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
- // Call Python dateutil callback: date_parse(string) -> Optional[float]
1148
- let result = date_parse.call1((s.as_ref(),))?;
1149
- if result.is_none() {
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
- .to_display_string()
1167
- .map(|s| Arc::from(s.as_str()));
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(|v| v.as_deref()));
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
+ }