fast_xlsx 0.1.2 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4a6f42c2c4c1f1a9e4a5f59441d183e2257abbbbfd9593fa22d7be4a9cf7519
4
- data.tar.gz: 1c89a99feafa34f127e14d49175503da754809ca2436197bbd1e4e59290a145d
3
+ metadata.gz: 14314a7af34dfb1bfd00bac2f8fde41c4fa03447020dfef48571055bdac3151d
4
+ data.tar.gz: 915ca1cd79de28ca34023af4d884eb9c6728bd86abd29aa19bd62ca08e3914fe
5
5
  SHA512:
6
- metadata.gz: 75823da46a7dcf4a0bc921211871fe591f9be3ccf788ce42456bd109f77778a9886dce51123b1d86e18255169770e3b28d9ebb5e03f4de497b170be6092bf150
7
- data.tar.gz: 5f5439f853da37aa5730636dc20e6daf784b9b0374ad03aba78c10720e3976d080ec287938209ea62fb3e996931b904b0ecd05e59a83b5dda8468fde0424fb8b
6
+ metadata.gz: 346ed04a149c9f6201fdb84af8b5b1a5a1008a17ac1df8faaa26af8d500dc2e13ecd8eb55705638a99a27501d7ca757f08a50825c7faac4a4a2593fb556a94e5
7
+ data.tar.gz: 712d24c13ab60499f91795cdbdd4d3c251e41c6b30b60dc8c9e3d25aaa671da98710d128dd673c859ad2ec336c817d69da6a46ea5e9f7a8015025444db59bcef
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-10-01
4
+
5
+ ### Changed
6
+
7
+ - `to_xlsx` and `save` release Ruby's global lock while building and compressing the file, so other threads (e.g. in Puma or Sidekiq) keep running. An interrupt (Ctrl-C, `Timeout`, `Thread#raise`) takes effect once the save finishes.
8
+ - Saving is 20-30% faster: files are compressed with zlib-rs instead of C zlib (which also drops the `libz-sys` dependency). Adding rows is about 15% faster from building with link-time optimisation.
9
+
10
+ ### Added
11
+
12
+ - `write`, `write_comment`, `insert_image`, `insert_chart` and `freeze_panes` also take a cell reference such as `"B2"`. `write` is now native, which makes it about 15% faster.
13
+ - `Worksheet#zoom`, `#tab_color`, `#hide_gridlines`, `#activate`, `#hide`, and `#page_setup` (orientation, paper size, fit to pages, rows/columns repeated on every page, print area, printed gridlines).
14
+
15
+ ### Fixed
16
+
17
+ - A `merge_range` that overlaps an earlier merge raises before writing anything; before, it also blanked the earlier merge's value.
18
+
19
+ ## [0.2.0] - 2026-10-01
20
+
21
+ ### Added
22
+
23
+ - Range methods (`autofilter`, `merge_range`, `conditional_format`, `data_validation`, `add_table`) also take an Excel reference (`"A1:D10"`) or rows and columns as Integers or Ranges (`0..9, 0..3`).
24
+ - `Workbook#define_name` for workbook-wide and sheet-scoped defined names.
25
+ - `Worksheet#group_rows` and `#group_columns` (outline groups, optionally collapsed); `group_rows` raises in `:constant` / `:low` mode.
26
+ - `Worksheet#protect(password:, allow:)` locks a sheet; `Format` options `locked: false` and `hidden: true` keep cells editable or hide formulas.
27
+
28
+ ### Changed
29
+
30
+ - `column_width`, `column_format` and the other methods that take a Range raise `ArgumentError` for an empty, reversed or endless Range, or one that isn't Integers, instead of a `TypeError` or `RangeError`.
31
+
3
32
  ## [0.1.2] - 2026-09-30
4
33
 
5
34
  ### Fixed
data/Cargo.lock CHANGED
@@ -25,7 +25,7 @@ dependencies = [
25
25
  "quote",
26
26
  "regex",
27
27
  "rustc-hash",
28
- "shlex 1.3.0",
28
+ "shlex",
29
29
  "syn",
30
30
  ]
31
31
 
@@ -41,16 +41,6 @@ version = "3.20.3"
41
41
  source = "registry+https://github.com/rust-lang/crates.io-index"
42
42
  checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
43
43
 
44
- [[package]]
45
- name = "cc"
46
- version = "1.5.1"
47
- source = "registry+https://github.com/rust-lang/crates.io-index"
48
- checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
49
- dependencies = [
50
- "find-msvc-tools",
51
- "shlex 2.0.1",
52
- ]
53
-
54
44
  [[package]]
55
45
  name = "cexpr"
56
46
  version = "0.6.0"
@@ -124,20 +114,12 @@ version = "2.5.0"
124
114
  source = "registry+https://github.com/rust-lang/crates.io-index"
125
115
  checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
126
116
 
127
- [[package]]
128
- name = "find-msvc-tools"
129
- version = "0.1.14"
130
- source = "registry+https://github.com/rust-lang/crates.io-index"
131
- checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
132
-
133
117
  [[package]]
134
118
  name = "flate2"
135
119
  version = "1.1.10"
136
120
  source = "registry+https://github.com/rust-lang/crates.io-index"
137
121
  checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
138
122
  dependencies = [
139
- "crc32fast",
140
- "libz-sys",
141
123
  "zlib-rs",
142
124
  ]
143
125
 
@@ -205,17 +187,6 @@ dependencies = [
205
187
  "windows-link",
206
188
  ]
207
189
 
208
- [[package]]
209
- name = "libz-sys"
210
- version = "1.1.29"
211
- source = "registry+https://github.com/rust-lang/crates.io-index"
212
- checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
213
- dependencies = [
214
- "cc",
215
- "pkg-config",
216
- "vcpkg",
217
- ]
218
-
219
190
  [[package]]
220
191
  name = "linux-raw-sys"
221
192
  version = "0.12.1"
@@ -279,12 +250,6 @@ version = "1.21.4"
279
250
  source = "registry+https://github.com/rust-lang/crates.io-index"
280
251
  checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
281
252
 
282
- [[package]]
283
- name = "pkg-config"
284
- version = "0.3.34"
285
- source = "registry+https://github.com/rust-lang/crates.io-index"
286
- checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
287
-
288
253
  [[package]]
289
254
  name = "proc-macro2"
290
255
  version = "1.0.107"
@@ -415,12 +380,6 @@ version = "1.3.0"
415
380
  source = "registry+https://github.com/rust-lang/crates.io-index"
416
381
  checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
417
382
 
418
- [[package]]
419
- name = "shlex"
420
- version = "2.0.1"
421
- source = "registry+https://github.com/rust-lang/crates.io-index"
422
- checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
423
-
424
383
  [[package]]
425
384
  name = "simd-adler32"
426
385
  version = "0.3.10"
@@ -463,12 +422,6 @@ version = "1.0.26"
463
422
  source = "registry+https://github.com/rust-lang/crates.io-index"
464
423
  checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
465
424
 
466
- [[package]]
467
- name = "vcpkg"
468
- version = "0.2.15"
469
- source = "registry+https://github.com/rust-lang/crates.io-index"
470
- checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
471
-
472
425
  [[package]]
473
426
  name = "windows-link"
474
427
  version = "0.2.1"
data/Cargo.toml CHANGED
@@ -11,3 +11,7 @@ resolver = "2"
11
11
  # harder to debug if something goes wrong. It's recommended to keep debug
12
12
  # symbols in the release build so that you can debug the final binary if needed.
13
13
  debug = true
