fast_xlsx 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14314a7af34dfb1bfd00bac2f8fde41c4fa03447020dfef48571055bdac3151d
4
- data.tar.gz: 915ca1cd79de28ca34023af4d884eb9c6728bd86abd29aa19bd62ca08e3914fe
3
+ metadata.gz: 8326db5fb0c181c2c47af1f6b0d5799aa0bb69ff2d3b63d5286dbd1cd32fa2b8
4
+ data.tar.gz: 560335ade1b435b9084cb3d48e4e4b20d906140c4d2a6e243751d93d1eb01964
5
5
  SHA512:
6
- metadata.gz: 346ed04a149c9f6201fdb84af8b5b1a5a1008a17ac1df8faaa26af8d500dc2e13ecd8eb55705638a99a27501d7ca757f08a50825c7faac4a4a2593fb556a94e5
7
- data.tar.gz: 712d24c13ab60499f91795cdbdd4d3c251e41c6b30b60dc8c9e3d25aaa671da98710d128dd673c859ad2ec336c817d69da6a46ea5e9f7a8015025444db59bcef
6
+ metadata.gz: 360e7a3686eb4705527139fc7c50834046aa4d45d7e3e79e6f913475cb039de2e64119600a8c9a0e1147136be98a6776f8bbe8db267b67533e99c390fdaced4e
7
+ data.tar.gz: c23307efb6c2c0ceb1faef4336be02512f98cda7bff60b01324e614d82374247187ee1225a04c04a1cf9208d8b4abce3852a943f166f57f53801c743c4df1af3
data/.yardopts ADDED
@@ -0,0 +1,7 @@
1
+ --markup markdown
2
+ --no-private
3
+ --hide-api private
4
+ lib/**/*.rb
5
+ -
6
+ README.md
7
+ CHANGELOG.md
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-10-01
4
+
5
+ ### Added
6
+
7
+ - API documentation: every public method has YARD docs (shown on rubydoc.info).
8
+
9
+ ### Changed
10
+
11
+ - Errors follow one rule (see the README): a row or column outside the sheet raises `RangeError` (row 1,048,576, column 16,384 or a row of more than 16,384 cells raised `FastXlsx::Error` or `ArgumentError`); a reversed range or single-cell merge raises `ArgumentError` (was `FastXlsx::Error`); a header or footer over 255 characters raises `FastXlsx::Error` like other text over Excel's limits (was `ArgumentError`); a negative or NaN image size, font size or margin raises `ArgumentError` (was written into the file or ignored); `zoom` converts numbers with `to_int` like other arguments (`zoom(150.9)` raised `TypeError`); `column_width` outside 0..255 and `row_height` outside 0..409 raise `ArgumentError` (a negative one hid the column or row).
12
+ - Date and time cells written with a format that has no `num_format` (e.g. `append(row, format: bold)`) get the default date format added, instead of showing as serial numbers. This applies to a cell's own format, a table column's and `column_format`'s; a format with a `num_format` is used as is.
13
+
14
+ ## [0.4.0] - 2026-10-01
15
+
16
+ ### Changed
17
+
18
+ - Dates and times written without a format now get `yyyy-mm-dd` (`Date`) or `yyyy-mm-dd hh:mm:ss` (`Time`, `DateTime`) instead of showing as serial numbers. A cell's own format, its table column's and `column_format` still win. Applying the format makes writing date-heavy data about 15% slower, the same as passing a date format yourself.
19
+
3
20
  ## [0.3.0] - 2026-10-01
4
21
 
5
22
  ### Changed
data/README.md CHANGED
@@ -82,6 +82,8 @@ ws.write(1, 2, Date.today, date) # format one cell
82
82
 
83
83
  Per-side borders override `border`. Unknown options and invalid values raise `ArgumentError`.
84
84
 
85
+ Dates and times are shown as `yyyy-mm-dd` or `yyyy-mm-dd hh:mm:ss`. A format without a `num_format` (yours, a table column's or `column_format`'s) keeps its look and gets that date format added, so `append(row, format: bold)` gives bold dates; a format with a `num_format` is used as is.
86
+
85
87
  ### Columns and filters
86
88
 
87
89
  ```ruby
@@ -250,8 +252,8 @@ Values are mapped by type:
250
252
  |---|---|
251
253
  | `Integer`, `Float`, any `Numeric` | number |
252
254
  | `String` | string |
253
- | `Time` | number (Excel serial date, local time) |
254
- | `Date`, `DateTime` | number (Excel serial date, own offset) |
255
+ | `Time`, `DateTime` | date and time (`yyyy-mm-dd hh:mm:ss` unless formatted), in its own offset |
256
+ | `Date` | date (`yyyy-mm-dd` unless formatted) |
255
257
  | `FastXlsx::Formula.new("SUM(A1:A9)")` | formula |
256
258
  | `FastXlsx::URL.new("https://…")`, `URL.new(url, text: "Title")` | hyperlink (optionally showing other text) |
257
259
  | `FastXlsx::RichString.new(["Total: ", bold], "1,234")` | text with a format per segment |
@@ -261,7 +263,16 @@ Values are mapped by type:
261
263
 
262
264
  `<<` and `concat` append after the last row written to that worksheet. In `:constant` / `:low` memory mode rows are written to disk as you go, so fill each worksheet top to bottom.
263
265
 
264
- Errors from the writer (invalid sheet names, writes to rows already on disk, …) raise `FastXlsx::Error`.
266
+ ### Errors
267
+
268
+ | Error | Raised for |
269
+ |---|---|
270
+ | `TypeError` | an argument of the wrong type, e.g. a String where a row number goes |
271
+ | `RangeError` | a row or column outside the sheet (1,048,576 × 16,384), or a negative count (an infinite Float raises `FloatDomainError`, a `RangeError`) |
272
+ | `ArgumentError` | the right type but a value that isn't allowed: an unknown option or symbol, an invalid color or cell reference, a reversed range or single-cell merge, a size or margin that is negative, NaN or beyond Excel's limits |
273
+ | `FastXlsx::Error` | what the workbook can't do: duplicate or invalid names, writing to rows already on disk, overlapping merges, hiding the sheet Excel opens on, text over Excel's limits (cells, URLs, headers and footers), unsupported images |
274
+
275
+ Numbers are converted like Ruby's own methods do (`to_int`), so `1.9` as a row is row `1`.
265
276
 
266
277
  ## Performance
267
278
 
@@ -273,17 +284,17 @@ Apple Silicon, Ruby 4.0.5. Each library uses its own idiomatic row-append API; x
273
284
 
274
285
  | Library | Time | vs fastest | Ruby objects allocated |
275
286
  |---|---:|---:|---:|
276
- | **fast_xlsx** (`memory: :constant`) | **69 ms** | 1.0x | 7 |
277
- | **fast_xlsx** (`memory: :low`) | **78 ms** | 1.1x | 7 |
278
- | **fast_xlsx** | **80 ms** | 1.2x | 10 |
279
- | [xlsxtream](https://github.com/felixbuenemann/xlsxtream) 3.1 | 171 ms | 2.5x | 561,728 |
280
- | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 (constant_memory) | 194 ms | 2.8x | 20,079 |
281
- | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 | 228 ms | 3.3x | 320,076 |
282
- | [write_xlsx](https://github.com/cxn03651/write_xlsx) 1.15 | 583 ms | 8.4x | 1,483,899 |
283
- | [caxlsx](https://github.com/caxlsx/caxlsx) 4.5 | 691 ms | 10.0x | 745,122 |
284
- | [rubyXL](https://github.com/weshatheleopard/rubyXL) 3.4 | 2650 ms | 38.3x | 8,700,448 |
285
-
286
- All outputs are 681–750 KB.
287
+ | **fast_xlsx** (`memory: :constant`) | **77 ms** | 1.0x | 7 |
288
+ | **fast_xlsx** (`memory: :low`) | **86 ms** | 1.1x | 7 |
289
+ | **fast_xlsx** | **89 ms** | 1.1x | 10 |
290
+ | [xlsxtream](https://github.com/felixbuenemann/xlsxtream) 3.1 | 183 ms | 2.4x | 561,728 |
291
+ | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 (constant_memory) | 200 ms | 2.6x | 20,079 |
292
+ | [fast_excel](https://github.com/Paxa/fast_excel) 0.5 | 238 ms | 3.1x | 320,076 |
293
+ | [write_xlsx](https://github.com/cxn03651/write_xlsx) 1.15 | 608 ms | 7.8x | 1,483,899 |
294
+ | [caxlsx](https://github.com/caxlsx/caxlsx) 4.5 | 689 ms | 8.9x | 745,122 |
295
+ | [rubyXL](https://github.com/weshatheleopard/rubyXL) 3.4 | 2755 ms | 35.6x | 8,700,448 |
296
+
297
+ All outputs are 689–750 KB.
287
298
 
288
299
  ### Memory
289
300
 
@@ -321,7 +332,12 @@ BUNDLE_GEMFILE=bench/Gemfile /usr/bin/time -l bundle exec ruby bench/memory.rb f
321
332
  bundle add fast_xlsx
322
333
  ```
