fast_xlsx 0.1.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.
@@ -0,0 +1,1339 @@
1
+ use std::cell::Cell;
2
+ use std::sync::{Arc, Mutex};
3
+
4
+ use magnus::{
5
+ function, method, prelude::*, r_hash::ForEach, typed_data::Obj, value::Lazy, Error,
6
+ ExceptionClass, Integer, RArray, RClass, RHash, RModule, RString, Ruby, Symbol, TryConvert,
7
+ Value,
8
+ };
9
+ use rust_xlsxwriter::{
10
+ Color, ConditionalFormat2ColorScale, ConditionalFormat3ColorScale, ConditionalFormatCell,
11
+ ConditionalFormatCellRule, ConditionalFormatDataBar, ConditionalFormatFormula,
12
+ ConditionalFormatText, ConditionalFormatTextRule, ConditionalFormatValue, DataValidation,
13
+ DataValidationRule, FormatAlign, FormatBorder, FormatScript, FormatUnderline,
14
+ IntoDataValidationValue, IntoExcelData, XlsxError,
15
+ };
16
+
17
+ use rust_xlsxwriter::{
18
+ Chart, ChartType, DocProperties, Image, Note, Table, TableColumn, TableFunction, TableStyle,
19
+ };
20
+
21
+ // These constants are defined in lib/fast_xlsx.rb before this extension loads.
22
+ fn fast_xlsx_const<T: TryConvert>(ruby: &Ruby, name: &str) -> T {
23
+ ruby.class_object()
24
+ .const_get::<_, RModule>("FastXlsx")
25
+ .and_then(|m| m.const_get(name))
26
+ .unwrap()
27
+ }
28
+
29
+ static ERROR: Lazy<ExceptionClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "Error"));
30
+ static FORMULA: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "Formula"));
31
+ static URL: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "URL"));
32
+ static RICH_STRING: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "RichString"));
33
+
34
+ fn xerr(e: XlsxError) -> Error {
35
+ let ruby = Ruby::get().unwrap();
36
+ Error::new(ruby.get_inner(&ERROR), e.to_string())
37
+ }
38
+
39
+ type Shared = Arc<Mutex<rust_xlsxwriter::Workbook>>;
40
+
41
+ #[magnus::wrap(class = "FastXlsx::Workbook", free_immediately)]
42
+ struct Workbook {
43
+ inner: Shared,
44
+ constant_memory: bool,
45
+ low_memory: bool,
46
+ }
47
+
48
+ #[magnus::wrap(class = "FastXlsx::Worksheet", free_immediately)]
49
+ struct Worksheet {
50
+ wb: Shared,
51
+ index: usize,
52
+ next_row: Cell<u32>,
53
+ // constant_memory and low_memory worksheets write finished rows to disk.
54
+ flushes_rows: bool,
55
+ }
56
+
57
+ impl Workbook {
58
+ fn new(constant_memory: bool, low_memory: bool) -> Self {
59
+ Workbook {
60
+ inner: Arc::new(Mutex::new(rust_xlsxwriter::Workbook::new())),
61
+ constant_memory,
62
+ low_memory,
63
+ }
64
+ }
65
+
66
+ fn add_worksheet(&self, name: Option<String>) -> Result<Worksheet, Error> {
67
+ let mut wb = self.inner.lock().unwrap();
68
+ let ws = if self.low_memory {
69
+ wb.add_worksheet_with_low_memory()
70
+ } else if self.constant_memory {
71
+ wb.add_worksheet_with_constant_memory()
72
+ } else {
73
+ wb.add_worksheet()
74
+ };
75
+ if let Some(name) = name {
76
+ ws.set_name(name).map_err(xerr)?;
77
+ }
78
+ Ok(Worksheet {
79
+ wb: self.inner.clone(),
80
+ index: wb.worksheets().len() - 1,
81
+ next_row: Cell::new(0),
82
+ flushes_rows: self.constant_memory || self.low_memory,
83
+ })
84
+ }
85
+
86
+ fn to_xlsx(ruby: &Ruby, rb_self: &Self) -> Result<RString, Error> {
87
+ let buf = rb_self
88
+ .inner
89
+ .lock()
90
+ .unwrap()
91
+ .save_to_buffer()
92
+ .map_err(xerr)?;
93
+ Ok(ruby.str_from_slice(&buf))
94
+ }
95
+
96
+ fn save(&self, path: String) -> Result<(), Error> {
97
+ self.inner.lock().unwrap().save(path).map_err(xerr)
98
+ }
99
+
100
+ fn set_properties(ruby: &Ruby, rb_self: &Self, fields: RHash) -> Result<(), Error> {
101
+ let mut props = DocProperties::new();
102
+ fields.foreach(|key: Symbol, value: String| {
103
+ props = match &*key.name()? {
104
+ "title" => props.clone().set_title(value),
105
+ "subject" => props.clone().set_subject(value),
106
+ "author" => props.clone().set_author(value),
107
+ "manager" => props.clone().set_manager(value),
108
+ "company" => props.clone().set_company(value),
109
+ "category" => props.clone().set_category(value),
110
+ "keywords" => props.clone().set_keywords(value),
111
+ "comments" => props.clone().set_comment(value),
112
+ "status" => props.clone().set_status(value),
113
+ other => {
114
+ return Err(Error::new(
115
+ ruby.exception_arg_error(),
116
+ format!("unknown property: {other}"),
117
+ ))
118
+ }
119
+ };
120
+ Ok(ForEach::Continue)
121
+ })?;
122
+ rb_self.inner.lock().unwrap().set_properties(&props);
123
+ Ok(())
124
+ }
125
+ }
126
+
127
+ // Excel stores datetimes as days since 1900-01-01 in local time.
128
+ fn excel_time(v: Value) -> Result<f64, Error> {
129
+ let secs: f64 = v.funcall("to_f", ())?;
130
+ let offset: i64 = v.funcall("utc_offset", ())?;
131
+ Ok((secs + offset as f64) / 86400.0 + 25569.0)
132
+ }
133
+
134
+ // Date / DateTime: Julian day and day fraction are both in the object's own
135
+ // offset. JD 2415019 is Excel serial 0 (1899-12-30).
136
+ fn excel_date(v: Value) -> Result<f64, Error> {
137
+ let jd: i64 = v.funcall("jd", ())?;
138
+ let fraction: f64 = f64::try_convert(v.funcall("day_fraction", ())?)?;
139
+ Ok((jd - 2_415_019) as f64 + fraction)
140
+ }
141
+
142
+ fn emit<T: IntoExcelData>(
143
+ ws: &mut rust_xlsxwriter::Worksheet,
144
+ row: u32,
145
+ col: u16,
146
+ data: T,
147
+ format: Option<&Format>,
148
+ ) -> Result<(), Error> {
149
+ match format {
150
+ Some(f) => ws.write_with_format(row, col, data, &f.0),
151
+ None => ws.write(row, col, data),
152
+ }
153
+ .map(|_| ())
154
+ .map_err(xerr)
155
+ }
156
+
157
+ // RichString#segments is [[text, Format or nil], ...]; nil means the default font.
158
+ fn write_rich_string(
159
+ ws: &mut rust_xlsxwriter::Worksheet,
160
+ row: u32,
161
+ col: u16,
162
+ v: Value,
163
+ format: Option<&Format>,
164
+ ) -> Result<(), Error> {
165
+ let default = rust_xlsxwriter::Format::default();
166
+ let segments: RArray = v.funcall("segments", ())?;
167
+ let mut parts: Vec<(Option<&Format>, String)> = Vec::with_capacity(segments.len());
168
+ for segment in segments.into_iter() {
169
+ let (text, seg_format): (String, Value) = TryConvert::try_convert(segment)?;
170
+ parts.push((Option::<&Format>::try_convert(seg_format)?, text));
171
+ }
172
+ let rich: Vec<(&rust_xlsxwriter::Format, &str)> = parts
173
+ .iter()
174
+ .map(|(f, text)| (f.map_or(&default, |f| &f.0), text.as_str()))
175
+ .collect();
176
+ match format {
177
+ Some(f) => ws.write_rich_string_with_format(row, col, &rich, &f.0),
178
+ None => ws.write_rich_string(row, col, &rich),
179
+ }
180
+ .map(|_| ())
181
+ .map_err(xerr)
182
+ }
183
+
184
+ fn put(
185
+ ruby: &Ruby,
186
+ ws: &mut rust_xlsxwriter::Worksheet,
187
+ row: u32,
188
+ col: u16,
189
+ v: Value,
190
+ format: Option<&Format>,
191
+ ) -> Result<(), Error> {
192
+ if v.is_nil() {
193
+ Ok(())
194
+ } else if let Some(s) = RString::from_value(v) {
195
+ // SAFETY: the borrowed str is copied by the writer before any Ruby code runs.
196
+ emit(ws, row, col, unsafe { s.as_str()? }, format)
197
+ } else if v.is_kind_of(ruby.class_numeric()) {
198
+ emit(ws, row, col, f64::try_convert(v)?, format)
199
+ } else if v.is_kind_of(ruby.class_time()) {
200
+ emit(ws, row, col, excel_time(v)?, format)
201
+ } else if v.is_kind_of(ruby.class_true_class()) || v.is_kind_of(ruby.class_false_class()) {
202
+ emit(ws, row, col, v.to_bool(), format)
203
+ } else if v.is_kind_of(ruby.get_inner(&FORMULA)) {
204
+ let expression: String = v.funcall("expression", ())?;
205
+ emit(
206
+ ws,
207
+ row,
208
+ col,
209
+ rust_xlsxwriter::Formula::new(expression),
210
+ format,
211
+ )
212
+ } else if v.is_kind_of(ruby.get_inner(&URL)) {
213
+ let url: String = v.funcall("url", ())?;
214
+ let mut link = rust_xlsxwriter::Url::new(url);
215
+ if let Some(text) = v.funcall::<_, _, Option<String>>("text", ())? {
216
+ link = link.set_text(text);
217
+ }
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
+ }
227
+ }
228
+
229
+ // "#RRGGBB" or 0xRRGGBB.
230
+ fn color(ruby: &Ruby, value: Value) -> Result<Color, Error> {
231
+ let rgb = if let Some(i) = Integer::from_value(value) {
232
+ i.to_u32().ok().filter(|n| *n <= 0xFF_FFFF)
233
+ } else if let Some(s) = RString::from_value(value) {
234
+ let s = s.to_string()?;
235
+ s.strip_prefix('#')
236
+ .filter(|hex| hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()))
237
+ .and_then(|hex| u32::from_str_radix(hex, 16).ok())
238
+ } else {
239
+ None
240
+ };
241
+ rgb.map(Color::RGB).ok_or_else(|| {
242
+ Error::new(
243
+ ruby.exception_arg_error(),
244
+ format!(
245
+ "invalid color {}: use \"#RRGGBB\" or 0xRRGGBB",
246
+ value.inspect()
247
+ ),
248
+ )
249
+ })
250
+ }
251
+
252
+ // A symbol option that must be one of `choices`.
253
+ fn choice<T: Clone>(
254
+ ruby: &Ruby,
255
+ option: &str,
256
+ value: Value,
257
+ choices: &[(&str, T)],
258
+ ) -> Result<T, Error> {
259
+ let name = Symbol::from_value(value).and_then(|s| s.name().ok());
260
+ name.and_then(|n| choices.iter().find(|(k, _)| *k == n))
261
+ .map(|(_, v)| v.clone())
262
+ .ok_or_else(|| {
263
+ let names: Vec<_> = choices.iter().map(|(k, _)| format!(":{k}")).collect();
264
+ Error::new(
265
+ ruby.exception_arg_error(),
266
+ format!(
267
+ "invalid {option} {}: expected one of {}",
268
+ value.inspect(),
269
+ names.join(", ")
270
+ ),
271
+ )
272
+ })
273
+ }
274
+
275
+ // A number or string compared against in a conditional format rule.
276
+ fn cf_value(ruby: &Ruby, v: Value) -> Result<ConditionalFormatValue, Error> {
277
+ if let Some(s) = RString::from_value(v) {
278
+ Ok(s.to_string()?.into())
279
+ } else if v.is_kind_of(ruby.class_numeric()) {
280
+ Ok(f64::try_convert(v)?.into())
281
+ } else {
282
+ Err(Error::new(
283
+ ruby.exception_arg_error(),
284
+ format!(
285
+ "invalid conditional format value {}: use a number or string",
286
+ v.inspect()
287
+ ),
288
+ ))
289
+ }
290
+ }
291
+
292
+ // A comparison criteria and its operand(s): one value, or [min, max] for the
293
+ // range criteria.
294
+ fn comparison(
295
+ ruby: &Ruby,
296
+ what: &str,
297
+ criteria: Value,
298
+ value: Value,
299
+ ) -> Result<(&'static str, Value, Option<Value>), Error> {
300
+ let op = choice(
301
+ ruby,
302
+ what,
303
+ criteria,
304
+ &[
305
+ ("==", "=="),
306
+ ("!=", "!="),
307
+ (">", ">"),
308
+ (">=", ">="),
309
+ ("<", "<"),
310
+ ("<=", "<="),
311
+ ("between", "between"),
312
+ ("not_between", "not_between"),
313
+ ],
314
+ )?;
315
+ if op != "between" && op != "not_between" {
316
+ return Ok((op, value, None));
317
+ }
318
+ let bounds = RArray::from_value(value)
319
+ .filter(|a| a.len() == 2)
320
+ .ok_or_else(|| {
321
+ Error::new(
322
+ ruby.exception_arg_error(),
323
+ format!("{op} needs value: [min, max], got {}", value.inspect()),
324
+ )
325
+ })?;
326
+ Ok((op, bounds.entry(0)?, Some(bounds.entry(1)?)))
327
+ }
328
+
329
+ // Maps a comparison() result onto a rust_xlsxwriter rule enum, converting the
330
+ // operands with $convert.
331
+ macro_rules! comparison_rule {
332
+ ($Rule:ident, $cmp:expr, $convert:expr) => {{
333
+ let (op, a, b) = $cmp;
334
+ let a = $convert(a)?;
335
+ match (op, b) {
336
+ ("between", Some(b)) => $Rule::Between(a, $convert(b)?),
337
+ ("not_between", Some(b)) => $Rule::NotBetween(a, $convert(b)?),
338
+ ("==", _) => $Rule::EqualTo(a),
339
+ ("!=", _) => $Rule::NotEqualTo(a),
340
+ (">", _) => $Rule::GreaterThan(a),
341
+ (">=", _) => $Rule::GreaterThanOrEqualTo(a),
342
+ ("<", _) => $Rule::LessThan(a),
343
+ _ => $Rule::LessThanOrEqualTo(a),
344
+ }
345
+ }};
346
+ }
347
+
348
+ fn cell_rule(
349
+ ruby: &Ruby,
350
+ criteria: Value,
351
+ value: Value,
352
+ ) -> Result<ConditionalFormatCellRule<ConditionalFormatValue>, Error> {
353
+ let cmp = comparison(ruby, "cell criteria", criteria, value)?;
354
+ Ok(comparison_rule!(ConditionalFormatCellRule, cmp, |v| {
355
+ cf_value(ruby, v)
356
+ }))
357
+ }
358
+
359
+ fn validation_rule<T: TryConvert + IntoDataValidationValue>(
360
+ ruby: &Ruby,
361
+ criteria: Value,
362
+ value: Value,
363
+ ) -> Result<DataValidationRule<T>, Error> {
364
+ let cmp = comparison(ruby, "validation criteria", criteria, value)?;
365
+ Ok(comparison_rule!(DataValidationRule, cmp, T::try_convert))
366
+ }
367
+
368
+ // Excel's 255-character limit, counted the way rust_xlsxwriter does (with the
369
+ // long &[Page]-style codes shortened), which otherwise drops the text silently.
370
+ fn check_header_footer(ruby: &Ruby, what: &str, text: &str) -> Result<(), Error> {
371
+ let mut expanded = text.to_string();
372
+ for (long, short) in [
373
+ ("&[Tab]", "&A"),
374
+ ("&[Date]", "&D"),
375
+ ("&[File]", "&F"),
376
+ ("&[Page]", "&P"),
377
+ ("&[Path]", "&Z"),
378
+ ("&[Time]", "&T"),
379
+ ("&[Pages]", "&N"),
380
+ ("&[Picture]", "&G"),
381
+ ] {
382
+ expanded = expanded.replace(long, short);
383
+ }
384
+ let len = expanded.chars().count();
385
+ if len > 255 {
386
+ return Err(Error::new(
387
+ ruby.exception_arg_error(),
388
+ format!("{what} is {len} characters; Excel allows 255"),
389
+ ));
390
+ }
391
+ Ok(())
392
+ }
393
+
394
+ // Rejects option keys outside `allowed`, so a typo raises instead of being ignored.
395
+ fn check_keys(ruby: &Ruby, options: RHash, allowed: &[&str], what: &str) -> Result<(), Error> {
396
+ options.foreach(|key: Symbol, _: Value| {
397
+ let name = key.name()?;
398
+ if allowed.contains(&&*name) {
399
+ Ok(ForEach::Continue)
400
+ } else {
401
+ Err(Error::new(
402
+ ruby.exception_arg_error(),
403
+ format!(
404
+ "unknown {what} option: {name} (expected one of {})",
405
+ allowed.join(", ")
406
+ ),
407
+ ))
408
+ }
409
+ })
410
+ }
411
+
412
+ const CHART_TYPES: &[(&str, ChartType)] = &[
413
+ ("area", ChartType::Area),
414
+ ("area_stacked", ChartType::AreaStacked),
415
+ ("bar", ChartType::Bar),
416
+ ("bar_stacked", ChartType::BarStacked),
417
+ ("column", ChartType::Column),
418
+ ("column_stacked", ChartType::ColumnStacked),
419
+ ("line", ChartType::Line),
420
+ ("line_stacked", ChartType::LineStacked),
421
+ ("pie", ChartType::Pie),
422
+ ("doughnut", ChartType::Doughnut),
423
+ ("radar", ChartType::Radar),
424
+ ("scatter", ChartType::Scatter),
425
+ ];
426
+
427
+ // :none, :light1..:light21, :medium1..:medium28, :dark1..:dark11.
428
+ const TABLE_STYLES: &[(&str, TableStyle)] = &[
429
+ ("none", TableStyle::None),
430
+ ("light1", TableStyle::Light1),
431
+ ("light2", TableStyle::Light2),
432
+ ("light3", TableStyle::Light3),
433
+ ("light4", TableStyle::Light4),
434
+ ("light5", TableStyle::Light5),
435
+ ("light6", TableStyle::Light6),
436
+ ("light7", TableStyle::Light7),
437
+ ("light8", TableStyle::Light8),
438
+ ("light9", TableStyle::Light9),
439
+ ("light10", TableStyle::Light10),
440
+ ("light11", TableStyle::Light11),
441
+ ("light12", TableStyle::Light12),
442
+ ("light13", TableStyle::Light13),
443
+ ("light14", TableStyle::Light14),
444
+ ("light15", TableStyle::Light15),
445
+ ("light16", TableStyle::Light16),
446
+ ("light17", TableStyle::Light17),
447
+ ("light18", TableStyle::Light18),
448
+ ("light19", TableStyle::Light19),
449
+ ("light20", TableStyle::Light20),
450
+ ("light21", TableStyle::Light21),
451
+ ("medium1", TableStyle::Medium1),
452
+ ("medium2", TableStyle::Medium2),
453
+ ("medium3", TableStyle::Medium3),
454
+ ("medium4", TableStyle::Medium4),
455
+ ("medium5", TableStyle::Medium5),
456
+ ("medium6", TableStyle::Medium6),
457
+ ("medium7", TableStyle::Medium7),
458
+ ("medium8", TableStyle::Medium8),
459
+ ("medium9", TableStyle::Medium9),
460
+ ("medium10", TableStyle::Medium10),
461
+ ("medium11", TableStyle::Medium11),
462
+ ("medium12", TableStyle::Medium12),
463
+ ("medium13", TableStyle::Medium13),
464
+ ("medium14", TableStyle::Medium14),
465
+ ("medium15", TableStyle::Medium15),
466
+ ("medium16", TableStyle::Medium16),
467
+ ("medium17", TableStyle::Medium17),
468
+ ("medium18", TableStyle::Medium18),
469
+ ("medium19", TableStyle::Medium19),
470
+ ("medium20", TableStyle::Medium20),
471
+ ("medium21", TableStyle::Medium21),
472
+ ("medium22", TableStyle::Medium22),
473
+ ("medium23", TableStyle::Medium23),
474
+ ("medium24", TableStyle::Medium24),
475
+ ("medium25", TableStyle::Medium25),
476
+ ("medium26", TableStyle::Medium26),
477
+ ("medium27", TableStyle::Medium27),
478
+ ("medium28", TableStyle::Medium28),
479
+ ("dark1", TableStyle::Dark1),
480
+ ("dark2", TableStyle::Dark2),
481
+ ("dark3", TableStyle::Dark3),
482
+ ("dark4", TableStyle::Dark4),
483
+ ("dark5", TableStyle::Dark5),
484
+ ("dark6", TableStyle::Dark6),
485
+ ("dark7", TableStyle::Dark7),
486
+ ("dark8", TableStyle::Dark8),
487
+ ("dark9", TableStyle::Dark9),
488
+ ("dark10", TableStyle::Dark10),
489
+ ("dark11", TableStyle::Dark11),
490
+ ];
491
+
492
+ const TABLE_TOTALS: &[(&str, TableFunction)] = &[
493
+ ("sum", TableFunction::Sum),
494
+ ("average", TableFunction::Average),
495
+ ("count", TableFunction::Count),
496
+ ("count_numbers", TableFunction::CountNumbers),
497
+ ("max", TableFunction::Max),
498
+ ("min", TableFunction::Min),
499
+ ("std_dev", TableFunction::StdDev),
500
+ ("var", TableFunction::Var),
501
+ ];
502
+
503
+ // A table column: a header String, or { header:, total:, total_label:, format: }.
504
+ fn table_column(ruby: &Ruby, v: Value) -> Result<TableColumn, Error> {
505
+ let Some(spec) = RHash::from_value(v) else {
506
+ return Ok(TableColumn::new().set_header(String::try_convert(v)?));
507
+ };
508
+ check_keys(
509
+ ruby,
510
+ spec,
511
+ &["header", "total", "total_label", "format"],
512
+ "table column",
513
+ )?;
514
+ let nil = ruby.qnil().as_value();
515
+ let get = |name: &str| spec.get(ruby.to_symbol(name)).unwrap_or(nil);
516
+ let mut column = TableColumn::new().set_header(String::try_convert(get("header"))?);
517
+ if !get("total").is_nil() {
518
+ column =
519
+ column.set_total_function(choice(ruby, "table total", get("total"), TABLE_TOTALS)?);
520
+ }
521
+ if !get("total_label").is_nil() {
522
+ column = column.set_total_label(String::try_convert(get("total_label"))?);
523
+ }
524
+ if let Some(format) = Option::<&Format>::try_convert(get("format"))? {
525
+ column = column.set_format(&format.0);
526
+ }
527
+ Ok(column)
528
+ }
529
+
530
+ const BORDERS: &[(&str, FormatBorder)] = &[
531
+ ("thin", FormatBorder::Thin),
532
+ ("medium", FormatBorder::Medium),
533
+ ("thick", FormatBorder::Thick),
534
+ ("dashed", FormatBorder::Dashed),
535
+ ("dotted", FormatBorder::Dotted),
536
+ ("double", FormatBorder::Double),
537
+ ("hair", FormatBorder::Hair),
538
+ ];
539
+
540
+ #[magnus::wrap(class = "FastXlsx::Format", free_immediately)]
541
+ struct Format(rust_xlsxwriter::Format);
542
+
543
+ impl Format {
544
+ fn new(ruby: &Ruby, options: RHash) -> Result<Self, Error> {
545
+ let mut f = rust_xlsxwriter::Format::new();
546
+ options.foreach(|key: Symbol, value: Value| {
547
+ let taken = std::mem::take(&mut f);
548
+ f = match &*key.name()? {
549
+ "bold" if value.to_bool() => taken.set_bold(),
550
+ "italic" if value.to_bool() => taken.set_italic(),
551
+ "underline" if value.is_kind_of(ruby.class_true_class()) => {
552
+ taken.set_underline(FormatUnderline::Single)
553
+ }
554
+ "underline" if value.to_bool() => taken.set_underline(choice(
555
+ ruby,
556
+ "underline",
557
+ value,
558
+ &[
559
+ ("single", FormatUnderline::Single),
560
+ ("double", FormatUnderline::Double),
561
+ ("single_accounting", FormatUnderline::SingleAccounting),
562
+ ("double_accounting", FormatUnderline::DoubleAccounting),
563
+ ],
564
+ )?),
565
+ "strikeout" if value.to_bool() => taken.set_font_strikethrough(),
566
+ "font_script" => taken.set_font_script(choice(
567
+ ruby,
568
+ "font_script",
569
+ value,
570
+ &[
571
+ ("superscript", FormatScript::Superscript),
572
+ ("subscript", FormatScript::Subscript),
573
+ ],
574
+ )?),
575
+ "rotation" => {
576
+ let degrees = i16::try_convert(value)?;
577
+ if !(-90..=90).contains(&degrees) && degrees != 270 {
578
+ return Err(Error::new(
579
+ ruby.exception_arg_error(),
580
+ format!("invalid rotation {degrees}: use -90..90 or 270"),
581
+ ));
582
+ }
583
+ taken.set_rotation(degrees)
584
+ }
585
+ "indent" => taken.set_indent(u8::try_convert(value)?),
586
+ "shrink" if value.to_bool() => taken.set_shrink(),
587
+ "border_color" => taken.set_border_color(color(ruby, value)?),
588
+ "num_format" => taken.set_num_format(String::try_convert(value)?),
589
+ "font_size" => taken.set_font_size(f64::try_convert(value)?),
590
+ "font_name" => taken.set_font_name(String::try_convert(value)?),
591
+ "font_color" => taken.set_font_color(color(ruby, value)?),
592
+ "bg_color" => taken.set_background_color(color(ruby, value)?),
593
+ "align" => taken.set_align(choice(
594
+ ruby,
595
+ "align",
596
+ value,
597
+ &[
598
+ ("left", FormatAlign::Left),
599
+ ("center", FormatAlign::Center),
600
+ ("right", FormatAlign::Right),
601
+ ],
602
+ )?),
603
+ "valign" => taken.set_align(choice(
604
+ ruby,
605
+ "valign",
606
+ value,
607
+ &[
608
+ ("top", FormatAlign::Top),
609
+ ("center", FormatAlign::VerticalCenter),
610
+ ("bottom", FormatAlign::Bottom),
611
+ ],
612
+ )?),
613
+ "text_wrap" if value.to_bool() => taken.set_text_wrap(),
614
+ "border" => taken.set_border(choice(ruby, "border", value, BORDERS)?),
615
+ "border_left" => taken.set_border_left(choice(ruby, "border", value, BORDERS)?),
616
+ "border_right" => taken.set_border_right(choice(ruby, "border", value, BORDERS)?),
617
+ "border_top" => taken.set_border_top(choice(ruby, "border", value, BORDERS)?),
618
+ "border_bottom" => taken.set_border_bottom(choice(ruby, "border", value, BORDERS)?),
619
+ "bold" | "italic" | "underline" | "text_wrap" | "strikeout" | "shrink" => taken,
620
+ other => {
621
+ return Err(Error::new(
622
+ ruby.exception_arg_error(),
623
+ format!("unknown format option: {other}"),
624
+ ))
625
+ }
626
+ };
627
+ Ok(ForEach::Continue)
628
+ })?;
629
+ Ok(Format(f))
630
+ }
631
+ }
632
+
633
+ // Visits each element by index. RArray::into_iter dups the array first, and
634
+ // dup'ing an Array longer than 3 elements turns the caller's array into a
635
+ // shared root: one extra live Ruby object per row for as long as the data lives.
636
+ // Reading the length each step also stays correct if Ruby code run by `f`
637
+ // (e.g. a to_s) changes the array.
638
+ fn each_entry(
639
+ ary: RArray,
640
+ mut f: impl FnMut(usize, Value) -> Result<(), Error>,
641
+ ) -> Result<(), Error> {
642
+ let mut i = 0;
643
+ while i < ary.len() {
644
+ f(i, ary.entry(i as isize)?)?;
645
+ i += 1;
646
+ }
647
+ Ok(())
648
+ }
649
+
650
+ // A row's format: one Format (or nil) for every cell, or an Array with one per cell.
651
+ enum RowFormat {
652
+ Same(Option<&'static Format>),
653
+ PerCell(RArray),
654
+ }
655
+
656
+ impl RowFormat {
657
+ fn from_value(v: Value) -> Result<Self, Error> {
658
+ match RArray::from_value(v) {
659
+ Some(formats) => Ok(RowFormat::PerCell(formats)),
660
+ None => Ok(RowFormat::Same(Option::<&Format>::try_convert(v)?)),
661
+ }
662
+ }
663
+
664
+ fn at(&self, col: usize) -> Result<Option<&Format>, Error> {
665
+ match self {
666
+ RowFormat::Same(f) => Ok(*f),
667
+ RowFormat::PerCell(formats) => {
668
+ Option::<&Format>::try_convert(formats.entry::<Value>(col as isize)?)
669
+ }
670
+ }
671
+ }
672
+ }
673
+
674
+ impl Worksheet {
675
+ fn advance(&self, row: u32) {
676
+ self.next_row.set(self.next_row.get().max(row + 1));
677
+ }
678
+
679
+ // rust_xlsxwriter silently drops writes to rows it has already flushed.
680
+ fn check_not_flushed(&self, ruby: &Ruby, row: u32) -> Result<(), Error> {
681
+ if self.flushes_rows && row + 1 < self.next_row.get() {
682
+ return Err(Error::new(
683
+ ruby.get_inner(&ERROR),
684
+ format!(
685
+ "row {row} was already written to disk (constant_memory / low_memory mode)"
686
+ ),
687
+ ));
688
+ }
689
+ Ok(())
690
+ }
691
+
692
+ fn with_ws<T>(
693
+ &self,
694
+ f: impl FnOnce(&mut rust_xlsxwriter::Worksheet) -> Result<T, Error>,
695
+ ) -> Result<T, Error> {
696
+ let mut wb = self.wb.lock().unwrap();
697
+ f(wb.worksheet_from_index(self.index).map_err(xerr)?)
698
+ }
699
+
700
+ fn write_row(
701
+ &self,
702
+ ruby: &Ruby,
703
+ row: u32,
704
+ cells: RArray,
705
+ format: &RowFormat,
706
+ ) -> Result<(), Error> {
707
+ self.with_ws(|ws| {
708
+ each_entry(cells, |i, v| {
709
+ let col = u16::try_from(i)
710
+ .map_err(|_| Error::new(ruby.exception_arg_error(), "too many columns"))?;
711
+ put(ruby, ws, row, col, v, format.at(i)?)
712
+ })
713
+ })?;
714
+ self.advance(row);
715
+ Ok(())
716
+ }
717
+
718
+ fn write(
719
+ ruby: &Ruby,
720
+ rb_self: &Self,
721
+ row: u32,
722
+ col: u16,
723
+ v: Value,
724
+ format: Option<&Format>,
725
+ ) -> Result<(), Error> {
726
+ rb_self.check_not_flushed(ruby, row)?;
727
+ rb_self.with_ws(|ws| put(ruby, ws, row, col, v, format))?;
728
+ rb_self.advance(row);
729
+ Ok(())
730
+ }
731
+
732
+ fn append(
733
+ ruby: &Ruby,
734
+ rb_self: Obj<Self>,
735
+ cells: RArray,
736
+ format: Value,
737
+ ) -> Result<Obj<Self>, Error> {
738
+ let format = RowFormat::from_value(format)?;
739
+ rb_self.write_row(ruby, rb_self.next_row.get(), cells, &format)?;
740
+ Ok(rb_self)
741
+ }
742
+
743
+ fn push(ruby: &Ruby, rb_self: Obj<Self>, cells: RArray) -> Result<Obj<Self>, Error> {
744
+ rb_self.write_row(ruby, rb_self.next_row.get(), cells, &RowFormat::Same(None))?;
745
+ Ok(rb_self)
746
+ }
747
+
748
+ fn concat(ruby: &Ruby, rb_self: Obj<Self>, rows: RArray) -> Result<Obj<Self>, Error> {
749
+ each_entry(rows, |_, r| {
750
+ let cells = RArray::try_convert(r)?;
751
+ rb_self.write_row(ruby, rb_self.next_row.get(), cells, &RowFormat::Same(None))
752
+ })?;
753
+ Ok(rb_self)
754
+ }
755
+
756
+ fn set_column_width(&self, first: u16, last: u16, width: f64) -> Result<(), Error> {
757
+ self.with_ws(|ws| {
758
+ ws.set_column_range_width(first, last, width)
759
+ .map(|_| ())
760
+ .map_err(xerr)
761
+ })
762
+ }
763
+
764
+ fn set_column_format(&self, first: u16, last: u16, format: &Format) -> Result<(), Error> {
765
+ self.with_ws(|ws| {
766
+ ws.set_column_range_format(first, last, &format.0)
767
+ .map(|_| ())
768
+ .map_err(xerr)
769
+ })
770
+ }
771
+
772
+ fn autofilter(
773
+ rb_self: Obj<Self>,
774
+ first_row: u32,
775
+ first_col: u16,
776
+ last_row: u32,
777
+ last_col: u16,
778
+ ) -> Result<Obj<Self>, Error> {
779
+ rb_self.with_ws(|ws| {
780
+ ws.autofilter(first_row, first_col, last_row, last_col)
781
+ .map(|_| ())
782
+ .map_err(xerr)
783
+ })?;
784
+ Ok(rb_self)
785
+ }
786
+
787
+ fn autofit(rb_self: Obj<Self>) -> Result<Obj<Self>, Error> {
788
+ rb_self.with_ws(|ws| {
789
+ ws.autofit();
790
+ Ok(())
791
+ })?;
792
+ Ok(rb_self)
793
+ }
794
+
795
+ fn freeze_panes(rb_self: Obj<Self>, row: u32, col: u16) -> Result<Obj<Self>, Error> {
796
+ rb_self.with_ws(|ws| ws.set_freeze_panes(row, col).map(|_| ()).map_err(xerr))?;
797
+ Ok(rb_self)
798
+ }
799
+
800
+ fn set_row_height(rb_self: Obj<Self>, row: u32, height: f64) -> Result<Obj<Self>, Error> {
801
+ rb_self.with_ws(|ws| ws.set_row_height(row, height).map(|_| ()).map_err(xerr))?;
802
+ Ok(rb_self)
803
+ }
804
+
805
+ fn set_header(ruby: &Ruby, rb_self: &Self, text: String) -> Result<(), Error> {
806
+ check_header_footer(ruby, "header", &text)?;
807
+ rb_self.with_ws(|ws| {
808
+ ws.set_header(text);
809
+ Ok(())
810
+ })
811
+ }
812
+
813
+ fn set_footer(ruby: &Ruby, rb_self: &Self, text: String) -> Result<(), Error> {
814
+ check_header_footer(ruby, "footer", &text)?;
815
+ rb_self.with_ws(|ws| {
816
+ ws.set_footer(text);
817
+ Ok(())
818
+ })
819
+ }
820
+
821
+ // Inches; a negative value keeps the current margin.
822
+ fn set_margins(
823
+ &self,
824
+ left: f64,
825
+ right: f64,
826
+ top: f64,
827
+ bottom: f64,
828
+ header: f64,
829
+ footer: f64,
830
+ ) -> Result<(), Error> {
831
+ self.with_ws(|ws| {
832
+ ws.set_margins(left, right, top, bottom, header, footer);
833
+ Ok(())
834
+ })
835
+ }
836
+
837
+ fn set_page_breaks(rb_self: Obj<Self>, rows: Vec<u32>) -> Result<Obj<Self>, Error> {
838
+ rb_self.with_ws(|ws| ws.set_page_breaks(&rows).map(|_| ()).map_err(xerr))?;
839
+ Ok(rb_self)
840
+ }
841
+
842
+ fn set_vertical_page_breaks(rb_self: Obj<Self>, cols: Vec<u32>) -> Result<Obj<Self>, Error> {
843
+ rb_self.with_ws(|ws| ws.set_vertical_page_breaks(&cols).map(|_| ()).map_err(xerr))?;
844
+ Ok(rb_self)
845
+ }
846
+
847
+ // rust_xlsxwriter only merges with a string, so merge with "" and then write
848
+ // the value into the first cell, which keeps its type.
849
+ #[allow(clippy::too_many_arguments)]
850
+ fn merge_range(
851
+ ruby: &Ruby,
852
+ rb_self: Obj<Self>,
853
+ first_row: u32,
854
+ first_col: u16,
855
+ last_row: u32,
856
+ last_col: u16,
857
+ v: Value,
858
+ format: Option<&Format>,
859
+ ) -> Result<Obj<Self>, Error> {
860
+ let default = rust_xlsxwriter::Format::new();
861
+ let merge_format = format.map_or(&default, |f| &f.0);
862
+ rb_self.with_ws(|ws| {
863
+ ws.merge_range(first_row, first_col, last_row, last_col, "", merge_format)
864
+ .map_err(xerr)?;
865
+ put(ruby, ws, first_row, first_col, v, format)
866
+ })?;
867
+ rb_self.advance(last_row);
868
+ Ok(rb_self)
869
+ }
870
+
871
+ #[allow(clippy::too_many_arguments)]
872
+ fn conditional_format(
873
+ ruby: &Ruby,
874
+ rb_self: Obj<Self>,
875
+ fr: u32,
876
+ fc: u16,
877
+ lr: u32,
878
+ lc: u16,
879
+ options: RHash,
880
+ ) -> 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
+ check_keys(
884
+ ruby,
885
+ options,
886
+ &["type", "criteria", "value", "format", "colors"],
887
+ "conditional_format",
888
+ )?;
889
+ let format = Option::<&Format>::try_convert(opt("format"))?;
890
+ let value = opt("value");
891
+ let kind = choice(
892
+ ruby,
893
+ "conditional format type",
894
+ opt("type"),
895
+ &[
896
+ ("cell", "cell"),
897
+ ("text", "text"),
898
+ ("formula", "formula"),
899
+ ("data_bar", "data_bar"),
900
+ ("color_scale", "color_scale"),
901
+ ],
902
+ )?;
903
+ rb_self.with_ws(|ws| {
904
+ let r = match kind {
905
+ "cell" => {
906
+ let mut cf = ConditionalFormatCell::new().set_rule(cell_rule(
907
+ ruby,
908
+ opt("criteria"),
909
+ value,
910
+ )?);
911
+ if let Some(f) = format {
912
+ cf = cf.set_format(&f.0);
913
+ }
914
+ ws.add_conditional_format(fr, fc, lr, lc, &cf)
915
+ }
916
+ "text" => {
917
+ let text = String::try_convert(value)?;
918
+ let rule = match choice(
919
+ ruby,
920
+ "text criteria",
921
+ opt("criteria"),
922
+ &[
923
+ ("contains", 0),
924
+ ("not_contains", 1),
925
+ ("begins_with", 2),
926
+ ("ends_with", 3),
927
+ ],
928
+ )? {
929
+ 0 => ConditionalFormatTextRule::Contains(text),
930
+ 1 => ConditionalFormatTextRule::DoesNotContain(text),
931
+ 2 => ConditionalFormatTextRule::BeginsWith(text),
932
+ _ => ConditionalFormatTextRule::EndsWith(text),
933
+ };
934
+ let mut cf = ConditionalFormatText::new().set_rule(rule);
935
+ if let Some(f) = format {
936
+ cf = cf.set_format(&f.0);
937
+ }
938
+ ws.add_conditional_format(fr, fc, lr, lc, &cf)
939
+ }
940
+ "formula" => {
941
+ let mut cf = ConditionalFormatFormula::new()
942
+ .set_rule(String::try_convert(value)?.as_str());
943
+ if let Some(f) = format {
944
+ cf = cf.set_format(&f.0);
945
+ }
946
+ ws.add_conditional_format(fr, fc, lr, lc, &cf)
947
+ }
948
+ "data_bar" => {
949
+ ws.add_conditional_format(fr, fc, lr, lc, &ConditionalFormatDataBar::new())
950
+ }
951
+ _ => {
952
+ let colors = if opt("colors").is_nil() {
953
+ 3
954
+ } else {
955
+ u8::try_convert(opt("colors"))?
956
+ };
957
+ match colors {
958
+ 2 => ws.add_conditional_format(
959
+ fr,
960
+ fc,
961
+ lr,
962
+ lc,
963
+ &ConditionalFormat2ColorScale::new(),
964
+ ),
965
+ 3 => ws.add_conditional_format(
966
+ fr,
967
+ fc,
968
+ lr,
969
+ lc,
970
+ &ConditionalFormat3ColorScale::new(),
971
+ ),
972
+ n => {
973
+ return Err(Error::new(
974
+ ruby.exception_arg_error(),
975
+ format!("invalid colors {n}: use 2 or 3"),
976
+ ))
977
+ }
978
+ }
979
+ }
980
+ };
981
+ r.map(|_| ()).map_err(xerr)
982
+ })?;
983
+ Ok(rb_self)
984
+ }
985
+
986
+ #[allow(clippy::too_many_arguments)]
987
+ fn data_validation(
988
+ ruby: &Ruby,
989
+ rb_self: Obj<Self>,
990
+ fr: u32,
991
+ fc: u16,
992
+ lr: u32,
993
+ lc: u16,
994
+ options: RHash,
995
+ ) -> 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
+ check_keys(
999
+ ruby,
1000
+ options,
1001
+ &[
1002
+ "type",
1003
+ "criteria",
1004
+ "value",
1005
+ "input_title",
1006
+ "input_message",
1007
+ "error_title",
1008
+ "error_message",
1009
+ ],
1010
+ "data_validation",
1011
+ )?;
1012
+ let (criteria, value) = (opt("criteria"), opt("value"));
1013
+ let dv = DataValidation::new();
1014
+ let mut dv = match choice(
1015
+ ruby,
1016
+ "data validation type",
1017
+ opt("type"),
1018
+ &[
1019
+ ("list", "list"),
1020
+ ("whole_number", "whole_number"),
1021
+ ("decimal", "decimal"),
1022
+ ("text_length", "text_length"),
1023
+ ],
1024
+ )? {
1025
+ "list" => match RArray::from_value(value) {
1026
+ Some(items) => dv
1027
+ .allow_list_strings(&items.to_vec::<String>()?)
1028
+ .map_err(xerr)?,
1029
+ None => dv
1030
+ .allow_list_formula(rust_xlsxwriter::Formula::new(String::try_convert(value)?)),
1031
+ },
1032
+ "whole_number" => dv.allow_whole_number(validation_rule(ruby, criteria, value)?),
1033
+ "decimal" => dv.allow_decimal_number(validation_rule(ruby, criteria, value)?),
1034
+ _ => dv.allow_text_length(validation_rule(ruby, criteria, value)?),
1035
+ };
1036
+ for (name, set) in [
1037
+ (
1038
+ "input_title",
1039
+ DataValidation::set_input_title as fn(_, String) -> _,
1040
+ ),
1041
+ ("input_message", DataValidation::set_input_message),
1042
+ ("error_title", DataValidation::set_error_title),
1043
+ ("error_message", DataValidation::set_error_message),
1044
+ ] {
1045
+ let text = opt(name);
1046
+ if !text.is_nil() {
1047
+ dv = set(dv, String::try_convert(text)?).map_err(xerr)?;
1048
+ }
1049
+ }
1050
+ rb_self.with_ws(|ws| {
1051
+ ws.add_data_validation(fr, fc, lr, lc, &dv)
1052
+ .map(|_| ())
1053
+ .map_err(xerr)
1054
+ })?;
1055
+ Ok(rb_self)
1056
+ }
1057
+
1058
+ fn write_comment(
1059
+ rb_self: Obj<Self>,
1060
+ row: u32,
1061
+ col: u16,
1062
+ text: String,
1063
+ author: Option<String>,
1064
+ ) -> Result<Obj<Self>, Error> {
1065
+ // Keep the text as written; rust_xlsxwriter would prefix "Author:\n".
1066
+ let mut note = Note::new(text).add_author_prefix(false);
1067
+ if let Some(author) = author {
1068
+ note = note.set_author(author);
1069
+ }
1070
+ rb_self.with_ws(|ws| ws.insert_note(row, col, &note).map(|_| ()).map_err(xerr))?;
1071
+ Ok(rb_self)
1072
+ }
1073
+
1074
+ fn insert_image(
1075
+ ruby: &Ruby,
1076
+ rb_self: Obj<Self>,
1077
+ row: u32,
1078
+ col: u16,
1079
+ bytes: RString,
1080
+ options: RHash,
1081
+ ) -> 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
+ check_keys(
1085
+ ruby,
1086
+ options,
1087
+ &[
1088
+ "scale", "width", "height", "x_offset", "y_offset", "alt_text",
1089
+ ],
1090
+ "insert_image",
1091
+ )?;
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
+ // SAFETY: the bytes are copied into the Image before any Ruby code runs.
1101
+ let mut image = Image::new_from_buffer(unsafe { bytes.as_slice() }).map_err(xerr)?;
1102
+ let pixels = |name: &str| -> Result<Option<f64>, Error> {
1103
+ let v = opt(name);
1104
+ if v.is_nil() {
1105
+ Ok(None)
1106
+ } else {
1107
+ f64::try_convert(v).map(Some)
1108
+ }
1109
+ };
1110
+ let (width, height) = (pixels("width")?, pixels("height")?);
1111
+ if !opt("scale").is_nil() {
1112
+ if width.is_some() || height.is_some() {
1113
+ return Err(Error::new(
1114
+ ruby.exception_arg_error(),
1115
+ "pass either scale: or width:/height:, not both",
1116
+ ));
1117
+ }
1118
+ let scale = f64::try_convert(opt("scale"))?;
1119
+ image = image.set_scale_width(scale).set_scale_height(scale);
1120
+ } else if width.is_some() || height.is_some() {
1121
+ // Displayed size at scale 1, as rust_xlsxwriter computes it from the DPI.
1122
+ let natural_w = image.width() * 96.0 / image.width_dpi();
1123
+ let natural_h = image.height() * 96.0 / image.height_dpi();
1124
+ let scale_w = width.map(|w| w / natural_w);
1125
+ let scale_h = height.map(|h| h / natural_h);
1126
+ // With only one dimension, scale the other by the same factor.
1127
+ let (sw, sh) = (scale_w.or(scale_h).unwrap(), scale_h.or(scale_w).unwrap());
1128
+ image = image.set_scale_width(sw).set_scale_height(sh);
1129
+ }
1130
+ if !opt("alt_text").is_nil() {
1131
+ image = image.set_alt_text(String::try_convert(opt("alt_text"))?);
1132
+ }
1133
+ let (x, y) = (offset("x_offset")?, offset("y_offset")?);
1134
+ rb_self.with_ws(|ws| {
1135
+ ws.insert_image_with_offset(row, col, &image, x, y)
1136
+ .map(|_| ())
1137
+ .map_err(xerr)
1138
+ })?;
1139
+ Ok(rb_self)
1140
+ }
1141
+
1142
+ fn insert_chart(
1143
+ ruby: &Ruby,
1144
+ rb_self: Obj<Self>,
1145
+ row: u32,
1146
+ col: u16,
1147
+ options: RHash,
1148
+ ) -> Result<Obj<Self>, Error> {
1149
+ let nil = ruby.qnil().as_value();
1150
+ 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
+ check_keys(
1160
+ ruby,
1161
+ options,
1162
+ &[
1163
+ "type", "series", "title", "x_axis", "y_axis", "width", "height",
1164
+ ],
1165
+ "insert_chart",
1166
+ )?;
1167
+
1168
+ let mut chart = Chart::new(choice(ruby, "chart type", opt("type"), CHART_TYPES)?);
1169
+ let series = RArray::try_convert(opt("series"))?;
1170
+ if series.is_empty() {
1171
+ return Err(arg_error(
1172
+ "series: needs at least one { values:, categories:, name: } hash".into(),
1173
+ ));
1174
+ }
1175
+ for s in series.into_iter() {
1176
+ let s = RHash::try_convert(s)?;
1177
+ check_keys(ruby, s, &["values", "categories", "name"], "chart series")?;
1178
+ let get = |name: &str| s.get(ruby.to_symbol(name)).unwrap_or(nil);
1179
+ let values = text(get("values"))?
1180
+ .ok_or_else(|| arg_error(format!("series {} needs values:", s.inspect())))?;
1181
+ let cs = chart.add_series().set_values(values.as_str());
1182
+ if let Some(categories) = text(get("categories"))? {
1183
+ cs.set_categories(categories.as_str());
1184
+ }
1185
+ if let Some(name) = text(get("name"))? {
1186
+ cs.set_name(name.as_str());
1187
+ }
1188
+ }
1189
+ if let Some(title) = text(opt("title"))? {
1190
+ chart.title().set_name(title.as_str());
1191
+ }
1192
+ if let Some(name) = text(opt("x_axis"))? {
1193
+ chart.x_axis().set_name(name.as_str());
1194
+ }
1195
+ if let Some(name) = text(opt("y_axis"))? {
1196
+ chart.y_axis().set_name(name.as_str());
1197
+ }
1198
+ if !opt("width").is_nil() {
1199
+ chart.set_width(u32::try_convert(opt("width"))?);
1200
+ }
1201
+ if !opt("height").is_nil() {
1202
+ chart.set_height(u32::try_convert(opt("height"))?);
1203
+ }
1204
+ rb_self.with_ws(|ws| ws.insert_chart(row, col, &chart).map(|_| ()).map_err(xerr))?;
1205
+ Ok(rb_self)
1206
+ }
1207
+
1208
+ #[allow(clippy::too_many_arguments)]
1209
+ fn add_table(
1210
+ ruby: &Ruby,
1211
+ rb_self: Obj<Self>,
1212
+ fr: u32,
1213
+ fc: u16,
1214
+ lr: u32,
1215
+ lc: u16,
1216
+ options: RHash,
1217
+ ) -> Result<Obj<Self>, Error> {
1218
+ check_keys(
1219
+ ruby,
1220
+ options,
1221
+ &[
1222
+ "columns",
1223
+ "style",
1224
+ "name",
1225
+ "total_row",
1226
+ "banded_rows",
1227
+ "autofilter",
1228
+ ],
1229
+ "add_table",
1230
+ )?;
1231
+ let nil = ruby.qnil().as_value();
1232
+ let opt = |name: &str| options.get(ruby.to_symbol(name)).unwrap_or(nil);
1233
+ let flag = |name: &str, default: bool| {
1234
+ let v = opt(name);
1235
+ if v.is_nil() {
1236
+ default
1237
+ } else {
1238
+ v.to_bool()
1239
+ }
1240
+ };
1241
+ // The table writes its header row, which must not be flushed yet.
1242
+ rb_self.check_not_flushed(ruby, fr)?;
1243
+ let mut table = Table::new()
1244
+ .set_total_row(flag("total_row", false))
1245
+ .set_banded_rows(flag("banded_rows", true))
1246
+ .set_autofilter(flag("autofilter", true));
1247
+ if !opt("columns").is_nil() {
1248
+ let specs = RArray::try_convert(opt("columns"))?;
1249
+ let width = usize::from(lc.saturating_sub(fc)) + 1;
1250
+ if specs.len() != width {
1251
+ return Err(Error::new(
1252
+ ruby.exception_arg_error(),
1253
+ format!(
1254
+ "columns: has {} entries but the range is {width} columns wide",
1255
+ specs.len()
1256
+ ),
1257
+ ));
1258
+ }
1259
+ let columns = specs
1260
+ .into_iter()
1261
+ .map(|v| table_column(ruby, v))
1262
+ .collect::<Result<Vec<_>, _>>()?;
1263
+ table = table.set_columns(&columns);
1264
+ }
1265
+ if !opt("style").is_nil() {
1266
+ table = table.set_style(choice(ruby, "table style", opt("style"), TABLE_STYLES)?);
1267
+ }
1268
+ if !opt("name").is_nil() {
1269
+ table = table.set_name(String::try_convert(opt("name"))?);
1270
+ }
1271
+ rb_self.with_ws(|ws| {
1272
+ ws.add_table(fr, fc, lr, lc, &table)
1273
+ .map(|_| ())
1274
+ .map_err(xerr)
1275
+ })?;
1276
+ // Continue appending under the header, so `<<` fills the table.
1277
+ rb_self.advance(fr);
1278
+ Ok(rb_self)
1279
+ }
1280
+
1281
+ fn name(&self) -> Result<String, Error> {
1282
+ self.with_ws(|ws| Ok(ws.name()))
1283
+ }
1284
+
1285
+ fn next_row(&self) -> u32 {
1286
+ self.next_row.get()
1287
+ }
1288
+ }
1289
+
1290
+ #[magnus::init]
1291
+ fn init(ruby: &Ruby) -> Result<(), Error> {
1292
+ let module = ruby.define_module("FastXlsx")?;
1293
+
1294
+ let wb = module.define_class("Workbook", ruby.class_object())?;
1295
+ wb.define_singleton_method("_new", function!(Workbook::new, 2))?;
1296
+ wb.define_method("_add_worksheet", method!(Workbook::add_worksheet, 1))?;
1297
+ wb.define_method("to_xlsx", method!(Workbook::to_xlsx, 0))?;
1298
+ wb.define_method("save", method!(Workbook::save, 1))?;
1299
+ wb.define_method("_properties", method!(Workbook::set_properties, 1))?;
1300
+
1301
+ let ws = module.define_class("Worksheet", ruby.class_object())?;
1302
+ ws.define_method("_write", method!(Worksheet::write, 4))?;
1303
+ ws.define_method("_append", method!(Worksheet::append, 2))?;
1304
+ 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
+ )?;
1309
+ ws.define_method("_autofit", method!(Worksheet::autofit, 0))?;
1310
+ ws.define_method("autofilter", method!(Worksheet::autofilter, 4))?;
1311
+ ws.define_method("name", method!(Worksheet::name, 0))?;
1312
+ ws.define_method("_write_comment", method!(Worksheet::write_comment, 4))?;
1313
+ ws.define_method("_insert_image", method!(Worksheet::insert_image, 4))?;
1314
+ ws.define_method("_insert_chart", method!(Worksheet::insert_chart, 3))?;
1315
+ ws.define_method("_add_table", method!(Worksheet::add_table, 5))?;
1316
+ ws.define_method(
1317
+ "_conditional_format",
1318
+ method!(Worksheet::conditional_format, 5),
1319
+ )?;
1320
+ ws.define_method("_data_validation", method!(Worksheet::data_validation, 5))?;
1321
+ ws.define_method("freeze_panes", method!(Worksheet::freeze_panes, 2))?;
1322
+ ws.define_method("row_height", method!(Worksheet::set_row_height, 2))?;
1323
+ ws.define_method("page_breaks", method!(Worksheet::set_page_breaks, 1))?;
1324
+ ws.define_method("_page_header", method!(Worksheet::set_header, 1))?;
1325
+ ws.define_method("_page_footer", method!(Worksheet::set_footer, 1))?;
1326
+ ws.define_method("_margins", method!(Worksheet::set_margins, 6))?;
1327
+ ws.define_method(
1328
+ "vertical_page_breaks",
1329
+ method!(Worksheet::set_vertical_page_breaks, 1),
1330
+ )?;
1331
+ ws.define_method("_merge_range", method!(Worksheet::merge_range, 6))?;
1332
+
1333
+ let format = module.define_class("Format", ruby.class_object())?;
1334
+ format.define_singleton_method("_new", function!(Format::new, 1))?;
1335
+ ws.define_method("<<", method!(Worksheet::push, 1))?;
1336
+ ws.define_method("concat", method!(Worksheet::concat, 1))?;
1337
+ ws.define_method("next_row", method!(Worksheet::next_row, 0))?;
1338
+ Ok(())
1339
+ }