fast_xlsx 0.1.0 → 0.1.2

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: e437e53063a29cc586d2d18200be43a28ec6a967907f053fb06b2c0494202cf9
4
- data.tar.gz: 6c7fbc5799ef0526bcd9733114c50d7442d998769f7f8b00bbdc821c216a8ba1
3
+ metadata.gz: d4a6f42c2c4c1f1a9e4a5f59441d183e2257abbbbfd9593fa22d7be4a9cf7519
4
+ data.tar.gz: 1c89a99feafa34f127e14d49175503da754809ca2436197bbd1e4e59290a145d
5
5
  SHA512:
6
- metadata.gz: 2ca68d242260d67c52265c2f373453d32da549842862fb4695f7c2f9d2267bf4bde9f1ebbafb200a44dc582a9c85e9a4082579169ccb7af03b349c74a991aeea
7
- data.tar.gz: 28230cf03c0f109e690273b7906b57ab18cc5ecdceafb348155c7b6c1e8370c85aadc3889d22775d30abd08058f43ea81ff6aa8f8d207f58ed039e1f03ef3110
6
+ metadata.gz: 75823da46a7dcf4a0bc921211871fe591f9be3ccf788ce42456bd109f77778a9886dce51123b1d86e18255169770e3b28d9ebb5e03f4de497b170be6092bf150
7
+ data.tar.gz: 5f5439f853da37aa5730636dc20e6daf784b9b0374ad03aba78c10720e3976d080ec287938209ea62fb3e996931b904b0ecd05e59a83b5dda8468fde0424fb8b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.2] - 2026-09-30
4
+
5
+ ### Fixed
6
+
7
+ - Values Excel cannot hold (a string over 32,767 characters, invalid UTF-8, a bad or over-long URL, more than 16,384 columns) raise before anything is written, instead of leaving the row half written.
8
+ - `merge_range` with such a value no longer leaves the range merged; its value also takes its table column's format.
9
+ - An unnamed worksheet takes the first free `SheetN` instead of a name that clashes with one given earlier (which failed only when saving).
10
+ - Cell values and formats are copied out of Ruby objects when a row is converted, so Ruby code run while converting (a `to_s`) cannot change or free them before they are written.
11
+ - Ruby code run while converting a value (a `to_s`, `jd`, ...) that touches the same workbook, or another thread writing to it, no longer deadlocks. A value that fails to convert leaves its row unwritten.
12
+ - In `:constant` / `:low` mode, `merge_range` and `add_table` on rows already written to disk raise `FastXlsx::Error` instead of being dropped; cells beside a tall merge can still be written.
13
+ - Table column formats apply to rows written after `add_table`.
14
+ - Invalid worksheet names raise without leaving an extra sheet behind; names that differ only in case raise at `add_worksheet`, not at save.
15
+ - Strings in other encodings (e.g. Windows-1252) are converted to UTF-8.
16
+ - `data_validation` lists accept numbers and other values (listed by `to_s`).
17
+ - Dates match Excel's 1900 date system (serials before 1900-03-01 were one too high); dates before 1900 raise `ArgumentError`.
18
+ - `Workbook#save` accepts a `Pathname`.
19
+
20
+ ## [0.1.1] - 2026-09-30
21
+
22
+ ### Fixed
23
+
24
+ - Precompiled (platform) gems failed to `require`: the extension was only looked up where a source install puts it, not in the per-Ruby-version directory that platform gems use. 0.1.0's platform gems cannot be loaded; upgrade to 0.1.1 (`bundle update fast_xlsx` if your lockfile has 0.1.0).
25
+
26
+ ### Changed
27
+
28
+ - Releases now install and load the built platform gems on Linux, macOS and Windows with Ruby 3.3, 3.4 and 4.0 before publishing.
29
+
3
30
  ## [0.1.0] - 2026-09-30
4
31
 
5
32
  First release: a fast `.xlsx` writer built on rust_xlsxwriter. Early API; it may still change before 1.0.
data/README.md CHANGED
@@ -276,6 +276,15 @@ cargo llvm-cov report --release # or --lcov / --html
276
276
 
277
277
  Rebuild in a clean shell afterwards (`rm -rf tmp && bundle exec rake compile`) so the everyday build is not instrumented.
278
278
 
279
+ Mutation tests ([cargo-mutants](https://mutants.rs)) change the Rust source one small edit at a time and check that a Ruby test fails for each change. `ext/fast_xlsx/tests/ruby_suite.rs` is what connects the two: it rebuilds the extension and runs the Ruby suite, so `cargo test` covers the Ruby tests too.
280
+
281
+ ```bash
282
+ cargo install cargo-mutants
283
+ cargo mutants --file ext/fast_xlsx/src/lib.rs --jobs 4 # ~7 minutes; results in mutants.out/
284
+ ```
285
+
286
+ Mutants listed in `mutants.out/missed.txt` point at behaviour no test checks. The `Mutation tests` workflow runs this weekly and on demand.
287
+
279
288
  ### Releasing
280
289
 
281
290
  First generate the showcase workbook and open it in Excel to check every feature renders (the test suite only inspects the XML):
data/bench/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- fast_xlsx (0.1.0)
4
+ fast_xlsx (0.1.2)
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.0)
80
+ fast_xlsx (0.1.2)
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
@@ -1,4 +1,4 @@
1
- use std::cell::Cell;
1
+ use std::cell::{Cell, RefCell};
2
2
  use std::sync::{Arc, Mutex};
3
3
 
4
4
  use magnus::{
@@ -7,11 +7,11 @@ use magnus::{
7
7
  Value,
8
8
  };
9
9
  use rust_xlsxwriter::{
10
- Color, ConditionalFormat2ColorScale, ConditionalFormat3ColorScale, ConditionalFormatCell,
11
- ConditionalFormatCellRule, ConditionalFormatDataBar, ConditionalFormatFormula,
12
- ConditionalFormatText, ConditionalFormatTextRule, ConditionalFormatValue, DataValidation,
13
- DataValidationRule, FormatAlign, FormatBorder, FormatScript, FormatUnderline,
14
- IntoDataValidationValue, IntoExcelData, XlsxError,
10
+ Color, ConditionalFormat, ConditionalFormat2ColorScale, ConditionalFormat3ColorScale,
11
+ ConditionalFormatCell, ConditionalFormatCellRule, ConditionalFormatDataBar,
12
+ ConditionalFormatFormula, ConditionalFormatText, ConditionalFormatTextRule,
13
+ ConditionalFormatValue, DataValidation, DataValidationRule, FormatAlign, FormatBorder,
14
+ FormatScript, FormatUnderline, IntoDataValidationValue, IntoExcelData, XlsxError,
15
15
  };
16
16
 
17
17
  use rust_xlsxwriter::{
@@ -49,9 +49,23 @@ struct Workbook {
49
49
  struct Worksheet {
50
50
  wb: Shared,
51
51
  index: usize,
52
+ // Where << / append write next.
52
53
  next_row: Cell<u32>,
54
+ // Highest row with cells written. In :constant / :low mode rows above it
55
+ // are on disk; rows a merge spans are held back, so merges don't count.
56
+ last_written_row: Cell<u32>,
53
57
  // constant_memory and low_memory worksheets write finished rows to disk.
54
58
  flushes_rows: bool,
59
+ // Formats of table columns, for cells written into a table's data rows
60
+ // after add_table (rust_xlsxwriter only formats cells that already exist).
61
+ // Owned copies, so they don't depend on the Ruby Format objects living on.
62
+ table_formats: RefCell<Vec<TableColumnFormat>>,
63
+ }
64
+
65
+ struct TableColumnFormat {
66
+ rows: std::ops::RangeInclusive<u32>,
67
+ col: u16,
68
+ format: Arc<rust_xlsxwriter::Format>,
55
69
  }
56
70
 
57
71
  impl Workbook {
@@ -65,6 +79,36 @@ impl Workbook {
65
79
 
66
80
  fn add_worksheet(&self, name: Option<String>) -> Result<Worksheet, Error> {
67
81
  let mut wb = self.inner.lock().unwrap();
82
+ // Excel sheet names are case-insensitive; rust_xlsxwriter only notices
83
+ // a clash when saving.
84
+ let taken: Vec<String> = wb
85
+ .worksheets()
86
+ .iter()
87
+ .map(|ws| ws.name().to_lowercase())
88
+ .collect();
89
+ let name = match name {
90
+ Some(name) => {
91
+ // Validate the name before adding: rust_xlsxwriter adds the
92
+ // sheet first, so a bad name would leave a "SheetN" behind.
93
+ rust_xlsxwriter::Worksheet::new()
94
+ .set_name(&name)
95
+ .map_err(xerr)?;
96
+ if taken.contains(&name.to_lowercase()) {
97
+ let ruby = Ruby::get().unwrap();
98
+ return Err(Error::new(
99
+ ruby.get_inner(&ERROR),
100
+ format!("a worksheet named {name:?} already exists (names ignore case)"),
101
+ ));
102
+ }
103
+ name
104
+ }
105
+ // Like Excel, the first free "SheetN"; rust_xlsxwriter's default
106
+ // counts sheets, which can clash with a name given earlier.
107
+ None => (1..)
108
+ .map(|n| format!("Sheet{n}"))
109
+ .find(|n| !taken.contains(&n.to_lowercase()))
110
+ .unwrap(),
111
+ };
68
112
  let ws = if self.low_memory {
69
113
  wb.add_worksheet_with_low_memory()
70
114
  } else if self.constant_memory {
@@ -72,14 +116,14 @@ impl Workbook {
72
116
  } else {
73
117
  wb.add_worksheet()
74
118
  };
75
- if let Some(name) = name {
76
- ws.set_name(name).map_err(xerr)?;
77
- }
119
+ ws.set_name(name).map_err(xerr)?;
78
120
  Ok(Worksheet {
79
121
  wb: self.inner.clone(),
80
122
  index: wb.worksheets().len() - 1,
81
123
  next_row: Cell::new(0),
124
+ last_written_row: Cell::new(0),
82
125
  flushes_rows: self.constant_memory || self.low_memory,
126
+ table_formats: RefCell::new(Vec::new()),
83
127
  })
84
128
  }
85
129
 
@@ -100,16 +144,17 @@ impl Workbook {
100
144
  fn set_properties(ruby: &Ruby, rb_self: &Self, fields: RHash) -> Result<(), Error> {
101
145
  let mut props = DocProperties::new();
102
146
  fields.foreach(|key: Symbol, value: String| {
147
+ let taken = std::mem::take(&mut props);
103
148
  props = match &*key.name()? {
104
- "title" => props.clone().set_title(value),
105
- "subject" => props.clone().set_subject(value),
106
- "author" => props.clone().set_author(value),
107
- "manager" => props.clone().set_manager(value),
108
- "company" => props.clone().set_company(value),
109
- "category" => props.clone().set_category(value),
110
- "keywords" => props.clone().set_keywords(value),
111
- "comments" => props.clone().set_comment(value),
112
- "status" => props.clone().set_status(value),
149
+ "title" => taken.set_title(value),
150
+ "subject" => taken.set_subject(value),
151
+ "author" => taken.set_author(value),
152
+ "manager" => taken.set_manager(value),
153
+ "company" => taken.set_company(value),
154
+ "category" => taken.set_category(value),
155
+ "keywords" => taken.set_keywords(value),
156
+ "comments" => taken.set_comment(value),
157
+ "status" => taken.set_status(value),
113
158
  other => {
114
159
  return Err(Error::new(
115
160
  ruby.exception_arg_error(),
@@ -124,19 +169,33 @@ impl Workbook {
124
169
  }
125
170
  }
126
171
 
127
- // Excel stores datetimes as days since 1900-01-01 in local time.
172
+ // Excel serial date from days (and fraction) since 1899-12-30. Excel's 1900
173
+ // date system counts a non-existent 1900-02-29 (serial 60), so real dates
174
+ // before 1900-03-01 are one lower; it has no dates before 1900-01-01.
175
+ fn excel_serial(days: f64) -> Result<f64, Error> {
176
+ if days < 2.0 {
177
+ let ruby = Ruby::get().unwrap();
178
+ return Err(Error::new(
179
+ ruby.exception_arg_error(),
180
+ "Excel cannot represent dates before 1900-01-01",
181
+ ));
182
+ }
183
+ Ok(if days < 61.0 { days - 1.0 } else { days })
184
+ }
185
+
186
+ // Time: seconds since 1970-01-01 (serial day 25569), in its own offset.
128
187
  fn excel_time(v: Value) -> Result<f64, Error> {
129
188
  let secs: f64 = v.funcall("to_f", ())?;
130
189
  let offset: i64 = v.funcall("utc_offset", ())?;
131
- Ok((secs + offset as f64) / 86400.0 + 25569.0)
190
+ excel_serial((secs + offset as f64) / 86400.0 + 25569.0)
132
191
  }
133
192
 
134
193
  // Date / DateTime: Julian day and day fraction are both in the object's own
135
- // offset. JD 2415019 is Excel serial 0 (1899-12-30).
194
+ // offset. JD 2415019 is 1899-12-30.
136
195
  fn excel_date(v: Value) -> Result<f64, Error> {
137
196
  let jd: i64 = v.funcall("jd", ())?;
138
197
  let fraction: f64 = f64::try_convert(v.funcall("day_fraction", ())?)?;
139
- Ok((jd - 2_415_019) as f64 + fraction)
198
+ excel_serial((jd - 2_415_019) as f64 + fraction)
140
199
  }
141
200
 
142
201
  fn emit<T: IntoExcelData>(
@@ -144,85 +203,136 @@ fn emit<T: IntoExcelData>(
144
203
  row: u32,
145
204
  col: u16,
146
205
  data: T,
147
- format: Option<&Format>,
206
+ format: Option<&rust_xlsxwriter::Format>,
148
207
  ) -> Result<(), Error> {
149
208
  match format {
150
- Some(f) => ws.write_with_format(row, col, data, &f.0),
209
+ Some(f) => ws.write_with_format(row, col, data, f),
151
210
  None => ws.write(row, col, data),
152
211
  }
153
212
  .map(|_| ())
154
213
  .map_err(xerr)
155
214
  }
156
215
 
157
- // RichString#segments is [[text, Format or nil], ...]; nil means the default font.
158
- fn write_rich_string(
159
- ws: &mut rust_xlsxwriter::Worksheet,
160
- row: u32,
161
- col: u16,
162
- v: Value,
163
- format: Option<&Format>,
164
- ) -> Result<(), Error> {
165
- let default = rust_xlsxwriter::Format::default();
166
- let segments: RArray = v.funcall("segments", ())?;
167
- let mut parts: Vec<(Option<&Format>, String)> = Vec::with_capacity(segments.len());
168
- for segment in segments.into_iter() {
169
- let (text, seg_format): (String, Value) = TryConvert::try_convert(segment)?;
170
- parts.push((Option::<&Format>::try_convert(seg_format)?, text));
216
+ // Excel's limit on the text in a cell.
217
+ const MAX_CHARS: usize = 32_767;
218
+ // Excel's column count.
219
+ const MAX_COLS: usize = 16_384;
220
+
221
+ // A cell value converted from Ruby. Converting may run Ruby code (to_s, jd,
222
+ // url, ...), so it happens before the workbook lock is taken: Ruby code that
223
+ // touches the same workbook, or another thread, would otherwise deadlock on it.
224
+ // Writing a CellValue runs no Ruby code.
225
+ // It holds Rust-owned copies, never Ruby objects: Ruby code run while the
226
+ // rest of the row converts could change or drop them, and the GC does not
227
+ // see references kept in a Rust Vec.
228
+ enum CellValue {
229
+ Empty,
230
+ Text(String),
231
+ Number(f64),
232
+ Bool(bool),
233
+ Formula(String),
234
+ Url(String, Option<String>),
235
+ // RichString segments; None means the default font.
236
+ Rich(Vec<(Option<Arc<rust_xlsxwriter::Format>>, String)>),
237
+ }
238
+
239
+ impl CellValue {
240
+ fn from_ruby(ruby: &Ruby, v: Value) -> Result<Self, Error> {
241
+ let value = if v.is_nil() {
242
+ CellValue::Empty
243
+ } else if let Some(s) = RString::from_value(v) {
244
+ // Other encodings (e.g. Windows-1252 / Big5 from a legacy CSV) are
245
+ // converted; invalid bytes, or binary non-ASCII, raise.
246
+ CellValue::Text(s.to_string()?)
247
+ } else if v.is_kind_of(ruby.class_numeric()) {
248
+ CellValue::Number(f64::try_convert(v)?)
249
+ } else if v.is_kind_of(ruby.class_time()) {
250
+ CellValue::Number(excel_time(v)?)
251
+ } else if v.is_kind_of(ruby.class_true_class()) || v.is_kind_of(ruby.class_false_class()) {
252
+ CellValue::Bool(v.to_bool())
253
+ } else if v.is_kind_of(ruby.get_inner(&FORMULA)) {
254
+ CellValue::Formula(v.funcall("expression", ())?)
255
+ } else if v.is_kind_of(ruby.get_inner(&URL)) {
256
+ CellValue::Url(v.funcall("url", ())?, v.funcall("text", ())?)
257
+ } else if v.is_kind_of(ruby.get_inner(&RICH_STRING)) {
258
+ let segments: RArray = v.funcall("segments", ())?;
259
+ let mut parts = Vec::with_capacity(segments.len());
260
+ each_entry(segments, |_, segment| {
261
+ let (text, seg_format): (String, Value) = TryConvert::try_convert(segment)?;
262
+ let seg_format = Option::<&Format>::try_convert(seg_format)?;
263
+ parts.push((seg_format.map(|f| f.0.clone()), text));
264
+ Ok(())
265
+ })?;
266
+ CellValue::Rich(parts)
267
+ } else if v.respond_to("jd", false)? {
268
+ CellValue::Number(excel_date(v)?)
269
+ } else {
270
+ CellValue::Text(v.funcall("to_s", ())?)
271
+ };
272
+ value.check()?;
273
+ Ok(value)
171
274
  }
172
- let rich: Vec<(&rust_xlsxwriter::Format, &str)> = parts
173
- .iter()
174
- .map(|(f, text)| (f.map_or(&default, |f| &f.0), text.as_str()))
175
- .collect();
176
- match format {
177
- Some(f) => ws.write_rich_string_with_format(row, col, &rich, &f.0),
178
- None => ws.write_rich_string(row, col, &rich),
275
+
276
+ // Rejects what the writer would, so a row or merge fails before anything
277
+ // is written. Numbers, booleans and formulas only fail on a bad row/column.
278
+ fn check(&self) -> Result<(), Error> {
279
+ thread_local! {
280
+ // Reused: every check writes to A1, replacing the one before.
281
+ static SCRATCH: RefCell<rust_xlsxwriter::Worksheet> =
282
+ RefCell::new(rust_xlsxwriter::Worksheet::new());
283
+ }
284
+ match self {
285
+ CellValue::Text(s) if s.chars().count() > MAX_CHARS => {
286
+ Err(xerr(XlsxError::MaxStringLengthExceeded))
287
+ }
288
+ // URLs and rich strings have more rules; let the writer apply them.
289
+ CellValue::Url(..) | CellValue::Rich(_) => {
290
+ SCRATCH.with_borrow_mut(|ws| self.write(ws, 0, 0, None))
291
+ }
292
+ _ => Ok(()),
293
+ }
179
294
  }
180
- .map(|_| ())
181
- .map_err(xerr)
182
- }
183
295
 
184
- fn put(
185
- ruby: &Ruby,
186
- ws: &mut rust_xlsxwriter::Worksheet,
187
- row: u32,
188
- col: u16,
189
- v: Value,
190
- format: Option<&Format>,
191
- ) -> Result<(), Error> {
192
- if v.is_nil() {
193
- Ok(())
194
- } else if let Some(s) = RString::from_value(v) {
195
- // SAFETY: the borrowed str is copied by the writer before any Ruby code runs.
196
- emit(ws, row, col, unsafe { s.as_str()? }, format)
197
- } else if v.is_kind_of(ruby.class_numeric()) {
198
- emit(ws, row, col, f64::try_convert(v)?, format)
199
- } else if v.is_kind_of(ruby.class_time()) {
200
- emit(ws, row, col, excel_time(v)?, format)
201
- } else if v.is_kind_of(ruby.class_true_class()) || v.is_kind_of(ruby.class_false_class()) {
202
- emit(ws, row, col, v.to_bool(), format)
203
- } else if v.is_kind_of(ruby.get_inner(&FORMULA)) {
204
- let expression: String = v.funcall("expression", ())?;
205
- emit(
206
- ws,
207
- row,
208
- col,
209
- rust_xlsxwriter::Formula::new(expression),
210
- format,
211
- )
212
- } else if v.is_kind_of(ruby.get_inner(&URL)) {
213
- let url: String = v.funcall("url", ())?;
214
- let mut link = rust_xlsxwriter::Url::new(url);
215
- if let Some(text) = v.funcall::<_, _, Option<String>>("text", ())? {
216
- link = link.set_text(text);
296
+ fn write(
297
+ &self,
298
+ ws: &mut rust_xlsxwriter::Worksheet,
299
+ row: u32,
300
+ col: u16,
301
+ format: Option<&rust_xlsxwriter::Format>,
302
+ ) -> Result<(), Error> {
303
+ match self {
304
+ CellValue::Empty => Ok(()),
305
+ CellValue::Text(s) => emit(ws, row, col, s.as_str(), format),
306
+ CellValue::Number(n) => emit(ws, row, col, *n, format),
307
+ CellValue::Bool(b) => emit(ws, row, col, *b, format),
308
+ CellValue::Formula(f) => emit(
309
+ ws,
310
+ row,
311
+ col,
312
+ rust_xlsxwriter::Formula::new(f.as_str()),
313
+ format,
314
+ ),
315
+ CellValue::Url(url, text) => {
316
+ let mut link = rust_xlsxwriter::Url::new(url.as_str());
317
+ if let Some(text) = text {
318
+ link = link.set_text(text.as_str());
319
+ }
320
+ emit(ws, row, col, link, format)
321
+ }
322
+ CellValue::Rich(parts) => {
323
+ let default = rust_xlsxwriter::Format::default();
324
+ let rich: Vec<(&rust_xlsxwriter::Format, &str)> = parts
325
+ .iter()
326
+ .map(|(f, text)| (f.as_deref().unwrap_or(&default), text.as_str()))
327
+ .collect();
328
+ match format {
329
+ Some(f) => ws.write_rich_string_with_format(row, col, &rich, f),
330
+ None => ws.write_rich_string(row, col, &rich),
331
+ }
332
+ .map(|_| ())
333
+ .map_err(xerr)
334
+ }
217
335
  }
218
- emit(ws, row, col, link, format)
219
- } else if v.is_kind_of(ruby.get_inner(&RICH_STRING)) {
220
- write_rich_string(ws, row, col, v, format)
221
- } else if v.respond_to("jd", false)? {
222
- emit(ws, row, col, excel_date(v)?, format)
223
- } else {
224
- let s: String = v.funcall("to_s", ())?;
225
- emit(ws, row, col, s, format)
226
336
  }
227
337
  }
228
338
 
@@ -391,6 +501,19 @@ fn check_header_footer(ruby: &Ruby, what: &str, text: &str) -> Result<(), Error>
391
501
  Ok(())
392
502
  }
393
503
 
504
+ // Option `name` of a Ruby options Hash as given; nil when missing. For
505
+ // choice(), which reports an invalid (or missing) value itself.
506
+ fn raw_opt(ruby: &Ruby, options: RHash, name: &str) -> Value {
507
+ options
508
+ .get(ruby.to_symbol(name))
509
+ .unwrap_or_else(|| ruby.qnil().as_value())
510
+ }
511
+
512
+ // Option `name` of a Ruby options Hash, converted; missing or nil is None.
513
+ fn opt<T: TryConvert>(ruby: &Ruby, options: RHash, name: &str) -> Result<Option<T>, Error> {
514
+ Option::<T>::try_convert(raw_opt(ruby, options, name))
515
+ }
516
+
394
517
  // Rejects option keys outside `allowed`, so a typo raises instead of being ignored.
395
518
  fn check_keys(ruby: &Ruby, options: RHash, allowed: &[&str], what: &str) -> Result<(), Error> {
396
519
  options.foreach(|key: Symbol, _: Value| {
@@ -501,9 +624,13 @@ const TABLE_TOTALS: &[(&str, TableFunction)] = &[
501
624
  ];
502
625
 
503
626
  // A table column: a header String, or { header:, total:, total_label:, format: }.
504
- fn table_column(ruby: &Ruby, v: Value) -> Result<TableColumn, Error> {
627
+ // Also returns a copy of the column's format, if any, for later writes.
628
+ fn table_column(
629
+ ruby: &Ruby,
630
+ v: Value,
631
+ ) -> Result<(TableColumn, Option<Arc<rust_xlsxwriter::Format>>), Error> {
505
632
  let Some(spec) = RHash::from_value(v) else {
506
- return Ok(TableColumn::new().set_header(String::try_convert(v)?));
633
+ return Ok((TableColumn::new().set_header(String::try_convert(v)?), None));
507
634
  };
508
635
  check_keys(
509
636
  ruby,
@@ -511,20 +638,19 @@ fn table_column(ruby: &Ruby, v: Value) -> Result<TableColumn, Error> {
511
638
  &["header", "total", "total_label", "format"],
512
639
  "table column",
513
640
  )?;
514
- let nil = ruby.qnil().as_value();
515
- let get = |name: &str| spec.get(ruby.to_symbol(name)).unwrap_or(nil);
516
- let mut column = TableColumn::new().set_header(String::try_convert(get("header"))?);
517
- if !get("total").is_nil() {
518
- column =
519
- column.set_total_function(choice(ruby, "table total", get("total"), TABLE_TOTALS)?);
641
+ let header = String::try_convert(raw_opt(ruby, spec, "header"))?;
642
+ let mut column = TableColumn::new().set_header(header);
643
+ if let Some(total) = opt::<Value>(ruby, spec, "total")? {
644
+ column = column.set_total_function(choice(ruby, "table total", total, TABLE_TOTALS)?);
520
645
  }
521
- if !get("total_label").is_nil() {
522
- column = column.set_total_label(String::try_convert(get("total_label"))?);
646
+ if let Some(label) = opt::<String>(ruby, spec, "total_label")? {
647
+ column = column.set_total_label(label);
523
648
  }
524
- if let Some(format) = Option::<&Format>::try_convert(get("format"))? {
525
- column = column.set_format(&format.0);
649
+ let format = opt::<&Format>(ruby, spec, "format")?.map(|f| f.0.clone());
650
+ if let Some(f) = &format {
651
+ column = column.set_format(&**f);
526
652
  }
527
- Ok(column)
653
+ Ok((column, format))
528
654
  }
529
655
 
530
656
  const BORDERS: &[(&str, FormatBorder)] = &[
@@ -538,7 +664,7 @@ const BORDERS: &[(&str, FormatBorder)] = &[
538
664
  ];
539
665
 
540
666
  #[magnus::wrap(class = "FastXlsx::Format", free_immediately)]
541
- struct Format(rust_xlsxwriter::Format);
667
+ struct Format(Arc<rust_xlsxwriter::Format>);
542
668
 
543
669
  impl Format {
544
670
  fn new(ruby: &Ruby, options: RHash) -> Result<Self, Error> {
@@ -626,7 +752,7 @@ impl Format {
626
752
  };
627
753
  Ok(ForEach::Continue)
628
754
  })?;
629
- Ok(Format(f))
755
+ Ok(Format(Arc::new(f)))
630
756
  }
631
757
  }
632
758
 
@@ -649,7 +775,7 @@ fn each_entry(
649
775
 
650
776
  // A row's format: one Format (or nil) for every cell, or an Array with one per cell.
651
777
  enum RowFormat {
652
- Same(Option<&'static Format>),
778
+ Same(Option<Arc<rust_xlsxwriter::Format>>),
653
779
  PerCell(RArray),
654
780
  }
655
781
 
@@ -657,16 +783,19 @@ impl RowFormat {
657
783
  fn from_value(v: Value) -> Result<Self, Error> {
658
784
  match RArray::from_value(v) {
659
785
  Some(formats) => Ok(RowFormat::PerCell(formats)),
660
- None => Ok(RowFormat::Same(Option::<&Format>::try_convert(v)?)),
786
+ None => Ok(RowFormat::Same(
787
+ Option::<&Format>::try_convert(v)?.map(|f| f.0.clone()),
788
+ )),
661
789
  }
662
790
  }
663
791
 
664
- fn at(&self, col: usize) -> Result<Option<&Format>, Error> {
792
+ fn at(&self, col: usize) -> Result<Option<Arc<rust_xlsxwriter::Format>>, Error> {
665
793
  match self {
666
- RowFormat::Same(f) => Ok(*f),
667
- RowFormat::PerCell(formats) => {
668
- Option::<&Format>::try_convert(formats.entry::<Value>(col as isize)?)
669
- }
794
+ RowFormat::Same(f) => Ok(f.clone()),
795
+ RowFormat::PerCell(formats) => Ok(Option::<&Format>::try_convert(
796
+ formats.entry::<Value>(col as isize)?,
797
+ )?
798
+ .map(|f| f.0.clone())),
670
799
  }
671
800
  }
672
801
  }
@@ -676,9 +805,14 @@ impl Worksheet {
676
805
  self.next_row.set(self.next_row.get().max(row + 1));
677
806
  }
678
807
 
808
+ fn note_written(&self, row: u32) {
809
+ self.last_written_row
810
+ .set(self.last_written_row.get().max(row));
811
+ }
812
+
679
813
  // rust_xlsxwriter silently drops writes to rows it has already flushed.
680
814
  fn check_not_flushed(&self, ruby: &Ruby, row: u32) -> Result<(), Error> {
681
- if self.flushes_rows && row + 1 < self.next_row.get() {
815
+ if self.flushes_rows && row < self.last_written_row.get() {
682
816
  return Err(Error::new(
683
817
  ruby.get_inner(&ERROR),
684
818
  format!(
@@ -697,6 +831,22 @@ impl Worksheet {
697
831
  f(wb.worksheet_from_index(self.index).map_err(xerr)?)
698
832
  }
699
833
 
834
+ // The format to write a cell with: its own, else its table column's.
835
+ fn cell_format<'a>(
836
+ tables: &'a [TableColumnFormat],
837
+ row: u32,
838
+ col: u16,
839
+ own: Option<&'a rust_xlsxwriter::Format>,
840
+ ) -> Option<&'a rust_xlsxwriter::Format> {
841
+ own.or_else(|| {
842
+ tables
843
+ .iter()
844
+ .rev()
845
+ .find(|t| t.col == col && t.rows.contains(&row))
846
+ .map(|t| &*t.format)
847
+ })
848
+ }
849
+
700
850
  fn write_row(
701
851
  &self,
702
852
  ruby: &Ruby,
@@ -704,14 +854,32 @@ impl Worksheet {
704
854
  cells: RArray,
705
855
  format: &RowFormat,
706
856
  ) -> Result<(), Error> {
857
+ // Convert the whole row first: no lock is held while Ruby code runs,
858
+ // and a value that fails to convert leaves the row unwritten.
859
+ let mut values = Vec::with_capacity(cells.len());
860
+ each_entry(cells, |i, v| {
861
+ if i >= MAX_COLS {
862
+ return Err(Error::new(
863
+ ruby.exception_arg_error(),
864
+ format!("too many columns: Excel allows {MAX_COLS}"),
865
+ ));
866
+ }
867
+ let col = i as u16;
868
+ values.push((col, CellValue::from_ruby(ruby, v)?, format.at(i)?));
869
+ Ok(())
870
+ })?;
871
+ let tables = self.table_formats.borrow();
707
872
  self.with_ws(|ws| {
708
- each_entry(cells, |i, v| {
709
- let col = u16::try_from(i)
710
- .map_err(|_| Error::new(ruby.exception_arg_error(), "too many columns"))?;
711
- put(ruby, ws, row, col, v, format.at(i)?)
712
- })
873
+ // Values were checked when converted, so only a bad row number
874
+ // fails here, and it fails on the first cell.
875
+ for (col, value, format) in &values {
876
+ let format = Self::cell_format(&tables, row, *col, format.as_deref());
877
+ value.write(ws, row, *col, format)?;
878
+ }
879
+ Ok(())
713
880
  })?;
714
881
  self.advance(row);
882
+ self.note_written(row);
715
883
  Ok(())
716
884
  }
717
885
 
@@ -724,8 +892,18 @@ impl Worksheet {
724
892
  format: Option<&Format>,
725
893
  ) -> Result<(), Error> {
726
894
  rb_self.check_not_flushed(ruby, row)?;
727
- rb_self.with_ws(|ws| put(ruby, ws, row, col, v, format))?;
895
+ let value = CellValue::from_ruby(ruby, v)?;
896
+ let tables = rb_self.table_formats.borrow();
897
+ rb_self.with_ws(|ws| {
898
+ value.write(
899
+ ws,
900
+ row,
901
+ col,
902
+ Self::cell_format(&tables, row, col, format.map(|f| &*f.0)),
903
+ )
904
+ })?;
728
905
  rb_self.advance(row);
906
+ rb_self.note_written(row);
729
907
  Ok(())
730
908
  }
731
909
 
@@ -857,14 +1035,27 @@ impl Worksheet {
857
1035
  v: Value,
858
1036
  format: Option<&Format>,
859
1037
  ) -> Result<Obj<Self>, Error> {
1038
+ rb_self.check_not_flushed(ruby, first_row)?;
1039
+ let value = CellValue::from_ruby(ruby, v)?;
1040
+ let tables = rb_self.table_formats.borrow();
1041
+ let format = Self::cell_format(&tables, first_row, first_col, format.map(|f| &*f.0));
860
1042
  let default = rust_xlsxwriter::Format::new();
861
- let merge_format = format.map_or(&default, |f| &f.0);
862
1043
  rb_self.with_ws(|ws| {
863
- ws.merge_range(first_row, first_col, last_row, last_col, "", merge_format)
864
- .map_err(xerr)?;
865
- put(ruby, ws, first_row, first_col, v, format)
1044
+ // The value was checked when converted, so once the range is
1045
+ // merged, writing it cannot fail.
1046
+ ws.merge_range(
1047
+ first_row,
1048
+ first_col,
1049
+ last_row,
1050
+ last_col,
1051
+ "",
1052
+ format.unwrap_or(&default),
1053
+ )
1054
+ .map_err(xerr)?;
1055
+ value.write(ws, first_row, first_col, format)
866
1056
  })?;
867
1057
  rb_self.advance(last_row);
1058
+ rb_self.note_written(first_row);
868
1059
  Ok(rb_self)
869
1060
  }
870
1061
 
@@ -878,20 +1069,19 @@ impl Worksheet {
878
1069
  lc: u16,
879
1070
  options: RHash,
880
1071
  ) -> Result<Obj<Self>, Error> {
881
- let nil = ruby.qnil().as_value();
882
- let opt = |name: &str| options.get(ruby.to_symbol(name)).unwrap_or(nil);
883
1072
  check_keys(
884
1073
  ruby,
885
1074
  options,
886
1075
  &["type", "criteria", "value", "format", "colors"],
887
1076
  "conditional_format",
888
1077
  )?;
889
- let format = Option::<&Format>::try_convert(opt("format"))?;
890
- let value = opt("value");
1078
+ let format = opt::<&Format>(ruby, options, "format")?;
1079
+ let value = raw_opt(ruby, options, "value");
1080
+ let criteria = raw_opt(ruby, options, "criteria");
891
1081
  let kind = choice(
892
1082
  ruby,
893
1083
  "conditional format type",
894
- opt("type"),
1084
+ raw_opt(ruby, options, "type"),
895
1085
  &[
896
1086
  ("cell", "cell"),
897
1087
  ("text", "text"),
@@ -900,86 +1090,69 @@ impl Worksheet {
900
1090
  ("color_scale", "color_scale"),
901
1091
  ],
902
1092
  )?;
903
- rb_self.with_ws(|ws| {
904
- let r = match kind {
905
- "cell" => {
906
- let mut cf = ConditionalFormatCell::new().set_rule(cell_rule(
907
- ruby,
908
- opt("criteria"),
909
- value,
910
- )?);
911
- if let Some(f) = format {
912
- cf = cf.set_format(&f.0);
913
- }
914
- ws.add_conditional_format(fr, fc, lr, lc, &cf)
915
- }
916
- "text" => {
917
- let text = String::try_convert(value)?;
918
- let rule = match choice(
919
- ruby,
920
- "text criteria",
921
- opt("criteria"),
922
- &[
923
- ("contains", 0),
924
- ("not_contains", 1),
925
- ("begins_with", 2),
926
- ("ends_with", 3),
927
- ],
928
- )? {
929
- 0 => ConditionalFormatTextRule::Contains(text),
930
- 1 => ConditionalFormatTextRule::DoesNotContain(text),
931
- 2 => ConditionalFormatTextRule::BeginsWith(text),
932
- _ => ConditionalFormatTextRule::EndsWith(text),
933
- };
934
- let mut cf = ConditionalFormatText::new().set_rule(rule);
935
- if let Some(f) = format {
936
- cf = cf.set_format(&f.0);
937
- }
938
- ws.add_conditional_format(fr, fc, lr, lc, &cf)
1093
+ // Built before taking the lock: building converts Ruby values.
1094
+ type AddRule = Box<dyn FnOnce(&mut rust_xlsxwriter::Worksheet) -> Result<(), XlsxError>>;
1095
+ fn rule<T: ConditionalFormat + Send + Sync + 'static>(
1096
+ (fr, fc, lr, lc): (u32, u16, u32, u16),
1097
+ cf: T,
1098
+ ) -> AddRule {
1099
+ Box::new(move |ws| ws.add_conditional_format(fr, fc, lr, lc, &cf).map(|_| ()))
1100
+ }
1101
+ let range = (fr, fc, lr, lc);
1102
+ let add_rule = match kind {
1103
+ "cell" => {
1104
+ let mut cf =
1105
+ ConditionalFormatCell::new().set_rule(cell_rule(ruby, criteria, value)?);
1106
+ if let Some(f) = format {
1107
+ cf = cf.set_format(&*f.0);
939
1108
  }
940
- "formula" => {
941
- let mut cf = ConditionalFormatFormula::new()
942
- .set_rule(String::try_convert(value)?.as_str());
943
- if let Some(f) = format {
944
- cf = cf.set_format(&f.0);
945
- }
946
- ws.add_conditional_format(fr, fc, lr, lc, &cf)
1109
+ rule(range, cf)
1110
+ }
1111
+ "text" => {
1112
+ let text = String::try_convert(value)?;
1113
+ let text_rule = match choice(
1114
+ ruby,
1115
+ "text criteria",
1116
+ criteria,
1117
+ &[
1118
+ ("contains", 0),
1119
+ ("not_contains", 1),
1120
+ ("begins_with", 2),
1121
+ ("ends_with", 3),
1122
+ ],
1123
+ )? {
1124
+ 0 => ConditionalFormatTextRule::Contains(text),
1125
+ 1 => ConditionalFormatTextRule::DoesNotContain(text),
1126
+ 2 => ConditionalFormatTextRule::BeginsWith(text),
1127
+ _ => ConditionalFormatTextRule::EndsWith(text),
1128
+ };
1129
+ let mut cf = ConditionalFormatText::new().set_rule(text_rule);
1130
+ if let Some(f) = format {
1131
+ cf = cf.set_format(&*f.0);
947
1132
  }
948
- "data_bar" => {
949
- ws.add_conditional_format(fr, fc, lr, lc, &ConditionalFormatDataBar::new())
1133
+ rule(range, cf)
1134
+ }
1135
+ "formula" => {
1136
+ let mut cf =
1137
+ ConditionalFormatFormula::new().set_rule(String::try_convert(value)?.as_str());
1138
+ if let Some(f) = format {
1139
+ cf = cf.set_format(&*f.0);
950
1140
  }
951
- _ => {
952
- let colors = if opt("colors").is_nil() {
953
- 3
954
- } else {
955
- u8::try_convert(opt("colors"))?
956
- };
957
- match colors {
958
- 2 => ws.add_conditional_format(
959
- fr,
960
- fc,
961
- lr,
962
- lc,
963
- &ConditionalFormat2ColorScale::new(),
964
- ),
965
- 3 => ws.add_conditional_format(
966
- fr,
967
- fc,
968
- lr,
969
- lc,
970
- &ConditionalFormat3ColorScale::new(),
971
- ),
972
- n => {
973
- return Err(Error::new(
974
- ruby.exception_arg_error(),
975
- format!("invalid colors {n}: use 2 or 3"),
976
- ))
977
- }
978
- }
1141
+ rule(range, cf)
1142
+ }
1143
+ "data_bar" => rule(range, ConditionalFormatDataBar::new()),
1144
+ _ => match opt::<u8>(ruby, options, "colors")?.unwrap_or(3) {
1145
+ 2 => rule(range, ConditionalFormat2ColorScale::new()),
1146
+ 3 => rule(range, ConditionalFormat3ColorScale::new()),
1147
+ n => {
1148
+ return Err(Error::new(
1149
+ ruby.exception_arg_error(),
1150
+ format!("invalid colors {n}: use 2 or 3"),
1151
+ ))
979
1152
  }
980
- };
981
- r.map(|_| ()).map_err(xerr)
982
- })?;
1153
+ },
1154
+ };
1155
+ rb_self.with_ws(|ws| add_rule(ws).map_err(xerr))?;
983
1156
  Ok(rb_self)
984
1157
  }
985
1158
 
@@ -993,8 +1166,6 @@ impl Worksheet {
993
1166
  lc: u16,
994
1167
  options: RHash,
995
1168
  ) -> Result<Obj<Self>, Error> {
996
- let nil = ruby.qnil().as_value();
997
- let opt = |name: &str| options.get(ruby.to_symbol(name)).unwrap_or(nil);
998
1169
  check_keys(
999
1170
  ruby,
1000
1171
  options,
@@ -1009,12 +1180,13 @@ impl Worksheet {
1009
1180
  ],
1010
1181
  "data_validation",
1011
1182
  )?;
1012
- let (criteria, value) = (opt("criteria"), opt("value"));
1183
+ let criteria = raw_opt(ruby, options, "criteria");
1184
+ let value = raw_opt(ruby, options, "value");
1013
1185
  let dv = DataValidation::new();
1014
1186
  let mut dv = match choice(
1015
1187
  ruby,
1016
1188
  "data validation type",
1017
- opt("type"),
1189
+ raw_opt(ruby, options, "type"),
1018
1190
  &[
1019
1191
  ("list", "list"),
1020
1192
  ("whole_number", "whole_number"),
@@ -1023,9 +1195,16 @@ impl Worksheet {
1023
1195
  ],
1024
1196
  )? {
1025
1197
  "list" => match RArray::from_value(value) {
1026
- Some(items) => dv
1027
- .allow_list_strings(&items.to_vec::<String>()?)
1028
- .map_err(xerr)?,
1198
+ // Items are shown as text in the dropdown, so numbers and
1199
+ // symbols are listed by their to_s.
1200
+ Some(items) => {
1201
+ let mut labels = Vec::with_capacity(items.len());
1202
+ each_entry(items, |_, item| {
1203
+ labels.push(item.funcall::<_, _, String>("to_s", ())?);
1204
+ Ok(())
1205
+ })?;
1206
+ dv.allow_list_strings(&labels).map_err(xerr)?
1207
+ }
1029
1208
  None => dv
1030
1209
  .allow_list_formula(rust_xlsxwriter::Formula::new(String::try_convert(value)?)),
1031
1210
  },
@@ -1042,9 +1221,8 @@ impl Worksheet {
1042
1221
  ("error_title", DataValidation::set_error_title),
1043
1222
  ("error_message", DataValidation::set_error_message),
1044
1223
  ] {
1045
- let text = opt(name);
1046
- if !text.is_nil() {
1047
- dv = set(dv, String::try_convert(text)?).map_err(xerr)?;
1224
+ if let Some(text) = opt::<String>(ruby, options, name)? {
1225
+ dv = set(dv, text).map_err(xerr)?;
1048
1226
  }
1049
1227
  }
1050
1228
  rb_self.with_ws(|ws| {
@@ -1079,8 +1257,6 @@ impl Worksheet {
1079
1257
  bytes: RString,
1080
1258
  options: RHash,
1081
1259
  ) -> Result<Obj<Self>, Error> {
1082
- let nil = ruby.qnil().as_value();
1083
- let opt = |name: &str| options.get(ruby.to_symbol(name)).unwrap_or(nil);
1084
1260
  check_keys(
1085
1261
  ruby,
1086
1262
  options,
@@ -1089,33 +1265,17 @@ impl Worksheet {
1089
1265
  ],
1090
1266
  "insert_image",
1091
1267
  )?;
1092
- let offset = |name: &str| -> Result<u32, Error> {
1093
- let v = opt(name);
1094
- if v.is_nil() {
1095
- Ok(0)
1096
- } else {
1097
- u32::try_convert(v)
1098
- }
1099
- };
1100
1268
  // SAFETY: the bytes are copied into the Image before any Ruby code runs.
1101
1269
  let mut image = Image::new_from_buffer(unsafe { bytes.as_slice() }).map_err(xerr)?;
1102
- let pixels = |name: &str| -> Result<Option<f64>, Error> {
1103
- let v = opt(name);
1104
- if v.is_nil() {
1105
- Ok(None)
1106
- } else {
1107
- f64::try_convert(v).map(Some)
1108
- }
1109
- };
1110
- let (width, height) = (pixels("width")?, pixels("height")?);
1111
- if !opt("scale").is_nil() {
1270
+ let width = opt::<f64>(ruby, options, "width")?;
1271
+ let height = opt::<f64>(ruby, options, "height")?;
1272
+ if let Some(scale) = opt::<f64>(ruby, options, "scale")? {
1112
1273
  if width.is_some() || height.is_some() {
1113
1274
  return Err(Error::new(
1114
1275
  ruby.exception_arg_error(),
1115
1276
  "pass either scale: or width:/height:, not both",
1116
1277
  ));
1117
1278
  }
1118
- let scale = f64::try_convert(opt("scale"))?;
1119
1279
  image = image.set_scale_width(scale).set_scale_height(scale);
1120
1280
  } else if width.is_some() || height.is_some() {
1121
1281
  // Displayed size at scale 1, as rust_xlsxwriter computes it from the DPI.
@@ -1127,10 +1287,11 @@ impl Worksheet {
1127
1287
  let (sw, sh) = (scale_w.or(scale_h).unwrap(), scale_h.or(scale_w).unwrap());
1128
1288
  image = image.set_scale_width(sw).set_scale_height(sh);
1129
1289
  }
1130
- if !opt("alt_text").is_nil() {
1131
- image = image.set_alt_text(String::try_convert(opt("alt_text"))?);
1290
+ if let Some(alt_text) = opt::<String>(ruby, options, "alt_text")? {
1291
+ image = image.set_alt_text(alt_text);
1132
1292
  }
1133
- let (x, y) = (offset("x_offset")?, offset("y_offset")?);
1293
+ let x = opt::<u32>(ruby, options, "x_offset")?.unwrap_or(0);
1294
+ let y = opt::<u32>(ruby, options, "y_offset")?.unwrap_or(0);
1134
1295
  rb_self.with_ws(|ws| {
1135
1296
  ws.insert_image_with_offset(row, col, &image, x, y)
1136
1297
  .map(|_| ())
@@ -1146,16 +1307,7 @@ impl Worksheet {
1146
1307
  col: u16,
1147
1308
  options: RHash,
1148
1309
  ) -> Result<Obj<Self>, Error> {
1149
- let nil = ruby.qnil().as_value();
1150
1310
  let arg_error = |msg: String| Error::new(ruby.exception_arg_error(), msg);
1151
- let text = |v: Value| -> Result<Option<String>, Error> {
1152
- if v.is_nil() {
1153
- Ok(None)
1154
- } else {
1155
- String::try_convert(v).map(Some)
1156
- }
1157
- };
1158
- let opt = |name: &str| options.get(ruby.to_symbol(name)).unwrap_or(nil);
1159
1311
  check_keys(
1160
1312
  ruby,
1161
1313
  options,
@@ -1165,41 +1317,47 @@ impl Worksheet {
1165
1317
  "insert_chart",
1166
1318
  )?;
1167
1319
 
1168
- let mut chart = Chart::new(choice(ruby, "chart type", opt("type"), CHART_TYPES)?);
1169
- let series = RArray::try_convert(opt("series"))?;
1320
+ let chart_type = choice(
1321
+ ruby,
1322
+ "chart type",
1323
+ raw_opt(ruby, options, "type"),
1324
+ CHART_TYPES,
1325
+ )?;
1326
+ let mut chart = Chart::new(chart_type);
1327
+ let series = RArray::try_convert(raw_opt(ruby, options, "series"))?;
1170
1328
  if series.is_empty() {
1171
1329
  return Err(arg_error(
1172
1330
  "series: needs at least one { values:, categories:, name: } hash".into(),
1173
1331
  ));
1174
1332
  }
1175
- for s in series.into_iter() {
1333
+ each_entry(series, |_, s| {
1176
1334
  let s = RHash::try_convert(s)?;
1177
1335
  check_keys(ruby, s, &["values", "categories", "name"], "chart series")?;
1178
- let get = |name: &str| s.get(ruby.to_symbol(name)).unwrap_or(nil);
1179
- let values = text(get("values"))?
1336
+ let values = opt::<String>(ruby, s, "values")?
1180
1337
  .ok_or_else(|| arg_error(format!("series {} needs values:", s.inspect())))?;
1181
1338
  let cs = chart.add_series().set_values(values.as_str());
1182
- if let Some(categories) = text(get("categories"))? {
1339
+ if let Some(categories) = opt::<String>(ruby, s, "categories")? {
1183
1340
  cs.set_categories(categories.as_str());
1184
1341
  }
1185
- if let Some(name) = text(get("name"))? {
1342
+ if let Some(name) = opt::<String>(ruby, s, "name")? {
1186
1343
  cs.set_name(name.as_str());
1187
1344
  }
1188
- }
1189
- if let Some(title) = text(opt("title"))? {
1345
+ Ok(())
1346
+ })?;
1347
+ if let Some(title) = opt::<String>(ruby, options, "title")? {
1190
1348
  chart.title().set_name(title.as_str());
1191
1349
  }
1192
- if let Some(name) = text(opt("x_axis"))? {
1350
+ if let Some(name) = opt::<String>(ruby, options, "x_axis")? {
1193
1351
  chart.x_axis().set_name(name.as_str());
1194
1352
  }
1195
- if let Some(name) = text(opt("y_axis"))? {
1353
+ if let Some(name) = opt::<String>(ruby, options, "y_axis")? {
1196
1354
  chart.y_axis().set_name(name.as_str());
1197
1355
  }
1198
- if !opt("width").is_nil() {
1199
- chart.set_width(u32::try_convert(opt("width"))?);
1356
+ if let Some(width) = opt::<u32>(ruby, options, "width")? {
1357
+ chart.set_width(width);
1200
1358
  }
1201
- if !opt("height").is_nil() {
1202
- chart.set_height(u32::try_convert(opt("height"))?);
1359
+ if let Some(height) = opt::<u32>(ruby, options, "height")? {
1360
+ chart.set_height(height);
1203
1361
  }
1204
1362
  rb_self.with_ws(|ws| ws.insert_chart(row, col, &chart).map(|_| ()).map_err(xerr))?;
1205
1363
  Ok(rb_self)
@@ -1228,24 +1386,19 @@ impl Worksheet {
1228
1386
  ],
1229
1387
  "add_table",
1230
1388
  )?;
1231
- let nil = ruby.qnil().as_value();
1232
- let opt = |name: &str| options.get(ruby.to_symbol(name)).unwrap_or(nil);
1233
- let flag = |name: &str, default: bool| {
1234
- let v = opt(name);
1235
- if v.is_nil() {
1236
- default
1237
- } else {
1238
- v.to_bool()
1239
- }
1389
+ // Ruby truthiness, as before: any non-nil, non-false value enables.
1390
+ let flag = |name: &str, default: bool| -> Result<bool, Error> {
1391
+ Ok(opt::<Value>(ruby, options, name)?.map_or(default, |v| v.to_bool()))
1240
1392
  };
1241
1393
  // The table writes its header row, which must not be flushed yet.
1242
1394
  rb_self.check_not_flushed(ruby, fr)?;
1395
+ let total_row = flag("total_row", false)?;
1243
1396
  let mut table = Table::new()
1244
- .set_total_row(flag("total_row", false))
1245
- .set_banded_rows(flag("banded_rows", true))
1246
- .set_autofilter(flag("autofilter", true));
1247
- if !opt("columns").is_nil() {
1248
- let specs = RArray::try_convert(opt("columns"))?;
1397
+ .set_total_row(total_row)
1398
+ .set_banded_rows(flag("banded_rows", true)?)
1399
+ .set_autofilter(flag("autofilter", true)?);
1400
+ let mut column_formats = Vec::new();
1401
+ if let Some(specs) = opt::<RArray>(ruby, options, "columns")? {
1249
1402
  let width = usize::from(lc.saturating_sub(fc)) + 1;
1250
1403
  if specs.len() != width {
1251
1404
  return Err(Error::new(
@@ -1256,25 +1409,45 @@ impl Worksheet {
1256
1409
  ),
1257
1410
  ));
1258
1411
  }
1259
- let columns = specs
1260
- .into_iter()
1261
- .map(|v| table_column(ruby, v))
1262
- .collect::<Result<Vec<_>, _>>()?;
1412
+ let mut columns = Vec::with_capacity(specs.len());
1413
+ each_entry(specs, |i, v| {
1414
+ let (column, format) = table_column(ruby, v)?;
1415
+ columns.push(column);
1416
+ if let Some(format) = format {
1417
+ column_formats.push((fc + i as u16, format));
1418
+ }
1419
+ Ok(())
1420
+ })?;
1263
1421
  table = table.set_columns(&columns);
1264
1422
  }
1265
- if !opt("style").is_nil() {
1266
- table = table.set_style(choice(ruby, "table style", opt("style"), TABLE_STYLES)?);
1423
+ if let Some(style) = opt::<Value>(ruby, options, "style")? {
1424
+ table = table.set_style(choice(ruby, "table style", style, TABLE_STYLES)?);
1267
1425
  }
1268
- if !opt("name").is_nil() {
1269
- table = table.set_name(String::try_convert(opt("name"))?);
1426
+ if let Some(name) = opt::<String>(ruby, options, "name")? {
1427
+ table = table.set_name(name);
1270
1428
  }
1271
1429
  rb_self.with_ws(|ws| {
1272
1430
  ws.add_table(fr, fc, lr, lc, &table)
1273
1431
  .map(|_| ())
1274
1432
  .map_err(xerr)
1275
1433
  })?;
1434
+ // Data rows: under the header, above the total row.
1435
+ let data_rows = (fr + 1)..=lr.saturating_sub(u32::from(total_row));
1436
+ rb_self
1437
+ .table_formats
1438
+ .borrow_mut()
1439
+ .extend(
1440
+ column_formats
1441
+ .into_iter()
1442
+ .map(|(col, format)| TableColumnFormat {
1443
+ rows: data_rows.clone(),
1444
+ col,
1445
+ format,
1446
+ }),
1447
+ );
1276
1448
  // Continue appending under the header, so `<<` fills the table.
1277
1449
  rb_self.advance(fr);
1450
+ rb_self.note_written(fr);
1278
1451
  Ok(rb_self)
1279
1452
  }
1280
1453
 
@@ -1295,17 +1468,14 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1295
1468
  wb.define_singleton_method("_new", function!(Workbook::new, 2))?;
1296
1469
  wb.define_method("_add_worksheet", method!(Workbook::add_worksheet, 1))?;
1297
1470
  wb.define_method("to_xlsx", method!(Workbook::to_xlsx, 0))?;
1298
- wb.define_method("save", method!(Workbook::save, 1))?;
1471
+ wb.define_method("_save", method!(Workbook::save, 1))?;
1299
1472
  wb.define_method("_properties", method!(Workbook::set_properties, 1))?;
1300
1473
 
1301
1474
  let ws = module.define_class("Worksheet", ruby.class_object())?;
1302
1475
  ws.define_method("_write", method!(Worksheet::write, 4))?;
1303
1476
  ws.define_method("_append", method!(Worksheet::append, 2))?;
1304
1477
  ws.define_method("_column_width", method!(Worksheet::set_column_width, 3))?;
1305
- ws.define_method(
1306
- "_column_format",
1307
- method!(Worksheet::set_column_format, 3),
1308
- )?;
1478
+ ws.define_method("_column_format", method!(Worksheet::set_column_format, 3))?;
1309
1479
  ws.define_method("_autofit", method!(Worksheet::autofit, 0))?;
1310
1480
  ws.define_method("autofilter", method!(Worksheet::autofilter, 4))?;
1311
1481
  ws.define_method("name", method!(Worksheet::name, 0))?;
@@ -0,0 +1,20 @@
1
+ // Runs the Ruby test suite against this crate's current source.
2
+ //
3
+ // The extension's behaviour is tested from Ruby (test/), but cargo-mutants only
4
+ // runs `cargo test`. This test rebuilds the extension from the (possibly
5
+ // mutated) source and runs the Ruby suite, so a mutant counts as caught when a
6
+ // Ruby test fails. The dev profile shares build artifacts with `cargo test`.
7
+ use std::path::Path;
8
+ use std::process::Command;
9
+
10
+ #[test]
11
+ fn ruby_test_suite_passes() {
12
+ let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
13
+ let status = Command::new("bundle")
14
+ .args(["exec", "rake", "compile", "test"])
15
+ .current_dir(&root)
16
+ .env("RB_SYS_CARGO_PROFILE", "dev")
17
+ .status()
18
+ .expect("could not run `bundle exec rake compile test`");
19
+ assert!(status.success(), "the Ruby test suite failed");
20
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastXlsx
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/fast_xlsx.rb CHANGED
@@ -45,7 +45,13 @@ module FastXlsx
45
45
  end
46
46
 
47
47
  # The native extension looks up FastXlsx::Error, so load it after Error is defined.
48
- require "fast_xlsx/fast_xlsx"
48
+ # Precompiled gems ship one binary per Ruby version (fast_xlsx/3.4/fast_xlsx);
49
+ # a gem compiled on install has a single fast_xlsx/fast_xlsx.
50
+ begin
51
+ require "fast_xlsx/#{RUBY_VERSION[/\d+\.\d+/]}/fast_xlsx"
52
+ rescue LoadError
53
+ require "fast_xlsx/fast_xlsx"
54
+ end
49
55
 
50
56
  # Owns the worksheets; serialize with #to_xlsx or #save.
51
57
  class Workbook
@@ -78,6 +84,11 @@ module FastXlsx
78
84
  worksheets.find { |ws| ws.name == name }
79
85
  end
80
86
 
87
+ # path: a String, Pathname or anything responding to #to_path.
88
+ def save(path)
89
+ _save(File.path(path))
90
+ end
91
+
81
92
  # Document properties shown in Excel's File > Info: title:, subject:,
82
93
  # author:, manager:, company:, category:, keywords:, comments:, status:.
83
94
  # Later calls add to earlier ones.
data/sig/fast_xlsx.rbs CHANGED
@@ -36,7 +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 save: (String path) -> void
39
+ def save: (String | _ToPath path) -> void
40
40
  end
41
41
 
42
42
  class Format
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.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
@@ -48,6 +48,7 @@ files:
48
48
  - ext/fast_xlsx/build.rs
49
49
  - ext/fast_xlsx/extconf.rb
50
50
  - ext/fast_xlsx/src/lib.rs
51
+ - ext/fast_xlsx/tests/ruby_suite.rs
51
52
  - lib/fast_xlsx.rb
52
53
  - lib/fast_xlsx/version.rb
53
54
  - sig/fast_xlsx.rbs