fast_xlsx 0.1.0-aarch64-linux-musl
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/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/lib/fast_xlsx/3.3/fast_xlsx.so +4 -0
- data/lib/fast_xlsx/3.4/fast_xlsx.so +4 -0
- data/lib/fast_xlsx/4.0/fast_xlsx.so +4 -0
- data/lib/fast_xlsx/version.rb +5 -0
- data/lib/fast_xlsx.rb +205 -0
- data/sig/fast_xlsx.rbs +100 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 23bb52f1ba96ede9ce2f8b6e71c3e9a161306b695763dbf6e3e8255443cfc0fe
|
|
4
|
+
data.tar.gz: 46eaafd06d60330edb7e2f85762aff1a831e8ee297d9303bc873eff97f9ecc3f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f2a1244f2a04568dba67072b384545e323fa0062632c61a86dc792c98b89a20ee7ff7f55ad4039ad83ad173c9b2e70110f3d19ebd74bde36167cc6c873f29e8f
|
|
7
|
+
data.tar.gz: 4ab8cfccf758ce3a6707c7fb2d289f8248c6223cf4bcb1a08d5a83d74c321bbc6b5ed1029c8b22ed3d0c7b940f10c9ac13b3547c74347b3315f93625e022cbe1
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-09-30
|
|
4
|
+
|
|
5
|
+
First release: a fast `.xlsx` writer built on rust_xlsxwriter. Early API; it may still change before 1.0.
|
|
6
|
+
|
|
7
|
+
### Workbooks and worksheets
|
|
8
|
+
|
|
9
|
+
- `FastXlsx::Workbook.new(memory: :standard | :constant | :low)`: keep every cell in memory, or write finished rows to disk with strings inline (`:constant`) or in the shared string table (`:low`)
|
|
10
|
+
- `Workbook#add_worksheet`, `#worksheet(name)`, `#worksheets`, `#properties` (document properties), `#to_xlsx`, `#save`
|
|
11
|
+
- `Worksheet#<<`, `#append`, `#concat`, `#write` (all return the worksheet); writes to rows already on disk raise `FastXlsx::Error`
|
|
12
|
+
- Rows and columns are 0-based
|
|
13
|
+
|
|
14
|
+
### Cell values
|
|
15
|
+
|
|
16
|
+
- Numeric, String, Time, Date/DateTime, boolean and nil
|
|
17
|
+
- `FastXlsx::Formula`, `FastXlsx::URL` (optionally showing other text), `FastXlsx::RichString` (a format per text segment)
|
|
18
|
+
|
|
19
|
+
### Formatting
|
|
20
|
+
|
|
21
|
+
- `FastXlsx::Format`: fonts, colors, number formats, alignment, wrapping, rotation, indent, borders and underline styles; unknown options and invalid values raise `ArgumentError`
|
|
22
|
+
- One format per row or per cell in `append`; `Worksheet#column_format` for column defaults
|
|
23
|
+
|
|
24
|
+
### Layout and features
|
|
25
|
+
|
|
26
|
+
- `column_width`, `autofit` (keeps widths set explicitly), `row_height`, `freeze_panes`, `merge_range`, `autofilter`
|
|
27
|
+
- `conditional_format` (cell, text, formula, data bar, color scale), `data_validation` (lists, numbers, text length, messages)
|
|
28
|
+
- `add_table` (styles, total row, column totals), `insert_chart`, `insert_image` (path or IO, scale or pixel size), `write_comment`
|
|
29
|
+
- Printing: `page_header`, `page_footer`, `margins`, `page_breaks`, `vertical_page_breaks`
|
|
30
|
+
- Unknown options to the option-hash methods raise `ArgumentError`
|
|
31
|
+
|
|
32
|
+
### Packaging
|
|
33
|
+
|
|
34
|
+
- Precompiled gems for Linux (glibc and musl, x86_64 and aarch64, arm), macOS (arm64 and x86_64) and Windows (x64); requires CRuby 3.3+
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zac
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
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
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Builds one workbook with a worksheet per feature, to check the output in
|
|
4
|
+
# Excel (or LibreOffice / Numbers) before a release.
|
|
5
|
+
#
|
|
6
|
+
# bundle exec rake compile
|
|
7
|
+
# ruby -Ilib examples/showcase.rb [showcase.xlsx]
|
|
8
|
+
require "date"
|
|
9
|
+
require "stringio"
|
|
10
|
+
require "zlib"
|
|
11
|
+
require "fast_xlsx"
|
|
12
|
+
|
|
13
|
+
path = ARGV[0] || "showcase.xlsx"
|
|
14
|
+
|
|
15
|
+
# A small gradient PNG, generated so the example needs no image file.
|
|
16
|
+
def gradient_png(width, height)
|
|
17
|
+
rows = (0...height).map do |y|
|
|
18
|
+
"\0".b + (0...width).map { |x| [x * 255 / width, y * 255 / height, 200].pack("C3") }.join
|
|
19
|
+
end
|
|
20
|
+
chunk = ->(type, data) { [data.bytesize].pack("N") + type + data + [Zlib.crc32(type + data)].pack("N") }
|
|
21
|
+
"\x89PNG\r\n\x1A\n".b +
|
|
22
|
+
chunk.call("IHDR", [width, height, 8, 2, 0, 0, 0].pack("NNC5")) +
|
|
23
|
+
chunk.call("IDAT", Zlib::Deflate.deflate(rows.join)) +
|
|
24
|
+
chunk.call("IEND", "".b)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
wb = FastXlsx::Workbook.new
|
|
28
|
+
wb.properties(title: "fast_xlsx showcase", author: "fast_xlsx", keywords: "example")
|
|
29
|
+
|
|
30
|
+
bold = FastXlsx::Format.new(bold: true)
|
|
31
|
+
header = FastXlsx::Format.new(bold: true, bg_color: "#DDEBF7", border_bottom: :thin, align: :center)
|
|
32
|
+
money = FastXlsx::Format.new(num_format: "#,##0.00")
|
|
33
|
+
date = FastXlsx::Format.new(num_format: "yyyy-mm-dd")
|
|
34
|
+
datetime = FastXlsx::Format.new(num_format: "yyyy-mm-dd hh:mm")
|
|
35
|
+
|
|
36
|
+
# Values: every cell type.
|
|
37
|
+
ws = wb.add_worksheet("Values")
|
|
38
|
+
ws.column_width(0, 22).column_width(1, 40)
|
|
39
|
+
ws.append(%w[Type Value], format: header)
|
|
40
|
+
ws << ["Integer", 42]
|
|
41
|
+
ws << ["Float", 3.14159]
|
|
42
|
+
ws.append(["Money format", 1_234_567.891], format: [nil, money])
|
|
43
|
+
ws.append(["Date", Date.new(2024, 2, 29)], format: [nil, date])
|
|
44
|
+
ws.append(["Time", Time.new(2024, 2, 29, 13, 45)], format: [nil, datetime])
|
|
45
|
+
ws << ["Boolean", true]
|
|
46
|
+
ws << ["nil (empty cell)", nil]
|
|
47
|
+
ws << ["Formula =SUM(B2:B3)", FastXlsx::Formula.new("SUM(B2:B3)")]
|
|
48
|
+
ws << ["URL", FastXlsx::URL.new("https://github.com/7a6163/fast_xlsx")]
|
|
49
|
+
ws << ["URL with text", FastXlsx::URL.new("https://github.com/7a6163/fast_xlsx", text: "fast_xlsx on GitHub")]
|
|
50
|
+
ws << ["Rich string",
|
|
51
|
+
FastXlsx::RichString.new(["Bold ", bold], "plain ", ["italic", FastXlsx::Format.new(italic: true)])]
|
|
52
|
+
ws << ["Symbol (to_s)", :symbol]
|
|
53
|
+
|
|
54
|
+
# Formats: one option per row.
|
|
55
|
+
ws = wb.add_worksheet("Formats")
|
|
56
|
+
ws.column_width(0, 24).column_width(1, 30)
|
|
57
|
+
ws.append(%w[Option Sample], format: header)
|
|
58
|
+
{
|
|
59
|
+
"bold" => { bold: true }, "italic" => { italic: true }, "strikeout" => { strikeout: true },
|
|
60
|
+
"underline :double" => { underline: :double }, "font_script :superscript" => { font_script: :superscript },
|
|
61
|
+
"font_size 16" => { font_size: 16 }, "font_name Courier New" => { font_name: "Courier New" },
|
|
62
|
+
"font_color #C00000" => { font_color: "#C00000" }, "bg_color #FFF2CC" => { bg_color: "#FFF2CC" },
|
|
63
|
+
"align :right" => { align: :right }, "valign :top" => { valign: :top }, "indent 2" => { indent: 2 },
|
|
64
|
+
"rotation 45" => { rotation: 45 }, "text_wrap" => { text_wrap: true }, "shrink" => { shrink: true },
|
|
65
|
+
"border :medium + color" => { border: :medium, border_color: "#2F5597" },
|
|
66
|
+
"border_bottom :double" => { border_bottom: :double }
|
|
67
|
+
}.each do |label, options|
|
|
68
|
+
text = label == "text_wrap" ? "long text that wraps inside the cell width" : "Sample text"
|
|
69
|
+
ws.append([label, text], format: [nil, FastXlsx::Format.new(**options)])
|
|
70
|
+
end
|
|
71
|
+
ws.row_height(14, 40)
|
|
72
|
+
|
|
73
|
+
# Layout: widths, autofit, merged title, frozen header, filter.
|
|
74
|
+
ws = wb.add_worksheet("Layout")
|
|
75
|
+
ws.merge_range(0, 0, 0, 3, "Merged title across A1:D1", FastXlsx::Format.new(bold: true, font_size: 14, align: :center))
|
|
76
|
+
ws.append(%w[Region Rep Amount Note], format: header)
|
|
77
|
+
20.times { |i| ws << [%w[North South East West][i % 4], "Rep #{i + 1}", (i + 1) * 125.5, "row #{i + 1}"] }
|
|
78
|
+
ws.column_format(2, money)
|
|
79
|
+
ws.column_width(3, 30) # kept by autofit
|
|
80
|
+
ws.autofit
|
|
81
|
+
ws.freeze_panes(2, 0)
|
|
82
|
+
ws.autofilter(1, 0, 21, 3)
|
|
83
|
+
|
|
84
|
+
# Conditional formats.
|
|
85
|
+
ws = wb.add_worksheet("Conditional")
|
|
86
|
+
red = FastXlsx::Format.new(font_color: "#9C0006", bg_color: "#FFC7CE")
|
|
87
|
+
green = FastXlsx::Format.new(font_color: "#006100", bg_color: "#C6EFCE")
|
|
88
|
+
ws.append(["Cell < 0", "Text contains 'error'", "Data bar", "Color scale", "Formula (A > 50)"], format: header)
|
|
89
|
+
values = [-30, 80, 15, -5, 60, 95, 40, -10, 70, 25]
|
|
90
|
+
values.each_with_index { |v, i| ws << [v, i.even? ? "ok" : "error #{i}", v.abs, v, v] }
|
|
91
|
+
ws.conditional_format(1, 0, 10, 0, type: :cell, criteria: :<, value: 0, format: red)
|
|
92
|
+
ws.conditional_format(1, 1, 10, 1, type: :text, criteria: :contains, value: "error", format: red)
|
|
93
|
+
ws.conditional_format(1, 2, 10, 2, type: :data_bar)
|
|
94
|
+
ws.conditional_format(1, 3, 10, 3, type: :color_scale)
|
|
95
|
+
ws.conditional_format(1, 4, 10, 4, type: :formula, value: "=$A2>50", format: green)
|
|
96
|
+
ws.column_width(0..4, 22)
|
|
97
|
+
|
|
98
|
+
# Data validation.
|
|
99
|
+
ws = wb.add_worksheet("Validation")
|
|
100
|
+
ws.append(["Status (dropdown)", "Quantity 1-10", "Price >= 0", "Code <= 5 chars"], format: header)
|
|
101
|
+
ws.data_validation(1, 0, 20, 0, type: :list, value: %w[Open Pending Closed],
|
|
102
|
+
input_title: "Status", input_message: "Pick a status")
|
|
103
|
+
ws.data_validation(1, 1, 20, 1, type: :whole_number, criteria: :between, value: [1, 10],
|
|
104
|
+
error_title: "Invalid quantity", error_message: "Enter a whole number from 1 to 10")
|
|
105
|
+
ws.data_validation(1, 2, 20, 2, type: :decimal, criteria: :>=, value: 0)
|
|
106
|
+
ws.data_validation(1, 3, 20, 3, type: :text_length, criteria: :<=, value: 5)
|
|
107
|
+
ws.column_width(0..3, 20)
|
|
108
|
+
|
|
109
|
+
# Table with a total row.
|
|
110
|
+
ws = wb.add_worksheet("Table")
|
|
111
|
+
sales = [%w[North Ann 1200], %w[South Bob 950], %w[East Cai 1430], %w[West Dee 780]].map { |r, n, a| [r, n, a.to_i] }
|
|
112
|
+
ws.add_table(0, 0, sales.size + 1, 2, total_row: true, style: :medium2,
|
|
113
|
+
columns: [{ header: "Region", total_label: "Total" }, "Rep",
|
|
114
|
+
{ header: "Sales", total: :sum, format: money }])
|
|
115
|
+
ws.concat(sales)
|
|
116
|
+
ws.column_width(0..2, 14)
|
|
117
|
+
|
|
118
|
+
# Charts.
|
|
119
|
+
ws = wb.add_worksheet("Chart")
|
|
120
|
+
ws.append(%w[Month Sales Costs], format: header)
|
|
121
|
+
ws.concat([["Jan", 10, 7], ["Feb", 25, 12], ["Mar", 18, 11], ["Apr", 30, 16], ["May", 27, 14]])
|
|
122
|
+
series = [{ name: "Sales", categories: "Chart!$A$2:$A$6", values: "Chart!$B$2:$B$6" },
|
|
123
|
+
{ name: "Costs", categories: "Chart!$A$2:$A$6", values: "Chart!$C$2:$C$6" }]
|
|
124
|
+
ws.insert_chart(0, 4, type: :column, series: series, title: "Column", x_axis: "Month", y_axis: "Amount")
|
|
125
|
+
ws.insert_chart(16, 4, type: :line, series: series, title: "Line", width: 600, height: 300)
|
|
126
|
+
ws.insert_chart(16, 0, type: :pie, series: [series.first], title: "Pie", width: 300, height: 300)
|
|
127
|
+
|
|
128
|
+
# Image and comment.
|
|
129
|
+
ws = wb.add_worksheet("Media")
|
|
130
|
+
ws << ["Generated PNG at scale 1, pixel size 240x60, and with an offset. B1 has a comment."]
|
|
131
|
+
ws.write_comment(0, 1, "This is a note (comment).", author: "fast_xlsx")
|
|
132
|
+
png = gradient_png(120, 40)
|
|
133
|
+
ws.insert_image(2, 0, StringIO.new(png), alt_text: "Gradient")
|
|
134
|
+
ws.insert_image(2, 3, StringIO.new(png), width: 240, height: 60)
|
|
135
|
+
ws.insert_image(8, 0, StringIO.new(png), x_offset: 20, y_offset: 10, scale: 1.5)
|
|
136
|
+
|
|
137
|
+
# Printing: check File > Print preview.
|
|
138
|
+
ws = wb.add_worksheet("Printing")
|
|
139
|
+
ws.page_header("&CPage &P of &N").page_footer("&L&A&R&D", margin: 0.2).margins(left: 0.5, right: 0.5)
|
|
140
|
+
ws.page_breaks([30])
|
|
141
|
+
ws.vertical_page_breaks([4])
|
|
142
|
+
ws.append(%w[Row A B C D E F], format: header)
|
|
143
|
+
60.times { |i| ws << [i + 1, *Array.new(6) { |c| (i + 1) * (c + 1) }] }
|
|
144
|
+
|
|
145
|
+
wb.save(path)
|
|
146
|
+
puts "wrote #{path}"
|
data/lib/fast_xlsx.rb
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fast_xlsx/version"
|
|
4
|
+
|
|
5
|
+
# Fast .xlsx writer backed by rust_xlsxwriter.
|
|
6
|
+
module FastXlsx
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Cell value written as an Excel formula, e.g. Formula.new("SUM(A1:A9)").
|
|
10
|
+
Formula = Data.define(:expression) do
|
|
11
|
+
def initialize(expression:)
|
|
12
|
+
super(expression: expression.to_s)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
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.
|
|
18
|
+
# Subclassed (not a Data.define block) so .new can call super, which lets it
|
|
19
|
+
# accept URL.new(url, text: ...) as well as the usual Data forms.
|
|
20
|
+
class URL < Data.define(:url, :text) # rubocop:disable Style/DataInheritance
|
|
21
|
+
def self.new(*args, **kwargs)
|
|
22
|
+
raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0..2)" if args.size > 2
|
|
23
|
+
|
|
24
|
+
kwargs[:url] = args[0] unless args.empty?
|
|
25
|
+
kwargs[:text] = args[1] if args.size > 1
|
|
26
|
+
super(**kwargs)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Also reached by URL[...] and #with, so values are normalized here.
|
|
30
|
+
def initialize(url:, text: nil)
|
|
31
|
+
super(url: url.to_s, text: text&.to_s)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
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].
|
|
37
|
+
class RichString
|
|
38
|
+
attr_reader :segments
|
|
39
|
+
|
|
40
|
+
def initialize(*parts)
|
|
41
|
+
raise ArgumentError, "RichString needs at least one segment" if parts.empty?
|
|
42
|
+
|
|
43
|
+
@segments = parts.map { |part| part.is_a?(Array) ? [part[0].to_s, part[1]] : [part.to_s, nil] }.freeze
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The native extension looks up FastXlsx::Error, so load it after Error is defined.
|
|
48
|
+
require "fast_xlsx/fast_xlsx"
|
|
49
|
+
|
|
50
|
+
# Owns the worksheets; serialize with #to_xlsx or #save.
|
|
51
|
+
class Workbook
|
|
52
|
+
MEMORY_MODES = %i[standard constant low].freeze
|
|
53
|
+
|
|
54
|
+
# memory: :standard keeps every cell in memory until saving. :constant and
|
|
55
|
+
# :low write each finished row to disk, so each worksheet must be filled
|
|
56
|
+
# top to bottom. :constant stores strings inline (memory stays flat); :low
|
|
57
|
+
# keeps Excel's shared string table (memory grows with the number of
|
|
58
|
+
# unique strings, output is standard).
|
|
59
|
+
def self.new(memory: :standard)
|
|
60
|
+
unless MEMORY_MODES.include?(memory)
|
|
61
|
+
raise ArgumentError, "unknown memory mode #{memory.inspect} (expected one of #{MEMORY_MODES.join(", ")})"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
_new(memory == :constant, memory == :low)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def add_worksheet(name = nil)
|
|
68
|
+
_add_worksheet(name).tap { |ws| worksheets << ws }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The same Worksheet objects add_worksheet returned, so their append
|
|
72
|
+
# position is shared.
|
|
73
|
+
def worksheets
|
|
74
|
+
@worksheets ||= []
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def worksheet(name)
|
|
78
|
+
worksheets.find { |ws| ws.name == name }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Document properties shown in Excel's File > Info: title:, subject:,
|
|
82
|
+
# author:, manager:, company:, category:, keywords:, comments:, status:.
|
|
83
|
+
# Later calls add to earlier ones.
|
|
84
|
+
def properties(**fields)
|
|
85
|
+
merged = (@properties || {}).merge(fields)
|
|
86
|
+
_properties(merged) # validates before anything is remembered
|
|
87
|
+
@properties = merged
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Cell writer for one sheet; create with Workbook#add_worksheet.
|
|
93
|
+
class Worksheet
|
|
94
|
+
def write(row, col, value, format = nil)
|
|
95
|
+
_write(row, col, value, format)
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def append(values, format: nil)
|
|
100
|
+
_append(values, format)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# columns: a 0-based column index or a Range of them. width is in characters.
|
|
104
|
+
def column_width(columns, width)
|
|
105
|
+
bounds = column_bounds(columns)
|
|
106
|
+
_column_width(*bounds, width)
|
|
107
|
+
@fixed_widths ||= {}
|
|
108
|
+
@fixed_widths.delete(bounds) # re-insert so autofit replays calls in order
|
|
109
|
+
@fixed_widths[bounds] = width
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Sizes columns to the data written so far. Widths set with
|
|
114
|
+
# column_width are kept.
|
|
115
|
+
def autofit
|
|
116
|
+
_autofit
|
|
117
|
+
@fixed_widths&.each { |bounds, width| _column_width(*bounds, width) }
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Merges the range and writes value (any cell type) into its first cell.
|
|
122
|
+
def merge_range(first_row, first_col, last_row, last_col, value, format = nil)
|
|
123
|
+
_merge_range(first_row, first_col, last_row, last_col, value, format)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Highlights cells in the range by rule. type: :cell, :text, :formula,
|
|
127
|
+
# :data_bar or :color_scale; see the README for each type's options.
|
|
128
|
+
def conditional_format(first_row, first_col, last_row, last_col, type:, **)
|
|
129
|
+
_conditional_format(first_row, first_col, last_row, last_col, { type: type, ** })
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Restricts what can be entered in the range. type: :list, :whole_number,
|
|
133
|
+
# :decimal or :text_length; see the README for the options.
|
|
134
|
+
def data_validation(first_row, first_col, last_row, last_col, type:, **)
|
|
135
|
+
_data_validation(first_row, first_col, last_row, last_col, { type: type, ** })
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Adds a comment (Excel "note") to a cell.
|
|
139
|
+
def write_comment(row, col, text, author: nil)
|
|
140
|
+
_write_comment(row, col, text, author)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Inserts a PNG, JPEG, GIF or BMP image with its top-left corner in the
|
|
144
|
+
# cell. source is a file path or an IO (anything responding to #read).
|
|
145
|
+
# Options: scale: or width:/height: (pixels), x_offset:, y_offset: (pixels), alt_text:.
|
|
146
|
+
def insert_image(row, col, source, **)
|
|
147
|
+
bytes = source.respond_to?(:read) ? source.read : File.binread(source)
|
|
148
|
+
_insert_image(row, col, bytes, { ** })
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Inserts a chart with its top-left corner in the cell. series is an Array
|
|
152
|
+
# of { values:, categories:, name: } with Excel ranges such as
|
|
153
|
+
# "Sheet1!$B$2:$B$13". Options: title:, x_axis:, y_axis:, width:, height:.
|
|
154
|
+
def insert_chart(row, col, type:, series:, **)
|
|
155
|
+
_insert_chart(row, col, { type: type, series: series, ** })
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Turns the range (header row included, total row too when total_row: true)
|
|
159
|
+
# into an Excel table. columns: header Strings or { header:, total:,
|
|
160
|
+
# total_label:, format: }; other options: style:, name:, total_row:,
|
|
161
|
+
# banded_rows:, autofilter:.
|
|
162
|
+
def add_table(first_row, first_col, last_row, last_col, **)
|
|
163
|
+
_add_table(first_row, first_col, last_row, last_col, { ** })
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Printed page header/footer using Excel codes such as "&CPage &P of &N".
|
|
167
|
+
# margin: is in inches.
|
|
168
|
+
def page_header(text, margin: nil)
|
|
169
|
+
_page_header(text)
|
|
170
|
+
margin ? margins(header: margin) : self
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def page_footer(text, margin: nil)
|
|
174
|
+
_page_footer(text)
|
|
175
|
+
margin ? margins(footer: margin) : self
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Print margins in inches; margins not given keep their current value.
|
|
179
|
+
def margins(left: nil, right: nil, top: nil, bottom: nil, header: nil, footer: nil)
|
|
180
|
+
_margins(*[left, right, top, bottom, header, footer].map { |m| m || -1.0 })
|
|
181
|
+
self
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Default format for cells in these columns that are written without one.
|
|
185
|
+
def column_format(columns, format)
|
|
186
|
+
_column_format(*column_bounds(columns), format)
|
|
187
|
+
self
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def column_bounds(columns)
|
|
193
|
+
columns.is_a?(Integer) ? [columns, columns] : columns.minmax
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Cell style, e.g. Format.new(bold: true). Pass to Worksheet#write.
|
|
198
|
+
class Format
|
|
199
|
+
def self.new(**options)
|
|
200
|
+
# Apply border: first so border_left: etc. override it whatever the order.
|
|
201
|
+
options = { border: options[:border], **options.except(:border) } if options.key?(:border)
|
|
202
|
+
_new(options)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
data/sig/fast_xlsx.rbs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module FastXlsx
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
interface _Reader
|
|
8
|
+
def read: () -> String
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
type cell = Numeric | String | Time | Date | Formula | URL | RichString | bool | nil | _ToS
|
|
12
|
+
|
|
13
|
+
class RichString
|
|
14
|
+
attr_reader segments: Array[[String, Format?]]
|
|
15
|
+
def initialize: (*(_ToS | [_ToS, Format?]) parts) -> void
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Formula
|
|
19
|
+
attr_reader expression: String
|
|
20
|
+
def self.new: (_ToS expression) -> Formula
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class URL
|
|
24
|
+
attr_reader url: String
|
|
25
|
+
attr_reader text: String?
|
|
26
|
+
def self.new: (_ToS url, ?text: _ToS?) -> URL
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class Workbook
|
|
30
|
+
MEMORY_MODES: Array[Symbol]
|
|
31
|
+
def self.new: (?memory: :standard | :constant | :low) -> Workbook
|
|
32
|
+
def add_worksheet: (?String? name) -> Worksheet
|
|
33
|
+
def worksheets: () -> Array[Worksheet]
|
|
34
|
+
def worksheet: (String name) -> Worksheet?
|
|
35
|
+
def properties: (?title: String, ?subject: String, ?author: String, ?manager: String,
|
|
36
|
+
?company: String, ?category: String, ?keywords: String,
|
|
37
|
+
?comments: String, ?status: String) -> self
|
|
38
|
+
def to_xlsx: () -> String
|
|
39
|
+
def save: (String path) -> void
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class Format
|
|
43
|
+
type color = String | Integer
|
|
44
|
+
type border = :thin | :medium | :thick | :dashed | :dotted | :double | :hair
|
|
45
|
+
|
|
46
|
+
type underline = bool | :single | :double | :single_accounting | :double_accounting
|
|
47
|
+
|
|
48
|
+
def self.new: (?bold: bool, ?italic: bool, ?underline: underline, ?strikeout: bool, ?text_wrap: bool,
|
|
49
|
+
?shrink: bool, ?num_format: String, ?font_size: Numeric, ?font_name: String,
|
|
50
|
+
?font_color: color, ?bg_color: color, ?font_script: :superscript | :subscript,
|
|
51
|
+
?align: :left | :center | :right, ?valign: :top | :center | :bottom,
|
|
52
|
+
?rotation: Integer, ?indent: Integer,
|
|
53
|
+
?border: border, ?border_left: border, ?border_right: border,
|
|
54
|
+
?border_top: border, ?border_bottom: border, ?border_color: color) -> Format
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class Worksheet
|
|
58
|
+
def write: (Integer row, Integer col, cell value, ?Format? format) -> self
|
|
59
|
+
def append: (Array[cell] values, ?format: Format? | Array[Format?]) -> self
|
|
60
|
+
def <<: (Array[cell] row) -> self
|
|
61
|
+
def concat: (Array[Array[cell]] rows) -> self
|
|
62
|
+
def column_width: (Integer | Range[Integer] columns, Numeric width) -> self
|
|
63
|
+
def column_format: (Integer | Range[Integer] columns, Format format) -> self
|
|
64
|
+
def autofit: () -> self
|
|
65
|
+
def autofilter: (Integer first_row, Integer first_col, Integer last_row, Integer last_col) -> self
|
|
66
|
+
def freeze_panes: (Integer row, Integer col) -> self
|
|
67
|
+
def row_height: (Integer row, Numeric height) -> self
|
|
68
|
+
def page_breaks: (Array[Integer] rows) -> self
|
|
69
|
+
def vertical_page_breaks: (Array[Integer] cols) -> self
|
|
70
|
+
def page_header: (String text, ?margin: Numeric?) -> self
|
|
71
|
+
def page_footer: (String text, ?margin: Numeric?) -> self
|
|
72
|
+
def margins: (?left: Numeric?, ?right: Numeric?, ?top: Numeric?, ?bottom: Numeric?,
|
|
73
|
+
?header: Numeric?, ?footer: Numeric?) -> self
|
|
74
|
+
def merge_range: (Integer first_row, Integer first_col, Integer last_row, Integer last_col, cell value, ?Format? format) -> self
|
|
75
|
+
def conditional_format: (Integer first_row, Integer first_col, Integer last_row, Integer last_col,
|
|
76
|
+
type: :cell | :text | :formula | :data_bar | :color_scale,
|
|
77
|
+
?criteria: Symbol, ?value: Numeric | String | Array[Numeric | String],
|
|
78
|
+
?format: Format, ?colors: 2 | 3) -> self
|
|
79
|
+
def data_validation: (Integer first_row, Integer first_col, Integer last_row, Integer last_col,
|
|
80
|
+
type: :list | :whole_number | :decimal | :text_length,
|
|
81
|
+
?criteria: Symbol, ?value: Numeric | String | Array[Numeric | String],
|
|
82
|
+
?input_title: String, ?input_message: String,
|
|
83
|
+
?error_title: String, ?error_message: String) -> self
|
|
84
|
+
def write_comment: (Integer row, Integer col, String text, ?author: String?) -> self
|
|
85
|
+
def insert_image: (Integer row, Integer col, String | _Reader source, ?scale: Numeric, ?width: Numeric, ?height: Numeric,
|
|
86
|
+
?x_offset: Integer, ?y_offset: Integer, ?alt_text: String) -> self
|
|
87
|
+
type chart_series = { values: String, ?categories: String, ?name: String }
|
|
88
|
+
|
|
89
|
+
def insert_chart: (Integer row, Integer col, type: Symbol, series: Array[chart_series],
|
|
90
|
+
?title: String, ?x_axis: String, ?y_axis: String,
|
|
91
|
+
?width: Integer, ?height: Integer) -> self
|
|
92
|
+
type table_column = String | { header: String, ?total: Symbol, ?total_label: String, ?format: Format }
|
|
93
|
+
|
|
94
|
+
def add_table: (Integer first_row, Integer first_col, Integer last_row, Integer last_col,
|
|
95
|
+
?columns: Array[table_column], ?style: Symbol, ?name: String,
|
|
96
|
+
?total_row: bool, ?banded_rows: bool, ?autofilter: bool) -> self
|
|
97
|
+
def name: () -> String
|
|
98
|
+
def next_row: () -> Integer
|
|
99
|
+
end
|
|
100
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fast_xlsx
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: aarch64-linux-musl
|
|
6
|
+
authors:
|
|
7
|
+
- Zac
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Writes Excel .xlsx files from Ruby through a native Rust extension (rust_xlsxwriter
|
|
14
|
+
+ magnus).
|
|
15
|
+
email:
|
|
16
|
+
- 579103+7a6163@users.noreply.github.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE.txt
|
|
23
|
+
- README.md
|
|
24
|
+
- Rakefile
|
|
25
|
+
- bench/Gemfile
|
|
26
|
+
- bench/Gemfile.lock
|
|
27
|
+
- bench/compare.rb
|
|
28
|
+
- bench/memory.rb
|
|
29
|
+
- bench/write.rb
|
|
30
|
+
- examples/showcase.rb
|
|
31
|
+
- lib/fast_xlsx.rb
|
|
32
|
+
- lib/fast_xlsx/3.3/fast_xlsx.so
|
|
33
|
+
- lib/fast_xlsx/3.4/fast_xlsx.so
|
|
34
|
+
- lib/fast_xlsx/4.0/fast_xlsx.so
|
|
35
|
+
- lib/fast_xlsx/version.rb
|
|
36
|
+
- sig/fast_xlsx.rbs
|
|
37
|
+
homepage: https://github.com/7a6163/fast_xlsx
|
|
38
|
+
licenses:
|
|
39
|
+
- MIT
|
|
40
|
+
metadata:
|
|
41
|
+
github_repo: ssh://github.com/7a6163/fast_xlsx
|
|
42
|
+
homepage_uri: https://github.com/7a6163/fast_xlsx
|
|
43
|
+
source_code_uri: https://github.com/7a6163/fast_xlsx
|
|
44
|
+
changelog_uri: https://github.com/7a6163/fast_xlsx/blob/main/CHANGELOG.md
|
|
45
|
+
rubygems_mfa_required: 'true'
|
|
46
|
+
post_install_message:
|
|
47
|
+
rdoc_options: []
|
|
48
|
+
require_paths:
|
|
49
|
+
- lib
|
|
50
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.3'
|
|
55
|
+
- - "<"
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: 4.1.dev
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: 3.3.22
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 3.5.23
|
|
65
|
+
signing_key:
|
|
66
|
+
specification_version: 4
|
|
67
|
+
summary: Fast xlsx writer for Ruby, powered by rust_xlsxwriter
|
|
68
|
+
test_files: []
|