14
+ # One codegen unit and link-time optimisation across crates: adding rows is
15
+ # about 15% faster, at the cost of a slower gem build.
16
+ lto = "fat"
17
+ codegen-units = 1
data/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # FastXlsx
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/fast_xlsx.svg)](https://badge.fury.io/rb/fast_xlsx)
3
4
  [![codecov](https://codecov.io/gh/7a6163/fast_xlsx/graph/badge.svg)](https://codecov.io/gh/7a6163/fast_xlsx)
4
5
 
5
6
  Fast `.xlsx` writer for Ruby, built on [rust_xlsxwriter](https://github.com/jmcnamara/rust_xlsxwriter) via [magnus](https://github.com/matsadler/magnus).
@@ -17,6 +18,7 @@ ws = wb.add_worksheet("Report") # later: wb.worksheet("Report")
17
18
  ws << ["id", "name", "created_at"] # append a row
18
19
  ws.concat(records.map { |r| [r.id, r.name, r.created_at] }) # append many rows in one call
19
20
  ws.write(0, 5, 42) # write a single cell (row, col, value)
21
+ ws.write("F1", 42) # or by its Excel reference
20
22
 
21
23
  wb.properties(title: "Q3 report", author: "Zac", keywords: "Confidential") # File > Info in Excel
22
24
  wb.save("report.xlsx") # or wb.to_xlsx => binary String
@@ -75,6 +77,8 @@ ws.write(1, 2, Date.today, date) # format one cell
75
77
  | `valign` | `:top`, `:center`, `:bottom` |
76
78
  | `border`, `border_left`, `border_right`, `border_top`, `border_bottom` | `:thin`, `:medium`, `:thick`, `:dashed`, `:dotted`, `:double`, `:hair` |
77
79
  | `border_color` | `"#RRGGBB"` or `0xRRGGBB` |
80
+ | `locked` | `false` keeps the cell editable on a protected sheet (default `true`) |
81
+ | `hidden` | `true` hides the cell's formula on a protected sheet |
78
82
 
79
83
  Per-side borders override `border`. Unknown options and invalid values raise `ArgumentError`.
80
84
 
@@ -85,9 +89,38 @@ ws.column_width(0, 20) # column A, width in characters
85
89
  ws.column_width(1..3, 12) # columns B–D
86
90
  ws.column_format(4, FastXlsx::Format.new(num_format: "#,##0.00")) # default for cells in E written without a format
87
91
  ws.autofit # size other columns to the data written so far; set widths are kept
88
- ws.autofilter(0, 0, 100, 3) # filter buttons on A1:D101 (first_row, first_col, last_row, last_col)
92
+ ws.autofilter("A1:D101") # filter buttons on A1:D101
89
93
  ```
90
94
 
95
+ ### Sheet view and printing
96
+
97
+ ```ruby
98
+ ws.zoom(150) # 10..400 percent
99
+ ws.tab_color("#C00000")
100
+ ws.hide_gridlines
101
+ ws.activate # Excel opens on this sheet (un-hides it if hidden)
102
+ other.hide # the sheet Excel opens on can't be hidden: activate another one first
103
+
104
+ ws.page_setup(landscape: true, paper: :a4, # or :letter, :legal, :tabloid, :a3, :a5, Excel's paper number, 0 = printer default
105
+ fit_width: 1, # 1 page wide, as many pages tall as needed
106
+ repeat_rows: 0, # print the header row on every page (an index or a Range)
107
+ print_area: "A1:D100", # any cell range style
108
+ gridlines: true) # print the gridlines
109
+ ```
110
+
111
+ ### Cell ranges
112
+
113
+ `autofilter`, `merge_range`, `conditional_format`, `data_validation` and `add_table` take a range in any of these styles:
114
+
115
+ ```ruby
116
+ ws.autofilter(0, 0, 100, 3) # four 0-based numbers: first_row, first_col, last_row, last_col
117
+ ws.autofilter("A1:D101") # an Excel reference; "$A$1:$D$101" and a single "B2" work too
118
+ ws.autofilter(0..100, 0..3) # rows and columns, each an Integer or a Range
119
+ ws.merge_range(0, 0..3, "Q3 report", title) # row 1, columns A–D
120
+ ```
121
+
122
+ Methods that take one cell (`write`, `write_comment`, `insert_image`, `insert_chart`, `freeze_panes`) take `(row, col)` or a reference like `"B2"`.
123
+
91
124
  `autofit` only sees rows still in memory, so in `:constant` / `:low` memory mode it ignores rows already written to disk; set widths with `column_width` instead.
92
125
 
93
126
  ### Layout
@@ -103,6 +136,36 @@ ws.page_footer("&L&A", margin: 0.2) # sheet name on the left; margin
103
136
  ws.margins(left: 0.5, top: 1) # other margins keep Excel's defaults
104
137
  ```
105
138
 
139
+ ### Outline groups
140
+
141
+ ```ruby
142
+ ws.group_rows(1..10) # rows 2–11 get an expand/collapse button
143
+ ws.group_rows(1..4) # grouping again nests them (up to 7 levels)
144
+ ws.group_columns(2..3, collapsed: true) # columns C–D, collapsed until expanded
145
+ ```
146
+
147
+ `group_rows` needs `memory: :standard`: in `:constant` / `:low` mode rust_xlsxwriter writes rows without their outline level, so it raises. `group_columns` works in every mode.
148
+
149
+ ### Defined names
150
+
151
+ ```ruby
152
+ wb.define_name("Rate", "=0.96") # workbook-wide; use as =A1*Rate
153
+ wb.define_name("Report!Sales", "=Report!$B$2:$B$13") # only on the Report sheet
154
+ ```
155
+
156
+ Invalid names raise `FastXlsx::Error` right away; duplicate names, and names for a sheet that doesn't exist, raise when saving.
157
+
158
+ ### Protection
159
+
160
+ ```ruby
161
+ input = FastXlsx::Format.new(locked: false)
162
+ ws.write(1, 1, 0, input) # B2 stays editable
163
+ ws.protect # lock everything else
164
+ ws.protect(password: "secret", allow: %i[sort use_autofilter]) # or with a password and allowed actions
165
+ ```
166
+
167
+ `allow:` takes `:format_cells`, `:format_columns`, `:format_rows`, `:insert_columns`, `:insert_rows`, `:insert_links`, `:delete_columns`, `:delete_rows`, `:sort`, `:use_autofilter`, `:use_pivot_tables`, `:edit_scenarios`, `:edit_objects`. The password only stops editing in Excel; it does not encrypt the file.
168
+
106
169
  ### Conditional formats
107
170
 
108
171
  ```ruby
@@ -210,17 +273,17 @@ Apple Silicon, Ruby 4.0.5. Each library uses its own idiomatic row-append API; x
210
273
 
211
274
  | Library | Time | vs fastest | Ruby objects allocated |
212
275
  |---|---:|---:|---:|
213
- | **fast_xlsx** (`memory: :constant`) | **92 ms** | 1.0x | 7 |
214
- | **fast_xlsx** (`memory: :low`) | **101 ms** | 1.1x | 7 |
215
- | **fast_xlsx** | **102 ms** | 1.1x | 10 |
216
- | [xlsxtream](https://github.com/felixbuenemann/xlsxtream) 3.1 | 181 ms | 2.0x | 561,728 |
217
- | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 (constant_memory) | 202 ms | 2.2x | 20,079 |
218
- | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 | 240 ms | 2.6x | 320,076 |
219
- | [write_xlsx](https://github.com/cxn03651/write_xlsx) 1.15 | 610 ms | 6.7x | 1,483,899 |
220
- | [caxlsx](https://github.com/caxlsx/caxlsx) 4.5 | 678 ms | 7.4x | 745,122 |
221
- | [rubyXL](https://github.com/weshatheleopard/rubyXL) 3.4 | 2624 ms | 28.6x | 8,700,448 |
222
-
223
- All outputs are 702–750 KB.
276
+ | **fast_xlsx** (`memory: :constant`) | **69 ms** | 1.0x | 7 |
277
+ | **fast_xlsx** (`memory: :low`) | **78 ms** | 1.1x | 7 |
278
+ | **fast_xlsx** | **80 ms** | 1.2x | 10 |
279
+ | [xlsxtream](https://github.com/felixbuenemann/xlsxtream) 3.1 | 171 ms | 2.5x | 561,728 |
280
+ | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 (constant_memory) | 194 ms | 2.8x | 20,079 |
281
+ | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 | 228 ms | 3.3x | 320,076 |
282
+ | [write_xlsx](https://github.com/cxn03651/write_xlsx) 1.15 | 583 ms | 8.4x | 1,483,899 |
283
+ | [caxlsx](https://github.com/caxlsx/caxlsx) 4.5 | 691 ms | 10.0x | 745,122 |
284
+ | [rubyXL](https://github.com/weshatheleopard/rubyXL) 3.4 | 2650 ms | 38.3x | 8,700,448 |
285
+
286
+ All outputs are 681–750 KB.
224
287
 
225
288
  ### Memory
226
289
 
@@ -228,14 +291,18 @@ All outputs are 702–750 KB.
228
291
 
229
292
  | Library | Unique strings | Repeated strings |
230
293
  |---|---:|---:|
231
- | **fast_xlsx** (`memory: :constant`) | **+2 MB** | **+2 MB** |
232
- | **fast_xlsx** (`memory: :low`) | +62 MB | **+2 MB** |
233
- | **fast_xlsx** | +270 MB | +217 MB |
234
- | fast_excel 0.5 (constant_memory) | +10 MB | +10 MB |
235
- | fast_excel 0.5 | +183 MB | +151 MB |
294
+ | **fast_xlsx** (`memory: :constant`) | **+1 MB** | **+1 MB** |
295
+ | **fast_xlsx** (`memory: :low`) | +61 MB | **+1 MB** |
296
+ | **fast_xlsx** | +271 MB | +217 MB |
297
+ | fast_excel 0.5 (constant_memory) | +10 MB | +9 MB |
298
+ | fast_excel 0.5 | +182 MB | +151 MB |
236
299
 
237
300
  The `:standard` mode uses more memory than fast_excel's: when saving, rust_xlsxwriter assembles each worksheet's XML in memory (so several worksheets can be built in parallel) instead of streaming it from a temp file. Use `memory: :constant` or `memory: :low` for large exports.
238
301
 
302
+ ### Threads
303
+
304
+ Most of the time goes into saving (building the XML and compressing it). `to_xlsx` and `save` do that without holding Ruby's global lock, so in a threaded server (Puma, Sidekiq) other threads keep running while a large export is saved. An interrupt (Ctrl-C, `Timeout`, `Thread#raise`) takes effect once the save finishes.
305
+
239
306
  ### Reproduce
240
307
 
241
308
  ```bash
data/bench/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- fast_xlsx (0.1.2)
4
+ fast_xlsx (0.3.0)
5
5
  rb_sys (~> 0.9.130)
6
6
 
7
7
  GEM
@@ -77,7 +77,7 @@ DEPENDENCIES
77
77
  CHECKSUMS
78
78
  caxlsx (4.5.0) sha256=e3d98d859f148df05d5462086b5079b523f29c1766b569535f1d68629ce743ff
79
79
  fast_excel (0.5.0) sha256=59c418bdcf586a6030798d4e3a9f575742badaceaf7bb90f38eee5feb5bdc478
80
- fast_xlsx (0.1.2)
80
+ fast_xlsx (0.3.0)
81
81
  ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
82
82
  ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
83
83
  ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
@@ -12,7 +12,9 @@ crate-type = ["cdylib"]
12
12
  [dependencies]
13
13
  magnus = "0.9"
14
14
  rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] }
15
- rust_xlsxwriter = { version = "0.99.1", features = ["constant_memory", "zlib"] }
15
+ # No "zlib" feature: without it zip compresses with zlib-rs, which saves
16
+ # 20-30% faster than the system zlib (and needs no C library).
17
+ rust_xlsxwriter = { version = "0.99.1", features = ["constant_memory"] }
16
18
 
17
19
  [build-dependencies]
18
20
  rb-sys-env = "0.2.2"
@@ -1,4 +1,5 @@
1
1
  use std::cell::{Cell, RefCell};
2
+ use std::sync::atomic::{AtomicUsize, Ordering};
2
3
  use std::sync::{Arc, Mutex};
3
4
 
4
5
  use magnus::{
@@ -15,7 +16,8 @@ use rust_xlsxwriter::{
15
16
  };
16
17
 
17
18
  use rust_xlsxwriter::{
18
- Chart, ChartType, DocProperties, Image, Note, Table, TableColumn, TableFunction, TableStyle,
19
+ Chart, ChartType, DocProperties, Image, Note, ProtectionOptions, Table, TableColumn,
20
+ TableFunction, TableStyle,
19
21
  };
20
22
 
21
23
  // These constants are defined in lib/fast_xlsx.rb before this extension loads.
@@ -38,9 +40,56 @@ fn xerr(e: XlsxError) -> Error {
38
40
 
39
41
  type Shared = Arc<Mutex<rust_xlsxwriter::Workbook>>;
40
42
 
43
+ // Runs `f` without Ruby's global lock, so other Ruby threads run meanwhile.
44
+ // `f` must not touch any Ruby object, and must release the workbook mutex
45
+ // before returning: a Ruby thread waiting on that mutex holds the lock this
46
+ // thread then needs back.
47
+ //
48
+ // Uses the "2" variant: rb_thread_call_without_gvl raises pending interrupts
49
+ // (Thread#raise, Timeout, Ctrl-C) by longjmp-ing over these Rust frames,
50
+ // which skips their destructors. This one never raises; if an interrupt is
51
+ // already pending it returns without calling `f`, which then runs with the
52
+ // lock held. Either way Ruby raises the interrupt after the method returns.
53
+ // Without an unblocking function, an interrupt waits for `f` to finish.
54
+ fn without_gvl<R>(f: impl FnOnce() -> R) -> R {
55
+ unsafe extern "C" fn call<F: FnOnce() -> R, R>(
56
+ data: *mut std::ffi::c_void,
57
+ ) -> *mut std::ffi::c_void {
58
+ let (f, result) = &mut *(data as *mut (Option<F>, Option<std::thread::Result<R>>));
59
+ // A panic must not unwind into Ruby's C code; it is resumed below.
60
+ *result = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
61
+ f.take().unwrap(),
62
+ )));
63
+ std::ptr::null_mut()
64
+ }
65
+ fn run<F: FnOnce() -> R, R>(f: F) -> R {
66
+ let mut data: (Option<F>, Option<std::thread::Result<R>>) = (Some(f), None);
67
+ unsafe {
68
+ rb_sys::rb_thread_call_without_gvl2(
69
+ Some(call::<F, R>),
70
+ &mut data as *mut _ as *mut std::ffi::c_void,
71
+ None,
72
+ std::ptr::null_mut(),
73
+ );
74
+ }
75
+ let Some(result) = data.1 else {
76
+ // An interrupt was pending, so `call` never ran.
77
+ return (data.0.take().unwrap())();
78
+ };
79
+ match result {
80
+ Ok(result) => result,
81
+ Err(panic) => std::panic::resume_unwind(panic),
82
+ }
83
+ }
84
+ run(f)
85
+ }
86
+
41
87
  #[magnus::wrap(class = "FastXlsx::Workbook", free_immediately)]
42
88
  struct Workbook {
43
89
  inner: Shared,
90
+ // Index of the sheet Excel opens on (0 unless one is activated), shared
91
+ // with the worksheets: it can't be hidden.
92
+ active: Arc<AtomicUsize>,
44
93
  constant_memory: bool,
45
94
  low_memory: bool,
46
95
  }
@@ -49,6 +98,7 @@ struct Workbook {
49
98
  struct Worksheet {
50
99
  wb: Shared,
51
100
  index: usize,
101
+ active: Arc<AtomicUsize>,
52
102
  // Where << / append write next.
53
103
  next_row: Cell<u32>,
54
104
  // Highest row with cells written. In :constant / :low mode rows above it
@@ -60,6 +110,8 @@ struct Worksheet {
60
110
  // after add_table (rust_xlsxwriter only formats cells that already exist).
61
111
  // Owned copies, so they don't depend on the Ruby Format objects living on.
62
112
  table_formats: RefCell<Vec<TableColumnFormat>>,
113
+ // Merged ranges as (first_row, first_col, last_row, last_col).
114
+ merges: RefCell<Vec<(u32, u16, u32, u16)>>,
63
115
  }
64
116
 
65
117
  struct TableColumnFormat {
@@ -72,6 +124,7 @@ impl Workbook {
72
124
  fn new(constant_memory: bool, low_memory: bool) -> Self {
73
125
  Workbook {
74
126
  inner: Arc::new(Mutex::new(rust_xlsxwriter::Workbook::new())),
127
+ active: Arc::new(AtomicUsize::new(0)),
75
128
  constant_memory,
76
129
  low_memory,
77
130
  }
@@ -120,25 +173,39 @@ impl Workbook {
120
173
  Ok(Worksheet {
121
174
  wb: self.inner.clone(),
122
175
  index: wb.worksheets().len() - 1,
176
+ active: self.active.clone(),
123
177
  next_row: Cell::new(0),
124
178
  last_written_row: Cell::new(0),
125
179
  flushes_rows: self.constant_memory || self.low_memory,
126
180
  table_formats: RefCell::new(Vec::new()),
181
+ merges: RefCell::new(Vec::new()),
127
182
  })
128
183
  }
129
184
 
185
+ // Saving (XML and compression) can take a while, so it runs without
186
+ // Ruby's global lock. The mutex guard is dropped inside the closure.
130
187
  fn to_xlsx(ruby: &Ruby, rb_self: &Self) -> Result<RString, Error> {
131
- let buf = rb_self
132
- .inner
133
- .lock()
134
- .unwrap()
135
- .save_to_buffer()
136
- .map_err(xerr)?;
188
+ let inner = &rb_self.inner;
189
+ let buf = without_gvl(|| inner.lock().unwrap().save_to_buffer()).map_err(xerr)?;
137
190
  Ok(ruby.str_from_slice(&buf))
138
191
  }
139
192
 
140
193
  fn save(&self, path: String) -> Result<(), Error> {
141
- self.inner.lock().unwrap().save(path).map_err(xerr)
194
+ let inner = &self.inner;
195
+ without_gvl(|| inner.lock().unwrap().save(path).map(|_| ())).map_err(xerr)
196
+ }
197
+
198
+ // "Name" for the whole workbook, "Sheet1!Name" for one sheet. Duplicate
199
+ // names and unknown sheets are reported when saving, since sheets can be
200
+ // added after the name.
201
+ fn define_name(rb_self: Obj<Self>, name: String, formula: String) -> Result<Obj<Self>, Error> {
202
+ rb_self
203
+ .inner
204
+ .lock()
205
+ .unwrap()
206
+ .define_name(name, &formula)
207
+ .map_err(xerr)?;
208
+ Ok(rb_self)
142
209
  }
143
210
 
144
211
  fn set_properties(ruby: &Ruby, rb_self: &Self, fields: RHash) -> Result<(), Error> {
@@ -215,8 +282,9 @@ fn emit<T: IntoExcelData>(
215
282
 
216
283
  // Excel's limit on the text in a cell.
217
284
  const MAX_CHARS: usize = 32_767;
218
- // Excel's column count.
285
+ // Excel's column and row counts.
219
286
  const MAX_COLS: usize = 16_384;
287
+ const MAX_ROWS: u32 = 1_048_576;
220
288
 
221
289
  // A cell value converted from Ruby. Converting may run Ruby code (to_s, jd,
222
290
  // url, ...), so it happens before the workbook lock is taken: Ruby code that
@@ -653,6 +721,35 @@ fn table_column(
653
721
  Ok((column, format))
654
722
  }
655
723
 
724
+ // Actions users may still take on a protected sheet (selecting cells is
725
+ // always allowed).
726
+ type ProtectionFlag = fn(&mut ProtectionOptions) -> &mut bool;
727
+ const PROTECTION_ALLOW: &[(&str, ProtectionFlag)] = &[
728
+ ("format_cells", |o| &mut o.format_cells),
729
+ ("format_columns", |o| &mut o.format_columns),
730
+ ("format_rows", |o| &mut o.format_rows),
731
+ ("insert_columns", |o| &mut o.insert_columns),
732
+ ("insert_rows", |o| &mut o.insert_rows),
733
+ ("insert_links", |o| &mut o.insert_links),
734
+ ("delete_columns", |o| &mut o.delete_columns),
735
+ ("delete_rows", |o| &mut o.delete_rows),
736
+ ("sort", |o| &mut o.sort),
737
+ ("use_autofilter", |o| &mut o.use_autofilter),
738
+ ("use_pivot_tables", |o| &mut o.use_pivot_tables),
739
+ ("edit_scenarios", |o| &mut o.edit_scenarios),
740
+ ("edit_objects", |o| &mut o.edit_objects),
741
+ ];
742
+
743
+ // Excel's paper size codes; page_setup also takes the number itself.
744
+ const PAPER_SIZES: &[(&str, u8)] = &[
745
+ ("letter", 1),
746
+ ("tabloid", 3),
747
+ ("legal", 5),
748
+ ("a3", 8),
749
+ ("a4", 9),
750
+ ("a5", 11),
751
+ ];
752
+
656
753
  const BORDERS: &[(&str, FormatBorder)] = &[
657
754
  ("thin", FormatBorder::Thin),
658
755
  ("medium", FormatBorder::Medium),
@@ -710,6 +807,11 @@ impl Format {
710
807
  }
711
808
  "indent" => taken.set_indent(u8::try_convert(value)?),
712
809
  "shrink" if value.to_bool() => taken.set_shrink(),
810
+ // For protected sheets: locked: false leaves a cell editable,
811
+ // hidden: true hides its formula.
812
+ "locked" if value.to_bool() => taken.set_locked(),
813
+ "locked" => taken.set_unlocked(),
814
+ "hidden" if value.to_bool() => taken.set_hidden(),
713
815
  "border_color" => taken.set_border_color(color(ruby, value)?),
714
816
  "num_format" => taken.set_num_format(String::try_convert(value)?),
715
817
  "font_size" => taken.set_font_size(f64::try_convert(value)?),
@@ -742,7 +844,8 @@ impl Format {
742
844
  "border_right" => taken.set_border_right(choice(ruby, "border", value, BORDERS)?),
743
845
  "border_top" => taken.set_border_top(choice(ruby, "border", value, BORDERS)?),
744
846
  "border_bottom" => taken.set_border_bottom(choice(ruby, "border", value, BORDERS)?),
745
- "bold" | "italic" | "underline" | "text_wrap" | "strikeout" | "shrink" => taken,
847
+ "bold" | "italic" | "underline" | "text_wrap" | "strikeout" | "shrink"
848
+ | "hidden" => taken,
746
849
  other => {
747
850
  return Err(Error::new(
748
851
  ruby.exception_arg_error(),
@@ -883,6 +986,29 @@ impl Worksheet {
883
986
  Ok(())
884
987
  }
885
988
 
989
+ // Worksheet#write. The (row, col, value, format = nil) form is handled
990
+ // here rather than in a Ruby wrapper, since it runs once per cell; the
991
+ // rest ("B2", or a wrong argument count) goes to Ruby's _write_ref.
992
+ fn write_any(ruby: &Ruby, rb_self: Obj<Self>, args: &[Value]) -> Result<Obj<Self>, Error> {
993
+ let index = |i: usize| args.get(i).and_then(|v| Integer::from_value(*v));
994
+ if let (3 | 4, Some(row), Some(col)) = (args.len(), index(0), index(1)) {
995
+ let format = match args.get(3) {
996
+ Some(f) => Option::<&Format>::try_convert(*f)?,
997
+ None => None,
998
+ };
999
+ Self::write(
1000
+ ruby,
1001
+ &rb_self,
1002
+ row.to_u32()?,
1003
+ col.to_u16()?,
1004
+ args[2],
1005
+ format,
1006
+ )?;
1007
+ return Ok(rb_self);
1008
+ }
1009
+ rb_self.funcall("_write_ref", args)
1010
+ }
1011
+
886
1012
  fn write(
887
1013
  ruby: &Ruby,
888
1014
  rb_self: &Self,
@@ -1036,6 +1162,35 @@ impl Worksheet {
1036
1162
  format: Option<&Format>,
1037
1163
  ) -> Result<Obj<Self>, Error> {
1038
1164
  rb_self.check_not_flushed(ruby, first_row)?;
1165
+ // rust_xlsxwriter blanks the range before it notices an overlap, which
1166
+ // wipes the earlier merge's value, so check first.
1167
+ // A range rust_xlsxwriter rejects anyway (reversed, too big) is left
1168
+ // to it, so its error names the real problem.
1169
+ // ponytail: linear scan; sheets have few merges.
1170
+ let range = (first_row, first_col, last_row, last_col);
1171
+ let valid = first_row <= last_row
1172
+ && first_col <= last_col
1173
+ && last_row < MAX_ROWS
1174
+ && usize::from(last_col) < MAX_COLS;
1175
+ let overlap = rb_self
1176
+ .merges
1177
+ .borrow()
1178
+ .iter()
1179
+ .copied()
1180
+ .find(|&(fr, fc, lr, lc)| {
1181
+ first_row <= lr && fr <= last_row && first_col <= lc && fc <= last_col
1182
+ })
1183
+ .filter(|_| valid);
1184
+ if let Some((fr, fc, lr, lc)) = overlap {
1185
+ return Err(Error::new(
1186
+ ruby.get_inner(&ERROR),
1187
+ format!(
1188
+ "merge range {} overlaps the earlier merge {}",
1189
+ rust_xlsxwriter::utility::cell_range(first_row, first_col, last_row, last_col),
1190
+ rust_xlsxwriter::utility::cell_range(fr, fc, lr, lc),
1191
+ ),
1192
+ ));
1193
+ }
1039
1194
  let value = CellValue::from_ruby(ruby, v)?;
1040
1195
  let tables = rb_self.table_formats.borrow();
1041
1196
  let format = Self::cell_format(&tables, first_row, first_col, format.map(|f| &*f.0));
@@ -1054,6 +1209,7 @@ impl Worksheet {
1054
1209
  .map_err(xerr)?;
1055
1210
  value.write(ws, first_row, first_col, format)
1056
1211
  })?;
1212
+ rb_self.merges.borrow_mut().push(range);
1057
1213
  rb_self.advance(last_row);
1058
1214
  rb_self.note_written(first_row);
1059
1215
  Ok(rb_self)
@@ -1451,6 +1607,207 @@ impl Worksheet {
1451
1607
  Ok(rb_self)
1452
1608
  }
1453
1609
 
1610
+ fn activate(rb_self: Obj<Self>) -> Result<Obj<Self>, Error> {
1611
+ let mut wb = rb_self.wb.lock().unwrap();
1612
+ // rust_xlsxwriter leaves sheets activated earlier selected, which
1613
+ // groups them in Excel (edits then go to all of them).
1614
+ for (i, ws) in wb.worksheets_mut().iter_mut().enumerate() {
1615
+ ws.set_active(i == rb_self.index);
1616
+ ws.set_selected(i == rb_self.index);
1617
+ }
1618
+ rb_self.active.store(rb_self.index, Ordering::Relaxed);
1619
+ Ok(rb_self)
1620
+ }
1621
+
1622
+ fn hide(ruby: &Ruby, rb_self: Obj<Self>) -> Result<Obj<Self>, Error> {
1623
+ // rust_xlsxwriter would quietly unhide it when saving.
1624
+ if rb_self.active.load(Ordering::Relaxed) == rb_self.index {
1625
+ return Err(Error::new(
1626
+ ruby.get_inner(&ERROR),
1627
+ "can't hide the sheet Excel opens on (the first one unless another is activated): activate another sheet first",
1628
+ ));
1629
+ }
1630
+ rb_self.with_ws(|ws| {
1631
+ ws.set_hidden(true);
1632
+ Ok(())
1633
+ })?;
1634
+ Ok(rb_self)
1635
+ }
1636
+
1637
+ fn zoom(ruby: &Ruby, rb_self: Obj<Self>, percent: Value) -> Result<Obj<Self>, Error> {
1638
+ // Integers only: converting would quietly truncate 150.9.
1639
+ let percent = Integer::from_value(percent)
1640
+ .ok_or_else(|| {
1641
+ Error::new(
1642
+ ruby.exception_type_error(),
1643
+ format!("zoom must be an Integer, got {}", percent.inspect()),
1644
+ )
1645
+ })?
1646
+ .to_i64()?;
1647
+ // rust_xlsxwriter only prints a warning for these.
1648
+ if !(10..=400).contains(&percent) {
1649
+ return Err(Error::new(
1650
+ ruby.exception_arg_error(),
1651
+ format!("invalid zoom {percent}: use 10..400"),
1652
+ ));
1653
+ }
1654
+ rb_self.with_ws(|ws| {
1655
+ ws.set_zoom(percent as u16);
1656
+ Ok(())
1657
+ })?;
1658
+ Ok(rb_self)
1659
+ }
1660
+
1661
+ fn tab_color(ruby: &Ruby, rb_self: Obj<Self>, value: Value) -> Result<Obj<Self>, Error> {
1662
+ let rgb = color(ruby, value)?;
1663
+ rb_self.with_ws(|ws| {
1664
+ ws.set_tab_color(rgb);
1665
+ Ok(())
1666
+ })?;
1667
+ Ok(rb_self)
1668
+ }
1669
+
1670
+ fn hide_gridlines(rb_self: Obj<Self>) -> Result<Obj<Self>, Error> {
1671
+ rb_self.with_ws(|ws| {
1672
+ ws.set_screen_gridlines(false);
1673
+ Ok(())
1674
+ })?;
1675
+ Ok(rb_self)
1676
+ }
1677
+
1678
+ // Ranges arrive as [first, last] / [first_row, first_col, last_row,
1679
+ // last_col], already parsed by lib/fast_xlsx.rb.
1680
+ fn page_setup(ruby: &Ruby, rb_self: &Self, options: RHash) -> Result<(), Error> {
1681
+ check_keys(
1682
+ ruby,
1683
+ options,
1684
+ &[
1685
+ "landscape",
1686
+ "paper",
1687
+ "fit_width",
1688
+ "fit_height",
1689
+ "repeat_rows",
1690
+ "repeat_columns",
1691
+ "print_area",
1692
+ "gridlines",
1693
+ ],
1694
+ "page_setup",
1695
+ )?;
1696
+ let landscape = opt::<Value>(ruby, options, "landscape")?.map(|v| v.to_bool());
1697
+ let paper = match opt::<Value>(ruby, options, "paper")? {
1698
+ None => None,
1699
+ Some(v) if Integer::from_value(v).is_some() => Some(
1700
+ Integer::from_value(v)
1701
+ .and_then(|n| n.to_u8().ok())
1702
+ .ok_or_else(|| {
1703
+ Error::new(
1704
+ ruby.exception_arg_error(),
1705
+ format!("invalid paper {}: use a symbol or Excel's paper number (0 for the printer's default)", v.inspect()),
1706
+ )
1707
+ })?,
1708
+ ),
1709
+ Some(v) => Some(choice(ruby, "paper", v, PAPER_SIZES)?),
1710
+ };
1711
+ let fit_width = opt::<u16>(ruby, options, "fit_width")?;
1712
+ let fit_height = opt::<u16>(ruby, options, "fit_height")?;
1713
+ let repeat_rows = opt::<(u32, u32)>(ruby, options, "repeat_rows")?;
1714
+ let repeat_columns = opt::<(u16, u16)>(ruby, options, "repeat_columns")?;
1715
+ let print_area = opt::<(u32, u16, u32, u16)>(ruby, options, "print_area")?;
1716
+ let gridlines = opt::<Value>(ruby, options, "gridlines")?.map(|v| v.to_bool());
1717
+ rb_self.with_ws(|ws| {
1718
+ match landscape {
1719
+ Some(true) => ws.set_landscape(),
1720
+ Some(false) => ws.set_portrait(),
1721
+ None => ws,
1722
+ };
1723
+ if let Some(paper) = paper {
1724
+ ws.set_paper_size(paper);
1725
+ }
1726
+ // 0 means as many pages as the content needs.
1727
+ if fit_width.unwrap_or(0) > 0 || fit_height.unwrap_or(0) > 0 {
1728
+ ws.set_print_fit_to_pages(fit_width.unwrap_or(0), fit_height.unwrap_or(0));
1729
+ }
1730
+ if let Some((first, last)) = repeat_rows {
1731
+ ws.set_repeat_rows(first, last).map_err(xerr)?;
1732
+ }
1733
+ if let Some((first, last)) = repeat_columns {
1734
+ ws.set_repeat_columns(first, last).map_err(xerr)?;
1735
+ }
1736
+ if let Some((fr, fc, lr, lc)) = print_area {
1737
+ ws.set_print_area(fr, fc, lr, lc).map_err(xerr)?;
1738
+ }
1739
+ if let Some(gridlines) = gridlines {
1740
+ ws.set_print_gridlines(gridlines);
1741
+ }
1742
+ Ok(())
1743
+ })
1744
+ }
1745
+
1746
+ fn group_rows(
1747
+ ruby: &Ruby,
1748
+ rb_self: Obj<Self>,
1749
+ first: u32,
1750
+ last: u32,
1751
+ collapsed: bool,
1752
+ ) -> Result<Obj<Self>, Error> {
1753
+ // rust_xlsxwriter writes no outline levels when it writes rows to
1754
+ // disk as it goes.
1755
+ if rb_self.flushes_rows {
1756
+ return Err(Error::new(
1757
+ ruby.get_inner(&ERROR),
1758
+ "group_rows needs memory: :standard (rows written to disk as they go lose their outline level)",
1759
+ ));
1760
+ }
1761
+ rb_self.with_ws(|ws| {
1762
+ if collapsed {
1763
+ ws.group_rows_collapsed(first, last)
1764
+ } else {
1765
+ ws.group_rows(first, last)
1766
+ }
1767
+ .map(|_| ())
1768
+ .map_err(xerr)
1769
+ })?;
1770
+ Ok(rb_self)
1771
+ }
1772
+
1773
+ fn group_columns(
1774
+ rb_self: Obj<Self>,
1775
+ first: u16,
1776
+ last: u16,
1777
+ collapsed: bool,
1778
+ ) -> Result<Obj<Self>, Error> {
1779
+ rb_self.with_ws(|ws| {
1780
+ if collapsed {
1781
+ ws.group_columns_collapsed(first, last)
1782
+ } else {
1783
+ ws.group_columns(first, last)
1784
+ }
1785
+ .map(|_| ())
1786
+ .map_err(xerr)
1787
+ })?;
1788
+ Ok(rb_self)
1789
+ }
1790
+
1791
+ fn protect(
1792
+ ruby: &Ruby,
1793
+ rb_self: Obj<Self>,
1794
+ password: Option<String>,
1795
+ allow: RArray,
1796
+ ) -> Result<Obj<Self>, Error> {
1797
+ let mut options = ProtectionOptions::new();
1798
+ each_entry(allow, |_, action| {
1799
+ *choice(ruby, "protect action", action, PROTECTION_ALLOW)?(&mut options) = true;
1800
+ Ok(())
1801
+ })?;
1802
+ rb_self.with_ws(|ws| {
1803
+ // Always set the password, so protecting again replaces it ("" is none).
1804
+ ws.protect_with_password(password.as_deref().unwrap_or(""));
1805
+ ws.protect_with_options(&options); // keeps the password
1806
+ Ok(())
1807
+ })?;
1808
+ Ok(rb_self)
1809
+ }
1810
+
1454
1811
  fn name(&self) -> Result<String, Error> {
1455
1812
  self.with_ws(|ws| Ok(ws.name()))
1456
1813
  }
@@ -1469,15 +1826,17 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1469
1826
  wb.define_method("_add_worksheet", method!(Workbook::add_worksheet, 1))?;
1470
1827
  wb.define_method("to_xlsx", method!(Workbook::to_xlsx, 0))?;
1471
1828
  wb.define_method("_save", method!(Workbook::save, 1))?;
1829
+ wb.define_method("define_name", method!(Workbook::define_name, 2))?;
1472
1830
  wb.define_method("_properties", method!(Workbook::set_properties, 1))?;
1473
1831
 
1474
1832
  let ws = module.define_class("Worksheet", ruby.class_object())?;
1475
1833
  ws.define_method("_write", method!(Worksheet::write, 4))?;
1834
+ ws.define_method("write", method!(Worksheet::write_any, -1))?;
1476
1835
  ws.define_method("_append", method!(Worksheet::append, 2))?;
1477
1836
  ws.define_method("_column_width", method!(Worksheet::set_column_width, 3))?;
1478
1837
  ws.define_method("_column_format", method!(Worksheet::set_column_format, 3))?;
1479
1838
  ws.define_method("_autofit", method!(Worksheet::autofit, 0))?;
1480
- ws.define_method("autofilter", method!(Worksheet::autofilter, 4))?;
1839
+ ws.define_method("_autofilter", method!(Worksheet::autofilter, 4))?;
1481
1840
  ws.define_method("name", method!(Worksheet::name, 0))?;
1482
1841
  ws.define_method("_write_comment", method!(Worksheet::write_comment, 4))?;
1483
1842
  ws.define_method("_insert_image", method!(Worksheet::insert_image, 4))?;
@@ -1488,7 +1847,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1488
1847
  method!(Worksheet::conditional_format, 5),
1489
1848
  )?;
1490
1849
  ws.define_method("_data_validation", method!(Worksheet::data_validation, 5))?;
1491
- ws.define_method("freeze_panes", method!(Worksheet::freeze_panes, 2))?;
1850
+ ws.define_method("_freeze_panes", method!(Worksheet::freeze_panes, 2))?;
1492
1851
  ws.define_method("row_height", method!(Worksheet::set_row_height, 2))?;
1493
1852
  ws.define_method("page_breaks", method!(Worksheet::set_page_breaks, 1))?;
1494
1853
  ws.define_method("_page_header", method!(Worksheet::set_header, 1))?;
@@ -1498,6 +1857,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1498
1857
  "vertical_page_breaks",
1499
1858
  method!(Worksheet::set_vertical_page_breaks, 1),
1500
1859
  )?;
1860
+ ws.define_method("activate", method!(Worksheet::activate, 0))?;
1861
+ ws.define_method("hide", method!(Worksheet::hide, 0))?;
1862
+ ws.define_method("zoom", method!(Worksheet::zoom, 1))?;
1863
+ ws.define_method("tab_color", method!(Worksheet::tab_color, 1))?;
1864
+ ws.define_method("hide_gridlines", method!(Worksheet::hide_gridlines, 0))?;
1865
+ ws.define_method("_page_setup", method!(Worksheet::page_setup, 1))?;
1866
+ ws.define_method("_group_rows", method!(Worksheet::group_rows, 3))?;
1867
+ ws.define_method("_group_columns", method!(Worksheet::group_columns, 3))?;
1868
+ ws.define_method("_protect", method!(Worksheet::protect, 2))?;
1501
1869
  ws.define_method("_merge_range", method!(Worksheet::merge_range, 6))?;
1502
1870
 
1503
1871
  let format = module.define_class("Format", ruby.class_object())?;
@@ -14,6 +14,9 @@ fn ruby_test_suite_passes() {
14
14
  .args(["exec", "rake", "compile", "test"])
15
15
  .current_dir(&root)
16
16
  .env("RB_SYS_CARGO_PROFILE", "dev")
17
+ // The suite needs a few CPU seconds; a mutant that loops forever is
18
+ // stopped instead of running on after cargo-mutants gives up on it.
19
+ .env("FAST_XLSX_TEST_CPU_SECONDS", "120")
17
20
  .status()
18
21
  .expect("could not run `bundle exec rake compile test`");
19
22
  assert!(status.success(), "the Ruby test suite failed");
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastXlsx
4
- VERSION = "0.1.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/fast_xlsx.rb CHANGED
@@ -102,10 +102,15 @@ module FastXlsx
102
102
 
103
103
  # Cell writer for one sheet; create with Workbook#add_worksheet.
104
104
  class Worksheet
105
- def write(row, col, value, format = nil)
105
+ # write(row, col, value, format = nil) or write("B2", value, format = nil)
106
+ # is native: the (row, col) form runs once per cell, so it skips a Ruby
107
+ # wrapper. Other forms come here.
108
+ def _write_ref(*args)
109
+ row, col, (value, format) = CellRange.cell(args, 1..2)
106
110
  _write(row, col, value, format)
107
111
  self
108
112
  end
113
+ private :_write_ref
109
114
 
110
115
  def append(values, format: nil)
111
116
  _append(values, format)
@@ -113,11 +118,11 @@ module FastXlsx
113
118
 
114
119
  # columns: a 0-based column index or a Range of them. width is in characters.
115
120
  def column_width(columns, width)
116
- bounds = column_bounds(columns)
117
- _column_width(*bounds, width)
121
+ range = CellRange.bounds(columns)
122
+ _column_width(*range, width)
118
123
  @fixed_widths ||= {}
119
- @fixed_widths.delete(bounds) # re-insert so autofit replays calls in order
120
- @fixed_widths[bounds] = width
124
+ @fixed_widths.delete(range) # re-insert so autofit replays calls in order
125
+ @fixed_widths[range] = width
121
126
  self
122
127
  end
123
128
 
@@ -129,32 +134,45 @@ module FastXlsx
129
134
  self
130
135
  end
131
136
 
137
+ # The methods below take a cell range in any of these styles:
138
+ # (first_row, first_col, last_row, last_col) four 0-based numbers
139
+ # ("A1:D10") or ("B2") an Excel reference
140
+ # (rows, cols) Integers or Ranges, e.g. (0..9, 0..3)
141
+
142
+ # Filter buttons on the range's first row.
143
+ def autofilter(*range)
144
+ _autofilter(*CellRange.split(range).first)
145
+ end
146
+
132
147
  # Merges the range and writes value (any cell type) into its first cell.
133
- def merge_range(first_row, first_col, last_row, last_col, value, format = nil)
134
- _merge_range(first_row, first_col, last_row, last_col, value, format)
148
+ def merge_range(*args)
149
+ range, (value, format) = CellRange.split(args, 1..2)
150
+ _merge_range(*range, value, format)
135
151
  end
136
152
 
137
153
  # Highlights cells in the range by rule. type: :cell, :text, :formula,
138
154
  # :data_bar or :color_scale; see the README for each type's options.
139
- def conditional_format(first_row, first_col, last_row, last_col, type:, **)
140
- _conditional_format(first_row, first_col, last_row, last_col, { type: type, ** })
155
+ def conditional_format(*range, type:, **)
156
+ _conditional_format(*CellRange.split(range).first, { type: type, ** })
141
157
  end
142
158
 
143
159
  # Restricts what can be entered in the range. type: :list, :whole_number,
144
160
  # :decimal or :text_length; see the README for the options.
145
- def data_validation(first_row, first_col, last_row, last_col, type:, **)
146
- _data_validation(first_row, first_col, last_row, last_col, { type: type, ** })
161
+ def data_validation(*range, type:, **)
162
+ _data_validation(*CellRange.split(range).first, { type: type, ** })
147
163
  end
148
164
 
149
165
  # Adds a comment (Excel "note") to a cell.
150
- def write_comment(row, col, text, author: nil)
166
+ def write_comment(*args, author: nil)
167
+ row, col, (text, *) = CellRange.cell(args, 1..1)
151
168
  _write_comment(row, col, text, author)
152
169
  end
153
170
 
154
171
  # Inserts a PNG, JPEG, GIF or BMP image with its top-left corner in the
155
172
  # cell. source is a file path or an IO (anything responding to #read).
156
173
  # Options: scale: or width:/height: (pixels), x_offset:, y_offset: (pixels), alt_text:.
157
- def insert_image(row, col, source, **)
174
+ def insert_image(*args, **)
175
+ row, col, (source, *) = CellRange.cell(args, 1..1)
158
176
  bytes = source.respond_to?(:read) ? source.read : File.binread(source)
159
177
  _insert_image(row, col, bytes, { ** })
160
178
  end
@@ -162,7 +180,8 @@ module FastXlsx
162
180
  # Inserts a chart with its top-left corner in the cell. series is an Array
163
181
  # of { values:, categories:, name: } with Excel ranges such as
164
182
  # "Sheet1!$B$2:$B$13". Options: title:, x_axis:, y_axis:, width:, height:.
165
- def insert_chart(row, col, type:, series:, **)
183
+ def insert_chart(*cell, type:, series:, **)
184
+ row, col, = CellRange.cell(cell)
166
185
  _insert_chart(row, col, { type: type, series: series, ** })
167
186
  end
168
187
 
@@ -170,8 +189,8 @@ module FastXlsx
170
189
  # into an Excel table. columns: header Strings or { header:, total:,
171
190
  # total_label:, format: }; other options: style:, name:, total_row:,
172
191
  # banded_rows:, autofilter:.
173
- def add_table(first_row, first_col, last_row, last_col, **)
174
- _add_table(first_row, first_col, last_row, last_col, { ** })
192
+ def add_table(*range, **)
193
+ _add_table(*CellRange.split(range).first, { ** })
175
194
  end
176
195
 
177
196
  # Printed page header/footer using Excel codes such as "&CPage &P of &N".
@@ -186,24 +205,151 @@ module FastXlsx
186
205
  margin ? margins(footer: margin) : self
187
206
  end
188
207
 
208
+ # Printing: landscape:, paper: (:letter, :legal, :tabloid, :a3, :a4, :a5 or
209
+ # Excel's paper number), fit_width:/fit_height: (pages; 0 or left out =
210
+ # as many as needed), repeat_rows:/repeat_columns: (an index or Range,
211
+ # printed on every page), print_area: (any cell range), gridlines:.
212
+ def page_setup(repeat_rows: nil, repeat_columns: nil, print_area: nil, **options)
213
+ options[:repeat_rows] = CellRange.bounds(repeat_rows) if repeat_rows
214
+ options[:repeat_columns] = CellRange.bounds(repeat_columns) if repeat_columns
215
+ options[:print_area] = CellRange.split(print_area.is_a?(Array) ? print_area : [print_area]).first if print_area
216
+ _page_setup(options)
217
+ self
218
+ end
219
+
189
220
  # Print margins in inches; margins not given keep their current value.
190
221
  def margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
191
222
  _margins(*[left, right, top, bottom, header, footer].map { |m| m || -1.0 })
192
223
  self
193
224
  end
194
225
 
226
+ # Outline group with an expand/collapse button. rows: a 0-based row index
227
+ # or a Range; grouping rows already grouped nests them (up to 7 levels).
228
+ # collapsed: true hides them until expanded.
229
+ def group_rows(rows, collapsed: false)
230
+ _group_rows(*CellRange.bounds(rows), collapsed)
231
+ end
232
+
233
+ def group_columns(columns, collapsed: false)
234
+ _group_columns(*CellRange.bounds(columns), collapsed)
235
+ end
236
+
237
+ # Keeps the rows above and the columns left of the cell visible while
238
+ # scrolling: (1, 0) or "A2" freezes the first row.
239
+ def freeze_panes(*cell)
240
+ _freeze_panes(*CellRange.cell(cell).first(2))
241
+ self
242
+ end
243
+
244
+ # Locks the sheet against editing. Cells whose format has locked: false
245
+ # stay editable. allow: actions users may still take, any of :format_cells,
246
+ # :format_columns, :format_rows, :insert_columns, :insert_rows,
247
+ # :insert_links, :delete_columns, :delete_rows, :sort, :use_autofilter,
248
+ # :use_pivot_tables, :edit_scenarios, :edit_objects.
249
+ def protect(password: nil, allow: [])
250
+ _protect(password, Array(allow))
251
+ end
252
+
195
253
  # Default format for cells in these columns that are written without one.
196
254
  def column_format(columns, format)
197
- _column_format(*column_bounds(columns), format)
255
+ _column_format(*CellRange.bounds(columns), format)
198
256
  self
199
257
  end
258
+ end
259
+
260
+ # Cell ranges in the styles Worksheet methods accept: four 0-based numbers,
261
+ # an Excel reference ("A1:D10", "B2"), or rows and columns as Integers or
262
+ # Ranges.
263
+ module CellRange
264
+ REF = /\A\$?([A-Za-z]{1,3})\$?([1-9]\d*)\z/ # ASCII only: /i also matches the Kelvin sign
265
+ FORMS = 'a range is (first_row, first_col, last_row, last_col), "A1:D10" or (rows, cols)'
266
+ CELL_FORMS = 'a cell is (row, col) or "B2"'
267
+
268
+ module_function
269
+
270
+ # Splits a range off the front of args and checks how many args follow.
271
+ # Returns [[first_row, first_col, last_row, last_col], the args after it].
272
+ def split(args, following = 0..0)
273
+ range, rest = parse(args)
274
+ check_following(rest, following, "cell range", FORMS)
275
+ [range, rest]
276
+ end
277
+
278
+ def parse(args)
279
+ case args
280
+ in [Numeric, Numeric, Numeric, Numeric, *rest] then [args.first(4), rest]
281
+ in [String => ref, *rest] then [excel(ref), rest]
282
+ in [Integer | Range => rows, Integer | Range => cols, *rest]
283
+ [rows_and_cols(rows, cols), rest]
284
+ else
285
+ raise ArgumentError, "expected a cell range, got #{args.inspect}; #{FORMS}"
286
+ end
287
+ end
288
+
289
+ # (row, col) or a single-cell reference ("B2") off the front of args, as
290
+ # [row, col, the args after it].
291
+ def cell(args, following = 0..0)
292
+ row, col, *rest = args.first.is_a?(String) ? [*single_cell(args.first), *args.drop(1)] : args
293
+ raise ArgumentError, "expected a cell, got #{args.inspect}; #{CELL_FORMS}" if col.nil?
294
+
295
+ check_following(rest, following, "cell", CELL_FORMS)
296
+ [row, col, rest]
297
+ end
298
+
299
+ def single_cell(ref)
300
+ first_row, first_col, last_row, last_col = excel(ref)
301
+ return [first_row, first_col] if first_row == last_row && first_col == last_col
302
+
303
+ raise ArgumentError, "expected a single cell like \"B2\", got #{ref.inspect}"
304
+ end
305
+
306
+ def check_following(rest, following, what, forms)
307
+ return if following.cover?(rest.size)
308
+
309
+ raise ArgumentError, "wrong number of arguments after the #{what} " \
310
+ "(given #{rest.size}, expected #{following.minmax.uniq.join("..")}); #{forms}"
311
+ end
312
+
313
+ def rows_and_cols(rows, cols)
314
+ first_row, last_row = bounds(rows)
315
+ first_col, last_col = bounds(cols)
316
+ [first_row, first_col, last_row, last_col]
317
+ end
318
+
319
+ # "A1:D10", "$A$1:$D$10" or a single "B2".
320
+ def excel(ref)
321
+ cells = cell_matches(ref)
322
+ rows = cells.map { |m| m[2].to_i - 1 }
323
+ cols = cells.map { |m| column(m[1]) }
324
+ [rows.min, cols.min, rows.max, cols.max]
325
+ end
326
+
327
+ def cell_matches(ref)
328
+ cells = ref.split(":", -1).map { |cell| REF.match(cell) }
329
+ return cells if (1..2).cover?(cells.size) && cells.all?
330
+
331
+ raise ArgumentError, "invalid cell range #{ref.inspect}: use e.g. \"A1:D10\" or \"B2\""
332
+ end
333
+
334
+ # "A" => 0, "AA" => 26.
335
+ def column(letters)
336
+ letters.upcase.each_char.reduce(0) { |n, c| (n * 26) + c.ord - 64 } - 1
337
+ end
338
+
339
+ # [first, last] of an index or a Range.
340
+ def bounds(indexes)
341
+ return [indexes, indexes] if indexes.is_a?(Integer)
342
+ unless indexes.is_a?(Range) && indexes.begin.is_a?(Integer) && indexes.end.is_a?(Integer)
343
+ raise ArgumentError, "expected an Integer or a Range of Integers, got #{indexes.inspect}"
344
+ end
200
345
 
201
- private
346
+ first, last = indexes.minmax
347
+ raise ArgumentError, "empty range #{indexes.inspect}" unless first
202
348
 
203
- def column_bounds(columns)
204
- columns.is_a?(Integer) ? [columns, columns] : columns.minmax
349
+ [first, last]
205
350
  end
206
351
  end
352
+ private_constant :CellRange
207
353
 
208
354
  # Cell style, e.g. Format.new(bold: true). Pass to Worksheet#write.
209
355
  class Format
data/sig/fast_xlsx.rbs CHANGED
@@ -36,6 +36,7 @@ module FastXlsx
36
36
  ?company: String, ?category: String, ?keywords: String,
37
37
  ?comments: String, ?status: String) -> self
38
38
  def to_xlsx: () -> String
39
+ def define_name: (String name, String formula) -> self
39
40
  def save: (String | _ToPath path) -> void
40
41
  end
41
42
 
@@ -46,7 +47,7 @@ module FastXlsx
46
47
  type underline = bool | :single | :double | :single_accounting | :double_accounting
47
48
 
48
49
  def self.new: (?bold: bool, ?italic: bool, ?underline: underline, ?strikeout: bool, ?text_wrap: bool,
49
- ?shrink: bool, ?num_format: String, ?font_size: Numeric, ?font_name: String,
50
+ ?shrink: bool, ?locked: bool, ?hidden: bool, ?num_format: String, ?font_size: Numeric, ?font_name: String,
50
51
  ?font_color: color, ?bg_color: color, ?font_script: :superscript | :subscript,
51
52
  ?align: :left | :center | :right, ?valign: :top | :center | :bottom,
52
53
  ?rotation: Integer, ?indent: Integer,
@@ -56,42 +57,73 @@ module FastXlsx
56
57
 
57
58
  class Worksheet
58
59
  def write: (Integer row, Integer col, cell value, ?Format? format) -> self
60
+ | (String cell, cell value, ?Format? format) -> self
59
61
  def append: (Array[cell] values, ?format: Format? | Array[Format?]) -> self
60
62
  def <<: (Array[cell] row) -> self
61
63
  def concat: (Array[Array[cell]] rows) -> self
62
64
  def column_width: (Integer | Range[Integer] columns, Numeric width) -> self
63
65
  def column_format: (Integer | Range[Integer] columns, Format format) -> self
64
66
  def autofit: () -> self
67
+ # A cell range: four 0-based numbers, an Excel reference ("A1:D10"), or
68
+ # rows and columns as Integers or Ranges. Methods with options take it as
69
+ # *cell_range so the keywords can follow.
70
+ type indexes = Integer | Range[Integer]
71
+ type cell_range = Integer | String | Range[Integer]
65
72
  def autofilter: (Integer first_row, Integer first_col, Integer last_row, Integer last_col) -> self
73
+ | (String range) -> self
74
+ | (indexes rows, indexes cols) -> self
66
75
  def freeze_panes: (Integer row, Integer col) -> self
76
+ | (String cell) -> self
67
77
  def row_height: (Integer row, Numeric height) -> self
68
78
  def page_breaks: (Array[Integer] rows) -> self
69
79
  def vertical_page_breaks: (Array[Integer] cols) -> self
70
80
  def page_header: (String text, ?margin: Numeric?) -> self
71
81
  def page_footer: (String text, ?margin: Numeric?) -> self
82
+ def activate: () -> self
83
+ def hide: () -> self
84
+ def zoom: (Integer percent) -> self
85
+ def tab_color: (String | Integer color) -> self
86
+ def hide_gridlines: () -> self
87
+ def page_setup: (?landscape: bool, ?paper: :letter | :legal | :tabloid | :a3 | :a4 | :a5 | Integer,
88
+ ?fit_width: Integer, ?fit_height: Integer, ?repeat_rows: indexes, ?repeat_columns: indexes,
89
+ ?print_area: String | Array[cell_range], ?gridlines: bool) -> self
72
90
  def margins: (?left: Numeric?, ?right: Numeric?, ?top: Numeric?, ?bottom: Numeric?,
73
91
  ?header: Numeric?, ?footer: Numeric?) -> self
92
+ type protect_action = :format_cells | :format_columns | :format_rows | :insert_columns | :insert_rows
93
+ | :insert_links | :delete_columns | :delete_rows | :sort | :use_autofilter
94
+ | :use_pivot_tables | :edit_scenarios | :edit_objects
95
+ def group_rows: (Integer | Range[Integer] rows, ?collapsed: bool) -> self
96
+ def group_columns: (Integer | Range[Integer] columns, ?collapsed: bool) -> self
97
+ def protect: (?password: String?, ?allow: protect_action | Array[protect_action] | nil) -> self
74
98
  def merge_range: (Integer first_row, Integer first_col, Integer last_row, Integer last_col, cell value, ?Format? format) -> self
75
- def conditional_format: (Integer first_row, Integer first_col, Integer last_row, Integer last_col,
99
+ | (String range, cell value, ?Format? format) -> self
100
+ | (indexes rows, indexes cols, cell value, ?Format? format) -> self
101
+ def conditional_format: (*cell_range range,
76
102
  type: :cell | :text | :formula | :data_bar | :color_scale,
77
103
  ?criteria: Symbol, ?value: Numeric | String | Array[Numeric | String],
78
104
  ?format: Format, ?colors: 2 | 3) -> self
79
- def data_validation: (Integer first_row, Integer first_col, Integer last_row, Integer last_col,
105
+ def data_validation: (*cell_range range,
80
106
  type: :list | :whole_number | :decimal | :text_length,
81
107
  ?criteria: Symbol, ?value: Numeric | String | Array[Numeric | String],
82
108
  ?input_title: String, ?input_message: String,
83
109
  ?error_title: String, ?error_message: String) -> self
84
110
  def write_comment: (Integer row, Integer col, String text, ?author: String?) -> self
111
+ | (String cell, String text, ?author: String?) -> self
85
112
  def insert_image: (Integer row, Integer col, String | _Reader source, ?scale: Numeric, ?width: Numeric, ?height: Numeric,
86
113
  ?x_offset: Integer, ?y_offset: Integer, ?alt_text: String) -> self
114
+ | (String cell, String | _Reader source, ?scale: Numeric, ?width: Numeric, ?height: Numeric,
115
+ ?x_offset: Integer, ?y_offset: Integer, ?alt_text: String) -> self
87
116
  type chart_series = { values: String, ?categories: String, ?name: String }
88
117
 
89
118
  def insert_chart: (Integer row, Integer col, type: Symbol, series: Array[chart_series],
90
119
  ?title: String, ?x_axis: String, ?y_axis: String,
91
120
  ?width: Integer, ?height: Integer) -> self
121
+ | (String cell, type: Symbol, series: Array[chart_series],
122
+ ?title: String, ?x_axis: String, ?y_axis: String,
123
+ ?width: Integer, ?height: Integer) -> self
92
124
  type table_column = String | { header: String, ?total: Symbol, ?total_label: String, ?format: Format }
93
125
 
94
- def add_table: (Integer first_row, Integer first_col, Integer last_row, Integer last_col,
126
+ def add_table: (*cell_range range,
95
127
  ?columns: Array[table_column], ?style: Symbol, ?name: String,
96
128
  ?total_row: bool, ?banded_rows: bool, ?autofilter: bool) -> self
97
129
  def name: () -> String
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast_xlsx
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac