fast_xlsx 0.2.0 → 0.4.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: 85532243a1cd812798374658f4b5673ea9cda88586e876b76281f99cbd8dd065
4
- data.tar.gz: 67747bd17f6f4ecf7ed9041767498de5d0d3d416922250578bf5b87c0d41ad1c
3
+ metadata.gz: '045446748368a71ae4010c17da8b09d0a23ac2c3e9bdb0d63bf5ed54bf04221c'
4
+ data.tar.gz: 213cafbae07330020d41e179fc8473a92e5eab5da4cc595fb60e62f3764fa21e
5
5
  SHA512:
6
- metadata.gz: 75d89ae4b66fce6129f09d8ba18d1766571fe740e3069eebc9be5334dbd254f5a75dbccf56c3d250a6a0deacfa109d86971b0974289d0822467b8971d6b3d40c
7
- data.tar.gz: e9ff13d9d4014d5b2034ffdff56897d87ff00df6688145c6ae4bc637690323389ccd53c4c8e651e5da5177fb06ba2f141f96a3fe9dc60e0949ce69672984edb4
6
+ metadata.gz: dce10715e570fffa0c5ff69899065c07a3f34f6beb8a9597c095a39f271991d11f05239e62135d853063759a016518fca9f61113e0ae8991c97663a62b4066ff
7
+ data.tar.gz: d87800324af3edab7719ce665cb836d213456102833e04351cfdec508ef063e4c4b16aa80e298ab764262b6a4c6c97e8b2844b48b97639a3e005572369ecf2ce
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-10-01
4
+
5
+ ### Changed
6
+
7
+ - Dates and times written without a format now get `yyyy-mm-dd` (`Date`) or `yyyy-mm-dd hh:mm:ss` (`Time`, `DateTime`) instead of showing as serial numbers. A cell's own format, its table column's and `column_format` still win. Applying the format makes writing date-heavy data about 15% slower, the same as passing a date format yourself.
8
+
9
+ ## [0.3.0] - 2026-10-01
10
+
11
+ ### Changed
12
+
13
+ - `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.
14
+ - 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.
15
+
16
+ ### Added
17
+
18
+ - `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.
19
+ - `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).
20
+
21
+ ### Fixed
22
+
23
+ - A `merge_range` that overlaps an earlier merge raises before writing anything; before, it also blanked the earlier merge's value.
24
+
3
25
  ## [0.2.0] - 2026-10-01
4
26
 
5
27
  ### Added
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
@@ -18,6 +18,7 @@ ws = wb.add_worksheet("Report") # later: wb.worksheet("Report")
18
18
  ws << ["id", "name", "created_at"] # append a row
19
19
  ws.concat(records.map { |r| [r.id, r.name, r.created_at] }) # append many rows in one call
20
20
  ws.write(0, 5, 42) # write a single cell (row, col, value)
21
+ ws.write("F1", 42) # or by its Excel reference
21
22
 
22
23
  wb.properties(title: "Q3 report", author: "Zac", keywords: "Confidential") # File > Info in Excel
23
24
  wb.save("report.xlsx") # or wb.to_xlsx => binary String
@@ -81,6 +82,8 @@ ws.write(1, 2, Date.today, date) # format one cell
81
82
 
82
83
  Per-side borders override `border`. Unknown options and invalid values raise `ArgumentError`.
83
84
 
85
+ Dates and times written without a format get `yyyy-mm-dd` or `yyyy-mm-dd hh:mm:ss`; a table column's format or `column_format` wins over that. A format you pass replaces it, so give date cells one with a `num_format` (e.g. `Format.new(bold: true, num_format: "yyyy-mm-dd")`), or Excel shows the serial number.
86
+
84
87
  ### Columns and filters
85
88
 
86
89
  ```ruby
@@ -91,6 +94,22 @@ ws.autofit # size other columns to the data written so fa
91
94
  ws.autofilter("A1:D101") # filter buttons on A1:D101
92
95
  ```
93
96
 
97
+ ### Sheet view and printing
98
+
99
+ ```ruby
100
+ ws.zoom(150) # 10..400 percent
101
+ ws.tab_color("#C00000")
102
+ ws.hide_gridlines
103
+ ws.activate # Excel opens on this sheet (un-hides it if hidden)
104
+ other.hide # the sheet Excel opens on can't be hidden: activate another one first
105
+
106
+ ws.page_setup(landscape: true, paper: :a4, # or :letter, :legal, :tabloid, :a3, :a5, Excel's paper number, 0 = printer default
107
+ fit_width: 1, # 1 page wide, as many pages tall as needed
108
+ repeat_rows: 0, # print the header row on every page (an index or a Range)
109
+ print_area: "A1:D100", # any cell range style
110
+ gridlines: true) # print the gridlines
111
+ ```
112
+
94
113
  ### Cell ranges
95
114
 
96
115
  `autofilter`, `merge_range`, `conditional_format`, `data_validation` and `add_table` take a range in any of these styles:
@@ -102,6 +121,8 @@ ws.autofilter(0..100, 0..3) # rows and columns, each an Integer or a Range
102
121
  ws.merge_range(0, 0..3, "Q3 report", title) # row 1, columns A–D
103
122
  ```
104
123
 
124
+ Methods that take one cell (`write`, `write_comment`, `insert_image`, `insert_chart`, `freeze_panes`) take `(row, col)` or a reference like `"B2"`.
125
+
105
126
  `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.
106
127
 
107
128
  ### Layout
@@ -231,8 +252,8 @@ Values are mapped by type:
231
252
  |---|---|
232
253
  | `Integer`, `Float`, any `Numeric` | number |
233
254
  | `String` | string |
234
- | `Time` | number (Excel serial date, local time) |
235
- | `Date`, `DateTime` | number (Excel serial date, own offset) |
255
+ | `Time`, `DateTime` | date and time (`yyyy-mm-dd hh:mm:ss` unless formatted), in its own offset |
256
+ | `Date` | date (`yyyy-mm-dd` unless formatted) |
236
257
  | `FastXlsx::Formula.new("SUM(A1:A9)")` | formula |
237
258
  | `FastXlsx::URL.new("https://…")`, `URL.new(url, text: "Title")` | hyperlink (optionally showing other text) |
238
259
  | `FastXlsx::RichString.new(["Total: ", bold], "1,234")` | text with a format per segment |
@@ -254,17 +275,17 @@ Apple Silicon, Ruby 4.0.5. Each library uses its own idiomatic row-append API; x
254
275
 
255
276
  | Library | Time | vs fastest | Ruby objects allocated |
256
277
  |---|---:|---:|---:|
257
- | **fast_xlsx** (`memory: :constant`) | **92 ms** | 1.0x | 7 |
258
- | **fast_xlsx** (`memory: :low`) | **101 ms** | 1.1x | 7 |
259
- | **fast_xlsx** | **102 ms** | 1.1x | 10 |
260
- | [xlsxtream](https://github.com/felixbuenemann/xlsxtream) 3.1 | 181 ms | 2.0x | 561,728 |
261
- | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 (constant_memory) | 202 ms | 2.2x | 20,079 |
262
- | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 | 240 ms | 2.6x | 320,076 |
263
- | [write_xlsx](https://github.com/cxn03651/write_xlsx) 1.15 | 610 ms | 6.7x | 1,483,899 |
264
- | [caxlsx](https://github.com/caxlsx/caxlsx) 4.5 | 678 ms | 7.4x | 745,122 |
265
- | [rubyXL](https://github.com/weshatheleopard/rubyXL) 3.4 | 2624 ms | 28.6x | 8,700,448 |
266
-
267
- All outputs are 702–750 KB.
278
+ | **fast_xlsx** (`memory: :constant`) | **77 ms** | 1.0x | 7 |
279
+ | **fast_xlsx** (`memory: :low`) | **86 ms** | 1.1x | 7 |
280
+ | **fast_xlsx** | **89 ms** | 1.1x | 10 |
281
+ | [xlsxtream](https://github.com/felixbuenemann/xlsxtream) 3.1 | 183 ms | 2.4x | 561,728 |
282
+ | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 (constant_memory) | 200 ms | 2.6x | 20,079 |
283
+ | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 | 238 ms | 3.1x | 320,076 |
284
+ | [write_xlsx](https://github.com/cxn03651/write_xlsx) 1.15 | 608 ms | 7.8x | 1,483,899 |
285
+ | [caxlsx](https://github.com/caxlsx/caxlsx) 4.5 | 689 ms | 8.9x | 745,122 |
286
+ | [rubyXL](https://github.com/weshatheleopard/rubyXL) 3.4 | 2755 ms | 35.6x | 8,700,448 |
287
+
288
+ All outputs are 689–750 KB.
268
289
 
269
290
  ### Memory
270
291
 
@@ -272,14 +293,18 @@ All outputs are 702–750 KB.
272
293
 
273
294
  | Library | Unique strings | Repeated strings |
274
295
  |---|---:|---:|
275
- | **fast_xlsx** (`memory: :constant`) | **+2 MB** | **+2 MB** |
276
- | **fast_xlsx** (`memory: :low`) | +62 MB | **+2 MB** |
277
- | **fast_xlsx** | +270 MB | +217 MB |
278
- | fast_excel 0.5 (constant_memory) | +10 MB | +10 MB |
279
- | fast_excel 0.5 | +183 MB | +151 MB |
296
+ | **fast_xlsx** (`memory: :constant`) | **+1 MB** | **+1 MB** |
297
+ | **fast_xlsx** (`memory: :low`) | +61 MB | **+1 MB** |
298
+ | **fast_xlsx** | +271 MB | +217 MB |
299
+ | fast_excel 0.5 (constant_memory) | +10 MB | +9 MB |
300
+ | fast_excel 0.5 | +182 MB | +151 MB |
280
301
 
281
302
  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.
282
303
 
304
+ ### Threads
305
+
306
+ 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.
307
+
283
308
  ### Reproduce
284
309
 
285
310
  ```bash
