fast_xlsx 0.7.0 → 0.8.1

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: eddcb1a0c063b94222c0a83e15b7a855a83b26674390ee87f59f1938d7007c9f
4
- data.tar.gz: 5aa9b9a7b6dbfb70bb53364b16fa31bb2c745534aefc9f0631367ed6cf658043
3
+ metadata.gz: f530f027eb2e902f4bed36fdfd75af2851ff93a9762915a56f9de3e966eab593
4
+ data.tar.gz: 794ddb87412acfa82840681a9caf32f85eb0d5b47fc0921c7091f3bbde020677
5
5
  SHA512:
6
- metadata.gz: d854850a2dd9d9b82d8bbbe4568f41336b3e4a89b977ddfcffb8e08a083305222111dded66d33e8f999dfbfae4d1da319e16f40835e83962bb7ac6081686b333
7
- data.tar.gz: 973794fd6d14e139f8173e0726f0938f61022f5b503d5b761b4abaf089b9e0dd3debec9083248859f9380a64fd6ead89ec2efc8af64296338960b6caed1269df
6
+ metadata.gz: 915d233e6c422187d23853c1c521bdefbf3336d85af03574882dc14a8055b1f692e3044588281c33c94e451be5123f516c4b07e2596111782c2b2a1f68fa261a
7
+ data.tar.gz: 98aa0c5de0d29816828a8fe6215c5132b7bd88297ed13bf6db9df9ddd6f0221e27f7a804a082e4430daffbb235193f1c85ffb898f41f6fee94940625d0caabb2
@@ -0,0 +1,7 @@
1
+ # cargo-mutants settings (see .github/workflows/mutants.yml).
2
+ exclude_re = [
3
+ # Equivalent mutant: sending every format through FastXlsx::Format._coerce,
4
+ # not just Hashes, changes nothing but speed (_coerce returns a Format or
5
+ # nil as it is), so no test can catch it.
6
+ 'replace match guard RHash::from_value\(\*?f\)\.is_some\(\) with true in Worksheet::write_any',
7
+ ]
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.1] - 2026-10-03
4
+
5
+ ### Fixed
6
+
7
+ - Rails' `ActiveSupport::TimeWithZone` (e.g. `created_at`) and other objects that act like a `Time` without being one were written as text; they are dates now.
8
+
9
+ ### Changed
10
+
11
+ - Writing `Time` values is about 25% faster (read through Ruby's C API instead of method calls), and `Date` values a little faster.
12
+
13
+ ## [0.8.0] - 2026-10-02
14
+
15
+ ### Changed
16
+
17
+ - The API is frozen for 1.0: 0.8.x releases only fix bugs. The README states the deprecation policy.
18
+
19
+ ### Added
20
+
21
+ - `write` and `merge_range` also take the format as `format:`, like `append` (positional still works).
22
+
3
23
  ## [0.7.0] - 2026-10-02
4
24
 
5
25
  ### Added
data/README.md CHANGED
@@ -5,9 +5,85 @@
5
5
 
6
6
  Fast `.xlsx` writer for Ruby, built on [rust_xlsxwriter](https://github.com/jmcnamara/rust_xlsxwriter) via [magnus](https://github.com/matsadler/magnus).
7
7
 
8
- > **Status: early.** The API may still change before 1.0.
8
+ > **Status: release candidate.** The API is frozen as of 0.8: 0.8.x releases only fix bugs, and 1.0 follows once it has been in real use without needing changes.
9
9
 
10
- ## Usage
10
+ **Roadmap:** [1.0.0](https://github.com/7a6163/fast_xlsx/milestone/1) (release plan) · [1.x](https://github.com/7a6163/fast_xlsx/milestone/2) (planned features)
11
+
12
+ **Contents:** [Installation](#installation) · [Getting started](#getting-started) · [Guide](#guide) · [Performance](#performance) · [Support and versioning](#support-and-versioning) · [Development](#development)
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ bundle add fast_xlsx
18
+ ```
19
+
20
+ Requires CRuby 3.3 or later. Precompiled gems cover Linux, macOS and Windows; see [Support and versioning](#support-and-versioning).
21
+
22
+ Coming from fast_excel? See [Migrating from fast_excel](docs/MIGRATING_FROM_FAST_EXCEL.md).
23
+
24
+ ## Getting started
25
+
26
+ A sales report, step by step: a header, one row per sale, a totals row, then sized columns, a frozen header and filter buttons.
27
+
28
+ ```ruby
29
+ require "date"
30
+ require "fast_xlsx"
31
+
32
+ # Sample data; in an app it comes from your database.
33
+ Sale = Struct.new(:region, :product, :units, :price, :sold_on)
34
+ sales = [
35
+ Sale.new("North", "Widget", 120, 9.5, Date.new(2026, 7, 3)),
36
+ Sale.new("North", "Gadget", 45, 24.0, Date.new(2026, 7, 9)),
37
+ Sale.new("South", "Widget", 80, 9.5, Date.new(2026, 7, 14)),
38
+ Sale.new("South", "Gizmo", 12, 120.0, Date.new(2026, 7, 21))
39
+ ]
40
+
41
+ # 1. A workbook with one worksheet.
42
+ wb = FastXlsx::Workbook.new
43
+ ws = wb.add_worksheet("Sales")
44
+
45
+ # 2. A header row, bold on a light blue fill.
46
+ header = FastXlsx::Format.new(bold: true, bg_color: "#DDEBF7", border_bottom: :thin)
47
+ ws.append(["Region", "Product", "Units", "Price", "Sold on", "Revenue"], format: header)
48
+
49
+ # 3. One row per sale. Dates show as yyyy-mm-dd on their own; a formula is a
50
+ # FastXlsx::Formula. In formulas rows count from 1, so the first sale is row 2.
51
+ money = FastXlsx::Format.new(num_format: "#,##0.00")
52
+ sales.each.with_index(2) do |sale, row|
53
+ ws.append([sale.region, sale.product, sale.units, sale.price, sale.sold_on,
54
+ FastXlsx::Formula.new("C#{row}*D#{row}")],
55
+ format: [nil, nil, nil, money, nil, money])
56
+ end
57
+
58
+ # 4. A totals row: the header's look, with the money format added.
59
+ last = sales.size + 1
60
+ ws.append(["Total", nil, FastXlsx::Formula.new("SUM(C2:C#{last})"), nil, nil,
61
+ FastXlsx::Formula.new("SUM(F2:F#{last})")],
62
+ format: [header, header, header, header, header, header.merge(num_format: "#,##0.00")])
63
+
64
+ # 5. Easier to read: columns sized to fit (set by hand where autofit can't tell), the header kept in view while
65
+ # scrolling, and filter buttons on the header (rows and columns count from 0).
66
+ ws.autofit
67
+ ws.column_width(3..5, 12) # money, dates, formulas: autofit measures raw values
68
+ ws.freeze_panes("A2")
69
+ ws.autofilter("A1:F#{last}")
70
+
71
+ # 6. Save it. (In Rails: send_data wb.to_xlsx, filename: "sales.xlsx", ...)
72
+ wb.save("sales.xlsx")
73
+ ```
74
+
75
+ - **Rows and columns count from 0** in method calls (`write(0, 0, ...)` is A1); Excel references like `"A2"` and formulas count from 1 as in Excel.
76
+ - **Formulas** are calculated by Excel when it opens the file.
77
+ - **Large exports** (tens of thousands of rows and up): use `FastXlsx::Workbook.new(memory: :constant)`. Rows go to disk as they are added, so memory stays flat; fill each worksheet from top to bottom. See [Memory modes](#memory-modes).
78
+ - **Rails**: build the workbook in the controller and `send_data wb.to_xlsx`; see [Rails](#rails).
79
+
80
+ The [Guide](#guide) below covers every feature; the [API docs](https://www.rubydoc.info/gems/fast_xlsx) list every method.
81
+
82
+ ## Guide
83
+
84
+ [Writing cells](#writing-cells) · [Rails](#rails) · [Memory modes](#memory-modes) · [Formats](#formats) · [Columns and filters](#columns-and-filters) · [Sheet view and printing](#sheet-view-and-printing) · [Cell ranges](#cell-ranges) · [Layout](#layout) · [Outline groups](#outline-groups) · [Defined names](#defined-names) · [Protection](#protection) · [Conditional formats](#conditional-formats) · [Data validation](#data-validation) · [Comments](#comments) · [Images](#images) · [Tables](#tables) · [Charts](#charts) · [Errors](#errors)
85
+
86
+ ### Writing cells
11
87
 
12
88
  ```ruby
13
89
  require "fast_xlsx"
@@ -19,6 +95,7 @@ ws << ["id", "name", "created_at"] # append a row
19
95
  ws.concat(records.map { |r| [r.id, r.name, r.created_at] }) # append many rows in one call
20
96
  ws.write(0, 5, 42) # write a single cell (row, col, value)
21
97
  ws.write("F1", 42) # or by its Excel reference
98
+ ws.write("F1", 42, { bold: true }) # a format positionally, or as format: like append
22
99
  ws["F1"] = 42 # the same (ws[0, 5] = 42 too)
23
100
 
24
101
  wb.properties(title: "Q3 report", author: "Zac", keywords: "Confidential") # File > Info in Excel
@@ -293,7 +370,7 @@ Values are mapped by type:
293
370
  |---|---|
294
371
  | `Integer`, `Float`, any `Numeric` | number |
295
372
  | `String` | string |
296
- | `Time`, `DateTime` | date and time (`yyyy-mm-dd hh:mm:ss` unless formatted), in its own offset |
373
+ | `Time`, `DateTime`, time-like objects such as Rails' `ActiveSupport::TimeWithZone` | date and time (`yyyy-mm-dd hh:mm:ss` unless formatted), in its own offset |
297
374
  | `Date` | date (`yyyy-mm-dd` unless formatted) |
298
375
  | `FastXlsx::Formula.new("SUM(A1:A9)")` | formula |
299
376
  | `FastXlsx::URL.new("https://…")`, `URL.new(url, text: "Title")` | hyperlink (optionally showing other text) |
@@ -367,17 +444,12 @@ BUNDLE_GEMFILE=bench/Gemfile /usr/bin/time -l bundle exec ruby bench/memory.rb b
367
444
  BUNDLE_GEMFILE=bench/Gemfile /usr/bin/time -l bundle exec ruby bench/memory.rb fast_xlsx:low unique
368
445
  ```
369
446
 
370
- ## Installation
371
-
372
- ```bash
373
- bundle add fast_xlsx
374
- ```
375
-
376
- ### Support and versioning
447
+ ## Support and versioning
377
448
 
378
449
  - **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).
379
450
  - **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.
380
451
  - **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".
452
+ - **Deprecations:** from 1.0, a method or option to be removed first prints a warning (with `warn`, so on by default) for at least one minor release, with the CHANGELOG naming its replacement; it goes only in the next major version.
381
453
  - **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.
382
454
 
383
455
  ## Development
data/bench/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- fast_xlsx (0.7.0)
4
+ fast_xlsx (0.8.1)
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.7.0)
80
+ fast_xlsx (0.8.1)
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
@@ -0,0 +1,123 @@
1
+ # Migrating from fast_excel
2
+
3
+ fast_xlsx started as a rewrite of [fast_excel](https://github.com/Paxa/fast_excel) on [rust_xlsxwriter](https://github.com/jmcnamara/rust_xlsxwriter), and keeps its spirit: append rows fast, with little memory. Most code moves over with a few renames. This guide lists them, then the differences in behaviour to check.
4
+
5
+ **Why switch**
6
+
7
+ - 2.8–3.3× faster on the [README benchmark](../README.md#speed), and `memory: :constant` adds about 1 MB however large the file.
8
+ - Precompiled gems for Linux, macOS and Windows: no C compiler or FFI at install.
9
+ - Dates show as dates without a format, every option is checked (a typo raises instead of being ignored), and saving doesn't block other threads.
10
+ - Conditional formats, data validation, tables, charts, images, comments, protection and printing options as Ruby methods.
11
+
12
+ **Before you start**
13
+
14
+ - fast_xlsx needs CRuby 3.3 or later (fast_excel supports 2.7+).
15
+ - Rows and columns are 0-based in both, and `<<` appends in both.
16
+
17
+ ## A report, before and after
18
+
19
+ ```ruby
20
+ # fast_excel
21
+ workbook = FastExcel.open(constant_memory: true)
22
+ worksheet = workbook.add_worksheet("Orders")
23
+ bold = workbook.bold_format
24
+ worksheet.append_row(["Order", "Total", "Placed at"], bold)
25
+ money = workbook.number_format("#,##0.00")
26
+ orders.each { |o| worksheet.append_row([o.number, o.total, o.created_at], [nil, money, nil]) }
27
+ worksheet.set_column_width(0, 20)
28
+ send_data workbook.read_string, filename: "orders.xlsx"
29
+ ```
30
+
31
+ ```ruby
32
+ # fast_xlsx
33
+ workbook = FastXlsx::Workbook.new(memory: :constant)
34
+ worksheet = workbook.add_worksheet("Orders")
35
+ worksheet.append(["Order", "Total", "Placed at"], format: { bold: true })
36
+ money = FastXlsx::Format.new(num_format: "#,##0.00")
37
+ orders.each { |o| worksheet.append([o.number, o.total, o.created_at], format: [nil, money, nil]) }
38
+ worksheet.column_width(0, 20)
39
+ send_data workbook.to_xlsx, filename: "orders.xlsx"
40
+ ```
41
+
42
+ `created_at` now shows as `yyyy-mm-dd hh:mm:ss` without a format; with fast_excel it was a number unless you passed a date format.
43
+
44
+ ## Method by method
45
+
46
+ ### Workbook
47
+
48
+ | fast_excel | fast_xlsx |
49
+ |---|---|
50
+ | `FastExcel.open` | `FastXlsx::Workbook.new` |
51
+ | `FastExcel.open(constant_memory: true)` | `FastXlsx::Workbook.new(memory: :constant)` (or `:low`, which keeps Excel's shared strings) |
52
+ | `FastExcel.open("report.xlsx")` … `workbook.close` | `FastXlsx::Workbook.new` … `workbook.save("report.xlsx")` |
53
+ | `workbook.read_string` | `workbook.to_xlsx` |
54
+ | `workbook.remove_tmp_folder` | not needed: no temp file is created |
55
+ | `workbook.add_worksheet(name)` | `workbook.add_worksheet(name)` |
56
+ | `workbook.get_worksheet_by_name(name)` | `workbook.worksheet(name)` |
57
+ | `workbook.add_format(bold: true)` | `FastXlsx::Format.new(bold: true)`, or pass `{ bold: true }` where a format goes |
58
+ | `workbook.bold_format` | `FastXlsx::Format.new(bold: true)` |
59
+ | `workbook.number_format("0.00")` | `FastXlsx::Format.new(num_format: "0.00")` |
60
+
61
+ ### Writing
62
+
63
+ | fast_excel | fast_xlsx |
64
+ |---|---|
65
+ | `worksheet << values` / `append_row(values, format)` | `worksheet << values` / `append(values, format: format)` |
66
+ | `write_value(row, col, value, format)` | `write(row, col, value, format)`, or `write("B2", value, format)`, or `worksheet["B2"] = value` |
67
+ | `write_number`, `write_string`, `write_datetime`, `write_formula`, `write_url`, `write_boolean` | `write`: the type comes from the value |
68
+ | `write_row(row, values, formats)` | `values.each_with_index { \|v, col\| worksheet.write(row, col, v, format) }`, or `append` when it is the next row |
69
+ | `FastExcel::Formula.new("SUM(A1:A9)")` | `FastXlsx::Formula.new("SUM(A1:A9)")` |
70
+ | `FastExcel::URL.new(url)` | `FastXlsx::URL.new(url)`, and `URL.new(url, text: "shown")` |
71
+ | `FastExcel.date_num(time, offset)` with a date format | `time` as it is: dates are converted and formatted for you |
72
+ | `worksheet.last_row_number` | `worksheet.next_row - 1` |
73
+
74
+ ### Columns, rows and filters
75
+
76
+ | fast_excel | fast_xlsx |
77
+ |---|---|
78
+ | `set_column_width(col, width)` | `column_width(col, width)` |
79
+ | `set_columns_width(first, last, width)` | `column_width(first..last, width)` |
80
+ | `set_column(first, last, width, format)` | `column_width(first..last, width)` and `column_format(first..last, format)` |
81
+ | `set_row(row, height, format)` | `row_height(row, height)` and `row_format(row, format)` |
82
+ | `worksheet.auto_width = true` (before writing) | `worksheet.autofit` (after writing; see below) |
83
+ | `enable_filters!(end_col: 3)` | `autofilter(0, 0, worksheet.next_row - 1, 3)`, or `autofilter("A1:D100")` |
84
+ | `freeze_panes`, `merge_range` and other libxlsxwriter functions | Ruby methods with the same idea: `freeze_panes("A2")`, `merge_range("A1:D1", "Title")`; see the [Guide](../README.md#guide) |
85
+
86
+ ### Format options
87
+
88
+ | fast_excel | fast_xlsx |
89
+ |---|---|
90
+ | `bold:`, `italic:`, `text_wrap:`, `shrink:`, `font_size:`, `font_name:`, `num_format:`, `rotation:`, `indent:`, `bg_color:` | the same |
91
+ | `font_family:` | `font_name:` |
92
+ | `font_strikeout: true` | `strikeout: true` |
93
+ | `underline: :underline_single` (`_double`, `_single_accounting`, `_double_accounting`) | `underline: true` or `:single` (`:double`, `:single_accounting`, `:double_accounting`) |
94
+ | `font_script: :font_subscript` / `:font_superscript` | `font_script: :subscript` / `:superscript` |
95
+ | `font_color: :orange`, `"#FF0000"`, `0xFF0000` | `"#FF0000"` or `0xFF0000`: color names aren't supported, use their hex value |
96
+ | `border: :border_thin` | `border: :thin` (also `:medium`, `:thick`, `:dashed`, `:dotted`, `:double`, `:hair`) |
97
+ | `left: :medium`, `top:`, `right:`, `bottom:` | `border_left: :medium`, `border_top:`, `border_right:`, `border_bottom:` |
98
+ | `left_color:`, `top_color:` … per side | `border_color:`, one color for every side |
99
+ | `align: { h: :align_center, v: :align_vertical_center }` | `align: :center, valign: :center` |
100
+
101
+ ## Differences in behaviour
102
+
103
+ - **Dates have a format.** fast_excel wrote `Time` and `Date` as plain numbers unless you passed a date format. fast_xlsx shows them as `yyyy-mm-dd` (`Date`) or `yyyy-mm-dd hh:mm:ss` (`Time`, `DateTime`), and adds that to a format without a `num_format` (a bold row stays bold). Pass your own `num_format` to keep a different one. Both use the value's own UTC offset, so times don't shift. Dates before 1900-03-01 now show correctly; fast_excel's showed one day late, because Excel counts a 1900-02-29 that never existed.
104
+ - **Errors raise.** Unknown or misspelled options, invalid colors and values Excel can't hold (a string over 32,767 characters, a row past 1,048,576) raise `ArgumentError`, `RangeError` or `FastXlsx::Error`; see [Errors](../README.md#errors). In `:constant` mode, writing to a row already on disk raises `FastXlsx::Error` (fast_excel raised `ArgumentError`).
105
+ - **Autofit runs after writing.** fast_excel measured text as you wrote it (strings only). `autofit` measures what is in memory when you call it, so in `:constant` / `:low` mode it only sees the last rows: set widths with `column_width` there. It measures raw values, so give formatted numbers, dates and formulas a width by hand.
106
+ - **No temp files.** fast_excel always wrote to a file, a temporary one without a filename. fast_xlsx builds the workbook in memory (`:standard`) or in its own temp files (`:constant` / `:low`) and gives you the result with `to_xlsx` or `save`.
107
+ - **Formats are values.** A `FastXlsx::Format` is frozen and created on its own, not from a workbook, so one format can serve several workbooks. Use `format.merge(italic: true)` for a variant.
108
+
109
+ ## Not available
110
+
111
+ - **The libxlsxwriter functions under `Libxlsxwriter.*`.** fast_xlsx has no raw binding; the [Guide](../README.md#guide) and the [API docs](https://www.rubydoc.info/gems/fast_xlsx) cover what is exposed, and the [roadmap](https://github.com/7a6163/fast_xlsx/milestone/2) what is planned.
112
+ - **`FastExcel.open(default_format: ...)`.** Use `column_format` / `row_format`, or pass the format when writing.
113
+ - **Some format options:** color names, per-side border colors, `font_outline`, `font_shadow`, `text_justlast`, and alignments other than left, center and right (horizontal) or top, center and bottom (vertical).
114
+ - **Reading or editing existing files**: neither library does this.
115
+
116
+ ## Checklist
117
+
118
+ 1. Replace `gem "fast_excel"` with `gem "fast_xlsx"`, and check you are on Ruby 3.3+.
119
+ 2. Swap `FastExcel.open` / `read_string` / `close` for `FastXlsx::Workbook.new` / `to_xlsx` / `save`.
120
+ 3. Rename the methods and format options from the tables above; run your code: a missed rename raises `NoMethodError` or `ArgumentError` rather than being ignored.
121
+ 4. Drop `FastExcel.date_num` and date formats you only added to make dates readable.
122
+ 5. Replace `auto_width = true` with `autofit` after writing (or `column_width` in `:constant` mode).
123
+ 6. Open one generated file in Excel and compare.
@@ -0,0 +1,44 @@
1
+ require "date"
2
+ require "fast_xlsx"
3
+
4
+ # Sample data; in an app it comes from your database.
5
+ Sale = Struct.new(:region, :product, :units, :price, :sold_on)
6
+ sales = [
7
+ Sale.new("North", "Widget", 120, 9.5, Date.new(2026, 7, 3)),
8
+ Sale.new("North", "Gadget", 45, 24.0, Date.new(2026, 7, 9)),
9
+ Sale.new("South", "Widget", 80, 9.5, Date.new(2026, 7, 14)),
10
+ Sale.new("South", "Gizmo", 12, 120.0, Date.new(2026, 7, 21))
11
+ ]
12
+
13
+ # 1. A workbook with one worksheet.
14
+ wb = FastXlsx::Workbook.new
15
+ ws = wb.add_worksheet("Sales")
16
+
17
+ # 2. A header row, bold on a light blue fill.
18
+ header = FastXlsx::Format.new(bold: true, bg_color: "#DDEBF7", border_bottom: :thin)
19
+ ws.append(["Region", "Product", "Units", "Price", "Sold on", "Revenue"], format: header)
20
+
21
+ # 3. One row per sale. Dates show as yyyy-mm-dd on their own; a formula is a
22
+ # FastXlsx::Formula. In formulas rows count from 1, so the first sale is row 2.
23
+ money = FastXlsx::Format.new(num_format: "#,##0.00")
24
+ sales.each.with_index(2) do |sale, row|
25
+ ws.append([sale.region, sale.product, sale.units, sale.price, sale.sold_on,
26
+ FastXlsx::Formula.new("C#{row}*D#{row}")],
27
+ format: [nil, nil, nil, money, nil, money])
28
+ end
29
+
30
+ # 4. A totals row: the header's look, with the money format added.
31
+ last = sales.size + 1
32
+ ws.append(["Total", nil, FastXlsx::Formula.new("SUM(C2:C#{last})"), nil, nil,
33
+ FastXlsx::Formula.new("SUM(F2:F#{last})")],
34
+ format: [header, header, header, header, header, header.merge(num_format: "#,##0.00")])
35
+
36
+ # 5. Easier to read: columns sized to fit (set by hand where autofit can't tell), the header kept in view while
37
+ # scrolling, and filter buttons on the header (rows and columns count from 0).
38
+ ws.autofit
39
+ ws.column_width(3..5, 12) # money, dates, formulas: autofit measures raw values
40
+ ws.freeze_panes("A2")
41
+ ws.autofilter("A1:F#{last}")
42
+
43
+ # 6. Save it. (In Rails: send_data wb.to_xlsx, filename: "sales.xlsx", ...)
44
+ wb.save("sales.xlsx")
@@ -10,7 +10,7 @@ publish = false
10
10
  crate-type = ["cdylib"]
11
11
 
12
12
  [dependencies]
13
- magnus = "0.9"
13
+ magnus = { version = "0.9", features = ["rb-sys"] }
14
14
  rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] }
15
15
  # No "zlib" feature: without it zip compresses with zlib-rs, which saves
16
16
  # 20-30% faster than the system zlib (and needs no C library).
@@ -52,6 +52,27 @@ fn xerr(e: XlsxError) -> Error {
52
52
 
53
53
  // Excel's limits that rust_xlsxwriter doesn't check: a negative width or
54
54
  // height hides the column or row, a larger one is capped or invalid.
55
+ const BOTH_FORMATS: &str = "pass the format either positionally or as format:, not both";
56
+
57
+ // The format from write's keywords ({ format: f }); others are rejected.
58
+ fn format_keyword(ruby: &Ruby, keywords: Value) -> Result<Value, Error> {
59
+ let keywords = RHash::try_convert(keywords)?;
60
+ let format = ruby.to_symbol("format");
61
+ keywords.foreach(|key: Value, _: Value| {
62
+ if key.eql(format)? {
63
+ Ok(ForEach::Continue)
64
+ } else {
65
+ Err(Error::new(
66
+ ruby.exception_arg_error(),
67
+ format!("unknown keyword: {}", key.inspect()),
68
+ ))
69
+ }
70
+ })?;
71
+ Ok(keywords
72
+ .get(format)
73
+ .unwrap_or_else(|| ruby.qnil().as_value()))
74
+ }
75
+
55
76
  // A size that must be a finite number above 0 (NaN or a negative one would
56
77
  // be written into the file as is).
57
78
  fn check_positive(ruby: &Ruby, what: &str, size: f64) -> Result<f64, Error> {
@@ -307,7 +328,29 @@ fn excel_serial(days: f64) -> Result<f64, Error> {
307
328
  }
308
329
 
309
330
  // Time: seconds since 1970-01-01 (serial day 25569), in its own offset.
331
+ // For a Time (checked by the caller): Ruby's C functions, with no method calls,
332
+ // since this runs once per cell.
310
333
  fn excel_time(v: Value) -> Result<f64, Error> {
334
+ use magnus::rb_sys::{protect, AsRawValue, FromRawValue};
335
+ // Both can raise (a Time out of range or uninitialized, or a zone object
336
+ // whose Ruby code fails), so they run under protect: a raise becomes an
337
+ // Err instead of a longjmp over these Rust frames.
338
+ let mut spec = None;
339
+ protect(|| {
340
+ // SAFETY: protect catches anything this raises.
341
+ spec = Some(unsafe { rb_sys::rb_time_timespec(v.as_raw()) });
342
+ rb_sys::Qnil as rb_sys::VALUE
343
+ })?;
344
+ // SAFETY: as above.
345
+ let offset = protect(|| unsafe { rb_sys::rb_time_utc_offset(v.as_raw()) })?;
346
+ let (spec, offset) = (spec.unwrap(), unsafe { Value::from_raw(offset) });
347
+ let secs = spec.tv_sec as f64 + spec.tv_nsec as f64 / 1e9;
348
+ excel_serial((secs + i64::try_convert(offset)? as f64) / 86400.0 + 25569.0)
349
+ }
350
+
351
+ // Something that acts like a Time without being one at the C level, such as
352
+ // Rails' ActiveSupport::TimeWithZone (it delegates to a Time).
353
+ fn excel_time_like(v: Value) -> Result<f64, Error> {
311
354
  let secs: f64 = v.funcall("to_f", ())?;
312
355
  let offset: i64 = v.funcall("utc_offset", ())?;
313
356
  excel_serial((secs + offset as f64) / 86400.0 + 25569.0)
@@ -315,9 +358,14 @@ fn excel_time(v: Value) -> Result<f64, Error> {
315
358
 
316
359
  // Date / DateTime: Julian day and day fraction are both in the object's own
317
360
  // offset. JD 2415019 is 1899-12-30.
318
- fn excel_date(v: Value) -> Result<f64, Error> {
361
+ // `time`: a DateTime, with a time of day; a Date's day fraction is always 0.
362
+ fn excel_date(v: Value, time: bool) -> Result<f64, Error> {
319
363
  let jd: i64 = v.funcall("jd", ())?;
320
- let fraction: f64 = f64::try_convert(v.funcall("day_fraction", ())?)?;
364
+ let fraction = if time {
365
+ f64::try_convert(v.funcall("day_fraction", ())?)?
366
+ } else {
367
+ 0.0
368
+ };
321
369
  excel_serial((jd - 2_415_019) as f64 + fraction)
322
370
  }
323
371
 
@@ -392,7 +440,10 @@ impl CellValue {
392
440
  CellValue::Rich(parts)
393
441
  } else if v.respond_to("jd", false)? {
394
442
  // Date has no #hour; DateTime does.
395
- CellValue::Date(excel_date(v)?, v.respond_to("hour", false)?)
443
+ let time = v.respond_to("hour", false)?;
444
+ CellValue::Date(excel_date(v, time)?, time)
445
+ } else if v.respond_to("utc_offset", false)? && v.respond_to("to_f", false)? {
446
+ CellValue::Date(excel_time_like(v)?, true)
396
447
  } else {
397
448
  CellValue::Text(v.funcall("to_s", ())?)
398
449
  };
@@ -1171,15 +1222,25 @@ impl Worksheet {
1171
1222
  // here rather than in a Ruby wrapper, since it runs once per cell; the
1172
1223
  // rest ("B2", or a wrong argument count) goes to Ruby's _write_ref.
1173
1224
  fn write_any(ruby: &Ruby, rb_self: Obj<Self>, args: &[Value]) -> Result<Obj<Self>, Error> {
1225
+ // A real format: keyword arrives as a trailing Hash; Ruby says whether
1226
+ // it was one, so a Hash written as the value isn't mistaken for it.
1227
+ let keyword_given = unsafe { rb_sys::rb_keyword_given_p() } != 0;
1228
+ let (args, keyword) = match (keyword_given, args.split_last()) {
1229
+ (true, Some((last, rest))) => (rest, Some(format_keyword(ruby, *last)?)),
1230
+ _ => (args, None),
1231
+ };
1174
1232
  let index = |i: usize| args.get(i).and_then(|v| Integer::from_value(*v));
1175
1233
  if let (3 | 4, Some(row), Some(col)) = (args.len(), index(0), index(1)) {
1234
+ if keyword.is_some() && args.len() == 4 {
1235
+ return Err(Error::new(ruby.exception_arg_error(), BOTH_FORMATS));
1236
+ }
1176
1237
  // A Hash of options becomes a (cached) Format, as in Ruby.
1177
- let format_arg = match args.get(3) {
1178
- Some(f) if RHash::from_value(*f).is_some() => Some(
1238
+ let format_arg = match keyword.or_else(|| args.get(3).copied()) {
1239
+ Some(f) if RHash::from_value(f).is_some() => Some(
1179
1240
  ruby.get_inner(&FORMAT)
1180
- .funcall::<_, _, Value>("_coerce", (*f,))?,
1241
+ .funcall::<_, _, Value>("_coerce", (f,))?,
1181
1242
  ),
1182
- f => f.copied(),
1243
+ f => f,
1183
1244
  };
1184
1245
  let format = match format_arg {
1185
1246
  Some(f) => Option::<&Format>::try_convert(f)?,
@@ -1195,7 +1256,14 @@ impl Worksheet {
1195
1256
  )?;
1196
1257
  return Ok(rb_self);
1197
1258
  }
1198
- rb_self.funcall("_write_ref", args)
1259
+ rb_self.funcall(
1260
+ "_write_ref",
1261
+ (
1262
+ ruby.ary_from_iter(args.iter().copied()),
1263
+ keyword.is_some(),
1264
+ keyword,
1265
+ ),
1266
+ )
1199
1267
  }
1200
1268
 
1201
1269
  fn write(
@@ -2,5 +2,5 @@
2
2
 
3
3
  module FastXlsx
4
4
  # The gem version.
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.1"
6
6
  end
data/lib/fast_xlsx.rb CHANGED
@@ -186,10 +186,13 @@ module FastXlsx
186
186
  # Writes one cell.
187
187
  # @overload write(row, col, value, format = nil)
188
188
  # @overload write(ref, value, format = nil)
189
+ # @overload write(row, col, value, format: nil)
190
+ # @overload write(ref, value, format: nil)
189
191
  # @param value [Numeric, String, Time, Date, DateTime, true, false, nil,
190
192
  # Formula, URL, RichString, #to_s] dates get yyyy-mm-dd (hh:mm:ss) unless
191
193
  # the format has a num_format
192
- # @param format [Format, Hash, nil] a Hash of options works too
194
+ # @param format [Format, Hash, nil] positional or format: (a little slower:
195
+ # Ruby builds a Hash for the keyword); a Hash of options works too
193
196
  # @return [self]
194
197
  #
195
198
  # @!method <<(values)
@@ -252,11 +255,15 @@ module FastXlsx
252
255
  # Hides the gridlines on screen (see {#page_setup} for printing).
253
256
  # @return [self]
254
257
  class Worksheet
258
+ NO_FORMAT = Object.new.freeze # format: not given
259
+ private_constant :NO_FORMAT
260
+
255
261
  # write(row, col, value, format = nil) or write("B2", value, format = nil)
256
262
  # is native: the (row, col) form runs once per cell, so it skips a Ruby
257
263
  # wrapper. Other forms come here.
258
- def _write_ref(*args)
264
+ def _write_ref(args, keyword_given, keyword_format)
259
265
  row, col, (value, format) = CellRange.cell(args, 1..2)
266
+ format = Format.check_both(args.size > (args.first.is_a?(String) ? 2 : 3), keyword_given, format, keyword_format)
260
267
  _write(row, col, value, Format._coerce(format))
261
268
  self
262
269
  end
@@ -311,10 +318,12 @@ module FastXlsx
311
318
 
312
319
  # Merges the range and writes value (any cell type) into its first cell.
313
320
  # @overload merge_range(*range, value, format = nil)
321
+ # @overload merge_range(*range, value, format: nil)
314
322
  # @return [self]
315
323
  # @raise [FastXlsx::Error] when it overlaps an earlier merge
316
- def merge_range(*args)
317
- range, (value, format) = CellRange.split(args, 1..2)
324
+ def merge_range(*args, format: NO_FORMAT)
325
+ range, (value, positional) = CellRange.split(args, 1..2)
326
+ format = Format.check_both(!positional.nil?, !NO_FORMAT.equal?(format), positional, format)
318
327
  _merge_range(*range, value, Format._coerce(format))
319
328
  end
320
329
 
@@ -726,6 +735,15 @@ module FastXlsx
726
735
  Format.new(**to_h, **other.to_h, **)
727
736
  end
728
737
 
738
+ # The format given positionally or as format:, raising if both were.
739
+ # @api private
740
+ def self.check_both(positional_given, keyword_given, positional, keyword)
741
+ return positional unless keyword_given
742
+ raise ArgumentError, "pass the format either positionally or as format:, not both" if positional_given
743
+
744
+ keyword
745
+ end
746
+
729
747
  # Distinct Hashes kept by {._coerce}; past this, the oldest is dropped.
730
748
  CACHE_SIZE = 1_024
731
749
 
data/sig/fast_xlsx.rbs CHANGED
@@ -67,7 +67,9 @@ module FastXlsx
67
67
  def []=: (String cell, cell value) -> cell
68
68
  | (Integer row, Integer col, cell value) -> cell
69
69
  def write: (Integer row, Integer col, cell value, ?format? format) -> self
70
+ | (Integer row, Integer col, cell value, format: format?) -> self
70
71
  | (String cell, cell value, ?format? format) -> self
72
+ | (String cell, cell value, format: format?) -> self
71
73
  def append: (Array[cell] values, ?format: format? | Array[format?]) -> self
72
74
  def <<: (Array[cell] row) -> self
73
75
  def concat: (Array[Array[cell]] rows) -> self
@@ -120,6 +122,7 @@ module FastXlsx
120
122
  def merge_range: (Integer first_row, Integer first_col, Integer last_row, Integer last_col, cell value, ?format? format) -> self
121
123
  | (String range, cell value, ?format? format) -> self
122
124
  | (indexes rows, indexes cols, cell value, ?format? format) -> self
125
+ | (*cell_range | cell args, format: format?) -> self
123
126
  def conditional_format: (*cell_range range,
124
127
  type: :cell | :text | :formula | :data_bar | :color_scale,
125
128
  ?criteria: Symbol, ?value: Numeric | String | Array[Numeric | String],
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.7.0
4
+ version: 0.8.1
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
+ - ".cargo/mutants.toml"
35
36
  - ".yardopts"
36
37
  - CHANGELOG.md
37
38
  - Cargo.lock
@@ -44,6 +45,8 @@ files:
44
45
  - bench/compare.rb
45
46
  - bench/memory.rb
46
47
  - bench/write.rb
48
+ - docs/MIGRATING_FROM_FAST_EXCEL.md
49
+ - examples/getting_started.rb
47
50
  - examples/showcase.rb
48
51
  - ext/fast_xlsx/Cargo.toml
49
52
  - ext/fast_xlsx/build.rs