fast_xlsx 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,15 +7,16 @@ 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::{
18
- Chart, ChartType, DocProperties, Image, Note, Table, TableColumn, TableFunction, TableStyle,
18
+ Chart, ChartType, DocProperties, Image, Note, ProtectionOptions, Table, TableColumn,
19
+ TableFunction, TableStyle,
19
20
  };
20
21
 
21
22
  // These constants are defined in lib/fast_xlsx.rb before this extension loads.
@@ -49,9 +50,23 @@ struct Workbook {
49
50
  struct Worksheet {
50
51
  wb: Shared,
51
52
  index: usize,
53
+ // Where << / append write next.
52
54
  next_row: Cell<u32>,
55
+ // Highest row with cells written. In :constant / :low mode rows above it
56
+ // are on disk; rows a merge spans are held back, so merges don't count.
57
+ last_written_row: Cell<u32>,
53
58
  // constant_memory and low_memory worksheets write finished rows to disk.
54
59
  flushes_rows: bool,
60
+ // Formats of table columns, for cells written into a table's data rows
61
+ // after add_table (rust_xlsxwriter only formats cells that already exist).
62
+ // Owned copies, so they don't depend on the Ruby Format objects living on.
63
+ table_formats: RefCell<Vec<TableColumnFormat>>,
64
+ }
65
+
66
+ struct TableColumnFormat {
67
+ rows: std::ops::RangeInclusive<u32>,
68
+ col: u16,
69
+ format: Arc<rust_xlsxwriter::Format>,
55
70
  }
56
71
 
57
72
  impl Workbook {
@@ -65,6 +80,36 @@ impl Workbook {
65
80
 
66
81
  fn add_worksheet(&self, name: Option<String>) -> Result<Worksheet, Error> {
67
82
  let mut wb = self.inner.lock().unwrap();
83
+ // Excel sheet names are case-insensitive; rust_xlsxwriter only notices
84
+ // a clash when saving.
85
+ let taken: Vec<String> = wb
86
+ .worksheets()
87
+ .iter()
88
+ .map(|ws| ws.name().to_lowercase())
89
+ .collect();
90
+ let name = match name {
91
+ Some(name) => {
92
+ // Validate the name before adding: rust_xlsxwriter adds the
93
+ // sheet first, so a bad name would leave a "SheetN" behind.
94
+ rust_xlsxwriter::Worksheet::new()
95
+ .set_name(&name)
96
+ .map_err(xerr)?;
97
+ if taken.contains(&name.to_lowercase()) {
98
+ let ruby = Ruby::get().unwrap();
99
+ return Err(Error::new(
100
+ ruby.get_inner(&ERROR),
101
+ format!("a worksheet named {name:?} already exists (names ignore case)"),
102
+ ));
103
+ }
104
+ name
105
+ }
106
+ // Like Excel, the first free "SheetN"; rust_xlsxwriter's default
107
+ // counts sheets, which can clash with a name given earlier.
108
+ None => (1..)
109
+ .map(|n| format!("Sheet{n}"))
110
+ .find(|n| !taken.contains(&n.to_lowercase()))
111
+ .unwrap(),
112
+ };
68
113
  let ws = if self.low_memory {
69
114
  wb.add_worksheet_with_low_memory()
70
115
  } else if self.constant_memory {
@@ -72,14 +117,14 @@ impl Workbook {
72
117
  } else {
73
118
  wb.add_worksheet()
74
119
  };
75
- if let Some(name) = name {
76
- ws.set_name(name).map_err(xerr)?;
77
- }
120
+ ws.set_name(name).map_err(xerr)?;
78
121
  Ok(Worksheet {
79
122
  wb: self.inner.clone(),
80
123
  index: wb.worksheets().len() - 1,
81
124
  next_row: Cell::new(0),
125
+ last_written_row: Cell::new(0),
82
126
  flushes_rows: self.constant_memory || self.low_memory,
127
+ table_formats: RefCell::new(Vec::new()),
83
128
  })
84
129
  }
85
130
 
@@ -97,19 +142,33 @@ impl Workbook {
97
142
  self.inner.lock().unwrap().save(path).map_err(xerr)
98
143
  }
99
144
 
145
+ // "Name" for the whole workbook, "Sheet1!Name" for one sheet. Duplicate
146
+ // names and unknown sheets are reported when saving, since sheets can be
147
+ // added after the name.
148
+ fn define_name(rb_self: Obj<Self>, name: String, formula: String) -> Result<Obj<Self>, Error> {
149
+ rb_self
150
+ .inner
151
+ .lock()
152
+ .unwrap()
153
+ .define_name(name, &formula)
154
+ .map_err(xerr)?;
155
+ Ok(rb_self)
156
+ }
157
+
100
158
  fn set_properties(ruby: &Ruby, rb_self: &Self, fields: RHash) -> Result<(), Error> {
101
159
  let mut props = DocProperties::new();
102
160
  fields.foreach(|key: Symbol, value: String| {
161
+ let taken = std::mem::take(&mut props);
103
162
  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),
163
+ "title" => taken.set_title(value),
164
+ "subject" => taken.set_subject(value),
165
+ "author" => taken.set_author(value),
166
+ "manager" => taken.set_manager(value),
167
+ "company" => taken.set_company(value),
168
+ "category" => taken.set_category(value),
169
+ "keywords" => taken.set_keywords(value),
170
+ "comments" => taken.set_comment(value),
171
+ "status" => taken.set_status(value),
113
172
  other => {
114
173
  return Err(Error::new(
115
174
  ruby.exception_arg_error(),
@@ -124,19 +183,33 @@ impl Workbook {
124
183
  }
125
184
  }
126
185
 
127
- // Excel stores datetimes as days since 1900-01-01 in local time.
186
+ // Excel serial date from days (and fraction) since 1899-12-30. Excel's 1900
187
+ // date system counts a non-existent 1900-02-29 (serial 60), so real dates
188
+ // before 1900-03-01 are one lower; it has no dates before 1900-01-01.
189
+ fn excel_serial(days: f64) -> Result<f64, Error> {
190
+ if days < 2.0 {
191
+ let ruby = Ruby::get().unwrap();
192
+ return Err(Error::new(
193
+ ruby.exception_arg_error(),
194
+ "Excel cannot represent dates before 1900-01-01",
195
+ ));
196
+ }
197
+ Ok(if days < 61.0 { days - 1.0 } else { days })
198
+ }
199
+
200
+ // Time: seconds since 1970-01-01 (serial day 25569), in its own offset.
128
201
  fn excel_time(v: Value) -> Result<f64, Error> {
129
202
  let secs: f64 = v.funcall("to_f", ())?;
130
203
  let offset: i64 = v.funcall("utc_offset", ())?;
131
- Ok((secs + offset as f64) / 86400.0 + 25569.0)
204
+ excel_serial((secs + offset as f64) / 86400.0 + 25569.0)
132
205
  }
133
206
 
134
207
  // 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).
208
+ // offset. JD 2415019 is 1899-12-30.
136
209
  fn excel_date(v: Value) -> Result<f64, Error> {
137
210
  let jd: i64 = v.funcall("jd", ())?;
138
211
  let fraction: f64 = f64::try_convert(v.funcall("day_fraction", ())?)?;
139
- Ok((jd - 2_415_019) as f64 + fraction)
212
+ excel_serial((jd - 2_415_019) as f64 + fraction)
140
213
  }
141
214
 
142
215
  fn emit<T: IntoExcelData>(
@@ -144,85 +217,136 @@ fn emit<T: IntoExcelData>(
144
217
  row: u32,
145
218
  col: u16,
146
219
  data: T,
147
- format: Option<&Format>,
220
+ format: Option<&rust_xlsxwriter::Format>,
148
221
  ) -> Result<(), Error> {
149
222
  match format {
150
- Some(f) => ws.write_with_format(row, col, data, &f.0),
223
+ Some(f) => ws.write_with_format(row, col, data, f),
151
224
  None => ws.write(row, col, data),
152
225
  }
153
226
  .map(|_| ())
154
227
  .map_err(xerr)
155
228
  }
156
229
 
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));
230
+ // Excel's limit on the text in a cell.
231
+ const MAX_CHARS: usize = 32_767;
232
+ // Excel's column count.
233
+ const MAX_COLS: usize = 16_384;
234
+
235
+ // A cell value converted from Ruby. Converting may run Ruby code (to_s, jd,
236
+ // url, ...), so it happens before the workbook lock is taken: Ruby code that
237
+ // touches the same workbook, or another thread, would otherwise deadlock on it.
238
+ // Writing a CellValue runs no Ruby code.
239
+ // It holds Rust-owned copies, never Ruby objects: Ruby code run while the
240
+ // rest of the row converts could change or drop them, and the GC does not
241
+ // see references kept in a Rust Vec.
242
+ enum CellValue {
243
+ Empty,
244
+ Text(String),
245
+ Number(f64),
246
+ Bool(bool),
247
+ Formula(String),
248
+ Url(String, Option<String>),
249
+ // RichString segments; None means the default font.
250
+ Rich(Vec<(Option<Arc<rust_xlsxwriter::Format>>, String)>),
251
+ }
252
+
253
+ impl CellValue {
254
+ fn from_ruby(ruby: &Ruby, v: Value) -> Result<Self, Error> {
255
+ let value = if v.is_nil() {
256
+ CellValue::Empty
257
+ } else if let Some(s) = RString::from_value(v) {
258
+ // Other encodings (e.g. Windows-1252 / Big5 from a legacy CSV) are
259
+ // converted; invalid bytes, or binary non-ASCII, raise.
260
+ CellValue::Text(s.to_string()?)
261
+ } else if v.is_kind_of(ruby.class_numeric()) {
262
+ CellValue::Number(f64::try_convert(v)?)
263
+ } else if v.is_kind_of(ruby.class_time()) {
264
+ CellValue::Number(excel_time(v)?)
265
+ } else if v.is_kind_of(ruby.class_true_class()) || v.is_kind_of(ruby.class_false_class()) {
266
+ CellValue::Bool(v.to_bool())
267
+ } else if v.is_kind_of(ruby.get_inner(&FORMULA)) {
268
+ CellValue::Formula(v.funcall("expression", ())?)
269
+ } else if v.is_kind_of(ruby.get_inner(&URL)) {
270
+ CellValue::Url(v.funcall("url", ())?, v.funcall("text", ())?)
271
+ } else if v.is_kind_of(ruby.get_inner(&RICH_STRING)) {
272
+ let segments: RArray = v.funcall("segments", ())?;
273
+ let mut parts = Vec::with_capacity(segments.len());
274
+ each_entry(segments, |_, segment| {
275
+ let (text, seg_format): (String, Value) = TryConvert::try_convert(segment)?;
276
+ let seg_format = Option::<&Format>::try_convert(seg_format)?;
277
+ parts.push((seg_format.map(|f| f.0.clone()), text));
278
+ Ok(())
279
+ })?;
280
+ CellValue::Rich(parts)
281
+ } else if v.respond_to("jd", false)? {
282
+ CellValue::Number(excel_date(v)?)
283
+ } else {
284
+ CellValue::Text(v.funcall("to_s", ())?)
285
+ };
286
+ value.check()?;
287
+ Ok(value)
171
288
  }
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),
289
+
290
+ // Rejects what the writer would, so a row or merge fails before anything
291
+ // is written. Numbers, booleans and formulas only fail on a bad row/column.
292
+ fn check(&self) -> Result<(), Error> {
293
+ thread_local! {
294
+ // Reused: every check writes to A1, replacing the one before.
295
+ static SCRATCH: RefCell<rust_xlsxwriter::Worksheet> =
296
+ RefCell::new(rust_xlsxwriter::Worksheet::new());
297
+ }
298
+ match self {
299
+ CellValue::Text(s) if s.chars().count() > MAX_CHARS => {
300
+ Err(xerr(XlsxError::MaxStringLengthExceeded))
301
+ }
302
+ // URLs and rich strings have more rules; let the writer apply them.
303
+ CellValue::Url(..) | CellValue::Rich(_) => {
304
+ SCRATCH.with_borrow_mut(|ws| self.write(ws, 0, 0, None))
305
+ }
306
+ _ => Ok(()),
307
+ }
179
308
  }
180
- .map(|_| ())
181
- .map_err(xerr)
182
- }
183
309
 
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);
310
+ fn write(
311
+ &self,
312
+ ws: &mut rust_xlsxwriter::Worksheet,
313
+ row: u32,
314
+ col: u16,
315
+ format: Option<&rust_xlsxwriter::Format>,
316
+ ) -> Result<(), Error> {
317
+ match self {
318
+ CellValue::Empty => Ok(()),
319
+ CellValue::Text(s) => emit(ws, row, col, s.as_str(), format),
320
+ CellValue::Number(n) => emit(ws, row, col, *n, format),
321
+ CellValue::Bool(b) => emit(ws, row, col, *b, format),
322
+ CellValue::Formula(f) => emit(
323
+ ws,
324
+ row,
325
+ col,
326
+ rust_xlsxwriter::Formula::new(f.as_str()),
327
+ format,
328
+ ),
329
+ CellValue::Url(url, text) => {
330
+ let mut link = rust_xlsxwriter::Url::new(url.as_str());
331
+ if let Some(text) = text {
332
+ link = link.set_text(text.as_str());
333
+ }
334
+ emit(ws, row, col, link, format)
335
+ }
336
+ CellValue::Rich(parts) => {
337
+ let default = rust_xlsxwriter::Format::default();
338
+ let rich: Vec<(&rust_xlsxwriter::Format, &str)> = parts
339
+ .iter()
340
+ .map(|(f, text)| (f.as_deref().unwrap_or(&default), text.as_str()))
341
+ .collect();
342
+ match format {
343
+ Some(f) => ws.write_rich_string_with_format(row, col, &rich, f),
344
+ None => ws.write_rich_string(row, col, &rich),
345
+ }
346
+ .map(|_| ())
347
+ .map_err(xerr)
348
+ }
217
349
  }
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
350
  }
227
351
  }
228
352
 
@@ -391,6 +515,19 @@ fn check_header_footer(ruby: &Ruby, what: &str, text: &str) -> Result<(), Error>
391
515
  Ok(())
392
516
  }
393
517
 
518
+ // Option `name` of a Ruby options Hash as given; nil when missing. For
519
+ // choice(), which reports an invalid (or missing) value itself.
520
+ fn raw_opt(ruby: &Ruby, options: RHash, name: &str) -> Value {
521
+ options
522
+ .get(ruby.to_symbol(name))
523
+ .unwrap_or_else(|| ruby.qnil().as_value())
524
+ }
525
+
526
+ // Option `name` of a Ruby options Hash, converted; missing or nil is None.
527
+ fn opt<T: TryConvert>(ruby: &Ruby, options: RHash, name: &str) -> Result<Option<T>, Error> {
528
+ Option::<T>::try_convert(raw_opt(ruby, options, name))
529
+ }
530
+
394
531
  // Rejects option keys outside `allowed`, so a typo raises instead of being ignored.
395
532
  fn check_keys(ruby: &Ruby, options: RHash, allowed: &[&str], what: &str) -> Result<(), Error> {
396
533
  options.foreach(|key: Symbol, _: Value| {
@@ -501,9 +638,13 @@ const TABLE_TOTALS: &[(&str, TableFunction)] = &[
501
638
  ];
502
639
 
503
640
  // A table column: a header String, or { header:, total:, total_label:, format: }.
504
- fn table_column(ruby: &Ruby, v: Value) -> Result<TableColumn, Error> {
641
+ // Also returns a copy of the column's format, if any, for later writes.
642
+ fn table_column(
643
+ ruby: &Ruby,
644
+ v: Value,
645
+ ) -> Result<(TableColumn, Option<Arc<rust_xlsxwriter::Format>>), Error> {
505
646
  let Some(spec) = RHash::from_value(v) else {
506
- return Ok(TableColumn::new().set_header(String::try_convert(v)?));
647
+ return Ok((TableColumn::new().set_header(String::try_convert(v)?), None));
507
648
  };
508
649
  check_keys(
509
650
  ruby,
@@ -511,22 +652,40 @@ fn table_column(ruby: &Ruby, v: Value) -> Result<TableColumn, Error> {
511
652
  &["header", "total", "total_label", "format"],
512
653
  "table column",
513
654
  )?;
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)?);
655
+ let header = String::try_convert(raw_opt(ruby, spec, "header"))?;
656
+ let mut column = TableColumn::new().set_header(header);
657
+ if let Some(total) = opt::<Value>(ruby, spec, "total")? {
658
+ column = column.set_total_function(choice(ruby, "table total", total, TABLE_TOTALS)?);
520
659
  }
521
- if !get("total_label").is_nil() {
522
- column = column.set_total_label(String::try_convert(get("total_label"))?);
660
+ if let Some(label) = opt::<String>(ruby, spec, "total_label")? {
661
+ column = column.set_total_label(label);
523
662
  }
524
- if let Some(format) = Option::<&Format>::try_convert(get("format"))? {
525
- column = column.set_format(&format.0);
663
+ let format = opt::<&Format>(ruby, spec, "format")?.map(|f| f.0.clone());
664
+ if let Some(f) = &format {
665
+ column = column.set_format(&**f);
526
666
  }
527
- Ok(column)
667
+ Ok((column, format))
528
668
  }
529
669
 
670
+ // Actions users may still take on a protected sheet (selecting cells is
671
+ // always allowed).
672
+ type ProtectionFlag = fn(&mut ProtectionOptions) -> &mut bool;
673
+ const PROTECTION_ALLOW: &[(&str, ProtectionFlag)] = &[
674
+ ("format_cells", |o| &mut o.format_cells),
675
+ ("format_columns", |o| &mut o.format_columns),
676
+ ("format_rows", |o| &mut o.format_rows),
677
+ ("insert_columns", |o| &mut o.insert_columns),
678
+ ("insert_rows", |o| &mut o.insert_rows),
679
+ ("insert_links", |o| &mut o.insert_links),
680
+ ("delete_columns", |o| &mut o.delete_columns),
681
+ ("delete_rows", |o| &mut o.delete_rows),
682
+ ("sort", |o| &mut o.sort),
683
+ ("use_autofilter", |o| &mut o.use_autofilter),
684
+ ("use_pivot_tables", |o| &mut o.use_pivot_tables),
685
+ ("edit_scenarios", |o| &mut o.edit_scenarios),
686
+ ("edit_objects", |o| &mut o.edit_objects),
687
+ ];
688
+
530
689
  const BORDERS: &[(&str, FormatBorder)] = &[
531
690
  ("thin", FormatBorder::Thin),
532
691
  ("medium", FormatBorder::Medium),
@@ -538,7 +697,7 @@ const BORDERS: &[(&str, FormatBorder)] = &[
538
697
  ];
539
698
 
540
699
  #[magnus::wrap(class = "FastXlsx::Format", free_immediately)]
541
- struct Format(rust_xlsxwriter::Format);
700
+ struct Format(Arc<rust_xlsxwriter::Format>);
542
701
 
543
702
  impl Format {
544
703
  fn new(ruby: &Ruby, options: RHash) -> Result<Self, Error> {
@@ -584,6 +743,11 @@ impl Format {
584
743
  }
585
744
  "indent" => taken.set_indent(u8::try_convert(value)?),
586
745
  "shrink" if value.to_bool() => taken.set_shrink(),
746
+ // For protected sheets: locked: false leaves a cell editable,
747
+ // hidden: true hides its formula.
748
+ "locked" if value.to_bool() => taken.set_locked(),
749
+ "locked" => taken.set_unlocked(),
750
+ "hidden" if value.to_bool() => taken.set_hidden(),
587
751
  "border_color" => taken.set_border_color(color(ruby, value)?),
588
752
  "num_format" => taken.set_num_format(String::try_convert(value)?),
589
753
  "font_size" => taken.set_font_size(f64::try_convert(value)?),
@@ -616,7 +780,8 @@ impl Format {
616
780
  "border_right" => taken.set_border_right(choice(ruby, "border", value, BORDERS)?),
617
781
  "border_top" => taken.set_border_top(choice(ruby, "border", value, BORDERS)?),
618
782
  "border_bottom" => taken.set_border_bottom(choice(ruby, "border", value, BORDERS)?),
619
- "bold" | "italic" | "underline" | "text_wrap" | "strikeout" | "shrink" => taken,
783
+ "bold" | "italic" | "underline" | "text_wrap" | "strikeout" | "shrink"
784
+ | "hidden" => taken,
620
785
  other => {
621
786
  return Err(Error::new(
622
787
  ruby.exception_arg_error(),
@@ -626,7 +791,7 @@ impl Format {
626
791
  };
627
792
  Ok(ForEach::Continue)
628
793
  })?;
629
- Ok(Format(f))
794
+ Ok(Format(Arc::new(f)))
630
795
  }
631
796
  }
632
797
 
@@ -649,7 +814,7 @@ fn each_entry(
649
814
 
650
815
  // A row's format: one Format (or nil) for every cell, or an Array with one per cell.
651
816
  enum RowFormat {
652
- Same(Option<&'static Format>),
817
+ Same(Option<Arc<rust_xlsxwriter::Format>>),
653
818
  PerCell(RArray),
654
819
  }
655
820
 
@@ -657,16 +822,19 @@ impl RowFormat {
657
822
  fn from_value(v: Value) -> Result<Self, Error> {
658
823
  match RArray::from_value(v) {
659
824
  Some(formats) => Ok(RowFormat::PerCell(formats)),
660
- None => Ok(RowFormat::Same(Option::<&Format>::try_convert(v)?)),
825
+ None => Ok(RowFormat::Same(
826
+ Option::<&Format>::try_convert(v)?.map(|f| f.0.clone()),
827
+ )),
661
828
  }
662
829
  }
663
830
 
664
- fn at(&self, col: usize) -> Result<Option<&Format>, Error> {
831
+ fn at(&self, col: usize) -> Result<Option<Arc<rust_xlsxwriter::Format>>, Error> {
665
832
  match self {
666
- RowFormat::Same(f) => Ok(*f),
667
- RowFormat::PerCell(formats) => {
668
- Option::<&Format>::try_convert(formats.entry::<Value>(col as isize)?)
669
- }
833
+ RowFormat::Same(f) => Ok(f.clone()),
834
+ RowFormat::PerCell(formats) => Ok(Option::<&Format>::try_convert(
835
+ formats.entry::<Value>(col as isize)?,
836
+ )?
837
+ .map(|f| f.0.clone())),
670
838
  }
671
839
  }
672
840
  }
@@ -676,9 +844,14 @@ impl Worksheet {
676
844
  self.next_row.set(self.next_row.get().max(row + 1));
677
845
  }
678
846
 
847
+ fn note_written(&self, row: u32) {
848
+ self.last_written_row
849
+ .set(self.last_written_row.get().max(row));
850
+ }
851
+
679
852
  // rust_xlsxwriter silently drops writes to rows it has already flushed.
680
853
  fn check_not_flushed(&self, ruby: &Ruby, row: u32) -> Result<(), Error> {
681
- if self.flushes_rows && row + 1 < self.next_row.get() {
854
+ if self.flushes_rows && row < self.last_written_row.get() {
682
855
  return Err(Error::new(
683
856
  ruby.get_inner(&ERROR),
684
857
  format!(
@@ -697,6 +870,22 @@ impl Worksheet {
697
870
  f(wb.worksheet_from_index(self.index).map_err(xerr)?)
698
871
  }
699
872
 
873
+ // The format to write a cell with: its own, else its table column's.
874
+ fn cell_format<'a>(
875
+ tables: &'a [TableColumnFormat],
876
+ row: u32,
877
+ col: u16,
878
+ own: Option<&'a rust_xlsxwriter::Format>,
879
+ ) -> Option<&'a rust_xlsxwriter::Format> {
880
+ own.or_else(|| {
881
+ tables
882
+ .iter()
883
+ .rev()
884
+ .find(|t| t.col == col && t.rows.contains(&row))
885
+ .map(|t| &*t.format)
886
+ })
887
+ }
888
+
700
889
  fn write_row(
701
890
  &self,
702
891
  ruby: &Ruby,
@@ -704,14 +893,32 @@ impl Worksheet {
704
893
  cells: RArray,
705
894
  format: &RowFormat,
706
895
  ) -> Result<(), Error> {
896
+ // Convert the whole row first: no lock is held while Ruby code runs,
897
+ // and a value that fails to convert leaves the row unwritten.
898
+ let mut values = Vec::with_capacity(cells.len());
899
+ each_entry(cells, |i, v| {
900
+ if i >= MAX_COLS {
901
+ return Err(Error::new(
902
+ ruby.exception_arg_error(),
903
+ format!("too many columns: Excel allows {MAX_COLS}"),
904
+ ));
905
+ }
906
+ let col = i as u16;
907
+ values.push((col, CellValue::from_ruby(ruby, v)?, format.at(i)?));
908
+ Ok(())
909
+ })?;
910
+ let tables = self.table_formats.borrow();
707
911
  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
- })
912
+ // Values were checked when converted, so only a bad row number
913
+ // fails here, and it fails on the first cell.
914
+ for (col, value, format) in &values {
915
+ let format = Self::cell_format(&tables, row, *col, format.as_deref());
916
+ value.write(ws, row, *col, format)?;
917
+ }
918
+ Ok(())
713
919
  })?;
714
920
  self.advance(row);
921
+ self.note_written(row);
715
922
  Ok(())
716
923
  }
717
924
 
@@ -724,8 +931,18 @@ impl Worksheet {
724
931
  format: Option<&Format>,
725
932
  ) -> Result<(), Error> {
726
933
  rb_self.check_not_flushed(ruby, row)?;
727
- rb_self.with_ws(|ws| put(ruby, ws, row, col, v, format))?;
934
+ let value = CellValue::from_ruby(ruby, v)?;
935
+ let tables = rb_self.table_formats.borrow();
936
+ rb_self.with_ws(|ws| {
937
+ value.write(
938
+ ws,
939
+ row,
940
+ col,
941
+ Self::cell_format(&tables, row, col, format.map(|f| &*f.0)),
942
+ )
943
+ })?;
728
944
  rb_self.advance(row);
945
+ rb_self.note_written(row);
729
946
  Ok(())
730
947
  }
731
948
 
@@ -857,14 +1074,27 @@ impl Worksheet {
857
1074
  v: Value,
858
1075
  format: Option<&Format>,
859
1076
  ) -> Result<Obj<Self>, Error> {
1077
+ rb_self.check_not_flushed(ruby, first_row)?;
1078
+ let value = CellValue::from_ruby(ruby, v)?;
1079
+ let tables = rb_self.table_formats.borrow();
1080
+ let format = Self::cell_format(&tables, first_row, first_col, format.map(|f| &*f.0));
860
1081
  let default = rust_xlsxwriter::Format::new();
861
- let merge_format = format.map_or(&default, |f| &f.0);
862
1082
  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)
1083
+ // The value was checked when converted, so once the range is
1084
+ // merged, writing it cannot fail.
1085
+ ws.merge_range(
1086
+ first_row,
1087
+ first_col,
1088
+ last_row,
1089
+ last_col,
1090
+ "",
1091
+ format.unwrap_or(&default),
1092
+ )
1093
+ .map_err(xerr)?;
1094
+ value.write(ws, first_row, first_col, format)
866
1095
  })?;
867
1096
  rb_self.advance(last_row);
1097
+ rb_self.note_written(first_row);
868
1098
  Ok(rb_self)
869
1099
  }
870
1100
 
@@ -878,20 +1108,19 @@ impl Worksheet {
878
1108
  lc: u16,
879
1109
  options: RHash,
880
1110
  ) -> 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
1111
  check_keys(
884
1112
  ruby,
885
1113
  options,
886
1114
  &["type", "criteria", "value", "format", "colors"],
887
1115
  "conditional_format",
888
1116
  )?;
889
- let format = Option::<&Format>::try_convert(opt("format"))?;
890
- let value = opt("value");
1117
+ let format = opt::<&Format>(ruby, options, "format")?;
1118
+ let value = raw_opt(ruby, options, "value");
1119
+ let criteria = raw_opt(ruby, options, "criteria");
891
1120
  let kind = choice(
892
1121
  ruby,
893
1122
  "conditional format type",
894
- opt("type"),
1123
+ raw_opt(ruby, options, "type"),
895
1124
  &[
896
1125
  ("cell", "cell"),
897
1126
  ("text", "text"),
@@ -900,86 +1129,69 @@ impl Worksheet {
900
1129
  ("color_scale", "color_scale"),
901
1130
  ],
902
1131
  )?;
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)
1132
+ // Built before taking the lock: building converts Ruby values.
1133
+ type AddRule = Box<dyn FnOnce(&mut rust_xlsxwriter::Worksheet) -> Result<(), XlsxError>>;
1134
+ fn rule<T: ConditionalFormat + Send + Sync + 'static>(
1135
+ (fr, fc, lr, lc): (u32, u16, u32, u16),
1136
+ cf: T,
1137
+ ) -> AddRule {
1138
+ Box::new(move |ws| ws.add_conditional_format(fr, fc, lr, lc, &cf).map(|_| ()))
1139
+ }
1140
+ let range = (fr, fc, lr, lc);
1141
+ let add_rule = match kind {
1142
+ "cell" => {
1143
+ let mut cf =
1144
+ ConditionalFormatCell::new().set_rule(cell_rule(ruby, criteria, value)?);
1145
+ if let Some(f) = format {
1146
+ cf = cf.set_format(&*f.0);
939
1147
  }
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)
1148
+ rule(range, cf)
1149
+ }
1150
+ "text" => {
1151
+ let text = String::try_convert(value)?;
1152
+ let text_rule = match choice(
1153
+ ruby,
1154
+ "text criteria",
1155
+ criteria,
1156
+ &[
1157
+ ("contains", 0),
1158
+ ("not_contains", 1),
1159
+ ("begins_with", 2),
1160
+ ("ends_with", 3),
1161
+ ],
1162
+ )? {
1163
+ 0 => ConditionalFormatTextRule::Contains(text),
1164
+ 1 => ConditionalFormatTextRule::DoesNotContain(text),
1165
+ 2 => ConditionalFormatTextRule::BeginsWith(text),
1166
+ _ => ConditionalFormatTextRule::EndsWith(text),
1167
+ };
1168
+ let mut cf = ConditionalFormatText::new().set_rule(text_rule);
1169
+ if let Some(f) = format {
1170
+ cf = cf.set_format(&*f.0);
947
1171
  }
948
- "data_bar" => {
949
- ws.add_conditional_format(fr, fc, lr, lc, &ConditionalFormatDataBar::new())
1172
+ rule(range, cf)
1173
+ }
1174
+ "formula" => {
1175
+ let mut cf =
1176
+ ConditionalFormatFormula::new().set_rule(String::try_convert(value)?.as_str());
1177
+ if let Some(f) = format {
1178
+ cf = cf.set_format(&*f.0);
950
1179
  }
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
- }
1180
+ rule(range, cf)
1181
+ }
1182
+ "data_bar" => rule(range, ConditionalFormatDataBar::new()),
1183
+ _ => match opt::<u8>(ruby, options, "colors")?.unwrap_or(3) {
1184
+ 2 => rule(range, ConditionalFormat2ColorScale::new()),
1185
+ 3 => rule(range, ConditionalFormat3ColorScale::new()),
1186
+ n => {
1187
+ return Err(Error::new(
1188
+ ruby.exception_arg_error(),
1189
+ format!("invalid colors {n}: use 2 or 3"),
1190
+ ))
979
1191
  }
980
- };
981
- r.map(|_| ()).map_err(xerr)
982
- })?;
1192
+ },
1193
+ };
1194
+ rb_self.with_ws(|ws| add_rule(ws).map_err(xerr))?;
983
1195
  Ok(rb_self)
984
1196
  }
985
1197
 
@@ -993,8 +1205,6 @@ impl Worksheet {
993
1205
  lc: u16,
994
1206
  options: RHash,
995
1207
  ) -> 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
1208
  check_keys(
999
1209
  ruby,
1000
1210
  options,
@@ -1009,12 +1219,13 @@ impl Worksheet {
1009
1219
  ],
1010
1220
  "data_validation",
1011
1221
  )?;
1012
- let (criteria, value) = (opt("criteria"), opt("value"));
1222
+ let criteria = raw_opt(ruby, options, "criteria");
1223
+ let value = raw_opt(ruby, options, "value");
1013
1224
  let dv = DataValidation::new();
1014
1225
  let mut dv = match choice(
1015
1226
  ruby,
1016
1227
  "data validation type",
1017
- opt("type"),
1228
+ raw_opt(ruby, options, "type"),
1018
1229
  &[
1019
1230
  ("list", "list"),
1020
1231
  ("whole_number", "whole_number"),
@@ -1023,9 +1234,16 @@ impl Worksheet {
1023
1234
  ],
1024
1235
  )? {
1025
1236
  "list" => match RArray::from_value(value) {
1026
- Some(items) => dv
1027
- .allow_list_strings(&items.to_vec::<String>()?)
1028
- .map_err(xerr)?,
1237
+ // Items are shown as text in the dropdown, so numbers and
1238
+ // symbols are listed by their to_s.
1239
+ Some(items) => {
1240
+ let mut labels = Vec::with_capacity(items.len());
1241
+ each_entry(items, |_, item| {
1242
+ labels.push(item.funcall::<_, _, String>("to_s", ())?);
1243
+ Ok(())
1244
+ })?;
1245
+ dv.allow_list_strings(&labels).map_err(xerr)?
1246
+ }
1029
1247
  None => dv
1030
1248
  .allow_list_formula(rust_xlsxwriter::Formula::new(String::try_convert(value)?)),
1031
1249
  },
@@ -1042,9 +1260,8 @@ impl Worksheet {
1042
1260
  ("error_title", DataValidation::set_error_title),
1043
1261
  ("error_message", DataValidation::set_error_message),
1044
1262
  ] {
1045
- let text = opt(name);
1046
- if !text.is_nil() {
1047
- dv = set(dv, String::try_convert(text)?).map_err(xerr)?;
1263
+ if let Some(text) = opt::<String>(ruby, options, name)? {
1264
+ dv = set(dv, text).map_err(xerr)?;
1048
1265
  }
1049
1266
  }
1050
1267
  rb_self.with_ws(|ws| {
@@ -1079,8 +1296,6 @@ impl Worksheet {
1079
1296
  bytes: RString,
1080
1297
  options: RHash,
1081
1298
  ) -> 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
1299
  check_keys(
1085
1300
  ruby,
1086
1301
  options,
@@ -1089,33 +1304,17 @@ impl Worksheet {
1089
1304
  ],
1090
1305
  "insert_image",
1091
1306
  )?;
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
1307
  // SAFETY: the bytes are copied into the Image before any Ruby code runs.
1101
1308
  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() {
1309
+ let width = opt::<f64>(ruby, options, "width")?;
1310
+ let height = opt::<f64>(ruby, options, "height")?;
1311
+ if let Some(scale) = opt::<f64>(ruby, options, "scale")? {
1112
1312
  if width.is_some() || height.is_some() {
1113
1313
  return Err(Error::new(
1114
1314
  ruby.exception_arg_error(),
1115
1315
  "pass either scale: or width:/height:, not both",
1116
1316
  ));
1117
1317
  }
1118
- let scale = f64::try_convert(opt("scale"))?;
1119
1318
  image = image.set_scale_width(scale).set_scale_height(scale);
1120
1319
  } else if width.is_some() || height.is_some() {
1121
1320
  // Displayed size at scale 1, as rust_xlsxwriter computes it from the DPI.
@@ -1127,10 +1326,11 @@ impl Worksheet {
1127
1326
  let (sw, sh) = (scale_w.or(scale_h).unwrap(), scale_h.or(scale_w).unwrap());
1128
1327
  image = image.set_scale_width(sw).set_scale_height(sh);
1129
1328
  }
1130
- if !opt("alt_text").is_nil() {
1131
- image = image.set_alt_text(String::try_convert(opt("alt_text"))?);
1329
+ if let Some(alt_text) = opt::<String>(ruby, options, "alt_text")? {
1330
+ image = image.set_alt_text(alt_text);
1132
1331
  }
1133
- let (x, y) = (offset("x_offset")?, offset("y_offset")?);
1332
+ let x = opt::<u32>(ruby, options, "x_offset")?.unwrap_or(0);
1333
+ let y = opt::<u32>(ruby, options, "y_offset")?.unwrap_or(0);
1134
1334
  rb_self.with_ws(|ws| {
1135
1335
  ws.insert_image_with_offset(row, col, &image, x, y)
1136
1336
  .map(|_| ())
@@ -1146,16 +1346,7 @@ impl Worksheet {
1146
1346
  col: u16,
1147
1347
  options: RHash,
1148
1348
  ) -> Result<Obj<Self>, Error> {
1149
- let nil = ruby.qnil().as_value();
1150
1349
  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
1350
  check_keys(
1160
1351
  ruby,
1161
1352
  options,
@@ -1165,41 +1356,47 @@ impl Worksheet {
1165
1356
  "insert_chart",
1166
1357
  )?;
1167
1358
 
1168
- let mut chart = Chart::new(choice(ruby, "chart type", opt("type"), CHART_TYPES)?);
1169
- let series = RArray::try_convert(opt("series"))?;
1359
+ let chart_type = choice(
1360
+ ruby,
1361
+ "chart type",
1362
+ raw_opt(ruby, options, "type"),
1363
+ CHART_TYPES,
1364
+ )?;
1365
+ let mut chart = Chart::new(chart_type);
1366
+ let series = RArray::try_convert(raw_opt(ruby, options, "series"))?;
1170
1367
  if series.is_empty() {
1171
1368
  return Err(arg_error(
1172
1369
  "series: needs at least one { values:, categories:, name: } hash".into(),
1173
1370
  ));
1174
1371
  }
1175
- for s in series.into_iter() {
1372
+ each_entry(series, |_, s| {
1176
1373
  let s = RHash::try_convert(s)?;
1177
1374
  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"))?
1375
+ let values = opt::<String>(ruby, s, "values")?
1180
1376
  .ok_or_else(|| arg_error(format!("series {} needs values:", s.inspect())))?;
1181
1377
  let cs = chart.add_series().set_values(values.as_str());
1182
- if let Some(categories) = text(get("categories"))? {
1378
+ if let Some(categories) = opt::<String>(ruby, s, "categories")? {
1183
1379
  cs.set_categories(categories.as_str());
1184
1380
  }
1185
- if let Some(name) = text(get("name"))? {
1381
+ if let Some(name) = opt::<String>(ruby, s, "name")? {
1186
1382
  cs.set_name(name.as_str());
1187
1383
  }
1188
- }
1189
- if let Some(title) = text(opt("title"))? {
1384
+ Ok(())
1385
+ })?;
1386
+ if let Some(title) = opt::<String>(ruby, options, "title")? {
1190
1387
  chart.title().set_name(title.as_str());
1191
1388
  }
1192
- if let Some(name) = text(opt("x_axis"))? {
1389
+ if let Some(name) = opt::<String>(ruby, options, "x_axis")? {
1193
1390
  chart.x_axis().set_name(name.as_str());
1194
1391
  }
1195
- if let Some(name) = text(opt("y_axis"))? {
1392
+ if let Some(name) = opt::<String>(ruby, options, "y_axis")? {
1196
1393
  chart.y_axis().set_name(name.as_str());
1197
1394
  }
1198
- if !opt("width").is_nil() {
1199
- chart.set_width(u32::try_convert(opt("width"))?);
1395
+ if let Some(width) = opt::<u32>(ruby, options, "width")? {
1396
+ chart.set_width(width);
1200
1397
  }
1201
- if !opt("height").is_nil() {
1202
- chart.set_height(u32::try_convert(opt("height"))?);
1398
+ if let Some(height) = opt::<u32>(ruby, options, "height")? {
1399
+ chart.set_height(height);
1203
1400
  }
1204
1401
  rb_self.with_ws(|ws| ws.insert_chart(row, col, &chart).map(|_| ()).map_err(xerr))?;
1205
1402
  Ok(rb_self)
@@ -1228,24 +1425,19 @@ impl Worksheet {
1228
1425
  ],
1229
1426
  "add_table",
1230
1427
  )?;
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
- }
1428
+ // Ruby truthiness, as before: any non-nil, non-false value enables.
1429
+ let flag = |name: &str, default: bool| -> Result<bool, Error> {
1430
+ Ok(opt::<Value>(ruby, options, name)?.map_or(default, |v| v.to_bool()))
1240
1431
  };
1241
1432
  // The table writes its header row, which must not be flushed yet.
1242
1433
  rb_self.check_not_flushed(ruby, fr)?;
1434
+ let total_row = flag("total_row", false)?;
1243
1435
  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"))?;
1436
+ .set_total_row(total_row)
1437
+ .set_banded_rows(flag("banded_rows", true)?)
1438
+ .set_autofilter(flag("autofilter", true)?);
1439
+ let mut column_formats = Vec::new();
1440
+ if let Some(specs) = opt::<RArray>(ruby, options, "columns")? {
1249
1441
  let width = usize::from(lc.saturating_sub(fc)) + 1;
1250
1442
  if specs.len() != width {
1251
1443
  return Err(Error::new(
@@ -1256,25 +1448,110 @@ impl Worksheet {
1256
1448
  ),
1257
1449
  ));
1258
1450
  }
1259
- let columns = specs
1260
- .into_iter()
1261
- .map(|v| table_column(ruby, v))
1262
- .collect::<Result<Vec<_>, _>>()?;
1451
+ let mut columns = Vec::with_capacity(specs.len());
1452
+ each_entry(specs, |i, v| {
1453
+ let (column, format) = table_column(ruby, v)?;
1454
+ columns.push(column);
1455
+ if let Some(format) = format {
1456
+ column_formats.push((fc + i as u16, format));
1457
+ }
1458
+ Ok(())
1459
+ })?;
1263
1460
  table = table.set_columns(&columns);
1264
1461
  }
1265
- if !opt("style").is_nil() {
1266
- table = table.set_style(choice(ruby, "table style", opt("style"), TABLE_STYLES)?);
1462
+ if let Some(style) = opt::<Value>(ruby, options, "style")? {
1463
+ table = table.set_style(choice(ruby, "table style", style, TABLE_STYLES)?);
1267
1464
  }
1268
- if !opt("name").is_nil() {
1269
- table = table.set_name(String::try_convert(opt("name"))?);
1465
+ if let Some(name) = opt::<String>(ruby, options, "name")? {
1466
+ table = table.set_name(name);
1270
1467
  }
1271
1468
  rb_self.with_ws(|ws| {
1272
1469
  ws.add_table(fr, fc, lr, lc, &table)
1273
1470
  .map(|_| ())
1274
1471
  .map_err(xerr)
1275
1472
  })?;
1473
+ // Data rows: under the header, above the total row.
1474
+ let data_rows = (fr + 1)..=lr.saturating_sub(u32::from(total_row));
1475
+ rb_self
1476
+ .table_formats
1477
+ .borrow_mut()
1478
+ .extend(
1479
+ column_formats
1480
+ .into_iter()
1481
+ .map(|(col, format)| TableColumnFormat {
1482
+ rows: data_rows.clone(),
1483
+ col,
1484
+ format,
1485
+ }),
1486
+ );
1276
1487
  // Continue appending under the header, so `<<` fills the table.
1277
1488
  rb_self.advance(fr);
1489
+ rb_self.note_written(fr);
1490
+ Ok(rb_self)
1491
+ }
1492
+
1493
+ fn group_rows(
1494
+ ruby: &Ruby,
1495
+ rb_self: Obj<Self>,
1496
+ first: u32,
1497
+ last: u32,
1498
+ collapsed: bool,
1499
+ ) -> Result<Obj<Self>, Error> {
1500
+ // rust_xlsxwriter writes no outline levels when it writes rows to
1501
+ // disk as it goes.
1502
+ if rb_self.flushes_rows {
1503
+ return Err(Error::new(
1504
+ ruby.get_inner(&ERROR),
1505
+ "group_rows needs memory: :standard (rows written to disk as they go lose their outline level)",
1506
+ ));
1507
+ }
1508
+ rb_self.with_ws(|ws| {
1509
+ if collapsed {
1510
+ ws.group_rows_collapsed(first, last)
1511
+ } else {
1512
+ ws.group_rows(first, last)
1513
+ }
1514
+ .map(|_| ())
1515
+ .map_err(xerr)
1516
+ })?;
1517
+ Ok(rb_self)
1518
+ }
1519
+
1520
+ fn group_columns(
1521
+ rb_self: Obj<Self>,
1522
+ first: u16,
1523
+ last: u16,
1524
+ collapsed: bool,
1525
+ ) -> Result<Obj<Self>, Error> {
1526
+ rb_self.with_ws(|ws| {
1527
+ if collapsed {
1528
+ ws.group_columns_collapsed(first, last)
1529
+ } else {
1530
+ ws.group_columns(first, last)
1531
+ }
1532
+ .map(|_| ())
1533
+ .map_err(xerr)
1534
+ })?;
1535
+ Ok(rb_self)
1536
+ }
1537
+
1538
+ fn protect(
1539
+ ruby: &Ruby,
1540
+ rb_self: Obj<Self>,
1541
+ password: Option<String>,
1542
+ allow: RArray,
1543
+ ) -> Result<Obj<Self>, Error> {
1544
+ let mut options = ProtectionOptions::new();
1545
+ each_entry(allow, |_, action| {
1546
+ *choice(ruby, "protect action", action, PROTECTION_ALLOW)?(&mut options) = true;
1547
+ Ok(())
1548
+ })?;
1549
+ rb_self.with_ws(|ws| {
1550
+ // Always set the password, so protecting again replaces it ("" is none).
1551
+ ws.protect_with_password(password.as_deref().unwrap_or(""));
1552
+ ws.protect_with_options(&options); // keeps the password
1553
+ Ok(())
1554
+ })?;
1278
1555
  Ok(rb_self)
1279
1556
  }
1280
1557
 
@@ -1295,19 +1572,17 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1295
1572
  wb.define_singleton_method("_new", function!(Workbook::new, 2))?;
1296
1573
  wb.define_method("_add_worksheet", method!(Workbook::add_worksheet, 1))?;
1297
1574
  wb.define_method("to_xlsx", method!(Workbook::to_xlsx, 0))?;
1298
- wb.define_method("save", method!(Workbook::save, 1))?;
1575
+ wb.define_method("_save", method!(Workbook::save, 1))?;
1576
+ wb.define_method("define_name", method!(Workbook::define_name, 2))?;
1299
1577
  wb.define_method("_properties", method!(Workbook::set_properties, 1))?;
1300
1578
 
1301
1579
  let ws = module.define_class("Worksheet", ruby.class_object())?;
1302
1580
  ws.define_method("_write", method!(Worksheet::write, 4))?;
1303
1581
  ws.define_method("_append", method!(Worksheet::append, 2))?;
1304
1582
  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
- )?;
1583
+ ws.define_method("_column_format", method!(Worksheet::set_column_format, 3))?;
1309
1584
  ws.define_method("_autofit", method!(Worksheet::autofit, 0))?;
1310
- ws.define_method("autofilter", method!(Worksheet::autofilter, 4))?;
1585
+ ws.define_method("_autofilter", method!(Worksheet::autofilter, 4))?;
1311
1586
  ws.define_method("name", method!(Worksheet::name, 0))?;
1312
1587
  ws.define_method("_write_comment", method!(Worksheet::write_comment, 4))?;
1313
1588
  ws.define_method("_insert_image", method!(Worksheet::insert_image, 4))?;
@@ -1328,6 +1603,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1328
1603
  "vertical_page_breaks",
1329
1604
  method!(Worksheet::set_vertical_page_breaks, 1),
1330
1605
  )?;
1606
+ ws.define_method("_group_rows", method!(Worksheet::group_rows, 3))?;
1607
+ ws.define_method("_group_columns", method!(Worksheet::group_columns, 3))?;
1608
+ ws.define_method("_protect", method!(Worksheet::protect, 2))?;
1331
1609
  ws.define_method("_merge_range", method!(Worksheet::merge_range, 6))?;
1332
1610
 
1333
1611
  let format = module.define_class("Format", ruby.class_object())?;