data/bench/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- fast_xlsx (0.2.0)
4
+ fast_xlsx (0.4.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.2.0)
80
+ fast_xlsx (0.4.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,6 @@
1
1
  use std::cell::{Cell, RefCell};
2
+ use std::sync::atomic::{AtomicUsize, Ordering};
3
+ use std::sync::LazyLock;
2
4
  use std::sync::{Arc, Mutex};
3
5
 
4
6
  use magnus::{
@@ -39,9 +41,56 @@ fn xerr(e: XlsxError) -> Error {
39
41
 
40
42
  type Shared = Arc<Mutex<rust_xlsxwriter::Workbook>>;
41
43
 
44
+ // Runs `f` without Ruby's global lock, so other Ruby threads run meanwhile.
45
+ // `f` must not touch any Ruby object, and must release the workbook mutex
46
+ // before returning: a Ruby thread waiting on that mutex holds the lock this
47
+ // thread then needs back.
48
+ //
49
+ // Uses the "2" variant: rb_thread_call_without_gvl raises pending interrupts
50
+ // (Thread#raise, Timeout, Ctrl-C) by longjmp-ing over these Rust frames,
51
+ // which skips their destructors. This one never raises; if an interrupt is
52
+ // already pending it returns without calling `f`, which then runs with the
53
+ // lock held. Either way Ruby raises the interrupt after the method returns.
54
+ // Without an unblocking function, an interrupt waits for `f` to finish.
55
+ fn without_gvl<R>(f: impl FnOnce() -> R) -> R {
56
+ unsafe extern "C" fn call<F: FnOnce() -> R, R>(
57
+ data: *mut std::ffi::c_void,
58
+ ) -> *mut std::ffi::c_void {
59
+ let (f, result) = &mut *(data as *mut (Option<F>, Option<std::thread::Result<R>>));
60
+ // A panic must not unwind into Ruby's C code; it is resumed below.
61
+ *result = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
62
+ f.take().unwrap(),
63
+ )));
64
+ std::ptr::null_mut()
65
+ }
66
+ fn run<F: FnOnce() -> R, R>(f: F) -> R {
67
+ let mut data: (Option<F>, Option<std::thread::Result<R>>) = (Some(f), None);
68
+ unsafe {
69
+ rb_sys::rb_thread_call_without_gvl2(
70
+ Some(call::<F, R>),
71
+ &mut data as *mut _ as *mut std::ffi::c_void,
72
+ None,
73
+ std::ptr::null_mut(),
74
+ );
75
+ }
76
+ let Some(result) = data.1 else {
77
+ // An interrupt was pending, so `call` never ran.
78
+ return (data.0.take().unwrap())();
79
+ };
80
+ match result {
81
+ Ok(result) => result,
82
+ Err(panic) => std::panic::resume_unwind(panic),
83
+ }
84
+ }
85
+ run(f)
86
+ }
87
+
42
88
  #[magnus::wrap(class = "FastXlsx::Workbook", free_immediately)]
43
89
  struct Workbook {
44
90
  inner: Shared,
91
+ // Index of the sheet Excel opens on (0 unless one is activated), shared
92
+ // with the worksheets: it can't be hidden.
93
+ active: Arc<AtomicUsize>,
45
94
  constant_memory: bool,
46
95
  low_memory: bool,
47
96
  }
