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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +124 -9
- data/lib/calcsheet/book.rb +25 -9
- data/lib/calcsheet/cell_range.rb +54 -0
- data/lib/calcsheet/configuration.rb +10 -0
- data/lib/calcsheet/display_format.rb +75 -0
- data/lib/calcsheet/errors.rb +5 -0
- data/lib/calcsheet/formula.rb +40 -0
- data/lib/calcsheet/limited_inflater.rb +16 -0
- data/lib/calcsheet/native_backends.rb +139 -0
- data/lib/calcsheet/option_validation.rb +4 -1
- data/lib/calcsheet/raw_row.rb +1 -1
- data/lib/calcsheet/readers/csv_reader.rb +43 -20
- data/lib/calcsheet/readers/csv_records.rb +193 -0
- data/lib/calcsheet/readers/ods/content_parser.rb +49 -69
- data/lib/calcsheet/readers/ods/scanner.rb +33 -6
- data/lib/calcsheet/readers/ods_reader.rb +16 -1
- data/lib/calcsheet/readers/sax_sheet.rb +6 -48
- data/lib/calcsheet/readers/xlsx/sheet_parser.rb +31 -64
- data/lib/calcsheet/reading.rb +29 -7
- data/lib/calcsheet/row.rb +2 -2
- data/lib/calcsheet/row_builder.rb +235 -0
- data/lib/calcsheet/sheet_list.rb +25 -0
- data/lib/calcsheet/sheet_selection.rb +3 -2
- data/lib/calcsheet/style.rb +114 -0
- data/lib/calcsheet/version.rb +1 -1
- data/lib/calcsheet/write_book.rb +49 -17
- data/lib/calcsheet/write_sheet.rb +220 -18
- data/lib/calcsheet/writers/csv_writer.rb +18 -7
- data/lib/calcsheet/writers/ods_writer.rb +249 -16
- data/lib/calcsheet/writers/open_formula.rb +67 -0
- data/lib/calcsheet/writers/xlsx_writer.rb +226 -37
- data/lib/calcsheet/writers/zip_buffered.rb +20 -1
- data/lib/calcsheet.rb +31 -8
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 291f855f2646898ede484b6dbaca57d4cac890387ece27c70352fbb463770057
|
|
4
|
+
data.tar.gz: 4cb2e6174412b74611168b5da6daf2844fcd8f33ae5c1d88b6b4f528b777267a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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`
|
|
179
|
-
|
|
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
|
|
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
|
|
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:
|
|
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;
|
data/lib/calcsheet/book.rb
CHANGED
|
@@ -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
|
|
27
|
+
# @param reader [Object] a reader backend (see Reading)
|
|
24
28
|
# @param source [Source] closed with the book
|
|
25
|
-
|
|
26
|
-
|
|
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 ||= @
|
|
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(@
|
|
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(@
|
|
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
|
-
@
|
|
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(@
|
|
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
|
data/lib/calcsheet/errors.rb
CHANGED
|
@@ -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
|