calcsheet 0.1.0 → 0.2.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +124 -9
  4. data/lib/calcsheet/book.rb +25 -9
  5. data/lib/calcsheet/cell_range.rb +54 -0
  6. data/lib/calcsheet/configuration.rb +10 -0
  7. data/lib/calcsheet/display_format.rb +75 -0
  8. data/lib/calcsheet/errors.rb +5 -0
  9. data/lib/calcsheet/formula.rb +40 -0
  10. data/lib/calcsheet/limited_inflater.rb +16 -0
  11. data/lib/calcsheet/native_backends.rb +139 -0
  12. data/lib/calcsheet/option_validation.rb +4 -1
  13. data/lib/calcsheet/raw_row.rb +1 -1
  14. data/lib/calcsheet/readers/csv_reader.rb +43 -20
  15. data/lib/calcsheet/readers/csv_records.rb +193 -0
  16. data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
  17. data/lib/calcsheet/readers/ods/scanner.rb +33 -6
  18. data/lib/calcsheet/readers/ods_reader.rb +16 -1
  19. data/lib/calcsheet/readers/sax_sheet.rb +6 -48
  20. data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
  21. data/lib/calcsheet/reading.rb +29 -7
  22. data/lib/calcsheet/row.rb +2 -2
  23. data/lib/calcsheet/row_builder.rb +235 -0
  24. data/lib/calcsheet/sheet_list.rb +25 -0
  25. data/lib/calcsheet/sheet_selection.rb +3 -2
  26. data/lib/calcsheet/style.rb +114 -0
  27. data/lib/calcsheet/version.rb +1 -1
  28. data/lib/calcsheet/write_book.rb +49 -17
  29. data/lib/calcsheet/write_sheet.rb +220 -18
  30. data/lib/calcsheet/writers/csv_writer.rb +18 -7
  31. data/lib/calcsheet/writers/ods_writer.rb +249 -16
  32. data/lib/calcsheet/writers/open_formula.rb +67 -0
  33. data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
  34. data/lib/calcsheet/writers/zip_buffered.rb +20 -1
  35. data/lib/calcsheet.rb +31 -8
  36. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79bc1c89fd78d1d3350d4018f828341811fbeb50ca15bcdf57bebf4673196d1a
4
- data.tar.gz: a32050d33976568d5a40ba3667bc4abce2a6c5bc28b6e7dd1ea49ecc63bc7bca
3
+ metadata.gz: 291f855f2646898ede484b6dbaca57d4cac890387ece27c70352fbb463770057
4
+ data.tar.gz: 4cb2e6174412b74611168b5da6daf2844fcd8f33ae5c1d88b6b4f528b777267a
5
5
  SHA512:
6
- metadata.gz: 5a2c9a9420a2f5869519ce1eaa10dd7463ab21e63ad45de2d2e948fe54af0f66bda5a9ce424e06b17b3adcf01c0bad941b8dce7a529fe486559696ee2669ccdc
7
- data.tar.gz: 06acdf306e97bd8a9305e2b5a537e1d119a6e3c47257831abacdba9a5fe52cd752316f87e7e05b74fbf9cc5e12c1ae10b83d9400b51b7319de68b5d02a3831d5
6
+ metadata.gz: c10916111f1bd9d947e7964642b7e6db53e6af1e5acd98e43c8c98067c6212a4a272d6e0a753b3cd33d5f91403f858e3b93108d7e15a8d11e11a20959dfefdfe
7
+ data.tar.gz: b6ee874c89945474f843bed12c44b68a84ad8e56d81bc58528a783e513fa34009c53f419340aa37c2d0d5c006f553a05abdfd22baa1145b2564715e3fe77042b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-22
11
+
12
+ ### Added
13
+
14
+ - The `backend:` option (`:auto`, the default, `:ruby` or `:native`; also in `Calcsheet.configure`) and
15
+ `Book#backend`, a hook for the optional calcsheet-native gem, which is not released yet. `:auto` uses it when it is
16
+ installed and compatible, and otherwise reads with the Ruby backends (one warning when it is installed but
17
+ unusable); `:native` raises the new `Calcsheet::BackendUnavailableError` instead.
18
+ - `CALCSHEET_BACKEND` runs the suite against a selected backend.
19
+ - Styled writing for XLSX and ODS (ADR 015): `book.style(name, ...)` (font, colours, alignment, wrap, borders, number
20
+ formats such as `"0.0 %"`) applied with `sheet.add(row, style:)`; `Calcsheet::Formula` values, translated to
21
+ OpenFormula in ODS and written as text in CSV; `sheet.merge("A1:C1")`; sheets without `columns:` whose values take
22
+ the type of their class, with `widths:`; `auto_width: true`, which measures the columns, keeping the rows in a
23
+ temporary file until the sheet ends. The writer backend contract takes the new arguments as optional ones, so a
24
+ backend written for the previous contract keeps working for typed sheets.
25
+
26
+ ### Changed
27
+
28
+ - The CSV, XLSX and ODS readers share one conversion of raw cells into values, with no change of behavior.
29
+ - ADR 011 supersedes ADR 007: the native backend also reads CSV, converts in Ruby, and must give the same results as
30
+ the Ruby backends.
31
+ - A read lists the sheets of an ODS only as far as it selects them: the default sheet no longer costs a scan of the
32
+ whole `content.xml`, about a quarter of the read. A damaged table, a bad sheet name or too many sheets after the
33
+ sheets read raise only when a read or `book.sheets` reaches them; the new open option `precheck_sheets: true`
34
+ lists every sheet first, as before (ADR 013). The backend contract has the optional `#each_sheet`.
35
+ - CSV records are read by calcsheet's own CsvRecords instead of the stdlib parser when the sample shows the line
36
+ ending: the same records and errors, about a third faster (ADR 014).
37
+ - A zip stream runs a minor GC every 2 MiB it inflates, unless one ran meanwhile, so that its garbage no longer
38
+ grows the peak memory of a reader that allocates little else (calcsheet-native, byte scanners) by tens of MB.
39
+
40
+ ### Fixed
41
+
42
+ - A whitespace-only cell at the end of a row is kept as text with `types: :string` and native values, as it already
43
+ was inside the row; declared types that read whitespace as `nil` still trim it. In header mode it was `nil`, without
44
+ a header it was dropped (`"x,y, "` gave `["x", "y"]`).
45
+ - `skip_blank: false` yields the blank records at the end of a CSV, as it does those between records. XLSX and ODS
46
+ sheets still end with their last non-blank row.
47
+ - The backend contract (and so the calcsheet-native protocol 1, not released yet) trims rows this way and has the
48
+ optional `#end_row(sheet_index)`, which the CSV reader answers.
49
+
10
50
  ## [0.1.0] - 2026-10-21