@@ -50,6 +99,7 @@ struct Workbook {
50
99
  struct Worksheet {
51
100
  wb: Shared,
52
101
  index: usize,
102
+ active: Arc<AtomicUsize>,
53
103
  // Where << / append write next.
54
104
  next_row: Cell<u32>,
55
105
  // Highest row with cells written. In :constant / :low mode rows above it
@@ -61,6 +111,10 @@ struct Worksheet {
61
111
  // after add_table (rust_xlsxwriter only formats cells that already exist).
62
112
  // Owned copies, so they don't depend on the Ruby Format objects living on.
63
113
  table_formats: RefCell<Vec<TableColumnFormat>>,
114
+ // Merged ranges as (first_row, first_col, last_row, last_col).
115
+ merges: RefCell<Vec<(u32, u16, u32, u16)>>,
116
+ // Column ranges given a format with column_format.
117
+ formatted_columns: RefCell<Vec<(u16, u16)>>,
64
118
  }
65
119
 
66
120
  struct TableColumnFormat {
@@ -73,6 +127,7 @@ impl Workbook {
73
127
  fn new(constant_memory: bool, low_memory: bool) -> Self {
74
128
  Workbook {
75
129
  inner: Arc::new(Mutex::new(rust_xlsxwriter::Workbook::new())),
130
+ active: Arc::new(AtomicUsize::new(0)),
76
131
  constant_memory,
77
132
  low_memory,
78
133
  }
@@ -121,25 +176,27 @@ impl Workbook {
121
176
  Ok(Worksheet {
122
177
  wb: self.inner.clone(),
123
178
  index: wb.worksheets().len() - 1,
179
+ active: self.active.clone(),
124
180
  next_row: Cell::new(0),
125
181
  last_written_row: Cell::new(0),
126
182
  flushes_rows: self.constant_memory || self.low_memory,
127
183
  table_formats: RefCell::new(Vec::new()),
184
+ merges: RefCell::new(Vec::new()),
185
+ formatted_columns: RefCell::new(Vec::new()),
128
186
  })
129
187
  }
130
188
 
189
+ // Saving (XML and compression) can take a while, so it runs without
190
+ // Ruby's global lock. The mutex guard is dropped inside the closure.
131
191
  fn to_xlsx(ruby: &Ruby, rb_self: &Self) -> Result<RString, Error> {
132
- let buf = rb_self
133
- .inner
134
- .lock()
135
- .unwrap()
136
- .save_to_buffer()
137
- .map_err(xerr)?;
192
+ let inner = &rb_self.inner;
193
+ let buf = without_gvl(|| inner.lock().unwrap().save_to_buffer()).map_err(xerr)?;
138
194
  Ok(ruby.str_from_slice(&buf))
139
195
  }
140
196
 
141
197
  fn save(&self, path: String) -> Result<(), Error> {
142
- self.inner.lock().unwrap().save(path).map_err(xerr)
198
+ let inner = &self.inner;
199
+ without_gvl(|| inner.lock().unwrap().save(path).map(|_| ())).map_err(xerr)
143
200
  }
144
201
 
145
202
  // "Name" for the whole workbook, "Sheet1!Name" for one sheet. Duplicate
@@ -229,8 +286,9 @@ fn emit<T: IntoExcelData>(
229
286
 
230
287
  // Excel's limit on the text in a cell.
231
288
  const MAX_CHARS: usize = 32_767;
232
- // Excel's column count.
289
+ // Excel's column and row counts.
233
290
  const MAX_COLS: usize = 16_384;
291
+ const MAX_ROWS: u32 = 1_048_576;
234
292
 
235
293
  // A cell value converted from Ruby. Converting may run Ruby code (to_s, jd,
236
294
  // url, ...), so it happens before the workbook lock is taken: Ruby code that
@@ -243,6 +301,8 @@ enum CellValue {
243
301
  Empty,
244
302
  Text(String),
245
303
  Number(f64),
304
+ // An Excel serial date; true when it has a time of day (Time, DateTime).
305
+ Date(f64, bool),
246
306
  Bool(bool),
247
307
  Formula(String),
248
308
  Url(String, Option<String>),
@@ -261,7 +321,7 @@ impl CellValue {
261
321
  } else if v.is_kind_of(ruby.class_numeric()) {
262
322
  CellValue::Number(f64::try_convert(v)?)
263
323
  } else if v.is_kind_of(ruby.class_time()) {
264
- CellValue::Number(excel_time(v)?)
324
+ CellValue::Date(excel_time(v)?, true)
265
325
  } else if v.is_kind_of(ruby.class_true_class()) || v.is_kind_of(ruby.class_false_class()) {
266
326
  CellValue::Bool(v.to_bool())
267
327
  } else if v.is_kind_of(ruby.get_inner(&FORMULA)) {
@@ -279,7 +339,8 @@ impl CellValue {
279
339
  })?;
280
340
  CellValue::Rich(parts)
281
341
  } else if v.respond_to("jd", false)? {
282
- CellValue::Number(excel_date(v)?)
342
+ // Date has no #hour; DateTime does.
343
+ CellValue::Date(excel_date(v)?, v.respond_to("hour", false)?)
283
344
  } else {
284
345
  CellValue::Text(v.funcall("to_s", ())?)
285
346
  };
@@ -287,6 +348,20 @@ impl CellValue {
287
348
  Ok(value)
288
349
  }
289
350
 
351
+ // The format for a value written without one: without a number format
352
+ // Excel shows a date as its serial number.
353
+ fn default_format(&self) -> Option<&'static rust_xlsxwriter::Format> {
354
+ static DATE: LazyLock<rust_xlsxwriter::Format> =
355
+ LazyLock::new(|| rust_xlsxwriter::Format::new().set_num_format("yyyy-mm-dd"));
356
+ static DATE_TIME: LazyLock<rust_xlsxwriter::Format> =
357
+ LazyLock::new(|| rust_xlsxwriter::Format::new().set_num_format("yyyy-mm-dd hh:mm:ss"));
358
+ match self {
359
+ CellValue::Date(_, false) => Some(&DATE),
360
+ CellValue::Date(_, true) => Some(&DATE_TIME),
361
+ _ => None,
362
+ }
363
+ }
364
+
290
365
  // Rejects what the writer would, so a row or merge fails before anything
291
366
  // is written. Numbers, booleans and formulas only fail on a bad row/column.
292
367
  fn check(&self) -> Result<(), Error> {
@@ -317,7 +392,7 @@ impl CellValue {
317
392
  match self {
318
393
  CellValue::Empty => Ok(()),
319
394
  CellValue::Text(s) => emit(ws, row, col, s.as_str(), format),
320
- CellValue::Number(n) => emit(ws, row, col, *n, format),
395
+ CellValue::Number(n) | CellValue::Date(n, _) => emit(ws, row, col, *n, format),
321
396
  CellValue::Bool(b) => emit(ws, row, col, *b, format),
322
397
  CellValue::Formula(f) => emit(
323
398
  ws,
@@ -686,6 +761,16 @@ const PROTECTION_ALLOW: &[(&str, ProtectionFlag)] = &[
686
761
  ("edit_objects", |o| &mut o.edit_objects),
687
762
  ];
688
763
 
764
+ // Excel's paper size codes; page_setup also takes the number itself.
765
+ const PAPER_SIZES: &[(&str, u8)] = &[
766
+ ("letter", 1),
767
+ ("tabloid", 3),
768
+ ("legal", 5),
769
+ ("a3", 8),
770
+ ("a4", 9),
771
+ ("a5", 11),
772
+ ];
773
+
689
774
  const BORDERS: &[(&str, FormatBorder)] = &[
690
775
  ("thin", FormatBorder::Thin),
691
776
  ("medium", FormatBorder::Medium),
@@ -886,6 +971,28 @@ impl Worksheet {
886
971
  })
887
972
  }
888
973
 
974
+ // The format a value is written with: its own, else its table column's,
975
+ // else its type's default (dates) unless column_format covers the column,
976
+ // whose format rust_xlsxwriter applies to cells written without one.
977
+ fn format_for<'a>(
978
+ &self,
979
+ tables: &'a [TableColumnFormat],
980
+ row: u32,
981
+ col: u16,
982
+ own: Option<&'a rust_xlsxwriter::Format>,
983
+ value: &CellValue,
984
+ ) -> Option<&'a rust_xlsxwriter::Format> {
985
+ Self::cell_format(tables, row, col, own).or_else(|| {
986
+ value.default_format().filter(|_| {
987
+ !self
988
+ .formatted_columns
989
+ .borrow()
990
+ .iter()
991
+ .any(|&(first, last)| (first..=last).contains(&col))
992
+ })
993
+ })
994
+ }
995
+
889
996
  fn write_row(
890
997
  &self,
891
998
  ruby: &Ruby,
@@ -912,7 +1019,7 @@ impl Worksheet {
912
1019
  // Values were checked when converted, so only a bad row number
913
1020
  // fails here, and it fails on the first cell.
914
1021
  for (col, value, format) in &values {
915
- let format = Self::cell_format(&tables, row, *col, format.as_deref());
1022
+ let format = self.format_for(&tables, row, *col, format.as_deref(), value);
916
1023
  value.write(ws, row, *col, format)?;
917
1024
  }
918
1025
  Ok(())
@@ -922,6 +1029,29 @@ impl Worksheet {
922
1029
  Ok(())
923
1030
  }
924
1031
 
1032
+ // Worksheet#write. The (row, col, value, format = nil) form is handled
1033
+ // here rather than in a Ruby wrapper, since it runs once per cell; the
1034
+ // rest ("B2", or a wrong argument count) goes to Ruby's _write_ref.
1035
+ fn write_any(ruby: &Ruby, rb_self: Obj<Self>, args: &[Value]) -> Result<Obj<Self>, Error> {
1036
+ let index = |i: usize| args.get(i).and_then(|v| Integer::from_value(*v));
1037
+ if let (3 | 4, Some(row), Some(col)) = (args.len(), index(0), index(1)) {
1038
+ let format = match args.get(3) {
1039
+ Some(f) => Option::<&Format>::try_convert(*f)?,
1040
+ None => None,
1041
+ };
1042
+ Self::write(
1043
+ ruby,
1044
+ &rb_self,
1045
+ row.to_u32()?,
1046
+ col.to_u16()?,
1047
+ args[2],
1048
+ format,
1049
+ )?;
1050
+ return Ok(rb_self);
1051
+ }
1052
+ rb_self.funcall("_write_ref", args)
1053
+ }
1054
+
925
1055
  fn write(
926
1056
  ruby: &Ruby,
927
1057
  rb_self: &Self,
@@ -938,7 +1068,7 @@ impl Worksheet {
938
1068
  ws,
939
1069
  row,
940
1070
  col,
941
- Self::cell_format(&tables, row, col, format.map(|f| &*f.0)),
1071
+ rb_self.format_for(&tables, row, col, format.map(|f| &*f.0), &value),
942
1072
  )
943
1073
  })?;
944
1074
  rb_self.advance(row);
@@ -983,7 +1113,9 @@ impl Worksheet {
983
1113
  ws.set_column_range_format(first, last, &format.0)
984
1114
  .map(|_| ())
985
1115
  .map_err(xerr)
986
- })
1116
+ })?;
1117
+ self.formatted_columns.borrow_mut().push((first, last));
1118
+ Ok(())
987
1119
  }
988
1120
 
989
1121
  fn autofilter(
@@ -1075,9 +1207,39 @@ impl Worksheet {
1075
1207
  format: Option<&Format>,
1076
1208
  ) -> Result<Obj<Self>, Error> {
1077
1209
  rb_self.check_not_flushed(ruby, first_row)?;
1210
+ // rust_xlsxwriter blanks the range before it notices an overlap, which
1211
+ // wipes the earlier merge's value, so check first.
1212
+ // A range rust_xlsxwriter rejects anyway (reversed, too big) is left
1213
+ // to it, so its error names the real problem.
1214
+ // ponytail: linear scan; sheets have few merges.
1215
+ let range = (first_row, first_col, last_row, last_col);
1216
+ let valid = first_row <= last_row
1217
+ && first_col <= last_col
1218
+ && last_row < MAX_ROWS
1219
+ && usize::from(last_col) < MAX_COLS;
1220
+ let overlap = rb_self
1221
+ .merges
1222
+ .borrow()
1223
+ .iter()
1224
+ .copied()
1225
+ .find(|&(fr, fc, lr, lc)| {
1226
+ first_row <= lr && fr <= last_row && first_col <= lc && fc <= last_col
1227
+ })
1228
+ .filter(|_| valid);
1229
+ if let Some((fr, fc, lr, lc)) = overlap {
1230
+ return Err(Error::new(
1231
+ ruby.get_inner(&ERROR),
1232
+ format!(
1233
+ "merge range {} overlaps the earlier merge {}",
1234
+ rust_xlsxwriter::utility::cell_range(first_row, first_col, last_row, last_col),
1235
+ rust_xlsxwriter::utility::cell_range(fr, fc, lr, lc),
1236
+ ),
1237
+ ));
1238
+ }
1078
1239
  let value = CellValue::from_ruby(ruby, v)?;
1079
1240
  let tables = rb_self.table_formats.borrow();
1080
- let format = Self::cell_format(&tables, first_row, first_col, format.map(|f| &*f.0));
1241
+ let format =
1242
+ rb_self.format_for(&tables, first_row, first_col, format.map(|f| &*f.0), &value);
1081
1243
  let default = rust_xlsxwriter::Format::new();
1082
1244
  rb_self.with_ws(|ws| {
1083
1245
  // The value was checked when converted, so once the range is
@@ -1093,6 +1255,7 @@ impl Worksheet {
1093
1255
  .map_err(xerr)?;
1094
1256
  value.write(ws, first_row, first_col, format)
1095
1257
  })?;
1258
+ rb_self.merges.borrow_mut().push(range);
1096
1259
  rb_self.advance(last_row);
1097
1260
  rb_self.note_written(first_row);
1098
1261
  Ok(rb_self)
@@ -1490,6 +1653,142 @@ impl Worksheet {
1490
1653
  Ok(rb_self)
1491
1654
  }
1492
1655
 
1656
+ fn activate(rb_self: Obj<Self>) -> Result<Obj<Self>, Error> {
1657
+ let mut wb = rb_self.wb.lock().unwrap();
1658
+ // rust_xlsxwriter leaves sheets activated earlier selected, which
1659
+ // groups them in Excel (edits then go to all of them).
1660
+ for (i, ws) in wb.worksheets_mut().iter_mut().enumerate() {
1661
+ ws.set_active(i == rb_self.index);
1662
+ ws.set_selected(i == rb_self.index);
1663
+ }
1664
+ rb_self.active.store(rb_self.index, Ordering::Relaxed);
1665
+ Ok(rb_self)
1666
+ }
1667
+
1668
+ fn hide(ruby: &Ruby, rb_self: Obj<Self>) -> Result<Obj<Self>, Error> {
1669
+ // rust_xlsxwriter would quietly unhide it when saving.
1670
+ if rb_self.active.load(Ordering::Relaxed) == rb_self.index {
1671
+ return Err(Error::new(
1672
+ ruby.get_inner(&ERROR),
1673
+ "can't hide the sheet Excel opens on (the first one unless another is activated): activate another sheet first",
1674
+ ));
1675
+ }
1676
+ rb_self.with_ws(|ws| {
1677
+ ws.set_hidden(true);
1678
+ Ok(())
1679
+ })?;
1680
+ Ok(rb_self)
1681
+ }
1682
+
1683
+ fn zoom(ruby: &Ruby, rb_self: Obj<Self>, percent: Value) -> Result<Obj<Self>, Error> {
1684
+ // Integers only: converting would quietly truncate 150.9.
1685
+ let percent = Integer::from_value(percent)
1686
+ .ok_or_else(|| {
1687
+ Error::new(
1688
+ ruby.exception_type_error(),
1689
+ format!("zoom must be an Integer, got {}", percent.inspect()),
1690
+ )
1691
+ })?
1692
+ .to_i64()?;
1693
+ // rust_xlsxwriter only prints a warning for these.
1694
+ if !(10..=400).contains(&percent) {
1695
+ return Err(Error::new(
1696
+ ruby.exception_arg_error(),
1697
+ format!("invalid zoom {percent}: use 10..400"),
1698
+ ));
1699
+ }
1700
+ rb_self.with_ws(|ws| {
1701
+ ws.set_zoom(percent as u16);
1702
+ Ok(())
1703
+ })?;
1704
+ Ok(rb_self)
1705
+ }
1706
+
1707
+ fn tab_color(ruby: &Ruby, rb_self: Obj<Self>, value: Value) -> Result<Obj<Self>, Error> {
1708
+ let rgb = color(ruby, value)?;
1709
+ rb_self.with_ws(|ws| {
1710
+ ws.set_tab_color(rgb);
1711
+ Ok(())
1712
+ })?;
1713
+ Ok(rb_self)
1714
+ }
1715
+
1716
+ fn hide_gridlines(rb_self: Obj<Self>) -> Result<Obj<Self>, Error> {
1717
+ rb_self.with_ws(|ws| {
1718
+ ws.set_screen_gridlines(false);
1719
+ Ok(())
1720
+ })?;
1721
+ Ok(rb_self)
1722
+ }
1723
+
1724
+ // Ranges arrive as [first, last] / [first_row, first_col, last_row,
1725
+ // last_col], already parsed by lib/fast_xlsx.rb.
1726
+ fn page_setup(ruby: &Ruby, rb_self: &Self, options: RHash) -> Result<(), Error> {
1727
+ check_keys(
1728
+ ruby,
1729
+ options,
1730
+ &[
1731
+ "landscape",
1732
+ "paper",
1733
+ "fit_width",
1734
+ "fit_height",
1735
+ "repeat_rows",
1736
+ "repeat_columns",
1737
+ "print_area",
1738
+ "gridlines",
1739
+ ],
1740
+ "page_setup",
1741
+ )?;
1742
+ let landscape = opt::<Value>(ruby, options, "landscape")?.map(|v| v.to_bool());
1743
+ let paper = match opt::<Value>(ruby, options, "paper")? {
1744
+ None => None,
1745
+ Some(v) if Integer::from_value(v).is_some() => Some(
1746
+ Integer::from_value(v)
1747
+ .and_then(|n| n.to_u8().ok())
1748
+ .ok_or_else(|| {
1749
+ Error::new(
1750
+ ruby.exception_arg_error(),
1751
+ format!("invalid paper {}: use a symbol or Excel's paper number (0 for the printer's default)", v.inspect()),
1752
+ )
1753
+ })?,
1754
+ ),
1755
+ Some(v) => Some(choice(ruby, "paper", v, PAPER_SIZES)?),
1756
+ };
1757
+ let fit_width = opt::<u16>(ruby, options, "fit_width")?;
1758
+ let fit_height = opt::<u16>(ruby, options, "fit_height")?;
1759
+ let repeat_rows = opt::<(u32, u32)>(ruby, options, "repeat_rows")?;
1760
+ let repeat_columns = opt::<(u16, u16)>(ruby, options, "repeat_columns")?;
1761
+ let print_area = opt::<(u32, u16, u32, u16)>(ruby, options, "print_area")?;
1762
+ let gridlines = opt::<Value>(ruby, options, "gridlines")?.map(|v| v.to_bool());
1763
+ rb_self.with_ws(|ws| {
1764
+ match landscape {
1765
+ Some(true) => ws.set_landscape(),
1766
+ Some(false) => ws.set_portrait(),
1767
+ None => ws,
1768
+ };
1769
+ if let Some(paper) = paper {
1770
+ ws.set_paper_size(paper);
1771
+ }
1772
+ // 0 means as many pages as the content needs.
1773
+ if fit_width.unwrap_or(0) > 0 || fit_height.unwrap_or(0) > 0 {
1774
+ ws.set_print_fit_to_pages(fit_width.unwrap_or(0), fit_height.unwrap_or(0));
1775
+ }
1776
+ if let Some((first, last)) = repeat_rows {
1777
+ ws.set_repeat_rows(first, last).map_err(xerr)?;
1778
+ }
1779
+ if let Some((first, last)) = repeat_columns {
1780
+ ws.set_repeat_columns(first, last).map_err(xerr)?;
1781
+ }
1782
+ if let Some((fr, fc, lr, lc)) = print_area {
1783
+ ws.set_print_area(fr, fc, lr, lc).map_err(xerr)?;
1784
+ }
1785
+ if let Some(gridlines) = gridlines {
1786
+ ws.set_print_gridlines(gridlines);
1787
+ }
1788
+ Ok(())
1789
+ })
1790
+ }
1791
+
1493
1792
  fn group_rows(
1494
1793
  ruby: &Ruby,
1495
1794
  rb_self: Obj<Self>,
@@ -1578,6 +1877,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1578
1877
 
1579
1878
  let ws = module.define_class("Worksheet", ruby.class_object())?;
1580
1879
  ws.define_method("_write", method!(Worksheet::write, 4))?;
1880
+ ws.define_method("write", method!(Worksheet::write_any, -1))?;
1581
1881
  ws.define_method("_append", method!(Worksheet::append, 2))?;
1582
1882
  ws.define_method("_column_width", method!(Worksheet::set_column_width, 3))?;
1583
1883
  ws.define_method("_column_format", method!(Worksheet::set_column_format, 3))?;
@@ -1593,7 +1893,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1593
1893
  method!(Worksheet::conditional_format, 5),
1594
1894
  )?;
1595
1895
  ws.define_method("_data_validation", method!(Worksheet::data_validation, 5))?;
1596
- ws.define_method("freeze_panes", method!(Worksheet::freeze_panes, 2))?;
1896
+ ws.define_method("_freeze_panes", method!(Worksheet::freeze_panes, 2))?;
1597
1897
  ws.define_method("row_height", method!(Worksheet::set_row_height, 2))?;
1598
1898
  ws.define_method("page_breaks", method!(Worksheet::set_page_breaks, 1))?;
1599
1899
  ws.define_method("_page_header", method!(Worksheet::set_header, 1))?;
@@ -1603,6 +1903,12 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1603
1903
  "vertical_page_breaks",
1604
1904
  method!(Worksheet::set_vertical_page_breaks, 1),
1605
1905
  )?;
1906
+ ws.define_method("activate", method!(Worksheet::activate, 0))?;
1907
+ ws.define_method("hide", method!(Worksheet::hide, 0))?;
1908
+ ws.define_method("zoom", method!(Worksheet::zoom, 1))?;
1909
+ ws.define_method("tab_color", method!(Worksheet::tab_color, 1))?;
1910
+ ws.define_method("hide_gridlines", method!(Worksheet::hide_gridlines, 0))?;
1911
+ ws.define_method("_page_setup", method!(Worksheet::page_setup, 1))?;
1606
1912
  ws.define_method("_group_rows", method!(Worksheet::group_rows, 3))?;
1607
1913
  ws.define_method("_group_columns", method!(Worksheet::group_columns, 3))?;
1608
1914
  ws.define_method("_protect", method!(Worksheet::protect, 2))?;
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastXlsx
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.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)
@@ -158,14 +163,16 @@ module FastXlsx
158
163
  end
159
164
 
160
165
  # Adds a comment (Excel "note") to a cell.
161
- def write_comment(row, col, text, author: nil)
166
+ def write_comment(*args, author: nil)
167
+ row, col, (text, *) = CellRange.cell(args, 1..1)
162
168
  _write_comment(row, col, text, author)
163
169
  end
164
170
 
165
171
  # Inserts a PNG, JPEG, GIF or BMP image with its top-left corner in the
166
172
  # cell. source is a file path or an IO (anything responding to #read).
167
173
  # Options: scale: or width:/height: (pixels), x_offset:, y_offset: (pixels), alt_text:.
168
- def insert_image(row, col, source, **)
174
+ def insert_image(*args, **)
175
+ row, col, (source, *) = CellRange.cell(args, 1..1)
169
176
  bytes = source.respond_to?(:read) ? source.read : File.binread(source)
170
177
  _insert_image(row, col, bytes, { ** })
171
178
  end
@@ -173,7 +180,8 @@ module FastXlsx
173
180
  # Inserts a chart with its top-left corner in the cell. series is an Array
174
181
  # of { values:, categories:, name: } with Excel ranges such as
175
182
  # "Sheet1!$B$2:$B$13". Options: title:, x_axis:, y_axis:, width:, height:.
176
- def insert_chart(row, col, type:, series:, **)
183
+ def insert_chart(*cell, type:, series:, **)
184
+ row, col, = CellRange.cell(cell)
177
185
  _insert_chart(row, col, { type: type, series: series, ** })
178
186
  end
179
187
 
@@ -197,6 +205,18 @@ module FastXlsx
197
205
  margin ? margins(footer: margin) : self
198
206
  end
199
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
+
200
220
  # Print margins in inches; margins not given keep their current value.
201
221
  def margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
202
222
  _margins(*[left, right, top, bottom, header, footer].map { |m| m || -1.0 })
@@ -214,6 +234,13 @@ module FastXlsx
214
234
  _group_columns(*CellRange.bounds(columns), collapsed)
215
235
  end
216
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
+
217
244
  # Locks the sheet against editing. Cells whose format has locked: false
218
245
  # stay editable. allow: actions users may still take, any of :format_cells,
219
246
  # :format_columns, :format_rows, :insert_columns, :insert_rows,
@@ -236,6 +263,7 @@ module FastXlsx
236
263
  module CellRange
237
264
  REF = /\A\$?([A-Za-z]{1,3})\$?([1-9]\d*)\z/ # ASCII only: /i also matches the Kelvin sign
238
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"'
239
267
 
240
268
  module_function
241
269
 
@@ -243,12 +271,7 @@ module FastXlsx
243
271
  # Returns [[first_row, first_col, last_row, last_col], the args after it].
244
272
  def split(args, following = 0..0)
245
273
  range, rest = parse(args)
246
- unless following.cover?(rest.size)
247
- expected = following.minmax.uniq.join("..")
248
- raise ArgumentError, "wrong number of arguments after the cell range (given #{rest.size}, " \
249
- "expected #{expected}); #{FORMS}"
250
- end
251
-
274
+ check_following(rest, following, "cell range", FORMS)
252
275
  [range, rest]
253
276
  end
254
277
 
@@ -263,6 +286,30 @@ module FastXlsx
263
286
  end
264
287
  end
265
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
+
266
313
  def rows_and_cols(rows, cols)
267
314
  first_row, last_row = bounds(rows)
268
315
  first_col, last_col = bounds(cols)
data/sig/fast_xlsx.rbs CHANGED
@@ -57,6 +57,7 @@ module FastXlsx
57
57
 
58
58
  class Worksheet
59
59
  def write: (Integer row, Integer col, cell value, ?Format? format) -> self
60
+ | (String cell, cell value, ?Format? format) -> self
60
61
  def append: (Array[cell] values, ?format: Format? | Array[Format?]) -> self
61
62
  def <<: (Array[cell] row) -> self
62
63
  def concat: (Array[Array[cell]] rows) -> self
@@ -72,11 +73,20 @@ module FastXlsx
72
73
  | (String range) -> self
73
74
  | (indexes rows, indexes cols) -> self
74
75
  def freeze_panes: (Integer row, Integer col) -> self
76
+ | (String cell) -> self
75
77
  def row_height: (Integer row, Numeric height) -> self
76
78
  def page_breaks: (Array[Integer] rows) -> self
77
79
  def vertical_page_breaks: (Array[Integer] cols) -> self
78
80
  def page_header: (String text, ?margin: Numeric?) -> self
79
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
80
90
  def margins: (?left: Numeric?, ?right: Numeric?, ?top: Numeric?, ?bottom: Numeric?,
81
91
  ?header: Numeric?, ?footer: Numeric?) -> self
82
92
  type protect_action = :format_cells | :format_columns | :format_rows | :insert_columns | :insert_rows
@@ -98,13 +108,19 @@ module FastXlsx
98
108
  ?input_title: String, ?input_message: String,
99
109
  ?error_title: String, ?error_message: String) -> self
100
110
  def write_comment: (Integer row, Integer col, String text, ?author: String?) -> self
111
+ | (String cell, String text, ?author: String?) -> self
101
112
  def insert_image: (Integer row, Integer col, String | _Reader source, ?scale: Numeric, ?width: Numeric, ?height: Numeric,
102
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
103
116
  type chart_series = { values: String, ?categories: String, ?name: String }
104
117
 
105
118
  def insert_chart: (Integer row, Integer col, type: Symbol, series: Array[chart_series],
106
119
  ?title: String, ?x_axis: String, ?y_axis: String,
107
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
108
124
  type table_column = String | { header: String, ?total: Symbol, ?total_label: String, ?format: Format }
109
125
 
110
126
  def add_table: (*cell_range range,
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac