python-calamine-reducto 0.7.20__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.20 → python_calamine_reducto-0.7.21}/.github/workflows/release.yml +1 -1
  2. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/Cargo.lock +2 -2
  3. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/Cargo.toml +1 -1
  4. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/PKG-INFO +1 -1
  5. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/python/python_calamine/__init__.py +4 -0
  6. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/python/python_calamine/_python_calamine.pyi +59 -9
  7. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/src/lib.rs +5 -2
  8. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/src/types/errors.rs +19 -0
  9. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/src/types/grid.rs +208 -22
  10. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/src/types/mod.rs +2 -2
  11. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/src/types/workbook.rs +1193 -235
  12. python_calamine_reducto-0.7.21/tests/data/formula_vals.xlsb +0 -0
  13. python_calamine_reducto-0.7.21/tests/sparse_builders.py +47 -0
  14. python_calamine_reducto-0.7.21/tests/test_bounded_reads.py +519 -0
  15. python_calamine_reducto-0.7.21/tests/test_error_values.py +137 -0
  16. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_gap_compression.py +72 -2
  17. python_calamine_reducto-0.7.21/tests/test_merge_cleanup.py +82 -0
  18. python_calamine_reducto-0.7.21/tests/test_shared_strings.py +169 -0
  19. python_calamine_reducto-0.7.21/tests/xlsb_builder.py +51 -0
  20. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/xlsx_builder.py +48 -1
  21. python_calamine_reducto-0.7.20/tests/test_bounded_reads.py +0 -195
  22. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/.github/workflows/CI.yml +0 -0
  23. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/.gitignore +0 -0
  24. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/.pre-commit-config.yaml +0 -0
  25. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/.python-version +0 -0
  26. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/CLAUDE.md +0 -0
  27. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/LICENSE +0 -0
  28. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/README.md +0 -0
  29. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/build.rs +0 -0
  30. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/pyproject.toml +0 -0
  31. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/python/python_calamine/py.typed +0 -0
  32. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/renovate.json +0 -0
  33. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/src/types/cell.rs +0 -0
  34. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/src/types/sheet.rs +0 -0
  35. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/src/types/table.rs +0 -0
  36. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/__init__.py +0 -0
  37. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/conftest.py +0 -0
  38. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/create_empty_merge_fixture.py +0 -0
  39. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/create_hidden_col_range_fixture.py +0 -0
  40. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/create_hyperlink_fixture.py +0 -0
  41. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/create_richtext_fixture.py +0 -0
  42. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/create_test_fixture.py +0 -0
  43. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.ods +0 -0
  44. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xls +0 -0
  45. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xlsb +0 -0
  46. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/any_sheets.xlsx +0 -0
  47. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/base.ods +0 -0
  48. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/base.xls +0 -0
  49. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/base.xlsb +0 -0
  50. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/base.xlsx +0 -0
  51. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/dropdown_test.xlsx +0 -0
  52. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/empty_file.ods +0 -0
  53. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/empty_file.xlsb +0 -0
  54. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/empty_file.xlsx +0 -0
  55. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/empty_merge_test.xlsx +0 -0
  56. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/formula_test.xlsx +0 -0
  57. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/grid_test.xlsx +0 -0
  58. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/hidden_col_range_test.xlsx +0 -0
  59. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/hyperlink_test.xlsx +0 -0
  60. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/image_test.xlsx +0 -0
  61. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/issue139.xlsx +0 -0
  62. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/multi_image_test.xlsx +0 -0
  63. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/password.ods +0 -0
  64. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/password.xls +0 -0
  65. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/password.xlsb +0 -0
  66. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/password.xlsx +0 -0
  67. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/richtext_test.xlsx +0 -0
  68. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/table-multiple.xlsb +0 -0
  69. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/table-multiple.xlsx +0 -0
  70. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/data/temperature-table.xlsb +0 -0
  71. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_band.py +0 -0
  72. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_base.py +0 -0
  73. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_grid.py +0 -0
  74. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_images.py +0 -0
  75. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_pandas.py +0 -0
  76. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_sheet_type.py +0 -0
  77. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_tables.py +0 -0
  78. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_trim_header_only.py +0 -0
  79. {python_calamine_reducto-0.7.20 → python_calamine_reducto-0.7.21}/tests/test_uncached_formulas.py +0 -0
  80. {python_calamine_reducto-0.7.20 → 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#5a6418cb25f78e47a841f51c42c85363ee2cd06b"
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.20"
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.20"
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.20
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
@@ -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",
@@ -241,6 +241,10 @@ class CellGrid:
241
241
  def col_coords(self) -> npt.NDArray[np.int32]:
242
242
  """Grid column -> 0-based sheet column."""
243
243
 
244
+ @property
245
+ def gap_unpinned(self) -> bool:
246
+ """Whether gap compression removed rows or columns across frozen panes or inside merges."""
247
+
244
248
  @property
245
249
  def uncached_formula_count(self) -> int | None:
246
250
  """Formula cells with no cached value (streaming xlsx grids), else `None`."""
@@ -274,12 +278,19 @@ class CellGrid:
274
278
  value: bool,
275
279
  ) -> None: ...
