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