323
334
 
324
- Precompiled gems are built for common platforms; other platforms need a Rust toolchain to install. Requires CRuby 3.3+; JRuby and TruffleRuby are not supported because this is a native extension.
335
+ ### Support and versioning
336
+
337
+ - **Ruby:** CRuby 3.3 and later. A Ruby version is dropped only after it reaches its end of life, and only in a minor release. JRuby and TruffleRuby are not supported (this is a native extension).
338
+ - **Precompiled gems:** Linux (x86_64 and aarch64, glibc and musl; ARM musl), macOS (arm64 and x86_64) and Windows (x64). Before each release the Linux x86_64, macOS arm64 and Windows gems are installed and loaded on Ruby 3.3, 3.4 and 4.0. Other platforms build from source and need a Rust toolchain.
339
+ - **Versions:** from 1.0, [Semantic Versioning](https://semver.org): no breaking API change before 2.0. Until then a minor release (0.x.0) can change behaviour; the [changelog](CHANGELOG.md) lists every change under "Changed".
340
+ - **Output:** `.xlsx` files that open in Excel 2007 and later, LibreOffice and Google Sheets. Updating rust_xlsxwriter can change the bytes of the file but not what it contains.
325
341
 
326
342
  ## Development
327
343
 
data/bench/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- fast_xlsx (0.3.0)
4
+ fast_xlsx (0.5.0)
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.3.0)
80
+ fast_xlsx (0.5.0)
81
81
  ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
82
82
  ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
83
83
  ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
@@ -1,5 +1,6 @@
1
1
  use std::cell::{Cell, RefCell};
2
2
  use std::sync::atomic::{AtomicUsize, Ordering};
3
+ use std::sync::LazyLock;
3
4
  use std::sync::{Arc, Mutex};
4
5
 
5
6
  use magnus::{
@@ -33,9 +34,43 @@ static FORMULA: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "Formula")
33
34
  static URL: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "URL"));
34
35
  static RICH_STRING: Lazy<RClass> = Lazy::new(|ruby| fast_xlsx_const(ruby, "RichString"));
35
36
 
37
+ // A row or column outside the sheet is a RangeError, like a negative one
38
+ // (which fails converting to an unsigned number before reaching the writer).
36
39
  fn xerr(e: XlsxError) -> Error {
37
40
  let ruby = Ruby::get().unwrap();
38
- Error::new(ruby.get_inner(&ERROR), e.to_string())
41
+ let class = match e {
42
+ XlsxError::RowColumnLimitError => ruby.exception_range_error(),
43
+ // Values that aren't allowed, like a reversed range.
44
+ XlsxError::RowColumnOrderError | XlsxError::MergeRangeSingleCell => {
45
+ ruby.exception_arg_error()
46
+ }
47
+ _ => ruby.get_inner(&ERROR),
48
+ };
49
+ Error::new(class, e.to_string())
50
+ }
51
+
52
+ // Excel's limits that rust_xlsxwriter doesn't check: a negative width or
53
+ // height hides the column or row, a larger one is capped or invalid.
54
+ // A size that must be a finite number above 0 (NaN or a negative one would
55
+ // be written into the file as is).
56
+ fn check_positive(ruby: &Ruby, what: &str, size: f64) -> Result<f64, Error> {
57
+ if size.is_finite() && size > 0.0 {
58
+ return Ok(size);
59
+ }
60
+ Err(Error::new(
61
+ ruby.exception_arg_error(),
62
+ format!("invalid {what} {size}: use a number above 0"),
63
+ ))
64
+ }
65
+
66
+ fn check_size(ruby: &Ruby, what: &str, size: f64, max: f64) -> Result<(), Error> {
67
+ if (0.0..=max).contains(&size) {
68
+ return Ok(());
69
+ }
70
+ Err(Error::new(
71
+ ruby.exception_arg_error(),
72
+ format!("invalid {what} {size}: use 0..{max}"),
73
+ ))
39
74
  }
40
75
 
41
76
  type Shared = Arc<Mutex<rust_xlsxwriter::Workbook>>;
@@ -112,12 +147,14 @@ struct Worksheet {
112
147
  table_formats: RefCell<Vec<TableColumnFormat>>,
113
148
  // Merged ranges as (first_row, first_col, last_row, last_col).
114
149
  merges: RefCell<Vec<(u32, u16, u32, u16)>>,
150
+ // Formats given with column_format: (first, last, style).
151
+ formatted_columns: RefCell<Vec<(u16, u16, Arc<Style>)>>,
115
152
  }
116
153
 
117
154
  struct TableColumnFormat {
118
155
  rows: std::ops::RangeInclusive<u32>,
119
156
  col: u16,
120
- format: Arc<rust_xlsxwriter::Format>,
157
+ format: Arc<Style>,
121
158
  }
122
159
 
123
160
  impl Workbook {
@@ -179,6 +216,7 @@ impl Workbook {
179
216
  flushes_rows: self.constant_memory || self.low_memory,
180
217
  table_formats: RefCell::new(Vec::new()),
181
218
  merges: RefCell::new(Vec::new()),
219
+ formatted_columns: RefCell::new(Vec::new()),
182
220
  })
183
221
  }
184
222
 
@@ -297,11 +335,13 @@ enum CellValue {
297
335
  Empty,
298
336
  Text(String),
299
337
  Number(f64),
338
+ // An Excel serial date; true when it has a time of day (Time, DateTime).
339
+ Date(f64, bool),
300
340
  Bool(bool),
301
341
  Formula(String),
302
342
  Url(String, Option<String>),
303
343
  // RichString segments; None means the default font.
304
- Rich(Vec<(Option<Arc<rust_xlsxwriter::Format>>, String)>),
344
+ Rich(Vec<(Option<Arc<Style>>, String)>),
305
345
  }
306
346
 
307
347
  impl CellValue {
@@ -315,7 +355,7 @@ impl CellValue {
315
355
  } else if v.is_kind_of(ruby.class_numeric()) {
316
356
  CellValue::Number(f64::try_convert(v)?)
317
357
  } else if v.is_kind_of(ruby.class_time()) {
318
- CellValue::Number(excel_time(v)?)
358
+ CellValue::Date(excel_time(v)?, true)
319
359
  } else if v.is_kind_of(ruby.class_true_class()) || v.is_kind_of(ruby.class_false_class()) {
320
360
  CellValue::Bool(v.to_bool())
321
361
  } else if v.is_kind_of(ruby.get_inner(&FORMULA)) {
@@ -333,7 +373,8 @@ impl CellValue {
333
373
  })?;
334
374
  CellValue::Rich(parts)
335
375
  } else if v.respond_to("jd", false)? {
336
- CellValue::Number(excel_date(v)?)
376
+ // Date has no #hour; DateTime does.
377
+ CellValue::Date(excel_date(v)?, v.respond_to("hour", false)?)
337
378
  } else {
338
379
  CellValue::Text(v.funcall("to_s", ())?)
339
380
  };
@@ -341,6 +382,14 @@ impl CellValue {
341
382
  Ok(value)
342
383
  }
343
384
 
385
+ // Some(has a time of day) for date cells.
386
+ fn date_kind(&self) -> Option<bool> {
387
+ match self {
388
+ CellValue::Date(_, time) => Some(*time),
389
+ _ => None,
390
+ }
391
+ }
392
+
344
393
  // Rejects what the writer would, so a row or merge fails before anything
345
394
  // is written. Numbers, booleans and formulas only fail on a bad row/column.
346
395
  fn check(&self) -> Result<(), Error> {
@@ -371,7 +420,7 @@ impl CellValue {
371
420
  match self {
372
421
  CellValue::Empty => Ok(()),
373
422
  CellValue::Text(s) => emit(ws, row, col, s.as_str(), format),
374
- CellValue::Number(n) => emit(ws, row, col, *n, format),
423
+ CellValue::Number(n) | CellValue::Date(n, _) => emit(ws, row, col, *n, format),
375
424
  CellValue::Bool(b) => emit(ws, row, col, *b, format),
376
425
  CellValue::Formula(f) => emit(
377
426
  ws,
@@ -391,7 +440,7 @@ impl CellValue {
391
440
  let default = rust_xlsxwriter::Format::default();
392
441
  let rich: Vec<(&rust_xlsxwriter::Format, &str)> = parts
393
442
  .iter()
394
- .map(|(f, text)| (f.as_deref().unwrap_or(&default), text.as_str()))
443
+ .map(|(f, text)| (f.as_deref().map_or(&default, |s| &s.format), text.as_str()))
395
444
  .collect();
396
445
  match format {
397
446
  Some(f) => ws.write_rich_string_with_format(row, col, &rich, f),
@@ -562,7 +611,7 @@ fn check_header_footer(ruby: &Ruby, what: &str, text: &str) -> Result<(), Error>
562
611
  let len = expanded.chars().count();
563
612
  if len > 255 {
564
613
  return Err(Error::new(
565
- ruby.exception_arg_error(),
614
+ ruby.get_inner(&ERROR),
566
615
  format!("{what} is {len} characters; Excel allows 255"),
567
616
  ));
568
617
  }
@@ -693,10 +742,7 @@ const TABLE_TOTALS: &[(&str, TableFunction)] = &[
693
742
 
694
743
  // A table column: a header String, or { header:, total:, total_label:, format: }.
695
744
  // Also returns a copy of the column's format, if any, for later writes.
696
- fn table_column(
697
- ruby: &Ruby,
698
- v: Value,
699
- ) -> Result<(TableColumn, Option<Arc<rust_xlsxwriter::Format>>), Error> {
745
+ fn table_column(ruby: &Ruby, v: Value) -> Result<(TableColumn, Option<Arc<Style>>), Error> {
700
746
  let Some(spec) = RHash::from_value(v) else {
701
747
  return Ok((TableColumn::new().set_header(String::try_convert(v)?), None));
702
748
  };
@@ -716,7 +762,7 @@ fn table_column(
716
762
  }
717
763
  let format = opt::<&Format>(ruby, spec, "format")?.map(|f| f.0.clone());
718
764
  if let Some(f) = &format {
719
- column = column.set_format(&**f);
765
+ column = column.set_format(&f.format);
720
766
  }
721
767
  Ok((column, format))
722
768
  }
@@ -761,11 +807,52 @@ const BORDERS: &[(&str, FormatBorder)] = &[
761
807
  ];
762
808
 
763
809
  #[magnus::wrap(class = "FastXlsx::Format", free_immediately)]
764
- struct Format(Arc<rust_xlsxwriter::Format>);
810
+ struct Format(Arc<Style>);
811
+
812
+ // A Format as written: the format itself, and for one without a num_format,
813
+ // copies with the default date formats added, used for date cells (Excel
814
+ // shows a date with no number format as its serial number).
815
+ struct Style {
816
+ format: rust_xlsxwriter::Format,
817
+ date: Option<rust_xlsxwriter::Format>,
818
+ date_time: Option<rust_xlsxwriter::Format>,
819
+ }
820
+
821
+ const DATE_FORMAT: &str = "yyyy-mm-dd";
822
+ const DATE_TIME_FORMAT: &str = "yyyy-mm-dd hh:mm:ss";
823
+
824
+ impl Style {
825
+ fn new(format: rust_xlsxwriter::Format, has_num_format: bool) -> Self {
826
+ let with =
827
+ |num_format: &str| (!has_num_format).then(|| format.clone().set_num_format(num_format));
828
+ Style {
829
+ date: with(DATE_FORMAT),
830
+ date_time: with(DATE_TIME_FORMAT),
831
+ format,
832
+ }
833
+ }
834
+
835
+ // The date variant for a date cell (`time`: it has a time of day), if
836
+ // this format has no num_format of its own.
837
+ fn for_date(&self, time: bool) -> Option<&rust_xlsxwriter::Format> {
838
+ if time {
839
+ self.date_time.as_ref()
840
+ } else {
841
+ self.date.as_ref()
842
+ }
843
+ }
844
+
845
+ // What a value is written with: `date` is Some(has time) for date cells.
846
+ fn pick(&self, date: Option<bool>) -> &rust_xlsxwriter::Format {
847
+ date.and_then(|time| self.for_date(time))
848
+ .unwrap_or(&self.format)
849
+ }
850
+ }
765
851
 
766
852
  impl Format {
767
853
  fn new(ruby: &Ruby, options: RHash) -> Result<Self, Error> {
768
854
  let mut f = rust_xlsxwriter::Format::new();
855
+ let mut has_num_format = false;
769
856
  options.foreach(|key: Symbol, value: Value| {
770
857
  let taken = std::mem::take(&mut f);
771
858
  f = match &*key.name()? {
@@ -813,8 +900,15 @@ impl Format {
813
900
  "locked" => taken.set_unlocked(),
814
901
  "hidden" if value.to_bool() => taken.set_hidden(),
815
902
  "border_color" => taken.set_border_color(color(ruby, value)?),
816
- "num_format" => taken.set_num_format(String::try_convert(value)?),
817
- "font_size" => taken.set_font_size(f64::try_convert(value)?),
903
+ "num_format" => {
904
+ has_num_format = true;
905
+ taken.set_num_format(String::try_convert(value)?)
906
+ }
907
+ "font_size" => taken.set_font_size(check_positive(
908
+ ruby,
909
+ "font_size",
910
+ f64::try_convert(value)?,
911
+ )?),
818
912
  "font_name" => taken.set_font_name(String::try_convert(value)?),
819
913
  "font_color" => taken.set_font_color(color(ruby, value)?),
820
914
  "bg_color" => taken.set_background_color(color(ruby, value)?),
@@ -855,7 +949,7 @@ impl Format {
855
949
  };
856
950
  Ok(ForEach::Continue)
857
951
  })?;
858
- Ok(Format(Arc::new(f)))
952
+ Ok(Format(Arc::new(Style::new(f, has_num_format))))
859
953
  }
860
954
  }
861
955
 
@@ -878,7 +972,7 @@ fn each_entry(
878
972
 
879
973
  // A row's format: one Format (or nil) for every cell, or an Array with one per cell.
880
974
  enum RowFormat {
881
- Same(Option<Arc<rust_xlsxwriter::Format>>),
975
+ Same(Option<Arc<Style>>),
882
976
  PerCell(RArray),
883
977
  }
884
978
 
@@ -892,7 +986,7 @@ impl RowFormat {
892
986
  }
893
987
  }
894
988
 
895
- fn at(&self, col: usize) -> Result<Option<Arc<rust_xlsxwriter::Format>>, Error> {
989
+ fn at(&self, col: usize) -> Result<Option<Arc<Style>>, Error> {
896
990
  match self {
897
991
  RowFormat::Same(f) => Ok(f.clone()),
898
992
  RowFormat::PerCell(formats) => Ok(Option::<&Format>::try_convert(
@@ -934,20 +1028,42 @@ impl Worksheet {
934
1028
  f(wb.worksheet_from_index(self.index).map_err(xerr)?)
935
1029
  }
936
1030
 
937
- // The format to write a cell with: its own, else its table column's.
938
- fn cell_format<'a>(
1031
+ // The format a value is written with: its own, else its table column's.
1032
+ // A date cell takes that format's date variant (when it has no
1033
+ // num_format); with neither, its column_format's date variant, or the
1034
+ // default date format when the column has no format. Other cells without
1035
+ // one get None, so rust_xlsxwriter applies any column format itself.
1036
+ fn format_for<'a>(
939
1037
  tables: &'a [TableColumnFormat],
1038
+ columns: &'a [(u16, u16, Arc<Style>)],
940
1039
  row: u32,
941
1040
  col: u16,
942
- own: Option<&'a rust_xlsxwriter::Format>,
1041
+ own: Option<&'a Style>,
1042
+ value: &CellValue,
943
1043
  ) -> Option<&'a rust_xlsxwriter::Format> {
944
- own.or_else(|| {
1044
+ static DEFAULTS: LazyLock<Style> =
1045
+ LazyLock::new(|| Style::new(rust_xlsxwriter::Format::new(), false));
1046
+ let date = value.date_kind();
1047
+ let style = own.or_else(|| {
945
1048
  tables
946
1049
  .iter()
947
1050
  .rev()
948
1051
  .find(|t| t.col == col && t.rows.contains(&row))
949
1052
  .map(|t| &*t.format)
950
- })
1053
+ });
1054
+ if let Some(style) = style {
1055
+ return Some(style.pick(date));
1056
+ }
1057
+ let time = date?;
1058
+ match columns
1059
+ .iter()
1060
+ .rev()
1061
+ .find(|(first, last, _)| (*first..=*last).contains(&col))
1062
+ {
1063
+ // None when it has a num_format: rust_xlsxwriter applies it.
1064
+ Some((_, _, style)) => style.for_date(time),
1065
+ None => DEFAULTS.for_date(time),
1066
+ }
951
1067
  }
952
1068
 
953
1069
  fn write_row(
@@ -963,7 +1079,7 @@ impl Worksheet {
963
1079
  each_entry(cells, |i, v| {
964
1080
  if i >= MAX_COLS {
965
1081
  return Err(Error::new(
966
- ruby.exception_arg_error(),
1082
+ ruby.exception_range_error(),
967
1083
  format!("too many columns: Excel allows {MAX_COLS}"),
968
1084
  ));
969
1085
  }
@@ -972,11 +1088,13 @@ impl Worksheet {
972
1088
  Ok(())
973
1089
  })?;
974
1090
  let tables = self.table_formats.borrow();
1091
+ let columns = self.formatted_columns.borrow();
975
1092
  self.with_ws(|ws| {
976
1093
  // Values were checked when converted, so only a bad row number
977
1094
  // fails here, and it fails on the first cell.
978
1095
  for (col, value, format) in &values {
979
- let format = Self::cell_format(&tables, row, *col, format.as_deref());
1096
+ let format =
1097
+ Self::format_for(&tables, &columns, row, *col, format.as_deref(), value);
980
1098
  value.write(ws, row, *col, format)?;
981
1099
  }
982
1100
  Ok(())
@@ -1020,12 +1138,13 @@ impl Worksheet {
1020
1138
  rb_self.check_not_flushed(ruby, row)?;
1021
1139
  let value = CellValue::from_ruby(ruby, v)?;
1022
1140
  let tables = rb_self.table_formats.borrow();
1141
+ let columns = rb_self.formatted_columns.borrow();
1023
1142
  rb_self.with_ws(|ws| {
1024
1143
  value.write(
1025
1144
  ws,
1026
1145
  row,
1027
1146
  col,
1028
- Self::cell_format(&tables, row, col, format.map(|f| &*f.0)),
1147
+ Self::format_for(&tables, &columns, row, col, format.map(|f| &*f.0), &value),
1029
1148
  )
1030
1149
  })?;
1031
1150
  rb_self.advance(row);
@@ -1057,8 +1176,15 @@ impl Worksheet {
1057
1176
  Ok(rb_self)
1058
1177
  }
1059
1178
 
1060
- fn set_column_width(&self, first: u16, last: u16, width: f64) -> Result<(), Error> {
1061
- self.with_ws(|ws| {
1179
+ fn set_column_width(
1180
+ ruby: &Ruby,
1181
+ rb_self: &Self,
1182
+ first: u16,
1183
+ last: u16,
1184
+ width: f64,
1185
+ ) -> Result<(), Error> {
1186
+ check_size(ruby, "column width", width, 255.0)?;
1187
+ rb_self.with_ws(|ws| {
1062
1188
  ws.set_column_range_width(first, last, width)
1063
1189
  .map(|_| ())
1064
1190
  .map_err(xerr)
@@ -1067,10 +1193,14 @@ impl Worksheet {
1067
1193
 
1068
1194
  fn set_column_format(&self, first: u16, last: u16, format: &Format) -> Result<(), Error> {
1069
1195
  self.with_ws(|ws| {
1070
- ws.set_column_range_format(first, last, &format.0)
1196
+ ws.set_column_range_format(first, last, &format.0.format)
1071
1197
  .map(|_| ())
1072
1198
  .map_err(xerr)
1073
- })
1199
+ })?;
1200
+ self.formatted_columns
1201
+ .borrow_mut()
1202
+ .push((first, last, format.0.clone()));
1203
+ Ok(())
1074
1204
  }
1075
1205
 
1076
1206
  fn autofilter(
@@ -1101,7 +1231,13 @@ impl Worksheet {
1101
1231
  Ok(rb_self)
1102
1232
  }
1103
1233
 
1104
- fn set_row_height(rb_self: Obj<Self>, row: u32, height: f64) -> Result<Obj<Self>, Error> {
1234
+ fn set_row_height(
1235
+ ruby: &Ruby,
1236
+ rb_self: Obj<Self>,
1237
+ row: u32,
1238
+ height: f64,
1239
+ ) -> Result<Obj<Self>, Error> {
1240
+ check_size(ruby, "row height", height, 409.0)?;
1105
1241
  rb_self.with_ws(|ws| ws.set_row_height(row, height).map(|_| ()).map_err(xerr))?;
1106
1242
  Ok(rb_self)
1107
1243
  }
@@ -1122,17 +1258,32 @@ impl Worksheet {
1122
1258
  })
1123
1259
  }
1124
1260
 
1125
- // Inches; a negative value keeps the current margin.
1261
+ // Inches; None keeps the current margin.
1262
+ #[allow(clippy::too_many_arguments)]
1126
1263
  fn set_margins(
1127
- &self,
1128
- left: f64,
1129
- right: f64,
1130
- top: f64,
1131
- bottom: f64,
1132
- header: f64,
1133
- footer: f64,
1264
+ ruby: &Ruby,
1265
+ rb_self: &Self,
1266
+ left: Option<f64>,
1267
+ right: Option<f64>,
1268
+ top: Option<f64>,
1269
+ bottom: Option<f64>,
1270
+ header: Option<f64>,
1271
+ footer: Option<f64>,
1134
1272
  ) -> Result<(), Error> {
1135
- self.with_ws(|ws| {
1273
+ // rust_xlsxwriter keeps a margin given as a negative number.
1274
+ let inches = |margin: Option<f64>| -> Result<f64, Error> {
1275
+ match margin {
1276
+ None => Ok(-1.0),
1277
+ Some(m) if m.is_finite() && m >= 0.0 => Ok(m),
1278
+ Some(m) => Err(Error::new(
1279
+ ruby.exception_arg_error(),
1280
+ format!("invalid margin {m}: use 0 or more inches"),
1281
+ )),
1282
+ }
1283
+ };
1284
+ let (left, right, top) = (inches(left)?, inches(right)?, inches(top)?);
1285
+ let (bottom, header, footer) = (inches(bottom)?, inches(header)?, inches(footer)?);
1286
+ rb_self.with_ws(|ws| {
1136
1287
  ws.set_margins(left, right, top, bottom, header, footer);
1137
1288
  Ok(())
1138
1289
  })
@@ -1193,7 +1344,15 @@ impl Worksheet {
1193
1344
  }
1194
1345
  let value = CellValue::from_ruby(ruby, v)?;
1195
1346
  let tables = rb_self.table_formats.borrow();
1196
- let format = Self::cell_format(&tables, first_row, first_col, format.map(|f| &*f.0));
1347
+ let columns = rb_self.formatted_columns.borrow();
1348
+ let format = Self::format_for(
1349
+ &tables,
1350
+ &columns,
1351
+ first_row,
1352
+ first_col,
1353
+ format.map(|f| &*f.0),
1354
+ &value,
1355
+ );
1197
1356
  let default = rust_xlsxwriter::Format::new();
1198
1357
  rb_self.with_ws(|ws| {
1199
1358
  // The value was checked when converted, so once the range is
@@ -1260,7 +1419,7 @@ impl Worksheet {
1260
1419
  let mut cf =
1261
1420
  ConditionalFormatCell::new().set_rule(cell_rule(ruby, criteria, value)?);
1262
1421
  if let Some(f) = format {
1263
- cf = cf.set_format(&*f.0);
1422
+ cf = cf.set_format(&f.0.format);
1264
1423
  }
1265
1424
  rule(range, cf)
1266
1425
  }
@@ -1284,7 +1443,7 @@ impl Worksheet {
1284
1443
  };
1285
1444
  let mut cf = ConditionalFormatText::new().set_rule(text_rule);
1286
1445
  if let Some(f) = format {
1287
- cf = cf.set_format(&*f.0);
1446
+ cf = cf.set_format(&f.0.format);
1288
1447
  }
1289
1448
  rule(range, cf)
1290
1449
  }
@@ -1292,7 +1451,7 @@ impl Worksheet {
1292
1451
  let mut cf =
1293
1452
  ConditionalFormatFormula::new().set_rule(String::try_convert(value)?.as_str());
1294
1453
  if let Some(f) = format {
1295
- cf = cf.set_format(&*f.0);
1454
+ cf = cf.set_format(&f.0.format);
1296
1455
  }
1297
1456
  rule(range, cf)
1298
1457
  }
@@ -1423,9 +1582,14 @@ impl Worksheet {
1423
1582
  )?;
1424
1583
  // SAFETY: the bytes are copied into the Image before any Ruby code runs.
1425
1584
  let mut image = Image::new_from_buffer(unsafe { bytes.as_slice() }).map_err(xerr)?;
1426
- let width = opt::<f64>(ruby, options, "width")?;
1427
- let height = opt::<f64>(ruby, options, "height")?;
1428
- if let Some(scale) = opt::<f64>(ruby, options, "scale")? {
1585
+ let positive = |name: &str| -> Result<Option<f64>, Error> {
1586
+ opt::<f64>(ruby, options, name)?
1587
+ .map(|v| check_positive(ruby, name, v))
1588
+ .transpose()
1589
+ };
1590
+ let width = positive("width")?;
1591
+ let height = positive("height")?;
1592
+ if let Some(scale) = positive("scale")? {
1429
1593
  if width.is_some() || height.is_some() {
1430
1594
  return Err(Error::new(
1431
1595
  ruby.exception_arg_error(),
@@ -1634,16 +1798,7 @@ impl Worksheet {
1634
1798
  Ok(rb_self)
1635
1799
  }
1636
1800
 
1637
- fn zoom(ruby: &Ruby, rb_self: Obj<Self>, percent: Value) -> Result<Obj<Self>, Error> {
1638
- // Integers only: converting would quietly truncate 150.9.
1639
- let percent = Integer::from_value(percent)
1640
- .ok_or_else(|| {
1641
- Error::new(
1642
- ruby.exception_type_error(),
1643
- format!("zoom must be an Integer, got {}", percent.inspect()),
1644
- )
1645
- })?
1646
- .to_i64()?;
1801
+ fn zoom(ruby: &Ruby, rb_self: Obj<Self>, percent: i64) -> Result<Obj<Self>, Error> {
1647
1802
  // rust_xlsxwriter only prints a warning for these.
1648
1803
  if !(10..=400).contains(&percent) {
1649
1804
  return Err(Error::new(
@@ -14,9 +14,9 @@ fn ruby_test_suite_passes() {
14
14
  .args(["exec", "rake", "compile", "test"])
15
15
  .current_dir(&root)
16
16
  .env("RB_SYS_CARGO_PROFILE", "dev")
17
- // The suite needs a few CPU seconds; a mutant that loops forever is
18
- // stopped instead of running on after cargo-mutants gives up on it.
19
- .env("FAST_XLSX_TEST_CPU_SECONDS", "120")
17
+ // The suite takes seconds; a mutant that hangs (a loop or a deadlock)
18
+ // is killed instead of running on after cargo-mutants gives up on it.
19
+ .env("FAST_XLSX_TEST_TIMEOUT_SECONDS", "120")
20
20
  .status()
21
21
  .expect("could not run `bundle exec rake compile test`");
22
22
  assert!(status.success(), "the Ruby test suite failed");
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastXlsx
4
- VERSION = "0.3.0"
4
+ # The gem version.
5
+ VERSION = "0.5.0"
5
6
  end
data/lib/fast_xlsx.rb CHANGED
@@ -3,21 +3,48 @@
3
3
  require_relative "fast_xlsx/version"
4
4
 
5
5
  # Fast .xlsx writer backed by rust_xlsxwriter.
6
+ #
7
+ # @example
8
+ # wb = FastXlsx::Workbook.new
9
+ # ws = wb.add_worksheet("Report")
10
+ # ws << ["id", "name", "created_at"]
11
+ # ws.concat(records.map { |r| [r.id, r.name, r.created_at] })
12
+ # wb.save("report.xlsx")
13
+ #
14
+ # Rows and columns are 0-based. Errors: TypeError for an argument of the wrong
15
+ # type, RangeError for a row or column outside the sheet, ArgumentError for a
16
+ # value that isn't allowed, {FastXlsx::Error} for what the workbook can't do.
6
17
  module FastXlsx
18
+ # Raised for what the workbook can't do: duplicate or invalid names, writing
19
+ # to rows already on disk, overlapping merges, text over Excel's limits, ...
7
20
  class Error < StandardError; end
8
21
 
9
- # Cell value written as an Excel formula, e.g. Formula.new("SUM(A1:A9)").
22
+ # A cell value written as an Excel formula.
23
+ #
24
+ # @!attribute [r] expression
25
+ # @return [String] the formula, without the leading "="
26
+ # @example
27
+ # ws << [1, 2, FastXlsx::Formula.new("SUM(A1:B1)")]
10
28
  Formula = Data.define(:expression) do
11
29
  def initialize(expression:)
12
30
  super(expression: expression.to_s)
13
31
  end
14
32
  end
15
33
 
16
- # Cell value written as a hyperlink, e.g. URL.new("https://example.com").
17
- # text: shown in the cell instead of the URL itself.
34
+ # A cell value written as a hyperlink.
35
+ #
36
+ # @!attribute [r] url
37
+ # @return [String] http(s)://, mailto:, file:// or internal:Sheet2!A1
38
+ # @!attribute [r] text
39
+ # @return [String, nil] shown in the cell instead of the URL
40
+ # @example
41
+ # FastXlsx::URL.new("https://example.com")
42
+ # FastXlsx::URL.new("https://example.com/report/42", text: "Q3 report")
18
43
  # Subclassed (not a Data.define block) so .new can call super, which lets it
19
44
  # accept URL.new(url, text: ...) as well as the usual Data forms.
20
45
  class URL < Data.define(:url, :text) # rubocop:disable Style/DataInheritance
46
+ # @overload new(url, text = nil)
47
+ # @overload new(url:, text: nil)
21
48
  def self.new(*args, **kwargs)
22
49
  raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0..2)" if args.size > 2
23
50
 
@@ -32,11 +59,18 @@ module FastXlsx
32
59
  end
33
60
  end
34
61
 
35
- # Text with a format per segment, e.g. RichString.new(["Total: ", bold], "1,234").
36
- # Each segment is a String (default font) or [String, Format].
62
+ # Text with a format per segment.
63
+ #
64
+ # @example
65
+ # bold = FastXlsx::Format.new(bold: true)
66
+ # ws << [FastXlsx::RichString.new(["Total: ", bold], "1,234")]
37
67
  class RichString
68
+ # @return [Array<Array(String, Format)>] [text, format or nil] per segment
38
69
  attr_reader :segments
39
70
 
71
+ # @param parts [Array<String, Array(String, Format)>] each a String (default
72
+ # font) or [String, Format]
73
+ # @raise [ArgumentError] when no segment is given
40
74
  def initialize(*parts)
41
75
  raise ArgumentError, "RichString needs at least one segment" if parts.empty?
42
76
 
@@ -53,15 +87,31 @@ module FastXlsx
53
87
  require "fast_xlsx/fast_xlsx"
54
88
  end
55
89
 
56
- # Owns the worksheets; serialize with #to_xlsx or #save.
90
+ # Owns the worksheets; serialize with {#to_xlsx} or {#save}.
91
+ #
92
+ # @!method to_xlsx
93
+ # The workbook as an .xlsx file. Runs without Ruby's global lock, so
94
+ # other threads keep running meanwhile.
95
+ # @return [String] binary
96
+ # @raise [FastXlsx::Error] e.g. duplicate defined names
97
+ #
98
+ # @!method define_name(name, formula)
99
+ # A defined name: "Rate" for the whole workbook, "Sheet1!Sales" for one
100
+ # sheet. Duplicates and unknown sheets are reported when saving.
101
+ # @param name [String]
102
+ # @param formula [String] e.g. "=0.96" or "=Sheet1!$A$1:$A$9"
103
+ # @return [self]
104
+ # @raise [FastXlsx::Error] for a name Excel doesn't allow
57
105
  class Workbook
106
+ # Accepted memory: modes.
58
107
  MEMORY_MODES = %i[standard constant low].freeze
59
108
 
60
- # memory: :standard keeps every cell in memory until saving. :constant and
61
- # :low write each finished row to disk, so each worksheet must be filled
62
- # top to bottom. :constant stores strings inline (memory stays flat); :low
63
- # keeps Excel's shared string table (memory grows with the number of
64
- # unique strings, output is standard).
109
+ # @param memory [Symbol] :standard keeps every cell in memory until saving.
110
+ # :constant and :low write each finished row to disk, so each worksheet
111
+ # must be filled top to bottom: :constant stores strings inline (memory
112
+ # stays flat), :low keeps Excel's shared string table (memory grows with
113
+ # the number of unique strings).
114
+ # @raise [ArgumentError] for an unknown mode
65
115
  def self.new(memory: :standard)
66
116
  unless MEMORY_MODES.include?(memory)
67
117
  raise ArgumentError, "unknown memory mode #{memory.inspect} (expected one of #{MEMORY_MODES.join(", ")})"
@@ -70,28 +120,40 @@ module FastXlsx
70
120
  _new(memory == :constant, memory == :low)
71
121
  end
72
122
 
123
+ # @param name [String, nil] nil takes the first free "SheetN"
124
+ # @return [Worksheet]
125
+ # @raise [FastXlsx::Error] for an invalid name or one already used
126
+ # (names ignore case)
73
127
  def add_worksheet(name = nil)
74
128
  _add_worksheet(name).tap { |ws| worksheets << ws }
75
129
  end
76
130
 
77
131
  # The same Worksheet objects add_worksheet returned, so their append
78
132
  # position is shared.
133
+ # @return [Array<Worksheet>]
79
134
  def worksheets
80
135
  @worksheets ||= []
81
136
  end
82
137
 
138
+ # @param name [String]
139
+ # @return [Worksheet, nil]
83
140
  def worksheet(name)
84
141
  worksheets.find { |ws| ws.name == name }
85
142
  end
86
143
 
87
- # path: a String, Pathname or anything responding to #to_path.
144
+ # Writes the .xlsx file. Runs without Ruby's global lock, like {#to_xlsx}.
145
+ # @param path [String, Pathname, #to_path]
146
+ # @return [nil]
88
147
  def save(path)
89
148
  _save(File.path(path))
90
149
  end
91
150
 
92
- # Document properties shown in Excel's File > Info: title:, subject:,
93
- # author:, manager:, company:, category:, keywords:, comments:, status:.
94
- # Later calls add to earlier ones.
151
+ # Document properties shown in Excel's File > Info. Later calls add to
152
+ # earlier ones.
153
+ # @param fields [Hash{Symbol => String}] title:, subject:, author:,
154
+ # manager:, company:, category:, keywords:, comments:, status:
155
+ # @return [self]
156
+ # @raise [ArgumentError] for an unknown field
95
157
  def properties(**fields)
96
158
  merged = (@properties || {}).merge(fields)
97
159
  _properties(merged) # validates before anything is remembered
@@ -100,7 +162,73 @@ module FastXlsx
100
162
  end
101
163
  end
102
164
 
103
- # Cell writer for one sheet; create with Workbook#add_worksheet.
165
+ # Cell writer for one sheet; create with {Workbook#add_worksheet}.
166
+ #
167
+ # A cell is (row, col) or a reference like "B2". A range is four 0-based
168
+ # numbers (first_row, first_col, last_row, last_col), a reference like
169
+ # "A1:D10", or rows and columns as Integers or Ranges, e.g. (0..9, 0..3).
170
+ #
171
+ # @!method write(*cell, value, format = nil)
172
+ # Writes one cell.
173
+ # @overload write(row, col, value, format = nil)
174
+ # @overload write(ref, value, format = nil)
175
+ # @param value [Numeric, String, Time, Date, DateTime, true, false, nil,
176
+ # Formula, URL, RichString, #to_s] dates get yyyy-mm-dd (hh:mm:ss) unless
177
+ # the format has a num_format
178
+ # @param format [Format, nil]
179
+ # @return [self]
180
+ #
181
+ # @!method <<(values)
182
+ # Appends a row after the last row written.
183
+ # @param values [Array] cell values, as for {#write}
184
+ # @return [self]
185
+ #
186
+ # @!method concat(rows)
187
+ # Appends several rows.
188
+ # @param rows [Array<Array>]
189
+ # @return [self]
190
+ #
191
+ # @!method next_row
192
+ # @return [Integer] the row {#<<} writes next
193
+ #
194
+ # @!method name
195
+ # @return [String]
196
+ #
197
+ # @!method row_height(row, height)
198
+ # @param height [Numeric] points, 0..409
199
+ # @return [self]
200
+ # @raise [ArgumentError] for a height outside 0..409
201
+ #
202
+ # @!method page_breaks(rows)
203
+ # Starts a printed page before each of these rows.
204
+ # @param rows [Array<Integer>]
205
+ # @return [self]
206
+ #
207
+ # @!method vertical_page_breaks(cols)
208
+ # Starts a printed page before each of these columns.
209
+ # @param cols [Array<Integer>]
210
+ # @return [self]
211
+ #
212
+ # @!method activate
213
+ # Makes Excel open on this sheet (un-hiding it if hidden).
214
+ # @return [self]
215
+ #
216
+ # @!method hide
217
+ # @return [self]
218
+ # @raise [FastXlsx::Error] for the sheet Excel opens on (the first one
219
+ # unless another is activated)
220
+ #
221
+ # @!method zoom(percent)
222
+ # @param percent [Integer, #to_int] 10..400
223
+ # @return [self]
224
+ #
225
+ # @!method tab_color(color)
226
+ # @param color [String, Integer] "#RRGGBB" or 0xRRGGBB
227
+ # @return [self]
228
+ #
229
+ # @!method hide_gridlines
230
+ # Hides the gridlines on screen (see {#page_setup} for printing).
231
+ # @return [self]
104
232
  class Worksheet
105
233
  # write(row, col, value, format = nil) or write("B2", value, format = nil)
106
234
  # is native: the (row, col) form runs once per cell, so it skips a Ruby
@@ -112,11 +240,18 @@ module FastXlsx
112
240
  end
113
241
  private :_write_ref
114
242
 
243
+ # Appends a row after the last row written.
244
+ # @param values [Array] cell values, as for {#write}
245
+ # @param format [Format, Array<Format, nil>, nil] one for every cell, or
246
+ # one per cell
247
+ # @return [self]
115
248
  def append(values, format: nil)
116
249
  _append(values, format)
117
250
  end
118
251
 
119
- # columns: a 0-based column index or a Range of them. width is in characters.
252
+ # @param columns [Integer, Range<Integer>]
253
+ # @param width [Numeric] characters, 0..255
254
+ # @return [self]
120
255
  def column_width(columns, width)
121
256
  range = CellRange.bounds(columns)
122
257
  _column_width(*range, width)
@@ -126,89 +261,131 @@ module FastXlsx
126
261
  self
127
262
  end
128
263
 
129
- # Sizes columns to the data written so far. Widths set with
130
- # column_width are kept.
264
+ # Sizes columns to the data written so far (in :constant / :low mode, the
265
+ # rows still in memory). Widths set with {#column_width} are kept.
266
+ # @return [self]
131
267
  def autofit
132
268
  _autofit
133
269
  @fixed_widths&.each { |bounds, width| _column_width(*bounds, width) }
134
270
  self
135
271
  end
136
272
 
137
- # The methods below take a cell range in any of these styles:
138
- # (first_row, first_col, last_row, last_col) four 0-based numbers
139
- # ("A1:D10") or ("B2") an Excel reference
140
- # (rows, cols) Integers or Ranges, e.g. (0..9, 0..3)
141
-
142
273
  # Filter buttons on the range's first row.
274
+ # @param range a cell range (see {Worksheet})
275
+ # @return [self]
143
276
  def autofilter(*range)
144
277
  _autofilter(*CellRange.split(range).first)
145
278
  end
146
279
 
147
280
  # Merges the range and writes value (any cell type) into its first cell.
281
+ # @overload merge_range(*range, value, format = nil)
282
+ # @return [self]
283
+ # @raise [FastXlsx::Error] when it overlaps an earlier merge
148
284
  def merge_range(*args)
149
285
  range, (value, format) = CellRange.split(args, 1..2)
150
286
  _merge_range(*range, value, format)
151
287
  end
152
288
 
153
- # Highlights cells in the range by rule. type: :cell, :text, :formula,
154
- # :data_bar or :color_scale; see the README for each type's options.
289
+ # Highlights cells in the range by rule; see the README for each type's
290
+ # options.
291
+ # @param range a cell range (see {Worksheet})
292
+ # @param type [Symbol] :cell, :text, :formula, :data_bar or :color_scale
293
+ # @option options [Symbol] :criteria e.g. :>, :between, :contains
294
+ # @option options [Numeric, String, Array] :value
295
+ # @option options [Format] :format
296
+ # @option options [Integer] :colors 2 or 3 (:color_scale)
297
+ # @return [self]
155
298
  def conditional_format(*range, type:, **)
156
299
  _conditional_format(*CellRange.split(range).first, { type: type, ** })
157
300
  end
158
301
 
159
- # Restricts what can be entered in the range. type: :list, :whole_number,
160
- # :decimal or :text_length; see the README for the options.
302
+ # Restricts what can be entered in the range; see the README.
303
+ # @param range a cell range (see {Worksheet})
304
+ # @param type [Symbol] :list, :whole_number, :decimal or :text_length
305
+ # @option options [Symbol] :criteria e.g. :>, :between
306
+ # @option options [Numeric, String, Array] :value
307
+ # @option options [String] :input_title, :input_message, :error_title, :error_message
308
+ # @return [self]
161
309
  def data_validation(*range, type:, **)
162
310
  _data_validation(*CellRange.split(range).first, { type: type, ** })
163
311
  end
164
312
 
165
- # Adds a comment (Excel "note") to a cell.
313
+ # Adds a comment (an Excel "note") to a cell.
314
+ # @overload write_comment(*cell, text, author: nil)
315
+ # @return [self]
166
316
  def write_comment(*args, author: nil)
167
317
  row, col, (text, *) = CellRange.cell(args, 1..1)
168
318
  _write_comment(row, col, text, author)
169
319
  end
170
320
 
171
- # Inserts a PNG, JPEG, GIF or BMP image with its top-left corner in the
172
- # cell. source is a file path or an IO (anything responding to #read).
173
- # Options: scale: or width:/height: (pixels), x_offset:, y_offset: (pixels), alt_text:.
321
+ # Inserts a PNG, JPEG, GIF or BMP image with its top-left corner in the cell.
322
+ # @overload insert_image(*cell, source, **options)
323
+ # @param source [String, #read] a file path or an IO
324
+ # @option options [Numeric] :scale
325
+ # @option options [Numeric] :width, :height pixels (the other keeps the ratio)
326
+ # @option options [Integer] :x_offset, :y_offset pixels
327
+ # @option options [String] :alt_text
328
+ # @return [self]
329
+ # @raise [FastXlsx::Error] for data that isn't a supported image
174
330
  def insert_image(*args, **)
175
331
  row, col, (source, *) = CellRange.cell(args, 1..1)
176
332
  bytes = source.respond_to?(:read) ? source.read : File.binread(source)
177
333
  _insert_image(row, col, bytes, { ** })
178
334
  end
179
335
 
180
- # Inserts a chart with its top-left corner in the cell. series is an Array
181
- # of { values:, categories:, name: } with Excel ranges such as
182
- # "Sheet1!$B$2:$B$13". Options: title:, x_axis:, y_axis:, width:, height:.
336
+ # Inserts a chart with its top-left corner in the cell.
337
+ # @param cell (row, col) or "B2"
338
+ # @param type [Symbol] :area, :bar, :column, :line, :pie, :doughnut,
339
+ # :radar, :scatter, and the stacked variants
340
+ # @param series [Array<Hash>] { values:, categories:, name: } with ranges
341
+ # such as "Sheet1!$B$2:$B$13"
342
+ # @option options [String] :title, :x_axis, :y_axis
343
+ # @option options [Integer] :width, :height pixels
344
+ # @return [self]
183
345
  def insert_chart(*cell, type:, series:, **)
184
346
  row, col, = CellRange.cell(cell)
185
347
  _insert_chart(row, col, { type: type, series: series, ** })
186
348
  end
187
349
 
188
350
  # Turns the range (header row included, total row too when total_row: true)
189
- # into an Excel table. columns: header Strings or { header:, total:,
190
- # total_label:, format: }; other options: style:, name:, total_row:,
191
- # banded_rows:, autofilter:.
351
+ # into an Excel table. Rows appended later under the header fill it.
352
+ # @param range a cell range (see {Worksheet})
353
+ # @option options [Array<String, Hash>] :columns header Strings or
354
+ # { header:, total:, total_label:, format: }
355
+ # @option options [Symbol] :style e.g. :medium2
356
+ # @option options [String] :name
357
+ # @option options [Boolean] :total_row, :banded_rows, :autofilter
358
+ # @return [self]
192
359
  def add_table(*range, **)
193
360
  _add_table(*CellRange.split(range).first, { ** })
194
361
  end
195
362
 
196
- # Printed page header/footer using Excel codes such as "&CPage &P of &N".
197
- # margin: is in inches.
363
+ # Printed page header, using Excel codes such as "&CPage &P of &N".
364
+ # @param margin [Numeric, nil] inches
365
+ # @return [self]
198
366
  def page_header(text, margin: nil)
199
367
  _page_header(text)
200
368
  margin ? margins(header: margin) : self
201
369
  end
202
370
 
371
+ # Printed page footer; see {#page_header}.
372
+ # @return [self]
203
373
  def page_footer(text, margin: nil)
204
374
  _page_footer(text)
205
375
  margin ? margins(footer: margin) : self
206
376
  end
207
377
 
208
- # Printing: landscape:, paper: (:letter, :legal, :tabloid, :a3, :a4, :a5 or
209
- # Excel's paper number), fit_width:/fit_height: (pages; 0 or left out =
210
- # as many as needed), repeat_rows:/repeat_columns: (an index or Range,
211
- # printed on every page), print_area: (any cell range), gridlines:.
378
+ # Printing options.
379
+ # @option options [Boolean] :landscape
380
+ # @option options [Symbol, Integer] :paper :letter, :legal, :tabloid, :a3,
381
+ # :a4, :a5 or Excel's paper number (0: the printer's default)
382
+ # @option options [Integer] :fit_width, :fit_height pages (0 or left out:
383
+ # as many as needed)
384
+ # @param repeat_rows [Integer, Range, nil] printed on every page
385
+ # @param repeat_columns [Integer, Range, nil] printed on every page
386
+ # @param print_area a cell range: "A1:D100" or [first_row, first_col, last_row, last_col]
387
+ # @option options [Boolean] :gridlines print the gridlines
388
+ # @return [self]
212
389
  def page_setup(repeat_rows: nil, repeat_columns: nil, print_area: nil, **options)
213
390
  options[:repeat_rows] = CellRange.bounds(repeat_rows) if repeat_rows
214
391
  options[:repeat_columns] = CellRange.bounds(repeat_columns) if repeat_columns
@@ -218,39 +395,56 @@ module FastXlsx
218
395
  end
219
396
 
220
397
  # Print margins in inches; margins not given keep their current value.
398
+ # @return [self]
399
+ # @raise [ArgumentError] for a negative or NaN margin
221
400
  def margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
222
- _margins(*[left, right, top, bottom, header, footer].map { |m| m || -1.0 })
401
+ _margins(left, right, top, bottom, header, footer)
223
402
  self
224
403
  end
225
404
 
226
- # Outline group with an expand/collapse button. rows: a 0-based row index
227
- # or a Range; grouping rows already grouped nests them (up to 7 levels).
228
- # collapsed: true hides them until expanded.
405
+ # Outline group with an expand/collapse button. Grouping rows already
406
+ # grouped nests them (up to 7 levels).
407
+ # @param rows [Integer, Range<Integer>]
408
+ # @param collapsed [Boolean] hidden until expanded
409
+ # @return [self]
410
+ # @raise [FastXlsx::Error] in :constant / :low memory mode, which can't
411
+ # write outline levels for rows
229
412
  def group_rows(rows, collapsed: false)
230
413
  _group_rows(*CellRange.bounds(rows), collapsed)
231
414
  end
232
415
 
416
+ # Outline group of columns; see {#group_rows}. Works in every memory mode.
417
+ # @param columns [Integer, Range<Integer>]
418
+ # @return [self]
233
419
  def group_columns(columns, collapsed: false)
234
420
  _group_columns(*CellRange.bounds(columns), collapsed)
235
421
  end
236
422
 
237
423
  # Keeps the rows above and the columns left of the cell visible while
238
424
  # scrolling: (1, 0) or "A2" freezes the first row.
425
+ # @return [self]
239
426
  def freeze_panes(*cell)
240
427
  _freeze_panes(*CellRange.cell(cell).first(2))
241
428
  self
242
429
  end
243
430
 
244
431
  # Locks the sheet against editing. Cells whose format has locked: false
245
- # stay editable. allow: actions users may still take, any of :format_cells,
246
- # :format_columns, :format_rows, :insert_columns, :insert_rows,
247
- # :insert_links, :delete_columns, :delete_rows, :sort, :use_autofilter,
248
- # :use_pivot_tables, :edit_scenarios, :edit_objects.
432
+ # stay editable. Calling it again replaces the password and actions.
433
+ # @param password [String, nil] stops editing in Excel; it doesn't
434
+ # encrypt the file
435
+ # @param allow [Symbol, Array<Symbol>] actions users may still take:
436
+ # :format_cells, :format_columns, :format_rows, :insert_columns,
437
+ # :insert_rows, :insert_links, :delete_columns, :delete_rows, :sort,
438
+ # :use_autofilter, :use_pivot_tables, :edit_scenarios, :edit_objects
439
+ # @return [self]
249
440
  def protect(password: nil, allow: [])
250
441
  _protect(password, Array(allow))
251
442
  end
252
443
 
253
444
  # Default format for cells in these columns that are written without one.
445
+ # @param columns [Integer, Range<Integer>]
446
+ # @param format [Format]
447
+ # @return [self]
254
448
  def column_format(columns, format)
255
449
  _column_format(*CellRange.bounds(columns), format)
256
450
  self
@@ -260,6 +454,7 @@ module FastXlsx
260
454
  # Cell ranges in the styles Worksheet methods accept: four 0-based numbers,
261
455
  # an Excel reference ("A1:D10", "B2"), or rows and columns as Integers or
262
456
  # Ranges.
457
+ # @api private
263
458
  module CellRange
264
459
  REF = /\A\$?([A-Za-z]{1,3})\$?([1-9]\d*)\z/ # ASCII only: /i also matches the Kelvin sign
265
460
  FORMS = 'a range is (first_row, first_col, last_row, last_col), "A1:D10" or (rows, cols)'
@@ -279,8 +474,8 @@ module FastXlsx
279
474
  case args
280
475
  in [Numeric, Numeric, Numeric, Numeric, *rest] then [args.first(4), rest]
281
476
  in [String => ref, *rest] then [excel(ref), rest]
282
- in [Integer | Range => rows, Integer | Range => cols, *rest]
283
- [rows_and_cols(rows, cols), rest]
477
+ in [Integer | Range, Integer | Range, *rest] # (no "=> name" here: YARD can't parse it)
478
+ [rows_and_cols(args[0], args[1]), rest]
284
479
  else
285
480
  raise ArgumentError, "expected a cell range, got #{args.inspect}; #{FORMS}"
286
481
  end
@@ -351,8 +546,19 @@ module FastXlsx
351
546
  end
352
547
  private_constant :CellRange
353
548
 
354
- # Cell style, e.g. Format.new(bold: true). Pass to Worksheet#write.
549
+ # A cell style; pass it to {Worksheet#write}, {Worksheet#append} and the
550
+ # other methods that take a format. See the README for every option.
551
+ #
552
+ # @example
553
+ # header = FastXlsx::Format.new(bold: true, bg_color: "#DDEBF7", border_bottom: :thin)
554
+ # money = FastXlsx::Format.new(num_format: "#,##0.00")
355
555
  class Format
556
+ # @param options [Hash] bold:, italic:, underline:, strikeout:,
557
+ # font_script:, font_size:, font_name:, font_color:, bg_color:,
558
+ # num_format:, align:, valign:, text_wrap:, rotation:, indent:, shrink:,
559
+ # border:, border_left:, border_right:, border_top:, border_bottom:,
560
+ # border_color:, locked:, hidden:
561
+ # @raise [ArgumentError] for an unknown option or an invalid value
356
562
  def self.new(**options)
357
563
  # Apply border: first so border_left: etc. override it whatever the order.
358
564
  options = { border: options[:border], **options.except(:border) } if options.key?(:border)
data/sig/fast_xlsx.rbs CHANGED
@@ -81,7 +81,7 @@ module FastXlsx
81
81
  def page_footer: (String text, ?margin: Numeric?) -> self
82
82
  def activate: () -> self
83
83
  def hide: () -> self
84
- def zoom: (Integer percent) -> self
84
+ def zoom: (int percent) -> self
85
85
  def tab_color: (String | Integer color) -> self
86
86
  def hide_gridlines: () -> self
87
87
  def page_setup: (?landscape: bool, ?paper: :letter | :legal | :tabloid | :a3 | :a4 | :a5 | Integer,
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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
@@ -32,6 +32,7 @@ extensions:
32
32
  - ext/fast_xlsx/extconf.rb
33
33
  extra_rdoc_files: []
34
34
  files:
35
+ - ".yardopts"
35
36
  - CHANGELOG.md
36
37
  - Cargo.lock
37
38
  - Cargo.toml