276
280
  def apply_formatting_rust(self) -> None:
277
- """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."""
278
282
 
279
283
  def apply_dateutil_reformat(
280
284
  self, date_parse: Callable[[str], float | None]
281
285
  ) -> None:
282
- """Reformat text cells that have a date format. `date_parse` gives a date serial."""
286
+ """Reformat text cells that have a date format. `date_parse` gives a date serial.
287
+
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
+ """
283
294
 
284
295
  def apply_raw_as_formatted(self) -> None: ...
285
296
  def apply_color_names(self, converter: Callable[[str], str | None]) -> None:
@@ -411,6 +422,7 @@ class CalamineWorkbook:
411
422
  coverage: float = 0.999,
412
423
  trim_header_only: bool = False,
413
424
  max_empty_run: int | None = None,
425
+ max_grid_cells: int | None = None,
414
426
  ) -> tuple[CellGrid, int, int, int]:
415
427
  """Stream a grid. Returns `(grid, declared_rows, declared_cols, dropped_cells)`.
416
428
 
@@ -418,10 +430,17 @@ class CalamineWorkbook:
418
430
  `max_empty_run` keeps at most that many rows (and columns) of each empty run inside the
419
431
  trimmed bounds. It engages only above `trigger_cells`, and only when it halves the grid.
420
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.
421
440
  """
422
441
 
423
442
  def get_sheet_filled_count(self, name: str) -> tuple[int, int, int]:
424
- """`(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."""
425
444
 
426
445
  def get_sheet_uncached_formula_count(self, name: str) -> int | None: ...
427
446
  def for_each_band(
@@ -438,8 +457,15 @@ class CalamineWorkbook:
438
457
  ) -> tuple[int, int, int]:
439
458
  """Stream the sheet as row bands. Returns `(declared_rows, declared_cols, dropped_cells)`."""
440
459
 
441
- def get_sheet_formulas(self, name: str) -> list[tuple[int, int, str]]:
442
- """`(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
+ """
443
469
 
444
470
  def get_sheet_images(self, name: str) -> list[tuple[bytes, str, int, int]]:
445
471
  """`(data, extension, anchor_row, anchor_col)` for each image."""
@@ -451,20 +477,40 @@ class CalamineWorkbook:
451
477
  """The links of the sheet's `<hyperlinks>` element, at most `limit`. Empty for xlsb, xls, and ods."""
452
478
 
453
479
  def get_sheet_hyperlink_formulas(
454
- self, name: str, limit: int | None = None
480
+ self, name: str, limit: int | None = None, max_formula_bytes: int = 268_435_456
455
481
  ) -> list[tuple[int, int, str]]:
456
- """`(row, col, formula)` for each formula that contains `HYPERLINK`, at most `limit`. Empty for xlsb, xls, and ods."""
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
+ """
457
486
 
458
487
  def get_sheet_freeze_panes(self, name: str) -> tuple[int, int]:
459
488
  """`(frozen_rows, frozen_cols)`."""
460
489
 
461
490
  def get_sheet_merged_ranges(self, name: str) -> list[tuple[int, int, int, int]]:
462
- """`(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
+ """
463
509
 
464
510
  def get_sheet_bounds(self, name: str) -> tuple[int, int]:
465
511
  """`(max_filled_row, max_filled_col)`, 1-based and exclusive.
466
512
 
467
- 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`.
468
514
  """
469
515
 
470
516
  class CalamineError(Exception): ...
@@ -476,6 +522,8 @@ class WorkbookClosed(CalamineError): ...
476
522
  class TablesNotLoaded(CalamineError): ...
477
523
  class TablesNotSupported(CalamineError): ...
478
524
  class TableNotFound(CalamineError): ...
525
+ class GridTooLarge(CalamineError): ...
526
+ class FormulasTooLarge(CalamineError): ...
479
527
 
480
528
  def load_workbook(
481
529
  path_or_filelike: str | os.PathLike | ReadBuffer, load_tables: bool = False
@@ -494,6 +542,8 @@ __all__ = [
494
542
  "CalamineWorkbook",
495
543
  "CellGrid",
496
544
  "CellProxy",
545
+ "FormulasTooLarge",
546
+ "GridTooLarge",
497
547
  "PasswordError",
498
548
  "SheetMetadata",
499
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
+ }
@@ -6,8 +6,8 @@ mod table;
6
6
  mod workbook;
7
7
  pub use cell::CellValue;
8
8
  pub use errors::{
9
- CalamineError, Error, PasswordError, TableNotFound, TablesNotLoaded, TablesNotSupported,
10
- WorkbookClosed, WorksheetNotFound, XmlError, ZipError,
9
+ CalamineError, Error, FormulasTooLarge, GridTooLarge, PasswordError, TableNotFound,
10
+ TablesNotLoaded, TablesNotSupported, WorkbookClosed, WorksheetNotFound, XmlError, ZipError,
11
11
  };
12
12
  pub use grid::{CellGrid, CellProxy};
13
13
  pub use sheet::{CalamineSheet, SheetMetadata, SheetTypeEnum, SheetVisibleEnum};