odsslicer 0.1.0__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 (34) hide show
  1. odsslicer-0.1.0/.github/workflows/ci.yml +31 -0
  2. odsslicer-0.1.0/.github/workflows/publish.yml +52 -0
  3. odsslicer-0.1.0/.gitignore +17 -0
  4. odsslicer-0.1.0/LICENSE +21 -0
  5. odsslicer-0.1.0/PKG-INFO +364 -0
  6. odsslicer-0.1.0/README.md +336 -0
  7. odsslicer-0.1.0/example/.vscode/launch.json +15 -0
  8. odsslicer-0.1.0/example/VSCode.command +4 -0
  9. odsslicer-0.1.0/example/example.ods +0 -0
  10. odsslicer-0.1.0/example/example.py +75 -0
  11. odsslicer-0.1.0/pyproject.toml +47 -0
  12. odsslicer-0.1.0/rsc/OpenDocument-v1.2-part1.pdf +0 -0
  13. odsslicer-0.1.0/rsc/OpenDocument-v1.2-part2.pdf +0 -0
  14. odsslicer-0.1.0/rsc/content.xml +177 -0
  15. odsslicer-0.1.0/rsc/xml_ods_fmt.txt +57 -0
  16. odsslicer-0.1.0/setup.cfg +4 -0
  17. odsslicer-0.1.0/src/odsslicer/__init__.py +28 -0
  18. odsslicer-0.1.0/src/odsslicer/classes.py +982 -0
  19. odsslicer-0.1.0/src/odsslicer.egg-info/PKG-INFO +364 -0
  20. odsslicer-0.1.0/src/odsslicer.egg-info/SOURCES.txt +32 -0
  21. odsslicer-0.1.0/src/odsslicer.egg-info/dependency_links.txt +1 -0
  22. odsslicer-0.1.0/src/odsslicer.egg-info/requires.txt +6 -0
  23. odsslicer-0.1.0/src/odsslicer.egg-info/scm_file_list.json +29 -0
  24. odsslicer-0.1.0/src/odsslicer.egg-info/scm_version.json +8 -0
  25. odsslicer-0.1.0/src/odsslicer.egg-info/top_level.txt +1 -0
  26. odsslicer-0.1.0/tests/.vscode/launch.json +15 -0
  27. odsslicer-0.1.0/tests/Notes 2.ods +0 -0
  28. odsslicer-0.1.0/tests/Notes.ods +0 -0
  29. odsslicer-0.1.0/tests/TEST.ods +0 -0
  30. odsslicer-0.1.0/tests/VSCode.command +4 -0
  31. odsslicer-0.1.0/tests/conftest.py +51 -0
  32. odsslicer-0.1.0/tests/test_odsslicer.py +833 -0
  33. odsslicer-0.1.0/tests/tests.py +81 -0
  34. odsslicer-0.1.0/tests/tests2.py +15 -0
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0 # full history so setuptools-scm can see tags
21
+
22
+ - name: Set up Python ${{ matrix.python-version }}
23
+ uses: actions/setup-python@v5
24
+ with:
25
+ python-version: ${{ matrix.python-version }}
26
+
27
+ - name: Install the package with test dependencies
28
+ run: pip install -e ".[test]"
29
+
30
+ - name: Run the test suite
31
+ run: pytest
@@ -0,0 +1,52 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ with:
13
+ fetch-depth: 0 # full history + tags, needed by setuptools-scm to derive the version
14
+
15
+ - name: Set up Python
16
+ uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+
20
+ - name: Install the package and run the test suite
21
+ run: |
22
+ pip install -e ".[test]"
23
+ pytest
24
+
25
+ - name: Build sdist and wheel
26
+ run: |
27
+ pip install build
28
+ python -m build
29
+
30
+ - name: Upload build artifacts
31
+ uses: actions/upload-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+
36
+ publish:
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment:
40
+ name: pypi
41
+ url: https://pypi.org/project/odsslicer/
42
+ permissions:
43
+ id-token: write # required for PyPI trusted publishing (OIDC), no API token needed
44
+ steps:
45
+ - name: Download build artifacts
46
+ uses: actions/download-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+
51
+ - name: Publish to PyPI
52
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+
5
+ # packaging artifacts (setuptools/build, setuptools-scm)
6
+ /dist/
7
+ /build/
8
+ *.egg-info/
9
+ .pytest_cache/
10
+
11
+ # scratch folder used during development to inspect raw ODS/XML internals,
12
+ # duplicates ods/tests/TEST.ods
13
+ /writing tests/
14
+
15
+ # artifacts generated by ODSReader.export_content_xml() / manual test runs
16
+ tests/*.xml
17
+ tests/Notes 2.txt
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,364 @@
1
+ Metadata-Version: 2.4
2
+ Name: odsslicer
3
+ Version: 0.1.0
4
+ Summary: A numpy-style reader/writer for .ods (OpenDocument Spreadsheet) files
5
+ Author-email: Anto <antonin.marchand@normalesup.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/antnardo/odsslicer
8
+ Project-URL: Repository, https://github.com/antnardo/odsslicer
9
+ Project-URL: Issues, https://github.com/antnardo/odsslicer/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Office/Business :: Office Suites
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: beautifulsoup4>=4.9
23
+ Requires-Dist: lxml>=4.6
24
+ Requires-Dist: numpy>=1.20
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # odsslicer
30
+
31
+ [![CI](https://github.com/antnardo/odsslicer/actions/workflows/ci.yml/badge.svg)](https://github.com/antnardo/odsslicer/actions/workflows/ci.yml)
32
+
33
+ Python reader for `.ods` files (OpenDocument Spreadsheet, LibreOffice/OpenOffice Calc), with a
34
+ numpy-inspired indexing API: `sheet["A1"]`, `sheet[0, 0]`, `sheet["A1:B3"]`, plain Python
35
+ slices, etc.
36
+
37
+ The module parses `content.xml` directly (via BeautifulSoup) and handles ODF cell types
38
+ (text, number, percentage, currency, date, time, boolean), formulas, as well as repeated and
39
+ merged rows/columns.
40
+
41
+ Write support: `cell.value = ...` then `reader.save(...)`. Repeated or merged cells are
42
+ automatically unrolled/unmerged in the background on first write access, and writing beyond a
43
+ sheet's current extent grows it automatically (new rows/columns) — see
44
+ [Writing](#writing-experimental) below for details and remaining limitations.
45
+
46
+ ## Installation
47
+
48
+ Not published on PyPI yet. Install directly from GitHub in the meantime:
49
+
50
+ ```bash
51
+ pip install git+https://github.com/antnardo/odsslicer.git
52
+ ```
53
+
54
+ Or clone it and install it editable (for local development):
55
+
56
+ ```bash
57
+ git clone https://github.com/antnardo/odsslicer.git
58
+ cd odsslicer
59
+ pip install -e ".[test]"
60
+ ```
61
+
62
+ ### Dependencies
63
+
64
+ - [`beautifulsoup4`](https://pypi.org/project/beautifulsoup4/) + `lxml` (XML parser)
65
+ - [`numpy`](https://pypi.org/project/numpy/)
66
+
67
+ Installed automatically as dependencies of the package above.
68
+
69
+ ## Quick usage
70
+
71
+ ```python
72
+ from odsslicer import ODSReader
73
+ from pathlib import Path
74
+
75
+ table = ODSReader(Path("workbook.ods"))
76
+ table.sheets_names # ["Sheet1", "Sheet2", ...]
77
+ table.sheets # list of Sheet (cached)
78
+ sheet = table.sheet("Sheet1")
79
+
80
+ sheet["A1"] # cell A1 (Cell)
81
+ sheet[0, 0] # equivalent: (row, col), 0-indexed
82
+ sheet[0] # entire row 1 (same as sheet["1"])
83
+ sheet[:, 0] # entire column A (same as sheet["A"])
84
+ sheet["A1:B3"] # block, equivalent to sheet[0:3, 0:2]
85
+
86
+ sheet["ZZZ100000"] # outside the data: returns an empty cell (value=None), no error
87
+ ```
88
+
89
+ An address or slice outside the data always returns empty cells (`value=None`) of the correct
90
+ shape, rather than an error — the shape follows the same conventions as numpy (a (n, 1) column
91
+ stays 2D, see `to_vector()` below to flatten it).
92
+
93
+ ### Cells (`Cell`)
94
+
95
+ ```python
96
+ cell = sheet["A1"]
97
+ cell.value # typed value (str / float / bool / datetime.date / datetime.time / None)
98
+ cell.text # text as displayed in the spreadsheet (always a str, or None)
99
+ str(cell) # == cell.text (or "None")
100
+ cell.format # "string" / "float" / "percentage" / "currency" / "date" / "time" / "boolean" / None
101
+ cell.row, cell.col # 0-indexed position
102
+ cell.address # spreadsheet-style address, e.g. "A1", "AZ12"
103
+ cell.is_formula # True if the cell holds an ODF formula
104
+ cell.is_empty # True if no value/text/format is set
105
+ ```
106
+
107
+ `Cell` supports the usual numeric conversions (`int()`, `float()`, `round()`, `abs()`, `-`,
108
+ `+`, `math.trunc/ceil/floor`) and comparisons (`==`, `<`, `>`, `<=`, `>=`), all of which operate
109
+ on `cell.value`. Note: comparing an empty cell (`value=None`) to a numeric cell raises a
110
+ `TypeError`, just like plain Python (`None < 3.4`).
111
+
112
+ Available formats are listed in `odsslicer.FORMATS` (ODF format -> conversion callable).
113
+
114
+ ### Arrays (`ArrayValues`)
115
+
116
+ Any multi-cell selection (`sheet[0]`, `sheet[:, 0]`, `sheet["A1:B3"]`, iterating over a
117
+ `Sheet`...) returns an `ArrayValues` object, a wrapper around a list of `Cell` (1D) or a list
118
+ of lists of `Cell` (2D):
119
+
120
+ ```python
121
+ arr = sheet["A1:B3"]
122
+ arr.dimension # 0 (a single cell), 1 (row/column), or 2 (block)
123
+ arr.size # numpy-style shape, e.g. (3, 2)
124
+ arr.to_list() # raw values (list or list of list), without the Cell objects
125
+ arr.to_numpy() # np.array of the values
126
+ arr.to_vector() # for a (n, 1) shape: returns a 1D ArrayValues of size (n,)
127
+ ```
128
+
129
+ Equality (`==`) between two `ArrayValues` compares the values (`to_list()`), not the identity
130
+ of the `Cell` objects.
131
+
132
+ ### Iteration
133
+
134
+ ```python
135
+ for row in sheet: # equivalent to sheet[:]
136
+ for cell in row:
137
+ print(cell.address, cell.value)
138
+ ```
139
+
140
+ ## Writing (experimental)
141
+
142
+ `Cell.value` is writable — the new value replaces the underlying XML content directly in
143
+ memory:
144
+
145
+ ```python
146
+ from odsslicer import ODSReader
147
+
148
+ table = ODSReader("workbook.ods")
149
+ sheet = table.sheet("Sheet1")
150
+
151
+ sheet["A1"].value = "new text"
152
+ sheet["A2"].value = 42.5
153
+ sheet["A3"].value = None # clears the cell
154
+
155
+ table.save("modified_workbook.ods") # or table.save() to overwrite the source file
156
+ ```
157
+
158
+ Accepted types for writing: `str`, `int`/`float`, `bool`, `datetime.date`, `datetime.time`,
159
+ and `None` (clears the cell). Writing a number over a cell already formatted as `percentage`
160
+ or `currency` keeps that format. Writing over a cell that held a formula erases the formula
161
+ (`is_formula` becomes `False` again).
162
+
163
+ `ODSReader.save(path=None)` rewrites the `.ods`: `content.xml` is regenerated from the
164
+ in-memory tree, every other zip member (`styles.xml`, `meta.xml`, `settings.xml`,
165
+ `manifest.xml`, thumbnail...) is copied through unchanged from the source file, and the ODF
166
+ convention (`mimetype` first, uncompressed) is respected. With no argument, `save()`
167
+ overwrites the source file.
168
+
169
+ ### Automatic unrolling of repeated and merged cells
170
+
171
+ ODS compresses identical rows/columns into a single XML element shared between several
172
+ `Cell`s, and represents a merge via a top-left "master" cell (carrying the
173
+ `table:number-*-spanned` attributes) plus hidden `table:covered-table-cell` cells. Writing to
174
+ one of these cells automatically triggers, in the background, the "unrolling" of the
175
+ structure involved — the compressed row/column is split into individual XML elements, and/or
176
+ the merge is undone — before the new value is applied:
177
+
178
+ ```python
179
+ sheet["C5"].value = 42 # C5 was part of a block of 6 compressed rows: the block is split
180
+ # into 6 independent rows, only C5's value changes, the other 35
181
+ # cells in the block keep their original value
182
+ ```
183
+
184
+ Writing to a merged cell (master or hidden) undoes the whole merge: every previously hidden
185
+ cell becomes independent again and reveals its own value — ODF already stores it internally
186
+ under `table:covered-table-cell`, exactly as LibreOffice would when manually un-merging.
187
+ `Cell` objects already obtained before the write remain valid and are automatically repointed
188
+ to their new individual XML element; `sheet.size` never changes as a result of unrolling (the
189
+ logical row/column count was already that value).
190
+
191
+ ### Automatic sheet growth
192
+
193
+ Writing to an address outside the current extent (`sheet.size`) grows the sheet instead of
194
+ raising an error: existing rows are widened with blank cells if the requested column exceeds
195
+ the current width, then new (full-width, blank) rows are appended if the requested row
196
+ exceeds the current height — including growing a completely empty sheet
197
+ (`sheet.size == (0, 0)`) from scratch:
198
+
199
+ ```python
200
+ sheet.size # (9, 2)
201
+ sheet["E12"].value = "corner"
202
+ sheet.size # (12, 5): rows 10-12 added, columns C-E added, everything else blank
203
+ ```
204
+
205
+ `sheet.size`/`n_rows`/`n_cols` immediately reflect the new extent, and a plain read
206
+ (`sheet.get_row(50)`, `sheet["Z1"].value` with no assignment) never grows anything — only a
207
+ write (`.value = ...`) triggers growth. New rows/cells don't inherit any particular style
208
+ (default formatting).
209
+
210
+ ### Displayed text: learned from an example rather than a raw conversion
211
+
212
+ ODF doesn't just store a cell's value (`office:value`): it also stores the text as displayed
213
+ (`text:p`), typically formatted according to the document's locale (decimal separator,
214
+ `%`/`€` suffix, date format...). Rather than imposing an arbitrary format on write,
215
+ `odsslicer` looks for **another cell of the same format** in the document (preferring the
216
+ cell's own prior content if it already had a value), compares its raw value to its displayed
217
+ text to infer a pattern (decimal separator, decimal count, prefix/suffix, or a date pattern
218
+ like `%d/%m/%y` etc.), checks that the pattern reproduces the example exactly, then applies it
219
+ to the new value:
220
+
221
+ ```python
222
+ sheet["A6"].text # "200.00 %" (value 2.0)
223
+ sheet["A6"].value = 0.5
224
+ sheet["A6"].text # "50.00 %" — same style as the cell's previous content
225
+
226
+ sheet["A8"].text # "28/02/21" (day/month/2-digit-year format)
227
+ sheet["A8"].value = date(2030, 1, 5)
228
+ sheet["A8"].text # "05/01/30"
229
+ ```
230
+
231
+ If no example is found, or if the inferred pattern doesn't reproduce the example's text
232
+ exactly (and is therefore deemed unreliable), `odsslicer` falls back to a plain Python
233
+ conversion rather than producing incoherent text. For "general" numbers (plain `float`
234
+ format, not percentage/currency), only the decimal separator is reused — never the decimal
235
+ count, which would truncate the new value's precision.
236
+
237
+ ### What is **not** supported
238
+
239
+ - No formula writing, no creating new sheets.
240
+ - No real ODF formatting engine (resolving `styles.xml`, the document's locale, the actual
241
+ currency): the inference above is a learn-by-example heuristic, not a read of the cell's
242
+ style — it can silently fail (falling back to a plain conversion) for a format no other
243
+ cell in the document already illustrates.
244
+
245
+ ## Cell addressing
246
+
247
+ `Sheet.address(string, n_rows=1)` converts a text address into a Python index/slice:
248
+
249
+ | Notation | Result |
250
+ |---------------|------------------------------------------|
251
+ | `"A1"` | `(0, 0)` — (row, col) |
252
+ | `"1"` | `0` — single row |
253
+ | `"A"` | `(slice(n_rows), 0)` — entire column |
254
+ | `"A1:B3"` | `(slice(0, 3), slice(0, 2))` |
255
+ | `"A:B"` | `(slice(n_rows), slice(0, 2))` |
256
+ | `"1:2"` | `slice(0, 2)` |
257
+
258
+ A malformed address (`"1A"`, `"A:2"`, `"2:A"`, `"B:A"`...) raises a `ValueError`.
259
+
260
+ `Sheet.string_address(row, col)` performs the reverse conversion (0-indexed index -> `"A1"`,
261
+ `"AZ12"`...) and `Sheet.string_to_col("AZ")` converts column letters to an index — both use
262
+ the usual spreadsheet bijective base-26 numbering (`Z` = 25, `AA` = 26, `AZ` = 51, `BA` =
263
+ 52...).
264
+
265
+ ## Known limitations
266
+
267
+ - **Writing**: see the detailed limitations in [Writing (experimental)](#writing-experimental)
268
+ above.
269
+ - **Formulas**: the value cached by the spreadsheet is read (`office:value`), the formula
270
+ itself is not re-evaluated (and writing obviously doesn't recompute anything either).
271
+ - Sheets/rows/columns beyond `MAX_REPEAT_ROWS` / `MAX_REPEAT_COLS` (see `src/odsslicer/classes.py`) are
272
+ detected and discarded to avoid materializing rows or columns of size `2**20`/`2**10`
273
+ created by LibreOffice for a sheet's default styling — a `[WARNING]` is printed if a row
274
+ length inconsistency is detected after this cleanup.
275
+
276
+ ## Tests
277
+
278
+ ```bash
279
+ git clone https://github.com/antnardo/odsslicer.git
280
+ cd odsslicer
281
+ pip install -e ".[test]"
282
+ pytest
283
+ ```
284
+
285
+ The suite (`tests/test_odsslicer.py`) covers addressing (`Sheet.address`,
286
+ `Sheet.string_address`/`string_to_col`), cell types, repeated and merged rows/columns, empty
287
+ sheets, `ArrayValues`, writing (`Cell.value = ...`, `ODSReader.save()` and its safeguards), as
288
+ well as regression tests for the fixed bugs (see below). It runs on every push/PR via
289
+ [GitHub Actions](.github/workflows/ci.yml) across Python 3.10 to 3.13.
290
+
291
+ ## Are there already equivalent PyPI modules?
292
+
293
+ | Package | Read/Write | Latest release | Status |
294
+ |---|---|---|---|
295
+ | `odfpy` | Low-level R/W | Jan. 2020 | Nearly abandoned (82 open issues), but still the brick pandas uses internally |
296
+ | `pyexcel-ods(3)` | R/W | > 1 year | Inactive |
297
+ | `ezodf` | R/W | Dec. 2015 | Abandoned for 10 years |
298
+ | `pandas` (`engine="odf"`) | Read (delegates to odfpy) | follows pandas | Convenient but loses formulas/fine-grained formats |
299
+ | `python-calamine` | Read-only (Rust), fast | active | The most actively maintained option for pure reading |
300
+ | `odfdo` (modern fork of odfpy) | Full R/W | recent, regular | Actively maintained, DOM-like API |
301
+ | `pandas-ods-reader` | Read-only -> DataFrame | May 2025 | Maintained, limited scope |
302
+
303
+ None of these packages offer a numpy-style API (`sheet["A1"]`, slicing by cell address) or the
304
+ same granularity on cell formats (currency/percentage/date/time) and merged/repeated cells —
305
+ that's the main argument for publishing this module rather than simply recommending `odfdo`
306
+ or `python-calamine`.
307
+
308
+ ## Versions
309
+
310
+ Version numbers are derived automatically from git tags (nothing to bump by hand in the
311
+ source) and follow [Semantic Versioning](https://semver.org/) — while the major version stays
312
+ `0`, the API can still change between minor versions. See the
313
+ [Releases](https://github.com/antnardo/odsslicer/releases) page for the changelog of each
314
+ version.
315
+
316
+ ## License
317
+
318
+ [MIT](LICENSE) — reuse with essentially no restriction, just keep the copyright notice.
319
+
320
+ ## Project name
321
+
322
+ Chosen name: **`odsslicer`** (available on PyPI as of 2026-07-29), to reflect the module's
323
+ real differentiator — numpy-style indexing/slicing by cell address — rather than a generic
324
+ "ods reader".
325
+
326
+ ## History of fixes made before publication
327
+
328
+ While rereading the module for this publication, the following bugs (present in the internal
329
+ version) were fixed in `src/odsslicer/classes.py`:
330
+
331
+ 1. **`Sheet.string_address`** produced a wrong address for most multi-letter columns (e.g.
332
+ column 27 → `"BB1"` instead of `"AB1"`, column 51 → `"ZZ1"` instead of `"AZ1"`) due to a
333
+ poorly implemented base-26 numbering. Fixed with the standard bijective numbering
334
+ algorithm.
335
+ 2. **`Sheet.get_col`** compared the requested column index to `self.n_rows` instead of
336
+ `self.n_cols` to detect an out-of-range access: on any sheet with more rows than columns,
337
+ requesting an out-of-range column raised an `IndexError` instead of returning an empty
338
+ column.
339
+ 3. The `[WARNING]` for rows of differing lengths **never** fired, even in the presence of a
340
+ genuine inconsistency: `rows_len` was a `map` iterator already exhausted once by `max()`,
341
+ hence empty on the second read used to compute the warning.
342
+ 4. **`Sheet.empty_row` / `Sheet.empty_col`**, when explicitly passed the `slice` argument,
343
+ returned one fewer element than expected (an element count was recomputed and then
344
+ mistakenly reused as a `range` stop bound).
345
+ 5. **`ODSReader.sheets`** was a single-use generator property (couldn't `len()` it or iterate
346
+ it twice), with dead code after the `yield` that could never execute. Replaced with a
347
+ plain, reusable list.
348
+ 6. `Cell.__floot__` (a typo for `__floor__`) was fixed — with no observed functional impact
349
+ (Python fell back to `__float__` for `math.floor()`), but it kept a misleading name.
350
+ 7. **Reading boolean cells**: the `"boolean"` format looked up the value in `office:value`
351
+ (like numbers) instead of the actual ODF attribute `office:boolean-value`, and converted
352
+ it with `bool(s)` — which returns `True` for the non-empty string `"false"`. A real ODF
353
+ boolean cell therefore always read back as `False`. Fixed (reading and writing are now
354
+ symmetric for this format).
355
+ 8. **`Cell.text`/`str(cell)` returned the literal string `"None"`** instead of the actual
356
+ text, in two common cases: a cell whose `<text:p>` is empty (typically a formula whose
357
+ cached result is an empty string) and a cell whose text is spread across several nodes
358
+ (`<text:span>` for partial formatting, e.g. "1st" with "st" as superscript). In both
359
+ cases, `text:p.string` (bs4) is `None` whenever there isn't *exactly* one text child, and
360
+ the old code did `str(p.string)`, turning that `None` into the string `"None"`. Fixed by
361
+ using `p.get_text()`, which correctly concatenates all descendant text (and returns `""`
362
+ for a genuinely empty cell).
363
+
364
+ All of these cases are covered by regression tests in `tests/test_odsslicer.py`.