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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/Cargo.lock +517 -0
- data/Cargo.toml +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +298 -0
- data/Rakefile +22 -0
- data/bench/Gemfile +14 -0
- data/bench/Gemfile.lock +110 -0
- data/bench/compare.rb +113 -0
- data/bench/memory.rb +43 -0
- data/bench/write.rb +53 -0
- data/examples/showcase.rb +146 -0
- data/ext/fast_xlsx/Cargo.toml +18 -0
- data/ext/fast_xlsx/build.rs +5 -0
- data/ext/fast_xlsx/extconf.rb +6 -0
- data/ext/fast_xlsx/src/lib.rs +1339 -0
- data/lib/fast_xlsx/version.rb +5 -0
- data/lib/fast_xlsx.rb +205 -0
- data/sig/fast_xlsx.rbs +100 -0
- metadata +80 -0
data/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# FastXlsx
|
|
2
|
+
|
|
3
|
+
[](https://codecov.io/gh/7a6163/fast_xlsx)
|
|
4
|
+
|
|
5
|
+
Fast `.xlsx` writer for Ruby, built on [rust_xlsxwriter](https://github.com/jmcnamara/rust_xlsxwriter) via [magnus](https://github.com/matsadler/magnus).
|
|
6
|
+
|
|
7
|
+
> **Status: early.** The API may still change before 1.0.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "fast_xlsx"
|
|
13
|
+
|
|
14
|
+
wb = FastXlsx::Workbook.new # or memory: :constant / :low, see below
|
|
15
|
+
ws = wb.add_worksheet("Report") # later: wb.worksheet("Report"), wb.worksheets
|
|
16
|
+
|
|
17
|
+
ws << ["id", "name", "created_at"] # append a row
|
|
18
|
+
ws.concat(records.map { |r| [r.id, r.name, r.created_at] }) # append many rows in one call
|
|
19
|
+
ws.write(0, 5, 42) # write a single cell (row, col, value)
|
|
20
|
+
|
|
21
|
+
wb.properties(title: "Q3 report", author: "Zac", keywords: "Confidential") # File > Info in Excel
|
|
22
|
+
wb.save("report.xlsx") # or wb.to_xlsx => binary String
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Memory modes
|
|
26
|
+
|
|
27
|
+
By default every cell stays in memory until the file is saved. For large exports, two modes write each finished row to a temp file instead:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
FastXlsx::Workbook.new # memory: :standard (default): everything in memory, any write order
|
|
31
|
+
FastXlsx::Workbook.new(memory: :constant) # rows on disk, strings stored inline in each cell
|
|
32
|
+
FastXlsx::Workbook.new(memory: :low) # rows on disk, strings in Excel's shared string table
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| | `:standard` (default) | `:constant` | `:low` |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| Finished rows | kept in memory | written to disk | written to disk |
|
|
38
|
+
| Memory grows with | all cells | nothing (flat) | the number of unique strings |
|
|
39
|
+
| Write order | any | top to bottom only | top to bottom only |
|
|
40
|
+
| `autofit` | full | only sees rows still in memory | only sees rows still in memory |
|
|
41
|
+
| Strings | shared string table | inline in each cell | shared string table |
|
|
42
|
+
| Output | standard | some readers (e.g. xsv) don't support inline strings | standard |
|
|
43
|
+
|
|
44
|
+
Which one:
|
|
45
|
+
|
|
46
|
+
- **`:standard`** for normal reports, when you need to go back and change earlier rows, or rely on `autofit`.
|
|
47
|
+
- **`:constant`** for large exports written row by row: memory stays flat whatever the data, and it is the fastest mode.
|
|
48
|
+
- **`:low`** for large exports that other programs will read: memory stays low when strings repeat (regions, statuses, …) and the file uses the standard shared string table. With many unique strings it keeps those strings in memory until `save`.
|
|
49
|
+
|
|
50
|
+
In both disk-backed modes, writing to a row that was already written to disk raises `FastXlsx::Error`, and tables must be added before their data (see [Tables](#tables)). An unknown mode raises `ArgumentError`.
|
|
51
|
+
|
|
52
|
+
### Formats
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
header = FastXlsx::Format.new(bold: true, bg_color: "#DDEBF7", border_bottom: :thin, align: :center)
|
|
56
|
+
date = FastXlsx::Format.new(num_format: "yyyy-mm-dd")
|
|
57
|
+
|
|
58
|
+
ws.append(["id", "name", "created_at"], format: header) # format every cell in the row
|
|
59
|
+
ws.append([1, "a", Time.now], format: [nil, nil, date]) # or one format (or nil) per cell
|
|
60
|
+
ws.write(1, 2, Date.today, date) # format one cell
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Option | Values |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `bold`, `italic`, `strikeout`, `text_wrap`, `shrink` | `true` / `false` |
|
|
66
|
+
| `underline` | `true` (single), `:single`, `:double`, `:single_accounting`, `:double_accounting` |
|
|
67
|
+
| `font_script` | `:superscript`, `:subscript` |
|
|
68
|
+
| `rotation` | degrees, `-90..90`, or `270` for stacked text |
|
|
69
|
+
| `indent` | indent level, e.g. `2` |
|
|
70
|
+
| `font_size` | number, e.g. `14` |
|
|
71
|
+
| `font_name` | e.g. `"Arial"` |
|
|
72
|
+
| `font_color`, `bg_color` | `"#RRGGBB"` or `0xRRGGBB` |
|
|
73
|
+
| `num_format` | Excel number format, e.g. `"#,##0.00"`, `"yyyy-mm-dd"` |
|
|
74
|
+
| `align` | `:left`, `:center`, `:right` |
|
|
75
|
+
| `valign` | `:top`, `:center`, `:bottom` |
|
|
76
|
+
| `border`, `border_left`, `border_right`, `border_top`, `border_bottom` | `:thin`, `:medium`, `:thick`, `:dashed`, `:dotted`, `:double`, `:hair` |
|
|
77
|
+
| `border_color` | `"#RRGGBB"` or `0xRRGGBB` |
|
|
78
|
+
|
|
79
|
+
Per-side borders override `border`. Unknown options and invalid values raise `ArgumentError`.
|
|
80
|
+
|
|
81
|
+
### Columns and filters
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
ws.column_width(0, 20) # column A, width in characters
|
|
85
|
+
ws.column_width(1..3, 12) # columns B–D
|
|
86
|
+
ws.column_format(4, FastXlsx::Format.new(num_format: "#,##0.00")) # default for cells in E written without a format
|
|
87
|
+
ws.autofit # size other columns to the data written so far; set widths are kept
|
|
88
|
+
ws.autofilter(0, 0, 100, 3) # filter buttons on A1:D101 (first_row, first_col, last_row, last_col)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`autofit` only sees rows still in memory, so in `:constant` / `:low` memory mode it ignores rows already written to disk; set widths with `column_width` instead.
|
|
92
|
+
|
|
93
|
+
### Layout
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
ws.freeze_panes(1, 0) # keep the first row visible while scrolling
|
|
97
|
+
ws.row_height(0, 30) # row 1, height in points
|
|
98
|
+
ws.merge_range(0, 0, 0, 3, "Q3 report", title) # merge A1:D1; the value can be any cell type
|
|
99
|
+
ws.page_breaks([50, 100]) # print a new page before rows 51 and 101
|
|
100
|
+
ws.vertical_page_breaks([8]) # and before column I
|
|
101
|
+
ws.page_header("&CPage &P of &N") # printed header, Excel header/footer codes
|
|
102
|
+
ws.page_footer("&L&A", margin: 0.2) # sheet name on the left; margin in inches
|
|
103
|
+
ws.margins(left: 0.5, top: 1) # other margins keep Excel's defaults
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Conditional formats
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
red = FastXlsx::Format.new(font_color: "#9C0006", bg_color: "#FFC7CE")
|
|
110
|
+
|
|
111
|
+
# rows 1–100 of column B (first_row, first_col, last_row, last_col)
|
|
112
|
+
ws.conditional_format(0, 1, 99, 1, type: :cell, criteria: :<, value: 0, format: red)
|
|
113
|
+
ws.conditional_format(0, 1, 99, 1, type: :cell, criteria: :between, value: [1, 10], format: red)
|
|
114
|
+
ws.conditional_format(0, 0, 99, 0, type: :text, criteria: :contains, value: "error", format: red)
|
|
115
|
+
ws.conditional_format(0, 0, 99, 3, type: :formula, value: "=$D1>100", format: red)
|
|
116
|
+
ws.conditional_format(0, 2, 99, 2, type: :data_bar)
|
|
117
|
+
ws.conditional_format(0, 2, 99, 2, type: :color_scale) # 3-color; colors: 2 for 2-color
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| `type` | `criteria` | `value` |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `:cell` | `:==`, `:!=`, `:>`, `:>=`, `:<`, `:<=`, `:between`, `:not_between` | number or string; `[min, max]` for the range criteria |
|
|
123
|
+
| `:text` | `:contains`, `:not_contains`, `:begins_with`, `:ends_with` | string |
|
|
124
|
+
| `:formula` | — | formula string, relative to the top-left cell |
|
|
125
|
+
| `:data_bar`, `:color_scale` | — | — |
|
|
126
|
+
|
|
127
|
+
### Data validation
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
ws.data_validation(1, 2, 100, 2, type: :list, value: %w[Open Closed]) # dropdown in C2:C101
|
|
131
|
+
ws.data_validation(1, 2, 100, 2, type: :list, value: "=$Z$1:$Z$10") # dropdown from a range
|
|
132
|
+
ws.data_validation(1, 3, 100, 3, type: :whole_number, criteria: :between, value: [1, 10],
|
|
133
|
+
input_title: "Quantity", input_message: "1 to 10",
|
|
134
|
+
error_title: "Invalid", error_message: "Enter a whole number from 1 to 10")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`type` is `:list`, `:whole_number`, `:decimal` or `:text_length`; the number types take the same `criteria` as `:cell` conditional formats.
|
|
138
|
+
|
|
139
|
+
### Comments
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
ws.write_comment(0, 0, "Checked by finance", author: "Zac") # Excel shows it as a note on A1
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Images
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
ws.insert_image(0, 0, "logo.png") # top-left corner in A1
|
|
149
|
+
ws.insert_image(0, 5, StringIO.new(blob.download), scale: 0.5, x_offset: 10, y_offset: 4, alt_text: "Logo")
|
|
150
|
+
ws.insert_image(10, 0, "chart.png", width: 320, height: 180) # pixel size; one of them keeps the aspect ratio
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A String is always treated as a path, so wrap raw bytes (such as Active Storage's `blob.download`) in a `StringIO`.
|
|
154
|
+
|
|
155
|
+
The source is a file path or any IO responding to `#read` (PNG, JPEG, GIF or BMP). Offsets are in pixels. Data that is not a supported image raises `FastXlsx::Error`.
|
|
156
|
+
|
|
157
|
+
### Tables
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
ws.concat([%w[Region Rep Sales], *sales]) # header row, then the data
|
|
161
|
+
ws.add_table(0, 0, sales.size + 1, 2, total_row: true, style: :medium2, # +1 row for the totals
|
|
162
|
+
columns: [{ header: "Region", total_label: "Total" }, "Rep", { header: "Sales", total: :sum }])
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The range includes the header row and, with `total_row: true`, the total row; the table writes the headers. `columns` must match the range width. Options: `style` (`:light1`–`:light21`, `:medium1`–`:medium28`, `:dark1`–`:dark11`, `:none`), `name`, `total_row`, `banded_rows`, `autofilter`. Column totals: `:sum`, `:average`, `:count`, `:count_numbers`, `:max`, `:min`, `:std_dev`, `:var`.
|
|
166
|
+
|
|
167
|
+
You can also add the table first and then append the data: after `add_table`, `<<` / `append` / `concat` continue right under the header row. In `:constant` / `:low` memory mode this is the only order that works; adding a table whose header row was already written to disk raises `FastXlsx::Error`.
|
|
168
|
+
|
|
169
|
+
### Charts
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
ws.concat([%w[Month Sales Costs], ["Jan", 10, 7], ["Feb", 25, 12], ["Mar", 18, 11]])
|
|
173
|
+
|
|
174
|
+
ws.insert_chart(1, 4, type: :column,
|
|
175
|
+
series: [
|
|
176
|
+
{ name: "Sales", categories: "Sheet1!$A$2:$A$4", values: "Sheet1!$B$2:$B$4" },
|
|
177
|
+
{ name: "Costs", categories: "Sheet1!$A$2:$A$4", values: "Sheet1!$C$2:$C$4" }
|
|
178
|
+
],
|
|
179
|
+
title: "Q1", x_axis: "Month", y_axis: "Amount", width: 600, height: 360)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`type`: `:column`, `:column_stacked`, `:bar`, `:bar_stacked`, `:line`, `:line_stacked`, `:area`, `:area_stacked`, `:pie`, `:doughnut`, `:radar`, `:scatter`. Ranges use Excel syntax, so a chart can plot data from another worksheet. Sizes are in pixels (default 480 × 288).
|
|
183
|
+
|
|
184
|
+
Values are mapped by type:
|
|
185
|
+
|
|
186
|
+
| Ruby | Excel |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `Integer`, `Float`, any `Numeric` | number |
|
|
189
|
+
| `String` | string |
|
|
190
|
+
| `Time` | number (Excel serial date, local time) |
|
|
191
|
+
| `Date`, `DateTime` | number (Excel serial date, own offset) |
|
|
192
|
+
| `FastXlsx::Formula.new("SUM(A1:A9)")` | formula |
|
|
193
|
+
| `FastXlsx::URL.new("https://…")`, `URL.new(url, text: "Title")` | hyperlink (optionally showing other text) |
|
|
194
|
+
| `FastXlsx::RichString.new(["Total: ", bold], "1,234")` | text with a format per segment |
|
|
195
|
+
| `true` / `false` | boolean |
|
|
196
|
+
| `nil` | empty cell |
|
|
197
|
+
| anything else | `to_s` as string |
|
|
198
|
+
|
|
199
|
+
`<<` 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.
|
|
200
|
+
|
|
201
|
+
Errors from the writer (invalid sheet names, writes to rows already on disk, …) raise `FastXlsx::Error`.
|
|
202
|
+
|
|
203
|
+
## Performance
|
|
204
|
+
|
|
205
|
+
Apple Silicon, Ruby 4.0.5. Each library uses its own idiomatic row-append API; xlsxtream is a streaming writer with fewer features.
|
|
206
|
+
|
|
207
|
+
### Speed
|
|
208
|
+
|
|
209
|
+
20,000 rows × 5 columns (integer, string, integer, `Time`, float), build + serialize to a String, median of 7 runs (3 for rubyXL):
|
|
210
|
+
|
|
211
|
+
| Library | Time | vs fastest | Ruby objects allocated |
|
|
212
|
+
|---|---:|---:|---:|
|
|
213
|
+
| **fast_xlsx** (`memory: :constant`) | **92 ms** | 1.0x | 7 |
|
|
214
|
+
| **fast_xlsx** (`memory: :low`) | **101 ms** | 1.1x | 7 |
|
|
215
|
+
| **fast_xlsx** | **102 ms** | 1.1x | 10 |
|
|
216
|
+
| [xlsxtream](https://github.com/felixbuenemann/xlsxtream) 3.1 | 181 ms | 2.0x | 561,728 |
|
|
217
|
+
| [fast_excel](https://github.com/Paxa/fast_excel) 0.5 (constant_memory) | 202 ms | 2.2x | 20,079 |
|
|
218
|
+
| [fast_excel](https://github.com/Paxa/fast_excel) 0.5 | 240 ms | 2.6x | 320,076 |
|
|
219
|
+
| [write_xlsx](https://github.com/cxn03651/write_xlsx) 1.15 | 610 ms | 6.7x | 1,483,899 |
|
|
220
|
+
| [caxlsx](https://github.com/caxlsx/caxlsx) 4.5 | 678 ms | 7.4x | 745,122 |
|
|
221
|
+
| [rubyXL](https://github.com/weshatheleopard/rubyXL) 3.4 | 2624 ms | 28.6x | 8,700,448 |
|
|
222
|
+
|
|
223
|
+
All outputs are 702–750 KB.
|
|
224
|
+
|
|
225
|
+
### Memory
|
|
226
|
+
|
|
227
|
+
200,000 rows × 5 columns saved to a file; extra peak RSS over a process that only builds the data, median of 3 runs. "Unique" gives every row a different 100-character string; "repeated" uses a handful of values (regions, statuses), as most reports do:
|
|
228
|
+
|
|
229
|
+
| Library | Unique strings | Repeated strings |
|
|
230
|
+
|---|---:|---:|
|
|
231
|
+
| **fast_xlsx** (`memory: :constant`) | **+2 MB** | **+2 MB** |
|
|
232
|
+
| **fast_xlsx** (`memory: :low`) | +62 MB | **+2 MB** |
|
|
233
|
+
| **fast_xlsx** | +270 MB | +217 MB |
|
|
234
|
+
| fast_excel 0.5 (constant_memory) | +10 MB | +10 MB |
|
|
235
|
+
| fast_excel 0.5 | +183 MB | +151 MB |
|
|
236
|
+
|
|
237
|
+
The `:standard` mode uses more memory than fast_excel's: when saving, rust_xlsxwriter assembles each worksheet's XML in memory (so several worksheets can be built in parallel) instead of streaming it from a temp file. Use `memory: :constant` or `memory: :low` for large exports.
|
|
238
|
+
|
|
239
|
+
### Reproduce
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
bundle exec rake compile
|
|
243
|
+
BUNDLE_GEMFILE=bench/Gemfile bundle install
|
|
244
|
+
BUNDLE_GEMFILE=bench/Gemfile bundle exec ruby bench/compare.rb # speed; optional row count argument
|
|
245
|
+
|
|
246
|
+
# memory: peak RSS of one run (use /usr/bin/time -v on Linux); subtract the baseline
|
|
247
|
+
BUNDLE_GEMFILE=bench/Gemfile /usr/bin/time -l bundle exec ruby bench/memory.rb baseline unique
|
|
248
|
+
BUNDLE_GEMFILE=bench/Gemfile /usr/bin/time -l bundle exec ruby bench/memory.rb fast_xlsx:low unique
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Installation
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
bundle add fast_xlsx
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
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.
|
|
258
|
+
|
|
259
|
+
## Development
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
bin/setup
|
|
263
|
+
bundle exec rake compile # build the Rust extension into lib/fast_xlsx/
|
|
264
|
+
bundle exec rake test
|
|
265
|
+
bundle exec rake # compile + test + rubocop
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Coverage of the Rust extension while the Ruby tests run (needs [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov) and `rustup component add llvm-tools-preview`):
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
rm -rf tmp lib/fast_xlsx/fast_xlsx.bundle # force an instrumented rebuild
|
|
272
|
+
eval "$(cargo llvm-cov show-env --export-prefix)"
|
|
273
|
+
bundle exec rake compile test
|
|
274
|
+
cargo llvm-cov report --release # or --lcov / --html
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Rebuild in a clean shell afterwards (`rm -rf tmp && bundle exec rake compile`) so the everyday build is not instrumented.
|
|
278
|
+
|
|
279
|
+
### Releasing
|
|
280
|
+
|
|
281
|
+
First generate the showcase workbook and open it in Excel to check every feature renders (the test suite only inspects the XML):
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
bundle exec rake compile
|
|
285
|
+
ruby -Ilib examples/showcase.rb showcase.xlsx
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Then bump `FastXlsx::VERSION`, update `CHANGELOG.md`, commit, and push a matching tag:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
The `Build gems` workflow builds the source gem plus precompiled gems for each platform and pushes them to RubyGems (trusted publishing) and GitHub Packages. It refuses to publish if the tag and `VERSION` differ.
|
|
295
|
+
|
|
296
|
+
## License
|
|
297
|
+
|
|
298
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
Minitest::TestTask.create
|
|
7
|
+
|
|
8
|
+
require "rubocop/rake_task"
|
|
9
|
+
|
|
10
|
+
RuboCop::RakeTask.new
|
|
11
|
+
|
|
12
|
+
require "rb_sys/extensiontask"
|
|
13
|
+
|
|
14
|
+
task build: :compile
|
|
15
|
+
|
|
16
|
+
GEMSPEC = Gem::Specification.load("fast_xlsx.gemspec")
|
|
17
|
+
|
|
18
|
+
RbSys::ExtensionTask.new("fast_xlsx", GEMSPEC) do |ext|
|
|
19
|
+
ext.lib_dir = "lib/fast_xlsx"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
task default: %i[compile test rubocop]
|
data/bench/Gemfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Libraries compared in bench/compare.rb. Kept out of the main Gemfile so CI
|
|
4
|
+
# does not install them. Usage: BUNDLE_GEMFILE=bench/Gemfile bundle exec ruby bench/compare.rb
|
|
5
|
+
|
|
6
|
+
source "https://rubygems.org"
|
|
7
|
+
|
|
8
|
+
gem "fast_xlsx", path: ".."
|
|
9
|
+
|
|
10
|
+
gem "caxlsx"
|
|
11
|
+
gem "fast_excel"
|
|
12
|
+
gem "rubyXL"
|
|
13
|
+
gem "write_xlsx"
|
|
14
|
+
gem "xlsxtream"
|
data/bench/Gemfile.lock
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: ..
|
|
3
|
+
specs:
|
|
4
|
+
fast_xlsx (0.1.0)
|
|
5
|
+
rb_sys (~> 0.9.130)
|
|
6
|
+
|
|
7
|
+
GEM
|
|
8
|
+
remote: https://rubygems.org/
|
|
9
|
+
specs:
|
|
10
|
+
caxlsx (4.5.0)
|
|
11
|
+
htmlentities (~> 4.3, >= 4.3.4)
|
|
12
|
+
marcel (~> 1.0)
|
|
13
|
+
nokogiri (~> 1.10, >= 1.10.4)
|
|
14
|
+
rubyzip (>= 2.4, < 4)
|
|
15
|
+
fast_excel (0.5.0)
|
|
16
|
+
ffi (> 1.9, < 2)
|
|
17
|
+
ffi (1.17.4-aarch64-linux-gnu)
|
|
18
|
+
ffi (1.17.4-aarch64-linux-musl)
|
|
19
|
+
ffi (1.17.4-arm-linux-gnu)
|
|
20
|
+
ffi (1.17.4-arm-linux-musl)
|
|
21
|
+
ffi (1.17.4-arm64-darwin)
|
|
22
|
+
ffi (1.17.4-x86_64-darwin)
|
|
23
|
+
ffi (1.17.4-x86_64-linux-gnu)
|
|
24
|
+
ffi (1.17.4-x86_64-linux-musl)
|
|
25
|
+
htmlentities (4.4.2)
|
|
26
|
+
marcel (1.2.1)
|
|
27
|
+
nkf (0.3.0)
|
|
28
|
+
nokogiri (1.19.4-aarch64-linux-gnu)
|
|
29
|
+
racc (~> 1.4)
|
|
30
|
+
nokogiri (1.19.4-aarch64-linux-musl)
|
|
31
|
+
racc (~> 1.4)
|
|
32
|
+
nokogiri (1.19.4-arm-linux-gnu)
|
|
33
|
+
racc (~> 1.4)
|
|
34
|
+
nokogiri (1.19.4-arm-linux-musl)
|
|
35
|
+
racc (~> 1.4)
|
|
36
|
+
nokogiri (1.19.4-arm64-darwin)
|
|
37
|
+
racc (~> 1.4)
|
|
38
|
+
nokogiri (1.19.4-x86_64-darwin)
|
|
39
|
+
racc (~> 1.4)
|
|
40
|
+
nokogiri (1.19.4-x86_64-linux-gnu)
|
|
41
|
+
racc (~> 1.4)
|
|
42
|
+
nokogiri (1.19.4-x86_64-linux-musl)
|
|
43
|
+
racc (~> 1.4)
|
|
44
|
+
racc (1.8.1)
|
|
45
|
+
rake-compiler-dock (1.12.0)
|
|
46
|
+
rb_sys (0.9.130)
|
|
47
|
+
rake-compiler-dock (= 1.12.0)
|
|
48
|
+
rubyXL (3.4.38)
|
|
49
|
+
nokogiri (>= 1.10.8)
|
|
50
|
+
rubyzip (>= 3.2.2)
|
|
51
|
+
rubyzip (3.7.0)
|
|
52
|
+
write_xlsx (1.15.1)
|
|
53
|
+
nkf
|
|
54
|
+
rubyzip (>= 2.4.0, < 4.0)
|
|
55
|
+
xlsxtream (3.1.0)
|
|
56
|
+
zip_kit (>= 6.2, < 7)
|
|
57
|
+
zip_kit (6.3.4)
|
|
58
|
+
|
|
59
|
+
PLATFORMS
|
|
60
|
+
aarch64-linux-gnu
|
|
61
|
+
aarch64-linux-musl
|
|
62
|
+
arm-linux-gnu
|
|
63
|
+
arm-linux-musl
|
|
64
|
+
arm64-darwin
|
|
65
|
+
x86_64-darwin
|
|
66
|
+
x86_64-linux-gnu
|
|
67
|
+
x86_64-linux-musl
|
|
68
|
+
|
|
69
|
+
DEPENDENCIES
|
|
70
|
+
caxlsx
|
|
71
|
+
fast_excel
|
|
72
|
+
fast_xlsx!
|
|
73
|
+
rubyXL
|
|
74
|
+
write_xlsx
|
|
75
|
+
xlsxtream
|
|
76
|
+
|
|
77
|
+
CHECKSUMS
|
|
78
|
+
caxlsx (4.5.0) sha256=e3d98d859f148df05d5462086b5079b523f29c1766b569535f1d68629ce743ff
|
|
79
|
+
fast_excel (0.5.0) sha256=59c418bdcf586a6030798d4e3a9f575742badaceaf7bb90f38eee5feb5bdc478
|
|
80
|
+
fast_xlsx (0.1.0)
|
|
81
|
+
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
|
|
82
|
+
ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
|
|
83
|
+
ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
|
|
84
|
+
ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95
|
|
85
|
+
ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
|
|
86
|
+
ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
|
|
87
|
+
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
|
|
88
|
+
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
|
|
89
|
+
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
|
|
90
|
+
marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f
|
|
91
|
+
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
|
|
92
|
+
nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
|
|
93
|
+
nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af
|
|
94
|
+
nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca
|
|
95
|
+
nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb
|
|
96
|
+
nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527
|
|
97
|
+
nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551
|
|
98
|
+
nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a
|
|
99
|
+
nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b
|
|
100
|
+
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
|
101
|
+
rake-compiler-dock (1.12.0) sha256=f13205c2738f3d2053afcd03491a9e4541b22a59a0bfc53fc8bc883bd8188023
|
|
102
|
+
rb_sys (0.9.130) sha256=7d486d99c1da02635515deaf9860fc5aea90bb4ab2589b2deec7fdc7d3548615
|
|
103
|
+
rubyXL (3.4.38) sha256=6b3f46a5ff8ec9903a562604a379a6b79b67cdec73515162b1785bd6092e6ce6
|
|
104
|
+
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
|
|
105
|
+
write_xlsx (1.15.1) sha256=6a97a5ea9af2fd2f248af4aa61cdced0933b6887c96826b6a04f8384a656d3a2
|
|
106
|
+
xlsxtream (3.1.0) sha256=68ce0cac504d86beb9bc626defd5977146c68e5ca2a4ab76c5c61c48e0a66fcd
|
|
107
|
+
zip_kit (6.3.4) sha256=407c6d39feef818678fae2d129077bd2f920c9806072af3482e671eeae3c48ea
|
|
108
|
+
|
|
109
|
+
BUNDLED WITH
|
|
110
|
+
4.0.10
|
data/bench/compare.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Compares fast_xlsx with other Ruby xlsx writers on the same data.
|
|
4
|
+
#
|
|
5
|
+
# BUNDLE_GEMFILE=bench/Gemfile bundle install
|
|
6
|
+
# bundle exec rake compile
|
|
7
|
+
# BUNDLE_GEMFILE=bench/Gemfile bundle exec ruby bench/compare.rb [rows]
|
|
8
|
+
require "stringio"
|
|
9
|
+
|
|
10
|
+
require "fast_xlsx"
|
|
11
|
+
require "fast_excel"
|
|
12
|
+
require "caxlsx"
|
|
13
|
+
require "write_xlsx"
|
|
14
|
+
require "xlsxtream"
|
|
15
|
+
require "rubyXL"
|
|
16
|
+
require "rubyXL/convenience_methods"
|
|
17
|
+
|
|
18
|
+
def elapsed
|
|
19
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
yield
|
|
21
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
ROWS = Integer(ARGV[0] || 20_000)
|
|
25
|
+
DATA = Array.new(ROWS) do |n|
|
|
26
|
+
[n, "String string #{n}" * 5, n * 7 % 1000, Time.at((n * 1000) + 1_492_922_688), n * 100.5]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Each writer builds one worksheet from DATA and returns the .xlsx bytes.
|
|
30
|
+
WRITERS = {
|
|
31
|
+
"fast_xlsx" => lambda {
|
|
32
|
+
wb = FastXlsx::Workbook.new
|
|
33
|
+
wb.add_worksheet.concat(DATA)
|
|
34
|
+
wb.to_xlsx
|
|
35
|
+
},
|
|
36
|
+
"fast_xlsx (memory: :constant)" => lambda {
|
|
37
|
+
wb = FastXlsx::Workbook.new(memory: :constant)
|
|
38
|
+
wb.add_worksheet.concat(DATA)
|
|
39
|
+
wb.to_xlsx
|
|
40
|
+
},
|
|
41
|
+
"fast_xlsx (memory: :low)" => lambda {
|
|
42
|
+
wb = FastXlsx::Workbook.new(memory: :low)
|
|
43
|
+
wb.add_worksheet.concat(DATA)
|
|
44
|
+
wb.to_xlsx
|
|
45
|
+
},
|
|
46
|
+
"fast_excel" => lambda {
|
|
47
|
+
wb = FastExcel.open
|
|
48
|
+
ws = wb.add_worksheet
|
|
49
|
+
DATA.each { |r| ws << r }
|
|
50
|
+
wb.read_string
|
|
51
|
+
},
|
|
52
|
+
"fast_excel (constant_memory)" => lambda {
|
|
53
|
+
wb = FastExcel.open(constant_memory: true)
|
|
54
|
+
ws = wb.add_worksheet
|
|
55
|
+
DATA.each { |r| ws << r }
|
|
56
|
+
wb.read_string
|
|
57
|
+
},
|
|
58
|
+
"write_xlsx" => lambda {
|
|
59
|
+
io = StringIO.new
|
|
60
|
+
wb = WriteXLSX.new(io)
|
|
61
|
+
ws = wb.add_worksheet
|
|
62
|
+
DATA.each_with_index { |r, i| ws.write_row(i, 0, r) }
|
|
63
|
+
wb.close
|
|
64
|
+
io.string
|
|
65
|
+
},
|
|
66
|
+
"xlsxtream" => lambda {
|
|
67
|
+
io = StringIO.new
|
|
68
|
+
Xlsxtream::Workbook.open(io) do |xlsx|
|
|
69
|
+
xlsx.write_worksheet("Sheet1") { |ws| DATA.each { |r| ws << r } }
|
|
70
|
+
end
|
|
71
|
+
io.string
|
|
72
|
+
},
|
|
73
|
+
"caxlsx" => lambda {
|
|
74
|
+
package = Axlsx::Package.new
|
|
75
|
+
package.workbook.add_worksheet { |ws| DATA.each { |r| ws.add_row(r) } }
|
|
76
|
+
package.to_stream.read
|
|
77
|
+
},
|
|
78
|
+
"rubyXL" => lambda {
|
|
79
|
+
wb = RubyXL::Workbook.new
|
|
80
|
+
ws = wb[0]
|
|
81
|
+
DATA.each_with_index { |r, i| r.each_with_index { |v, j| ws.add_cell(i, j, v) } }
|
|
82
|
+
wb.stream.read
|
|
83
|
+
}
|
|
84
|
+
}.freeze
|
|
85
|
+
|
|
86
|
+
def measure(writer, runs)
|
|
87
|
+
bytes = writer.call # warm up
|
|
88
|
+
times = Array.new(runs) do
|
|
89
|
+
GC.start
|
|
90
|
+
elapsed { writer.call }
|
|
91
|
+
end
|
|
92
|
+
GC.start
|
|
93
|
+
before = GC.stat(:total_allocated_objects)
|
|
94
|
+
writer.call
|
|
95
|
+
{ ms: times.sort[runs / 2] * 1000, allocs: GC.stat(:total_allocated_objects) - before, kb: bytes.bytesize / 1024 }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
puts "#{ROWS} rows x 5 columns (integer, string, integer, Time, float), Ruby #{RUBY_VERSION}, #{RUBY_PLATFORM}"
|
|
99
|
+
puts
|
|
100
|
+
|
|
101
|
+
results = WRITERS.to_h do |name, writer|
|
|
102
|
+
runs = name == "rubyXL" ? 3 : 7
|
|
103
|
+
[name, measure(writer, runs)]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
fastest = results.values.map { |r| r[:ms] }.min
|
|
107
|
+
puts "| Library | Time (median) | vs fastest | Ruby objects allocated | Output |"
|
|
108
|
+
puts "|---|---:|---:|---:|---:|"
|
|
109
|
+
results.sort_by { |_, r| r[:ms] }.each do |name, r|
|
|
110
|
+
puts format("| %<name>s | %<ms>.0f ms | %<x>.1fx | %<allocs>s | %<kb>d KB |",
|
|
111
|
+
name: name, ms: r[:ms], x: r[:ms] / fastest,
|
|
112
|
+
allocs: r[:allocs].to_s.reverse.scan(/\d{1,3}/).join(",").reverse, kb: r[:kb])
|
|
113
|
+
end
|
data/bench/memory.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Writes one workbook in this process so its peak memory can be read by the OS:
|
|
4
|
+
#
|
|
5
|
+
# BUNDLE_GEMFILE=bench/Gemfile /usr/bin/time -l bundle exec ruby bench/memory.rb fast_xlsx:low unique # macOS
|
|
6
|
+
# BUNDLE_GEMFILE=bench/Gemfile /usr/bin/time -v bundle exec ruby bench/memory.rb fast_xlsx:low unique # Linux
|
|
7
|
+
#
|
|
8
|
+
# writer: baseline (build the data only), fast_xlsx[:constant|:low], fast_excel[:constant]
|
|
9
|
+
# data: unique (every string differs) or repeated (a few distinct strings)
|
|
10
|
+
# Subtract the baseline's peak to get what writing the file adds.
|
|
11
|
+
require "fast_xlsx"
|
|
12
|
+
require "fileutils"
|
|
13
|
+
require "tmpdir"
|
|
14
|
+
|
|
15
|
+
writer = ARGV[0]
|
|
16
|
+
kind = ARGV[1] || "unique"
|
|
17
|
+
rows = Integer(ARGV[2] || 200_000)
|
|
18
|
+
regions = %w[North South East West Central]
|
|
19
|
+
statuses = %w[Open Closed Pending Cancelled]
|
|
20
|
+
data = Array.new(rows) do |n|
|
|
21
|
+
label = kind == "unique" ? "String string #{n}" * 5 : regions[n % 5]
|
|
22
|
+
[n, label, statuses[n % 4], Time.at((n * 1000) + 1_492_922_688), n * 100.5]
|
|
23
|
+
end
|
|
24
|
+
out = File.join(Dir.tmpdir, "fast_xlsx_memory_#{Process.pid}.xlsx")
|
|
25
|
+
|
|
26
|
+
case writer
|
|
27
|
+
when "baseline"
|
|
28
|
+
nil
|
|
29
|
+
when /\Afast_xlsx/
|
|
30
|
+
mode = writer.split(":")[1]
|
|
31
|
+
wb = FastXlsx::Workbook.new(memory: (mode || "standard").to_sym)
|
|
32
|
+
wb.add_worksheet.concat(data)
|
|
33
|
+
wb.save(out)
|
|
34
|
+
when /\Afast_excel/
|
|
35
|
+
require "fast_excel"
|
|
36
|
+
wb = FastExcel.open(out, constant_memory: writer.end_with?(":constant"))
|
|
37
|
+
ws = wb.add_worksheet
|
|
38
|
+
data.each { |r| ws << r }
|
|
39
|
+
wb.close
|
|
40
|
+
else
|
|
41
|
+
abort "unknown writer #{writer}"
|
|
42
|
+
end
|
|
43
|
+
FileUtils.rm_f(out)
|
data/bench/write.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Usage: bundle exec rake compile && ruby -Ilib bench/write.rb
|
|
4
|
+
# Compares against fast_excel when it can be loaded (FAST_EXCEL=/path/to/fast_excel/lib/fast_excel).
|
|
5
|
+
require "fast_xlsx"
|
|
6
|
+
|
|
7
|
+
def elapsed
|
|
8
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
9
|
+
yield
|
|
10
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ROWS = 20_000
|
|
14
|
+
DATA = Array.new(ROWS) do |n|
|
|
15
|
+
[n, "String string #{n}" * 5, n * 7 % 1000, Time.at((n * 1000) + 1_492_922_688), n * 100.5]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def report(label, runs = 7, &block)
|
|
19
|
+
times = Array.new(runs) do
|
|
20
|
+
GC.start
|
|
21
|
+
elapsed(&block)
|
|
22
|
+
end
|
|
23
|
+
puts " #{label.ljust(26)} #{(times.sort[runs / 2] * 1000).round(1)} ms"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
require ENV.fetch("FAST_EXCEL", "fast_excel")
|
|
28
|
+
rescue LoadError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
[false, true].each do |cm|
|
|
33
|
+
puts "memory=#{cm ? "constant" : "standard"}, #{ROWS}x5 cells, median of 7"
|
|
34
|
+
report("fast_xlsx <<") do
|
|
35
|
+
wb = FastXlsx::Workbook.new(memory: cm ? :constant : :standard)
|
|
36
|
+
ws = wb.add_worksheet
|
|
37
|
+
DATA.each { |r| ws << r }
|
|
38
|
+
wb.to_xlsx
|
|
39
|
+
end
|
|
40
|
+
report("fast_xlsx concat") do
|
|
41
|
+
wb = FastXlsx::Workbook.new(memory: cm ? :constant : :standard)
|
|
42
|
+
wb.add_worksheet.concat(DATA)
|
|
43
|
+
wb.to_xlsx
|
|
44
|
+
end
|
|
45
|
+
next unless defined?(FastExcel)
|
|
46
|
+
|
|
47
|
+
report("fast_excel <<") do
|
|
48
|
+
wb = FastExcel.open(constant_memory: cm)
|
|
49
|
+
ws = wb.add_worksheet
|
|
50
|
+
DATA.each { |r| ws << r }
|
|
51
|
+
wb.read_string
|
|
52
|
+
end
|
|
53
|
+
end
|