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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -1
- data/README.md +54 -1
- data/bench/Gemfile.lock +2 -2
- data/ext/fast_xlsx/src/lib.rs +569 -291
- data/ext/fast_xlsx/tests/ruby_suite.rs +23 -0
- data/lib/fast_xlsx/version.rb +1 -1
- data/lib/fast_xlsx.rb +120 -16
- data/sig/fast_xlsx.rbs +21 -5
- metadata +2 -1
data/ext/fast_xlsx/src/lib.rs
CHANGED
|
@@ -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,
|
|
11
|
-
ConditionalFormatCellRule, ConditionalFormatDataBar,
|
|
12
|
-
ConditionalFormatText, ConditionalFormatTextRule,
|
|
13
|
-
|
|
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,
|
|
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
|
-
|
|
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" =>
|
|
105
|
-
"subject" =>
|
|
106
|
-
"author" =>
|
|
107
|
-
"manager" =>
|
|
108
|
-
"company" =>
|
|
109
|
-
"category" =>
|
|
110
|
-
"keywords" =>
|
|
111
|
-
"comments" =>
|
|
112
|
-
"status" =>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
515
|
-
let
|
|
516
|
-
let
|
|
517
|
-
|
|
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
|
|
522
|
-
column = column.set_total_label(
|
|
660
|
+
if let Some(label) = opt::<String>(ruby, spec, "total_label")? {
|
|
661
|
+
column = column.set_total_label(label);
|
|
523
662
|
}
|
|
524
|
-
|
|
525
|
-
|
|
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"
|
|
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
|
|
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(
|
|
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
|
|
831
|
+
fn at(&self, col: usize) -> Result<Option<Arc<rust_xlsxwriter::Format>>, Error> {
|
|
665
832
|
match self {
|
|
666
|
-
RowFormat::Same(f) => Ok(
|
|
667
|
-
RowFormat::PerCell(formats) =>
|
|
668
|
-
|
|
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
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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 =
|
|
890
|
-
let 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
|
-
|
|
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
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
949
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
|
1131
|
-
image = image.set_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
|
|
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
|
|
1169
|
-
|
|
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
|
-
|
|
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
|
|
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) =
|
|
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) =
|
|
1381
|
+
if let Some(name) = opt::<String>(ruby, s, "name")? {
|
|
1186
1382
|
cs.set_name(name.as_str());
|
|
1187
1383
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
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) =
|
|
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) =
|
|
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
|
|
1199
|
-
chart.set_width(
|
|
1395
|
+
if let Some(width) = opt::<u32>(ruby, options, "width")? {
|
|
1396
|
+
chart.set_width(width);
|
|
1200
1397
|
}
|
|
1201
|
-
if
|
|
1202
|
-
chart.set_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
|
-
|
|
1232
|
-
let
|
|
1233
|
-
|
|
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(
|
|
1245
|
-
.set_banded_rows(flag("banded_rows", true))
|
|
1246
|
-
.set_autofilter(flag("autofilter", true));
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
|
|
1261
|
-
|
|
1262
|
-
.
|
|
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
|
|
1266
|
-
table = table.set_style(choice(ruby, "table style",
|
|
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
|
|
1269
|
-
table = table.set_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("
|
|
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("
|
|
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())?;
|