11
51
 
12
52
  First release.
@@ -58,5 +98,6 @@ First release.
58
98
  - Report vulnerabilities as described in
59
99
  [SECURITY.md](https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/blob/main/SECURITY.md).
60
100
 
61
- [Unreleased]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/compare/v0.1.0...develop
101
+ [Unreleased]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/compare/v0.2.0...develop
102
+ [0.2.0]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/compare/v0.1.0...v0.2.0
62
103
  [0.1.0]: https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/tags/v0.1.0
data/README.md CHANGED
@@ -20,7 +20,7 @@ memory, not gigabytes. It is built for imports and exports of large lists:
20
20
 
21
21
  - [Installation](#installation)
22
22
  - [Reading](#reading)
23
- - [Writing](#writing)
23
+ - [Writing](#writing) ([styles, formulas and merges](#styles-formulas-and-merges))
24
24
  - [Types](#types)
25
25
  - [Row numbers](#row-numbers)
26
26
  - [Errors](#errors)
@@ -31,6 +31,7 @@ memory, not gigabytes. It is built for imports and exports of large lists:
31
31
  - [Time zones](#time-zones)
32
32
  - [Configuration](#configuration)
33
33
  - [Options reference](#options-reference)
34
+ - [Native backend](#native-backend)
34
35
  - [Concurrency](#concurrency)
35
36
  - [Performance](#performance)
36
37
  - [Compatibility](#compatibility)
@@ -44,7 +45,7 @@ Calcsheet will be published on [rubygems.org](https://rubygems.org/gems/calcshee
44
45
  release. Once it is released, add it to your Gemfile:
45
46
 
46
47
  ```rb
47
- gem "calcsheet", "~> 0.1"
48
+ gem "calcsheet", "~> 0.2"
48
49
  ```
49
50
 
50
51
  The runtime dependencies are `ox` (a SAX parser with a C extension), `rubyzip`, `zip_kit`, `bigdecimal` and `csv`.
@@ -158,7 +159,7 @@ opened: `max_file_size`, `max_uncompressed_size`, `max_compression_ratio`, `comp
158
159
  | `types:` | `:native` | `:string` reads every column without a declared type as text. |
159
160
  | `on_error:` | `:collect` | `:collect` attaches cell errors to the row; `:raise` raises the first one as `Calcsheet::CellError`. |
160
161
  | `start_at:` | `nil` | Resume an interrupted job: a row number, or `{ sheet:, row: }` when reading several sheets. |
161
- | `skip_blank:` | `true` | Rows without a non-blank cell are not yielded; row numbers keep the gaps. |
162
+ | `skip_blank:` | `true` | Rows without a non-blank cell are not yielded; row numbers keep the gaps. `false` yields them without values: between non-blank rows in every format, and also at the end of a CSV, whose blank records are records of the file. |
162
163
  | `include_hidden:` | `false` | `sheet: :all` includes the hidden sheets. A hidden sheet is otherwise read only when named or indexed. |
163
164
  | `required_headers:` | `nil` | Header names that must be present (ignoring case and whitespace), checked before the first row. |
164
165
 
@@ -175,8 +176,9 @@ Each `Calcsheet::Row` has:
175
176
  which `row["D"]` keeps meaning);
176
177
  - `errors` (an Array of `Calcsheet::CellError`) and `valid?`.
177
178
 
178
- Without a schema, `values` are as long as the row with trailing blank cells trimmed; in header mode they are padded
179
- with `nil` to the width of the header, and cells beyond the header are kept.
179
+ Without a schema, `values` end with the last non-blank cell or, after it, the last whitespace-only text kept as text
180
+ (see `required:` below); empty cells and whitespace that reads as `nil` are trimmed at the end. In header mode the
181
+ values are padded with `nil` to the width of the header, and cells beyond the header are kept.
180
182
 
181
183
  ```ruby
182
184
  require "calcsheet"
@@ -347,6 +349,59 @@ writing them if the file must hold rounded values. Numbers with more than 15 sig
347
349
  given: Calcsheet reads integers and numbers of more than 17 significant digits back exactly, rounds fractions of 16
348
350
  or 17 to 15, and Excel keeps only 15.
349
351
 
352
+ ### Styles, formulas and merges
353
+
354
+ ```ruby
355
+ require "calcsheet"
356
+
357
+ Calcsheet.write("report.xlsx") do |book|
358
+ book.style(:title, bold: true, size: 14, align: :center)
359
+ book.style(:head, bold: true, fill: "DDEEFF", wrap: true, valign: :top,
360
+ border: { style: :thin, color: "000000", edges: %i[top bottom] })
361
+ book.style(:percent, format: "0.0 %")
362
+
363
+ book.sheet("Answers", auto_width: true) do |sheet| # no columns: a free sheet
364
+ sheet.merge("A1:C1") # before the row it starts on
365
+ sheet.add(["Answers to question 1", nil, nil], style: :title)
366
+ sheet.add(["Answer", "Count", "Share"], style: :head)
367
+ sheet << ["Yes", 30, Calcsheet::Formula.new("B3/B5")]
368
+ sheet << ["No", 10, Calcsheet::Formula.new("B4/B5")]
369
+ sheet.add(["Total", Calcsheet::Formula.new("SUM(B3:B4)"), 1], style: [nil, nil, :percent])
370
+ end
371
+ end
372
+
373
+ Calcsheet.open("report.xlsx") do |book|
374
+ book.rows(header: false).map(&:values).last # => ["Total", nil, 1]
375
+ end
376
+ ```
377
+
378
+ `book.style(name, **attributes)` registers a style for every sheet of the book: `bold:`, `italic:`, `size:` (points),
379
+ `color:` and `fill:` (`"RRGGBB"`), `align:` (`:left`, `:center`, `:right`), `valign:` (`:top`, `:center`,
380
+ `:bottom`), `wrap:`, `border:` (`{ style:, color:, edges: }`, `style` one of `:thin`, `:medium`, `:thick`,
381
+ `:dashed`, `:dotted`, `:double`, `edges` some of `:top`, `:bottom`, `:left`, `:right`; all four by default) and
382
+ `format:`, a number format both formats can show: `"0"`, `"0.00"`, `"#,##0.00"`, percentages such as `"0%"` or
383
+ `"0.0 %"` (0.25 shows as 25.0 %), dates and times from `yyyy`, `mm`, `dd`, `hh`, `ss` (`"dd/mm/yyyy"`, `"hh:mm"`),
384
+ or `"@"` for text. An unknown attribute or value raises `ArgumentError`. `sheet.add(row, style:)` writes a row as
385
+ `<<` does, with the name of a style for every cell or an Array of names (or `nil`) by column; a style given for a
386
+ column beyond the values styles an empty cell (the border of a merged title). XLSX and ODS apply styles, CSV ignores
387
+ them.
388
+
389
+ - **Free sheets.** `book.sheet(name)` without `columns:` writes rows of any length (Arrays only, no header row), each
390
+ value with the type of its class: `String`, `Integer`, `BigDecimal`, `Date`, `Time`, `true`, `false`; a `Float` or
391
+ a `DateTime` raises `WriteError`, as in a typed column. `widths: [12, nil, 30]` sets their column widths.
392
+ - **Formulas.** `Calcsheet::Formula.new("SUM(B3:B4)")` (a leading `=` is optional) writes a formula in any sheet and
393
+ column, in the syntax of Excel: ODS gets it translated to OpenFormula (`of:=SUM([.B3:.B4])`, `;` between
394
+ arguments), CSV writes the text `=SUM(B3:B4)`. No result is stored: Excel and LibreOffice compute it when they open
395
+ the file, and calcsheet reads such a cell back as `nil`.
396
+ - **Merges.** `sheet.merge("A1:C1")` merges a range, rows counted from the first of the sheet (the header included).
397
+ ODS writes a merge on its first cell, so it must be declared before that row is written; the other cells it covers
398
+ must be empty (`WriteError` otherwise), and a merge may not overlap another. CSV ignores merges.
399
+ - **`auto_width: true`** gives each column without a `width:` the width of its longest value, headers included, in
400
+ characters scaled by the style's font size and weight (a rough measure, as Excel's own autofit is). Formulas and
401
+ the first cell of a merge across columns are not measured; `sheet.add(row, measure: false)` leaves out a row such
402
+ as a long note. The widths come before the rows in both formats, so the rows of an `auto_width` sheet wait in a
403
+ temporary file until the sheet ends.
404
+
350
405
  ### To an IO
351
406
 
352
407
  ```ruby
@@ -581,7 +636,10 @@ but `max_rows` applies when the file is opened: give it to `Calcsheet.open` or t
581
636
  alone; an application can silence the warning with `Zip.warn_invalid_date = false` (after `require "zip"`).
582
637
  - `max_uncompressed_size` is cumulative because an ODS keeps every sheet in one `content.xml`, inflated again from
583
638
  its start for each sheet read, sampled or merge-checked: however many passes a crafted file causes, one book
584
- never inflates more than the budget. `max_sheets` bounds those passes.
639
+ never inflates more than the budget. `max_sheets` bounds those passes. A read lists the sheets of an ODS only as
640
+ far as it selects them (the default sheet costs the scan up to it, not of the whole file), so a damaged table, a
641
+ bad sheet name or too many sheets after the sheets read raise only when a read or `book.sheets` reaches them;
642
+ `precheck_sheets: true` lists every sheet first, as `book.sheets` does.
585
643
  - Verbose, repetitive XML (ODS exports especially) deflates beyond 100:1, so an entry may always inflate up to
586
644
  `compression_ratio_floor`; only beyond it does the ratio apply. Memory stays flat either way: entries are
587
645
  streamed. The XLSX shared strings are the exception: they are kept in memory, 8 bytes per string beyond their
@@ -709,6 +767,9 @@ Writing:
709
767
 
710
768
  - The header row is bold and frozen (XLSX `pane`, ODS `settings.xml`) when `header: true`. Columns get their
711
769
  `width:`; ODS widths are rounded to whole characters, from 1 to 255.
770
+ - Styles (see [styles, formulas and merges](#styles-formulas-and-merges)) are XLSX cell formats with their fonts,
771
+ fills and borders, and ODS common cell styles `Calcsheet1`, `Calcsheet2`... in `styles.xml`. A workbook with
772
+ formulas asks Excel to compute them when it opens (`fullCalcOnLoad`).
712
773
  - Number formats: `scale:` decimals (`0.00`), dates `yyyy-mm-dd`, datetimes `yyyy-mm-dd hh:mm:ss`. `scale:` is
713
774
  display only: the full value is stored.
714
775
  - XLSX text is written as inline strings (no shared strings table) and every sheet is streamed as its rows arrive,
@@ -808,7 +869,9 @@ word and format lists are Arrays of non-empty Strings.
808
869
  | `compression_ratio_floor` | `536_870_912` (512 MiB) | open | Bytes any zip entry may inflate to whatever its ratio, except the XLSX shared strings (a fixed 16 MiB); `nil` or `0` for none. |
809
870
  | `max_zip_entries` | `10_000` | open | Entries of a zip archive; always a positive Integer. |
810
871
  | `max_sheets` | `1_000` | open | Sheets of an XLSX or ODS workbook; `nil` for no limit. |
872
+ | `precheck_sheets` | `false` | open | ODS: list every sheet, scanning the whole `content.xml`, before a read starts, so that a damaged table, a bad sheet name or too many sheets after the one read raise before the first row. `false` scans only as far as the sheets the read selects. |
811
873
  | `max_cell_bytes` | `1_048_576` (1 MiB) | open | Bytes of the text of one cell; `nil` for no limit, which also lifts the bound on what the XML parser buffers. See [limits](#limits). |
874
+ | `backend` | `:auto` | open | The reader: `:auto` uses calcsheet-native when it is installed and compatible, else the Ruby backends; `:ruby` always the Ruby backends; `:native` calcsheet-native, or raises `Calcsheet::BackendUnavailableError`. `book.backend` tells which read. |
812
875
  | `decimals` | `:bigdecimal` | read | `:bigdecimal` or `:float`: `:float` returns native fractional numbers, and fractional durations, as `Float`. |
813
876
  | `time_zone` | `nil` | read, write | `nil` (UTC), `"UTC"` or `"Z"`, a UTC offset such as `"+01:00"` or `"+0100"`, or an object responding to `local` (and, for writing, to `utc_to_local` or `utc_offset`): see [time zones](#time-zones). |
814
877
  | `decimal_separator` | `"."` | read, write CSV | `"."` or `","`: text read as `:decimal` or `:integer`; CSV output. |
@@ -831,6 +894,50 @@ word and format lists are Arrays of non-empty Strings.
831
894
  | `excel_it` | `false` | write CSV | The preset of `;`, BOM, decimal comma and `%d/%m/%Y` dates. |
832
895
  | `sanitize_sheet_names` | `false` | write | Repair invalid sheet names and number duplicates instead of raising. |
833
896
 
897
+ ## Native backend
898
+
899
+ Calcsheet reads with its Ruby backends (Ox, rubyzip), which hold Ruby's global VM lock. `calcsheet-native` is an
900
+ optional, separate gem with a Rust core that tokenizes CSV, XLSX and ODS without the lock and hands the raw cells
901
+ back to Calcsheet, which converts them exactly as it does for the Ruby backends. Calcsheet never depends on it and
902
+ never needs a Rust toolchain.
903
+
904
+ `calcsheet-native` is not released yet. Once it is, it is installed by adding it next to Calcsheet in the Gemfile;
905
+ nothing in your code changes:
906
+
907
+ ```rb
908
+ gem "calcsheet", "~> 0.2"
909
+ gem "calcsheet-native"
910
+ ```
911
+
912
+ It is planned as precompiled gems for `x86_64-linux` and `aarch64-linux` (glibc), `arm64-darwin` and `x86_64-darwin`,
913
+ for Ruby 3.3, 3.4 and 4.0; not for musl (Alpine) or Windows. On any other platform Calcsheet keeps reading with its
914
+ Ruby backends.
915
+
916
+ **Same results.** With the same file and the same options the native backend gives the same result as the Ruby
917
+ backend: the same values with the same classes, row numbers, sheet names, cell errors and limits. A file the Ruby
918
+ backend refuses, it refuses too, with the same exception class, attributes and location. The only differences allowed
919
+ are those listed, each approved, in the documentation of `calcsheet-native`; a difference found otherwise is a defect.
920
+
921
+ The `backend` option (see the [options reference](#options-reference)) chooses the reader:
922
+
923
+ ```ruby
924
+ Calcsheet.open("contacts.xlsx", backend: :ruby) { |book| book.backend } # => :ruby
925
+ ```
926
+
927
+ ```rb
928
+ Calcsheet.open("contacts.xlsx") { |book| book.backend } # :native with calcsheet-native installed, else :ruby
929
+ Calcsheet.open("contacts.xlsx", backend: :native) # calcsheet-native, or Calcsheet::BackendUnavailableError
930
+ ```
931
+
932
+ - `:auto` (the default) uses `calcsheet-native` when it is installed and compatible. When it is installed but cannot
933
+ be used (a protocol version this Calcsheet does not support, an incomplete or failing gem), Calcsheet prints one
934
+ warning to standard error and reads with the Ruby backends. When it is not installed, it is silent.
935
+ - `:native` raises `Calcsheet::BackendUnavailableError` instead of falling back when `calcsheet-native` is not
936
+ installed, when it is installed but cannot be used (as above), and when your application registered its own reader
937
+ for that format in `Calcsheet.backends`.
938
+ - `book.backend` is `:ruby` or `:native`: the reader that this book uses, for logs and support.
939
+ - Writing is always done by Calcsheet's Ruby writers.
940
+
834
941
  ## Concurrency
835
942
 
836
943
  Call `Calcsheet.configure` (or `Calcsheet.config`) once at boot, before threads read or write: the configuration is
@@ -839,7 +946,7 @@ snapshot of the options, and books are independent; a single book must not be sh
839
946
 
840
947
  Ox parses while holding Ruby's global VM lock, so two large reads in two threads of one process take turns instead
841
948
  of running in parallel (measured: 66 s for two reads that take 27 s each alone). Process large files in separate
842
- worker processes, or with one worker thread per process, until a native backend that releases the lock exists.
949
+ worker processes, or with one worker thread per process, until `calcsheet-native`, which releases the lock, is available (see [native backend](#native-backend)).
843
950
 
844
951
  ## Performance
845
952
 
@@ -871,10 +978,10 @@ decides.
871
978
 
872
979
  - XLS (Excel 97–2003): the only Ruby reader is GPL-licensed. Save the file as XLSX, ODS or CSV.
873
980
  - Random access to cells (`sheet["B7"]`) and editing existing files while keeping their formatting.
874
- - Writing formulas, rich formatting, charts, conditional formats, data validation, images.
981
+ - Writing rich text, charts, conditional formats, data validation, images; formulas with a stored result.
875
982
  - Merged reading of sheets without a header row.
876
983
 
877
- Possible future work: a faster native reader as a separate, optional gem (see [ADR 007](https://gitlab.opinioni.net/demetra-developers/rubygems/calcsheet/-/blob/main/docs/adr/007-optional-native-backend.md)); random access to rows
984
+ Possible future work: random access to rows
878
985
  (`book.row(900_000)`) by inflating a sheet once into a temporary file and indexing the byte offset of each row
879
986
  (about 8 MB per million rows), then seeking and parsing one row; editing an uploaded file while keeping everything
880
987
  else (copying the untouched zip entries, rewriting only the changed cells with new strings inline, marking formulas
@@ -887,12 +994,19 @@ appears.
887
994
  bundle exec rspec # the suite; slow and external-tool cases are excluded
888
995
  bundle exec rspec --tag performance # 1M-row reads and writes with their time and memory bounds
889
996
  bundle exec rspec --tag libreoffice # written files opened by LibreOffice headless (needs soffice)
997
+ CALCSHEET_BACKEND=native CALCSHEET_FAKE_NATIVE=1 COVERAGE=false bundle exec rspec # the suite on a stand-in backend
890
998
  bundle exec rubocop
891
999
  bundle exec rake # the default task: the suite, then RuboCop
892
1000
  bundle exec yard stats --list-undoc # the public API left undocumented (options in .yardopts)
893
1001
  bundle exec yard # the API documentation, in tmp/yard
894
1002
  ```
895
1003
 
1004
+ - `CALCSHEET_BACKEND` (`ruby`, the default, or `native`) chooses the reader backend the suite runs against. Under
1005
+ `native` every example runs with `backend: :native`, the examples of Ruby internals (tagged `ruby_internals:` with
1006
+ a reason, or in the specs of the readers' parts) are excluded, and an example fails when a csv, xlsx or ods book
1007
+ that should have been read by calcsheet-native was read by a Ruby backend. `CALCSHEET_FAKE_NATIVE=1` installs the
1008
+ stand-in of `spec/support/fake_native.rb` (backends that subclass the Ruby readers); without it the
1009
+ calcsheet-native gem must be installed, and the same suite then verifies its backends.
896
1010
  - Every fixture is synthetic (neutral names, `example.com` addresses); `spec/fixtures/README.md` says how each one
897
1011
  was produced. Large files are generated by the specs that need them and never committed.
898
1012
  - The code comments are YARD in RDoc markup (`+code+`). The public API is what `.yardopts` shows; implementation
@@ -903,6 +1017,7 @@ bundle exec yard # the API documentation, in tmp/yard
903
1017
  - `rspec` on Ruby 3.3, 3.4 and 4.0, with a JUnit test report and a Cobertura coverage report that merge requests
904
1018
  show; it fails below 99% line or 98.5% branch coverage (checked only when `CI` is set, so focused local runs
905
1019
  are not);
1020
+ - `rspec: fake native`, the suite with `CALCSHEET_BACKEND=native CALCSHEET_FAKE_NATIVE=1`;
906
1021
  - `rubocop`;
907
1022
  - `gem_build`, which checks the files of the built gem, and `gem_smoke`, which installs it on Ruby 3.3 outside the
908
1023
  bundle, with the locked runtime dependencies, and writes and reads a file in each format;
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "reading"
4
+ require_relative "sheet_list"
4
5
 
5
6
  module Calcsheet
6
7
  # An opened workbook, returned by Calcsheet.open. Every read method takes the read options
@@ -18,26 +19,32 @@ module Calcsheet
18
19
  attr_reader :format
19
20
  # @return [Options]
20
21
  attr_reader :options
22
+ # @return [Symbol] +:native+ when calcsheet-native reads the book, +:ruby+ otherwise (calcsheet's own reader, or
23
+ # one an application registered in Calcsheet.backends): for logs and support
24
+ attr_reader :backend
21
25
 
22
26
  # @api private
23
- # @param backend [Object] a reader backend (see Reading)
27
+ # @param reader [Object] a reader backend (see Reading)
24
28
  # @param source [Source] closed with the book
25
- def initialize(backend, format:, options:, source:)
26
- @backend = backend
29
+ # @param backend [Symbol] +:ruby+ or +:native+, the origin of +reader+
30
+ def initialize(reader, format:, options:, source:, backend:)
31
+ @reader = reader
27
32
  @format = format
28
33
  @options = options
29
34
  @source = source
35
+ @backend = backend
30
36
  @closed = false
31
37
  end
32
38
 
33
39
  # @return [Array<SheetInfo>] every sheet in workbook order, hidden ones included (a CSV file has one, named after
34
40
  # the file); read once and kept
35
41
  # @raise [IOError] after #close
36
- # @raise [MalformedFileError] an ODS whose content.xml cannot be scanned (ODS lists its sheets at the first call)
42
+ # @raise [MalformedFileError] an ODS whose content.xml cannot be scanned (ODS lists its sheets at the first call,
43
+ # scanning the whole content.xml; a read lists them only as far as it selects, unless +precheck_sheets+)
37
44
  # @raise [TooManySheetsError] an ODS with more sheets than +max_sheets+
38
45
  def sheets
39
46
  check_open
40
- @sheets ||= @backend.sheets.dup.freeze
47
+ @sheets ||= @reader.sheets.dup.freeze
41
48
  end
42
49
 
43
50
  # The header and the first data rows of each selected sheet, with native values, reading only the beginning of
@@ -54,7 +61,7 @@ module Calcsheet
54
61
  # @raise [IOError] after #close
55
62
  # @raise [Calcsheet::Error] reading the sheets (MalformedFileError, ZipBombError...)
56
63
  def sample(rows: nil, sheets: :all, include_hidden: false, **overrides)
57
- reading = Reading.new(@backend, self.sheets, @options, overrides, sheet: sheets, include_hidden: include_hidden)
64
+ reading = Reading.new(@reader, selectable_sheets, @options, overrides, sheet: sheets, include_hidden: include_hidden)
58
65
  rows ||= reading.options.sample_rows
59
66
  raise ArgumentError, "rows: must be a non-negative Integer, got #{rows.inspect}" unless rows.is_a?(Integer) && rows >= 0
60
67
 
@@ -72,7 +79,7 @@ module Calcsheet
72
79
  # @raise [IOError] after #close
73
80
  # @raise [Calcsheet::Error] reading the header rows (MalformedFileError, ZipBombError...)
74
81
  def merge_check(sheets: :all, include_hidden: false)
75
- Reading.new(@backend, self.sheets, @options, sheet: sheets, include_hidden: include_hidden).merge_check
82
+ Reading.new(@reader, selectable_sheets, @options, sheet: sheets, include_hidden: include_hidden).merge_check
76
83
  end
77
84
 
78
85
  # @param read [Hash] read options (Reading::KEYS: +sheet:+, +header:+, +columns:+, +types:+, +on_error:+,
@@ -139,7 +146,7 @@ module Calcsheet
139
146
 
140
147
  @closed = true
141
148
  begin
142
- @backend.close
149
+ @reader.close
143
150
  ensure
144
151
  @source.close
145
152
  end
@@ -154,7 +161,16 @@ module Calcsheet
154
161
 
155
162
  def reading(read)
156
163
  settings, overrides = Reading.split(read)
157
- Reading.new(@backend, sheets, @options, overrides, **settings).tap { _1.open_check = method(:check_open) }
164
+ Reading.new(@reader, selectable_sheets, @options, overrides, **settings).tap { _1.open_check = method(:check_open) }
165
+ end
166
+
167
+ # The sheets a read selects from: all of them (#sheets) once listed, with +precheck_sheets+, or for a backend
168
+ # that lists them at no cost; else a SheetList, which finds them only as far as the selection reads (ODS).
169
+ def selectable_sheets
170
+ check_open
171
+ return sheets if @sheets || @options.precheck_sheets || !@reader.respond_to?(:each_sheet)
172
+
173
+ @sheet_list ||= SheetList.new(@reader)
158
174
  end
159
175
 
160
176
  def check_open
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "column_letter"
4
+ require_relative "grid"
5
+
6
+ module Calcsheet
7
+ # A rectangle of cells, from its top-left to its bottom-right cell: rows counted from 1, columns from 0. Parsed
8
+ # from an A1 reference such as "A1:B2" (WriteSheet#merge).
9
+ #
10
+ # @api private
11
+ CellRange = Data.define(:first_row, :first_column, :last_row, :last_column) do
12
+ # @param reference [String] "A1:B2", case-insensitive, its corners in any order
13
+ # @return [CellRange]
14
+ # @raise [ArgumentError] for another text, or a cell beyond the grid
15
+ def self.parse(reference)
16
+ match = reference.is_a?(String) && CellRange::REFERENCE.match(reference)
17
+ raise ArgumentError, "a cell range is written like \"A1:B2\", got #{reference.inspect}" unless match
18
+
19
+ columns = [ match[1], match[3] ].map { ColumnLetter.to_index(_1) }
20
+ rows = [ match[2], match[4] ].map(&:to_i)
21
+ if columns.max > Grid::LAST_COLUMN_INDEX || rows.max > Grid::MAX_ROWS || rows.min < 1
22
+ raise ArgumentError, "cell range #{reference.inspect} is beyond the grid (A1:#{Grid::LAST_COLUMN}#{Grid::MAX_ROWS})"
23
+ end
24
+
25
+ new(first_row: rows.min, first_column: columns.min, last_row: rows.max, last_column: columns.max)
26
+ end
27
+
28
+ # @return [Integer] the cells it covers
29
+ def size
30
+ (last_row - first_row + 1) * (last_column - first_column + 1)
31
+ end
32
+
33
+ # @return [Boolean] whether it shares a cell with +other+
34
+ def overlap?(other)
35
+ first_row <= other.last_row && other.first_row <= last_row &&
36
+ first_column <= other.last_column && other.first_column <= last_column
37
+ end
38
+
39
+ # @return [Boolean] whether the cell at +row+ (from 1) and +column+ (from 0) is inside
40
+ def cover?(row, column)
41
+ row.between?(first_row, last_row) && column.between?(first_column, last_column)
42
+ end
43
+
44
+ # @return [String] "A1:B2"
45
+ def to_s
46
+ "#{ColumnLetter.from_index(first_column)}#{first_row}:#{ColumnLetter.from_index(last_column)}#{last_row}"
47
+ end
48
+ end
49
+
50
+ class CellRange
51
+ # Two cells: column letters (at most three) and a row number without leading zero.
52
+ REFERENCE = /\A([A-Za-z]{1,3})([1-9]\d{0,6}):([A-Za-z]{1,3})([1-9]\d{0,6})\z/
53
+ end
54
+ end
@@ -29,11 +29,17 @@ module Calcsheet
29
29
  max_zip_entries: 10_000,
30
30
  # The sheets an XLSX or ODS workbook may have; each one can cost a pass over an ODS content.xml.
31
31
  max_sheets: 1_000,
32
+ # ODS: list every sheet (scanning the whole content.xml) before the first row of a read, so that a damaged
33
+ # table, a bad sheet name or too many sheets after the one read raise before it starts; false scans only as far
34
+ # as the sheets the read selects.
35
+ precheck_sheets: false,
32
36
  # The bytes of the text of one cell (an XLSX or ODS string; a CSV field once CSV::Parser has built it, so not what
33
37
  # parsing it costs, see ADR 008), and the bytes six times this plus 64 KiB between two "<" of an XLSX or ODS
34
38
  # part, which bound what the XML parser buffers. The text of a number or a date is at most
35
39
  # Cast::MAX_NUMBER_BYTES whatever this is. nil for no limit.
36
40
  max_cell_bytes: 1_048_576,
41
+ # The reader: :auto (calcsheet-native when it is installed and compatible, else Ruby), :ruby or :native.
42
+ backend: :auto,
37
43
  # :bigdecimal or :float: the class of a native fractional number (and of a fractional duration).
38
44
  decimals: :bigdecimal,
39
45
  time_zone: nil,
@@ -83,8 +89,12 @@ module Calcsheet
83
89
  # @return [Integer] the entries of a zip archive
84
90
  # @!attribute [rw] max_sheets
85
91
  # @return [Integer, nil] the sheets of an XLSX or ODS workbook; nil for no limit
92
+ # @!attribute [rw] precheck_sheets
93
+ # @return [Boolean] ODS: list every sheet before a read starts, checking the whole content.xml
86
94
  # @!attribute [rw] max_cell_bytes
87
95
  # @return [Integer, nil] the bytes of the text of one cell; nil for no limit
96
+ # @!attribute [rw] backend
97
+ # @return [Symbol] +:auto+, +:ruby+ or +:native+: the reader of the books opened (see Book#backend)
88
98
  # @!attribute [rw] decimals
89
99
  # @return [Symbol] +:bigdecimal+ or +:float+, the class of native fractional numbers and durations
90
100
  # @!attribute [rw] time_zone
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Calcsheet
4
+ # The number format of a written style (+format:+ of WriteBook#style): how XLSX and ODS show a value, which is
5
+ # stored unchanged. A small grammar that both formats can express:
6
+ #
7
+ # - a number: +"0"+, +"0.00"+ (decimals), +"#,##0"+ or +"#,##0.00"+ (thousands grouped);
8
+ # - a percentage: a number followed by +"%"+ or +" %"+ (+"0.0 %"+): the value 0.25 shows as 25.0 %;
9
+ # - a date, a time or both: +yyyy+, +mm+ (month, or minutes after +hh+ or before +ss+), +dd+, +hh+, +ss+,
10
+ # separated by "-", "/", ".", ":" or a space (+"dd/mm/yyyy"+, +"yyyy-mm-dd hh:mm:ss"+, +"hh:mm"+);
11
+ # - +"@"+, text.
12
+ #
13
+ # @attr_reader code [String] the code as given, frozen
14
+ # @attr_reader kind [Symbol] +:number+, +:percent+, +:date+, +:datetime+, +:time+ or +:text+
15
+ # @attr_reader decimals [Integer] the decimals of a number or a percentage
16
+ # @attr_reader grouping [Boolean] the thousands of a number or a percentage are grouped
17
+ # @attr_reader parts [Array<Array(Symbol, String)>] the fields of a date or time in order: +[:year, "yyyy"]+,
18
+ # +[:month, "mm"]+, +[:day, "dd"]+, +[:hours, "hh"]+, +[:minutes, "mm"]+, +[:seconds, "ss"]+ or +[:text, "-"]+
19
+ DisplayFormat = Data.define(:code, :kind, :decimals, :grouping, :parts) do
20
+ # @param code [String]
21
+ # @return [DisplayFormat]
22
+ # @raise [ArgumentError] for a code outside the grammar
23
+ def self.parse(code)
24
+ raise ArgumentError, "format: must be a String, got #{code.inspect}" unless code.is_a?(String)
25
+ return new(code: code.dup.freeze, kind: :text, decimals: 0, grouping: false, parts: []) if code == "@"
26
+
27
+ if (match = DisplayFormat::NUMBER.match(code))
28
+ decimals = match[2] ? match[2].size - 1 : 0
29
+ kind = match[3] ? :percent : :number
30
+ return new(code: code.dup.freeze, kind: kind, decimals: decimals, grouping: !match[1].nil?, parts: [])
31
+ end
32
+
33
+ parts = DisplayFormat.temporal_parts(code) or
34
+ raise ArgumentError, "format: #{code.inspect} is not one of the supported number, percentage, date or time formats"
35
+ fields = parts.map(&:first) - [ :text ]
36
+ date = fields.intersect?(%i[year month day])
37
+ time = fields.intersect?(%i[hours minutes seconds])
38
+ kind = date && time ? :datetime : (date ? :date : :time)
39
+ new(code: code.dup.freeze, kind: kind, decimals: 0, grouping: false, parts: parts.freeze)
40
+ end
41
+
42
+ # @api private
43
+ # The fields of a date or time code, "mm" read as minutes after "hh" or before "ss"; nil for another code.
44
+ def self.temporal_parts(code)
45
+ tokens = code.scan(DisplayFormat::TEMPORAL_TOKEN)
46
+ return if tokens.join != code || tokens.none? { _1.size > 1 }
47
+
48
+ parts = tokens.map do |token|
49
+ case token
50
+ when "yyyy" then [ :year, token ]
51
+ when "mm" then [ :month, token ]
52
+ when "dd" then [ :day, token ]
53
+ when "hh" then [ :hours, token ]
54
+ when "ss" then [ :seconds, token ]
55
+ else [ :text, token ]
56
+ end
57
+ end
58
+ parts.each_with_index do |(field, _), index|
59
+ next unless field == :month
60
+
61
+ before = parts[0...index].reverse.find { _1[0] != :text }
62
+ after = parts[(index + 1)..].find { _1[0] != :text }
63
+ parts[index] = [ :minutes, "mm" ] if before&.first == :hours || after&.first == :seconds
64
+ end
65
+ parts
66
+ end
67
+ end
68
+
69
+ class DisplayFormat
70
+ # A number or a percentage: optional grouping, digits, optional decimals, optional "%" or " %".
71
+ NUMBER = /\A(#,##)?0(\.0+)?( ?%)?\z/
72
+ # A field or a separator of a date or time code.
73
+ TEMPORAL_TOKEN = /yyyy|mm|dd|hh|ss|[-\/.: ]/
74
+ end
75
+ end
@@ -27,6 +27,11 @@ module Calcsheet
27
27
  # another compression method, or a format without a backend.
28
28
  class UnsupportedFormatError < Error; end
29
29
 
30
+ # +backend: :native+ was asked for and calcsheet-native cannot read the book: it is not installed, its protocol is
31
+ # not one this calcsheet supports, it does not read the format, or Calcsheet.backends holds an application's own
32
+ # reader for the format. Raised by Calcsheet.open (before the source is opened when +format:+ is given).
33
+ class BackendUnavailableError < Error; end
34
+
30
35
  # The file is damaged. The parser error is kept as +cause+.
31
36
  class MalformedFileError < Error
32
37
  # @return [String, nil] the sheet name, when the damage is in a sheet
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "column"
4
+
5
+ module Calcsheet
6
+ # A formula to write in a cell, in the syntax of Excel: +Formula.new("SUM(B2:B5)")+ (a leading "=" is dropped).
7
+ # XLSX writes it as is, ODS translates its references and argument separators to OpenFormula, CSV writes it as the
8
+ # text "=SUM(B2:B5)". No value is cached with it: Excel and LibreOffice compute it when they open the file, and
9
+ # calcsheet reads such a cell back as nil.
10
+ #
11
+ # @attr_reader text [String] the formula without its leading "=", frozen UTF-8
12
+ Formula = Data.define(:text) do
13
+ # @param text [String]
14
+ # @raise [ArgumentError] unless +text+ is a non-empty String convertible to UTF-8, of at most MAX_LENGTH
15
+ # characters
16
+ def initialize(text:)
17
+ unless text.is_a?(String) && (utf8 = Column.utf8(text))
18
+ raise ArgumentError, "formula: expected a String convertible to UTF-8, got #{text.inspect}"
19
+ end
20
+
21
+ utf8 = utf8.delete_prefix("=")
22
+ raise ArgumentError, "formula: the formula is empty" if utf8.strip.empty?
23
+ if utf8.length > Formula::MAX_LENGTH
24
+ raise ArgumentError, "formula: #{utf8.length} characters, more than the #{Formula::MAX_LENGTH} of Excel"
25
+ end
26
+
27
+ super(text: utf8.freeze)
28
+ end
29
+
30
+ # @return [String] the formula as a spreadsheet shows it, with its "="
31
+ def to_s
32
+ "=#{text}"
33
+ end
34
+ end
35
+
36
+ class Formula
37
+ # The longest formula Excel accepts, in characters.
38
+ MAX_LENGTH = 8_192
39
+ end
40
+ end
@@ -107,6 +107,8 @@ module Calcsheet
107
107
 
108
108
  # The compressed bytes read and inflated at a time.
109
109
  INPUT_CHUNK_SIZE = 4 * 1024
110
+ # The inflated bytes between two minor GCs (see #collect_garbage).
111
+ GC_BYTES = 2 * 1_048_576
110
112
  # The fewest consumed bytes at the front of the buffer that #take drops: it drops them once they are at least
111
113
  # this many and at least half of the buffer. A buffer consumed in full is reset whatever its size.
112
114
  COMPACT_THRESHOLD = 64 * 1024
@@ -140,6 +142,8 @@ module Calcsheet
140
142
  @offset = 0
141
143
  @inflated = 0
142
144
  @crc = 0
145
+ @next_collection = GC_BYTES
146
+ @gc_count = nil
143
147
  @finished = false
144
148
  @failure = nil
145
149
  @crc_mismatch = false
@@ -291,6 +295,18 @@ module Calcsheet
291
295
  @tokens&.check(piece)
292
296
  @crc = Zlib.crc32(piece, @crc)
293
297
  @buffer << piece
298
+ collect_garbage if @inflated >= @next_collection
299
+ end
300
+
301
+ # Each piece Zlib hands out, and each slice a read hands out, is garbage once copied: about twice the bytes
302
+ # inflated. A reader that allocates little else (a byte scanner, calcsheet-native) lets the GC wait for its malloc
303
+ # threshold, so the peak memory grows by tens of megabytes. A minor GC every GC_BYTES inflated, unless one ran
304
+ # meanwhile, keeps it flat at no measurable cost: young garbage is cheap to collect.
305
+ def collect_garbage
306
+ @next_collection = @inflated + GC_BYTES
307
+ count = GC.count
308
+ GC.start(full_mark: false) if count == @gc_count
309
+ @gc_count = GC.count
294
310
  end
295
311
 
296
312
  def check_ratio