xlsxrb 0.1.5 → 0.1.6
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 +4 -4
- data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +2332 -0
- data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +4 -0
- data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +8 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +42 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +5 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +47 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +30 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +9 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +7 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +19 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +31 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +18 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +12 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +11 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +13 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +95 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +31 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +58 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +24 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +17 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +23 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +22 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +30 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +20 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +15 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +26 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +21 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +131 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +14 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +33 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +21 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +63 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +55 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +35 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +7 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +10 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +22 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +29 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +17 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +13 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +10 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +8 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +6 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +30 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +10 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +10 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +5 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +15 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +3 -0
- data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +40 -0
- data/CHANGELOG.md +18 -0
- data/README.md +39 -32
- data/Rakefile +2 -1
- data/Steepfile +0 -2
- data/benchmark.rb +376 -0
- data/docs/ARCHITECTURE.md +12 -5
- data/docs/wasm/ruby.wasm +0 -0
- data/lib/xlsxrb/elements/cell.rb +39 -7
- data/lib/xlsxrb/elements/row.rb +17 -4
- data/lib/xlsxrb/elements/types.rb +4 -0
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +104 -40
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +144 -23
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +160 -145
- data/lib/xlsxrb/ooxml/xml_builder.rb +16 -10
- data/lib/xlsxrb/ooxml/zip_reader.rb +104 -18
- data/lib/xlsxrb/ooxml/zip_writer.rb +63 -25
- data/lib/xlsxrb/style_builder.rb +158 -30
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb.rb +443 -236
- data/rbs_collection.lock.yaml +28 -0
- data/sig/generated/xlsxrb/elements/cell.rbs +30 -0
- data/sig/generated/xlsxrb/elements/column.rbs +30 -0
- data/sig/generated/xlsxrb/elements/row.rbs +32 -0
- data/sig/generated/xlsxrb/elements/types.rbs +74 -0
- data/sig/generated/xlsxrb/elements/workbook.rbs +25 -0
- data/sig/generated/xlsxrb/elements/worksheet.rbs +27 -0
- data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
- data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
- data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
- data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
- data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
- data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
- data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
- data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
- data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
- data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
- data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
- data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
- data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
- data/sig/generated/xlsxrb/style_builder.rbs +212 -50
- data/sig/generated/xlsxrb.rbs +1276 -0
- data/sig/rexml.rbs +4 -0
- metadata +76 -1
data/README.md
CHANGED
|
@@ -6,17 +6,22 @@ A Ruby library for reading and writing XLSX files with streaming support.
|
|
|
6
6
|
|
|
7
7
|
The Ruby ecosystem already has great XLSX libraries. Each is well-designed for its purpose:
|
|
8
8
|
|
|
9
|
-
| Library | Read | Write |
|
|
10
|
-
| -------------------------------------------------- | ---- | ----- |
|
|
11
|
-
| [roo](https://rubygems.org/gems/roo) | ✅ | ❌ |
|
|
12
|
-
| [creek](https://rubygems.org/gems/creek) | ✅ | ❌ |
|
|
13
|
-
| [xsv](https://rubygems.org/gems/xsv) | ✅ | ❌ |
|
|
14
|
-
| [caxlsx / axlsx](https://rubygems.org/gems/caxlsx) | ❌ | ✅ |
|
|
15
|
-
| [xlsxtream](https://rubygems.org/gems/xlsxtream) | ❌ | ✅ |
|
|
16
|
-
| [
|
|
17
|
-
| [
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
| Library | Read | Write | Model | Write String Storage | Rich Formatting |
|
|
10
|
+
| -------------------------------------------------- | ---- | ----- | --------------------- | -------------------- | --------------- |
|
|
11
|
+
| [roo](https://rubygems.org/gems/roo) | ✅ | ❌ | Streaming | N/A (Read-only) | ⚠️ (Formulas, Basic styles) |
|
|
12
|
+
| [creek](https://rubygems.org/gems/creek) | ✅ | ❌ | Streaming | N/A (Read-only) | ❌ (Raw cell values) |
|
|
13
|
+
| [xsv](https://rubygems.org/gems/xsv) | ✅ | ❌ | Streaming | N/A (Read-only) | ❌ (Fast plain text) |
|
|
14
|
+
| [caxlsx / axlsx](https://rubygems.org/gems/caxlsx) | ❌ | ✅ | In-Memory | Inline (opt: SST) | ✅ (Charts, Styles) |
|
|
15
|
+
| [xlsxtream](https://rubygems.org/gems/xlsxtream) | ❌ | ✅ | Streaming | Inline (opt: SST) | ❌ (Plain data only) |
|
|
16
|
+
| [fast_excel](https://rubygems.org/gems/fast_excel) | ❌ | ✅ | Streaming (C Ext) | SST (opt: Inline) | ⚠️ (Basic styles) |
|
|
17
|
+
| [rubyXL](https://rubygems.org/gems/rubyXL) | ✅ | ✅ | In-Memory | Inline / Direct | ✅ (DOM editing) |
|
|
18
|
+
| **[xlsxrb](https://github.com/niku/xlsxrb)** | ✅ | ✅ | **Streaming / In-Memory** | **SST** | ✅ **(Full Features)** |
|
|
19
|
+
|
|
20
|
+
Each of these libraries makes deliberate tradeoffs, and they do so thoughtfully:
|
|
21
|
+
* **Memory & Execution Model (Streaming vs In-Memory)**: Streaming libraries write or read rows sequentially on-the-fly to maintain a constant, low-memory footprint regardless of row count. In-memory libraries build complete document object trees, offering flexible random access and cell updates at the cost of high RAM usage on large sheets.
|
|
22
|
+
* **String Storage Architecture (SST vs Inline Strings)**:
|
|
23
|
+
* **SST (Shared String Table)**: De-duplicates strings into a central dictionary (`xl/sharedStrings.xml`), referencing them by numeric IDs in cell entries (`<c t="s"><v>0</v></c>`). This is standard Microsoft Excel behavior, producing significantly smaller raw XML documents (50–100% smaller) and reducing Excel's memory footprint when opening spreadsheets.
|
|
24
|
+
* **Inline Strings**: Writes text directly into cell payloads (`<c t="inlineStr"><is><t>...</t></is></c>`). Bypassing the dictionary enables blazing-fast raw throughput for simple data exports, but inflates uncompressed XML size and limits advanced formatting (e.g. styling, cell merges, charts).
|
|
20
25
|
|
|
21
26
|
Traditionally, attempting to build a "complete package" that offers both reading and writing, rich features, high performance, strict compatibility, and comprehensive documentation presents an inherent open-source challenge: the cumulative maintenance overhead often exceeds the capacity of individual human maintainers.
|
|
22
27
|
|
|
@@ -156,37 +161,39 @@ For detailed specification references and policies, see [SPEC_SOURCES.md](docs/S
|
|
|
156
161
|
|
|
157
162
|
## Benchmarks
|
|
158
163
|
|
|
159
|
-
The following benchmarks measure the time
|
|
164
|
+
The following benchmarks measure the time, peak memory, and GC count required to process a 1,000,000 cells (100,000 rows × 10 columns) spreadsheet across popular Ruby Excel libraries. Each test is executed across 3 independent runs in isolated subprocesses; median values are reported along with the mean execution time.
|
|
160
165
|
|
|
161
166
|
### Write Performance (1,000,000 cells)
|
|
162
167
|
|
|
163
|
-
| Library | Time
|
|
164
|
-
| ---------------------- |
|
|
165
|
-
| xlsxtream
|
|
166
|
-
|
|
|
167
|
-
|
|
|
168
|
-
| xlsxrb (
|
|
169
|
-
|
|
|
170
|
-
| rubyXL
|
|
168
|
+
| Library | Model | Write String Storage | Time (Median) | Time (Mean) | Peak Memory | GC Count |
|
|
169
|
+
| ---------------------- | ----------- | -------------------- | ------------- | ----------- | ----------- | -------- |
|
|
170
|
+
| xlsxtream 3.1.0 | Streaming | Inline String | 1.23 s | 1.25 s | 18.1 MB | 1061.0 |
|
|
171
|
+
| xlsxrb (Streaming) | Streaming | SST (Shared) | 1.62 s | 1.62 s | 94.5 MB | 39.0 |
|
|
172
|
+
| fast_excel 0.5.0 (C) | Streaming | SST (Shared) | 2.03 s | 2.03 s | 147.9 MB | 263.0 |
|
|
173
|
+
| xlsxrb (In-Memory) | In-Memory | SST (Shared) | 4.06 s | 4.06 s | 280.3 MB | 32.0 |
|
|
174
|
+
| caxlsx 4.5.0 | In-Memory | Inline String | 5.36 s | 5.35 s | 188.7 MB | 23.0 |
|
|
175
|
+
| rubyXL 3.4.38 | In-Memory | Inline String | 38.02 s | 38.03 s | 2166.0 MB | 104.0 |
|
|
176
|
+
|
|
177
|
+
> **Note**: All libraries are evaluated in their **default, out-of-the-box configuration**. Under the same Microsoft Excel-standard Shared String Table (SST) architecture, Pure Ruby `xlsxrb` (Streaming: 1.62s, In-Memory: 4.06s) writes 1,000,000 cells faster than the C-extension `fast_excel` (2.03s) and in-memory gems like `caxlsx` (5.36s).
|
|
171
178
|
|
|
172
179
|
### Read Performance (1,000,000 cells)
|
|
173
180
|
|
|
174
|
-
| Library
|
|
175
|
-
|
|
|
176
|
-
| xlsxrb (Streaming) |
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
|
|
|
180
|
-
| xsv
|
|
181
|
-
| rubyXL
|
|
181
|
+
| Library | Model | Time (Median) | Time (Mean) | Peak Memory | GC Count |
|
|
182
|
+
| ---------------------- | ----------- | ------------- | ----------- | ----------- | -------- |
|
|
183
|
+
| xlsxrb (Streaming) | Streaming | 3.38 s | 3.37 s | 90.6 MB | 40.0 |
|
|
184
|
+
| xlsxrb (In-Memory) | In-Memory | 5.75 s | 5.94 s | 250.9 MB | 55.0 |
|
|
185
|
+
| creek 2.6.3 | Streaming | 7.90 s | 7.93 s | 835.7 MB | 481.0 |
|
|
186
|
+
| roo 3.0.0 | Streaming | 10.34 s | 10.28 s | 139.0 MB | 107.0 |
|
|
187
|
+
| xsv 1.4.1 | Streaming | 16.39 s | 17.28 s | 75.4 MB | 2215.0 |
|
|
188
|
+
| rubyXL 3.4.38 | In-Memory | 35.45 s | 35.38 s | 2281.3 MB | 146.0 |
|
|
182
189
|
|
|
183
|
-
### Running the Benchmarks Locally
|
|
190
|
+
### Running the Benchmarks Locally (Reproducibility)
|
|
184
191
|
|
|
185
|
-
The benchmark
|
|
192
|
+
The benchmark suite leverages [`bundler/inline`](https://bundler.io/v2.5/guides/bundler_in_a_single_file_ruby_script.html) to automatically manage and download all peer ecosystem gems without modifying the project's core `Gemfile` or requiring manual global `gem install` steps. Each library is executed in an isolated subprocess (`Bundler.with_unbundled_env`) across multiple runs with standard business dataset rows (integers, strings, floats, booleans, dates) to ensure clean memory and GC measurements without cross-contamination.
|
|
186
193
|
|
|
187
|
-
To run the benchmark
|
|
194
|
+
To run the complete benchmark suite:
|
|
188
195
|
```bash
|
|
189
|
-
ruby benchmark.rb 100000
|
|
196
|
+
ruby benchmark.rb 100000 10
|
|
190
197
|
```
|
|
191
198
|
|
|
192
199
|
## Security (Protection against CSV/Excel Injection)
|
data/Rakefile
CHANGED
|
@@ -527,7 +527,8 @@ namespace :doc do
|
|
|
527
527
|
end
|
|
528
528
|
desc "Generate RBS signature files from inline annotations"
|
|
529
529
|
task :sig do
|
|
530
|
-
|
|
530
|
+
files = FileList["lib/**/*.rb"].to_a
|
|
531
|
+
sh "bundle", "exec", "rbs-inline", "--output=sig/generated", "--base=lib", *files
|
|
531
532
|
end
|
|
532
533
|
|
|
533
534
|
desc "Run static type checking with Steep"
|
data/Steepfile
CHANGED
data/benchmark.rb
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rubocop:disable all
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "bundler/inline"
|
|
8
|
+
|
|
9
|
+
puts "Ensuring benchmark peer ecosystem gems are available (bundler/inline)..."
|
|
10
|
+
gemfile(true) do
|
|
11
|
+
source "https://rubygems.org"
|
|
12
|
+
gem "caxlsx", "4.5.0"
|
|
13
|
+
gem "xlsxtream", "3.1.0"
|
|
14
|
+
gem "fast_excel", "0.5.0", platform: :mri
|
|
15
|
+
gem "rubyXL", "3.4.38"
|
|
16
|
+
gem "roo", "3.0.0"
|
|
17
|
+
gem "creek", "2.6.3"
|
|
18
|
+
gem "xsv", "1.4.1"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
RUNS = (ENV["RUNS"] || "3").to_i
|
|
22
|
+
ROWS = (ARGV[0] || "100000").to_i
|
|
23
|
+
COLS = (ARGV[1] || "10").to_i
|
|
24
|
+
|
|
25
|
+
AVAILABLE_GEMS = {
|
|
26
|
+
"xlsxrb_stream" => true,
|
|
27
|
+
"xlsxrb_inmemory" => true,
|
|
28
|
+
"xlsxtream" => true,
|
|
29
|
+
"fast_excel" => true,
|
|
30
|
+
"caxlsx" => true,
|
|
31
|
+
"rubyXL" => true,
|
|
32
|
+
"creek" => true,
|
|
33
|
+
"roo" => true,
|
|
34
|
+
"xsv" => true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
puts "=" * 80
|
|
38
|
+
puts "Benchmarking Excel Libraries (#{ROWS} rows x #{COLS} cols = #{ROWS * COLS} cells)"
|
|
39
|
+
puts "Runs per benchmark: #{RUNS} (Median reported, Mean calculated)"
|
|
40
|
+
puts "Ruby: #{RUBY_DESCRIPTION}"
|
|
41
|
+
puts "=" * 80
|
|
42
|
+
|
|
43
|
+
RUNNER_SCRIPT = <<~'RUBY'
|
|
44
|
+
require "json"
|
|
45
|
+
require "stringio"
|
|
46
|
+
|
|
47
|
+
def measure
|
|
48
|
+
gc_before = GC.stat[:count]
|
|
49
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
50
|
+
|
|
51
|
+
yield
|
|
52
|
+
|
|
53
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
gc_after = GC.stat[:count]
|
|
55
|
+
|
|
56
|
+
# Peak memory in MB via /proc/self/status or getrusage
|
|
57
|
+
peak_mb = 0.0
|
|
58
|
+
if File.exist?("/proc/self/status")
|
|
59
|
+
status = File.read("/proc/self/status")
|
|
60
|
+
if status =~ /VmHWM:\s+(\d+)\s+kB/i
|
|
61
|
+
peak_mb = $1.to_f / 1024.0
|
|
62
|
+
elsif status =~ /VmRSS:\s+(\d+)\s+kB/i
|
|
63
|
+
peak_mb = $1.to_f / 1024.0
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
time: (t1 - t0),
|
|
69
|
+
peak_memory_mb: peak_mb,
|
|
70
|
+
gc_count: (gc_after - gc_before)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def generate_row(r, cols)
|
|
75
|
+
base = [r + 1, "User #{r + 1}", 123.45, true, "Active", (r + 1) * 10, "Tokyo", 99.9, false, "Item #{r % 50}"]
|
|
76
|
+
if cols <= base.size
|
|
77
|
+
base.first(cols)
|
|
78
|
+
else
|
|
79
|
+
base + Array.new(cols - base.size) { |c| "col_#{c}_#{r}" }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
lib = ARGV[0]
|
|
84
|
+
mode = ARGV[1] # "write" or "read"
|
|
85
|
+
rows = ARGV[2].to_i
|
|
86
|
+
cols = ARGV[3].to_i
|
|
87
|
+
filename = ARGV[4]
|
|
88
|
+
|
|
89
|
+
result = case [lib, mode]
|
|
90
|
+
when ["xlsxtream", "write"]
|
|
91
|
+
require "xlsxtream"
|
|
92
|
+
measure do
|
|
93
|
+
Xlsxtream::Workbook.open(filename) do |wb|
|
|
94
|
+
wb.write_worksheet("Data") do |sheet|
|
|
95
|
+
rows.times do |r|
|
|
96
|
+
sheet << generate_row(r, cols)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
when ["fast_excel", "write"]
|
|
102
|
+
require "fast_excel"
|
|
103
|
+
measure do
|
|
104
|
+
wb = FastExcel.open(filename)
|
|
105
|
+
sheet = wb.add_worksheet("Data")
|
|
106
|
+
rows.times do |r|
|
|
107
|
+
sheet.append_row(generate_row(r, cols))
|
|
108
|
+
end
|
|
109
|
+
wb.close
|
|
110
|
+
end
|
|
111
|
+
when ["caxlsx", "write"]
|
|
112
|
+
require "caxlsx"
|
|
113
|
+
measure do
|
|
114
|
+
p = Axlsx::Package.new
|
|
115
|
+
wb = p.workbook
|
|
116
|
+
wb.add_worksheet(name: "Data") do |sheet|
|
|
117
|
+
rows.times do |r|
|
|
118
|
+
sheet.add_row(generate_row(r, cols))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
p.serialize(filename)
|
|
122
|
+
end
|
|
123
|
+
when ["rubyXL", "write"]
|
|
124
|
+
require "rubyXL"
|
|
125
|
+
measure do
|
|
126
|
+
wb = RubyXL::Workbook.new
|
|
127
|
+
sheet = wb[0]
|
|
128
|
+
sheet.sheet_name = "Data"
|
|
129
|
+
rows.times do |r|
|
|
130
|
+
row_data = generate_row(r, cols)
|
|
131
|
+
row_data.each_with_index do |val, c|
|
|
132
|
+
sheet.add_cell(r, c, val)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
wb.write(filename)
|
|
136
|
+
end
|
|
137
|
+
when ["xlsxrb_stream", "write"]
|
|
138
|
+
require_relative "lib/xlsxrb"
|
|
139
|
+
measure do
|
|
140
|
+
Xlsxrb.generate(filename) do |wb|
|
|
141
|
+
wb.sheet("Data") do |sheet|
|
|
142
|
+
rows.times do |r|
|
|
143
|
+
sheet.row(generate_row(r, cols))
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
when ["xlsxrb_inmemory", "write"]
|
|
149
|
+
require_relative "lib/xlsxrb"
|
|
150
|
+
measure do
|
|
151
|
+
wb = Xlsxrb.build do |b|
|
|
152
|
+
b.sheet("Data") do |s|
|
|
153
|
+
rows.times do |r|
|
|
154
|
+
s.row(generate_row(r, cols))
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
Xlsxrb.write(filename, wb)
|
|
159
|
+
end
|
|
160
|
+
when ["xlsxrb_stream", "read"]
|
|
161
|
+
require_relative "lib/xlsxrb"
|
|
162
|
+
measure do
|
|
163
|
+
count = 0
|
|
164
|
+
Xlsxrb.foreach(filename) do |sheet|
|
|
165
|
+
sheet.each do |row|
|
|
166
|
+
row.cells.each do |cell|
|
|
167
|
+
_val = cell.value
|
|
168
|
+
count += 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
when ["xlsxrb_inmemory", "read"]
|
|
174
|
+
require_relative "lib/xlsxrb"
|
|
175
|
+
measure do
|
|
176
|
+
wb = Xlsxrb.read(filename)
|
|
177
|
+
count = 0
|
|
178
|
+
wb.sheets.each do |sheet|
|
|
179
|
+
sheet.rows.each do |row|
|
|
180
|
+
row.cells.each do |cell|
|
|
181
|
+
_val = cell.value
|
|
182
|
+
count += 1
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
when ["creek", "read"]
|
|
188
|
+
require "creek"
|
|
189
|
+
measure do
|
|
190
|
+
creek = Creek::Book.new(filename)
|
|
191
|
+
count = 0
|
|
192
|
+
creek.sheets.each do |sheet|
|
|
193
|
+
sheet.rows.each do |row|
|
|
194
|
+
row.each_value do |_val|
|
|
195
|
+
count += 1
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
when ["roo", "read"]
|
|
201
|
+
require "roo"
|
|
202
|
+
measure do
|
|
203
|
+
xlsx = Roo::Excelx.new(filename)
|
|
204
|
+
count = 0
|
|
205
|
+
xlsx.each_row_streaming do |row|
|
|
206
|
+
row.each do |cell|
|
|
207
|
+
_val = cell&.value
|
|
208
|
+
count += 1
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
when ["xsv", "read"]
|
|
213
|
+
require "xsv"
|
|
214
|
+
measure do
|
|
215
|
+
x = Xsv.open(filename)
|
|
216
|
+
count = 0
|
|
217
|
+
x.sheets.each do |sheet|
|
|
218
|
+
sheet.each do |row|
|
|
219
|
+
row.each do |_val|
|
|
220
|
+
count += 1
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
when ["rubyXL", "read"]
|
|
226
|
+
require "rubyXL"
|
|
227
|
+
measure do
|
|
228
|
+
wb = RubyXL::Parser.parse(filename)
|
|
229
|
+
count = 0
|
|
230
|
+
wb.worksheets.each do |sheet|
|
|
231
|
+
sheet.each do |row|
|
|
232
|
+
next unless row
|
|
233
|
+
row.cells.each do |cell|
|
|
234
|
+
_val = cell&.value
|
|
235
|
+
count += 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
else
|
|
241
|
+
raise "Unknown benchmark target: #{lib} #{mode}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
puts result.to_json
|
|
245
|
+
RUBY
|
|
246
|
+
|
|
247
|
+
runner_file = "benchmark_runner.rb"
|
|
248
|
+
File.write(runner_file, RUNNER_SCRIPT)
|
|
249
|
+
|
|
250
|
+
def run_isolated(lib, mode, rows, cols, filename)
|
|
251
|
+
cmd = ["ruby", "-Ilib", "benchmark_runner.rb", lib, mode, rows.to_s, cols.to_s, filename]
|
|
252
|
+
stdout, stderr, status = Bundler.with_unbundled_env do
|
|
253
|
+
Open3.capture3(*cmd)
|
|
254
|
+
end
|
|
255
|
+
unless status.success?
|
|
256
|
+
warn "Failed to run #{lib} #{mode}: #{stderr}"
|
|
257
|
+
return nil
|
|
258
|
+
end
|
|
259
|
+
JSON.parse(stdout.strip, symbolize_names: true)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def run_benchmark_series(name, lib, mode, rows, cols, filename, runs)
|
|
263
|
+
unless AVAILABLE_GEMS[lib]
|
|
264
|
+
puts "Skipping #{name} (gem not installed)"
|
|
265
|
+
return nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
print "Running #{name} (#{runs} runs)... "
|
|
269
|
+
$stdout.flush
|
|
270
|
+
results = []
|
|
271
|
+
runs.times do |_i|
|
|
272
|
+
File.delete(filename) if File.exist?(filename) && mode == "write"
|
|
273
|
+
res = run_isolated(lib, mode, rows, cols, filename)
|
|
274
|
+
if res
|
|
275
|
+
results << res
|
|
276
|
+
print "#{res[:time].round(2)}s "
|
|
277
|
+
$stdout.flush
|
|
278
|
+
else
|
|
279
|
+
print "ERR "
|
|
280
|
+
$stdout.flush
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
puts
|
|
284
|
+
|
|
285
|
+
return nil if results.empty?
|
|
286
|
+
|
|
287
|
+
times = results.map { |r| r[:time] }.sort
|
|
288
|
+
mems = results.map { |r| r[:peak_memory_mb] }.sort
|
|
289
|
+
gcs = results.map { |r| r[:gc_count] }.sort
|
|
290
|
+
|
|
291
|
+
median_time = times[times.size / 2]
|
|
292
|
+
mean_time = times.sum / times.size
|
|
293
|
+
median_mem = mems[mems.size / 2]
|
|
294
|
+
median_gc = gcs[gcs.size / 2]
|
|
295
|
+
|
|
296
|
+
{
|
|
297
|
+
name: name,
|
|
298
|
+
median_time: median_time,
|
|
299
|
+
mean_time: mean_time,
|
|
300
|
+
median_mem: median_mem,
|
|
301
|
+
median_gc: median_gc
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# 1. Generate a standard reference file for reading benchmarks
|
|
306
|
+
ref_file = "bench_reference_data.xlsx"
|
|
307
|
+
puts "\n[Setup] Generating reference file (#{ROWS} x #{COLS}) for read benchmarks..."
|
|
308
|
+
run_isolated("xlsxrb_stream", "write", ROWS, COLS, ref_file)
|
|
309
|
+
|
|
310
|
+
# 2. Benchmark Write
|
|
311
|
+
puts "\n=== Benchmarking Write Performance ==="
|
|
312
|
+
write_targets = [
|
|
313
|
+
["xlsxtream 3.1.0", "xlsxtream", "Streaming", "Inline String"],
|
|
314
|
+
["xlsxrb (Streaming)", "xlsxrb_stream", "Streaming", "SST (Shared)"],
|
|
315
|
+
["fast_excel 0.5.0 (C)", "fast_excel", "Streaming", "SST (Shared)"],
|
|
316
|
+
["caxlsx 4.5.0", "caxlsx", "In-Memory", "Inline String"],
|
|
317
|
+
["xlsxrb (In-Memory)", "xlsxrb_inmemory", "In-Memory", "SST (Shared)"],
|
|
318
|
+
["rubyXL 3.4.38", "rubyXL", "In-Memory", "Inline String"]
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
write_results = []
|
|
322
|
+
write_targets.each do |name, lib, model, storage|
|
|
323
|
+
target_file = "bench_write_#{lib}.xlsx"
|
|
324
|
+
res = run_benchmark_series(name, lib, "write", ROWS, COLS, target_file, RUNS)
|
|
325
|
+
if res
|
|
326
|
+
res[:model] = model
|
|
327
|
+
res[:storage] = storage
|
|
328
|
+
write_results << res
|
|
329
|
+
end
|
|
330
|
+
FileUtils.rm_f(target_file)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# 3. Benchmark Read
|
|
334
|
+
puts "\n=== Benchmarking Read Performance ==="
|
|
335
|
+
read_targets = [
|
|
336
|
+
["xlsxrb (Streaming)", "xlsxrb_stream", "Streaming"],
|
|
337
|
+
["xlsxrb (In-Memory)", "xlsxrb_inmemory", "In-Memory"],
|
|
338
|
+
["creek 2.6.3", "creek", "Streaming"],
|
|
339
|
+
["roo 3.0.0", "roo", "Streaming"],
|
|
340
|
+
["xsv 1.4.1", "xsv", "Streaming"],
|
|
341
|
+
["rubyXL 3.4.38", "rubyXL", "In-Memory"]
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
read_results = []
|
|
345
|
+
read_targets.each do |name, lib, model|
|
|
346
|
+
res = run_benchmark_series(name, lib, "read", ROWS, COLS, ref_file, RUNS)
|
|
347
|
+
if res
|
|
348
|
+
res[:model] = model
|
|
349
|
+
read_results << res
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Cleanup
|
|
354
|
+
FileUtils.rm_f(ref_file)
|
|
355
|
+
FileUtils.rm_f(runner_file)
|
|
356
|
+
|
|
357
|
+
# Print Tables
|
|
358
|
+
puts "\n" + ("=" * 80)
|
|
359
|
+
puts "### Write Performance (#{ROWS * COLS} cells: #{ROWS} rows x #{COLS} cols)"
|
|
360
|
+
puts ""
|
|
361
|
+
puts "| Library | Model | Write String Storage | Time (Median) | Time (Mean) | Peak Memory | GC Count |"
|
|
362
|
+
puts "| ---------------------- | ----------- | -------------------- | ------------- | ----------- | ----------- | -------- |"
|
|
363
|
+
write_results.sort_by { |r| r[:median_time] }.each do |r|
|
|
364
|
+
printf "| %-22s | %-11s | %-20s | %6.2f s | %6.2f s | %7.1f MB | %6.1f |\n",
|
|
365
|
+
r[:name], r[:model], r[:storage], r[:median_time], r[:mean_time], r[:median_mem], r[:median_gc]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
puts "\n### Read Performance (#{ROWS * COLS} cells: #{ROWS} rows x #{COLS} cols)"
|
|
369
|
+
puts ""
|
|
370
|
+
puts "| Library | Model | Time (Median) | Time (Mean) | Peak Memory | GC Count |"
|
|
371
|
+
puts "| ---------------------- | ----------- | ------------- | ----------- | ----------- | -------- |"
|
|
372
|
+
read_results.sort_by { |r| r[:median_time] }.each do |r|
|
|
373
|
+
printf "| %-22s | %-11s | %6.2f s | %6.2f s | %7.1f MB | %6.1f |\n",
|
|
374
|
+
r[:name], r[:model], r[:median_time], r[:mean_time], r[:median_mem], r[:median_gc]
|
|
375
|
+
end
|
|
376
|
+
puts "=" * 80
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -26,12 +26,19 @@ As a strict rule, **we do not accept dynamic method definitions using `method_mi
|
|
|
26
26
|
2. **Developer Experience**: IDE autocompletion, jump-to-definition, and YARD documentation work perfectly.
|
|
27
27
|
3. **Traceability**: If a method exists, you can `grep` for it.
|
|
28
28
|
|
|
29
|
-
Even in cases where proxy patterns (e.g. `WorksheetProxy`) or OOXML builder mappings (e.g. `ChartBuilder`, `SeriesBuilder`) would traditionally benefit from dynamic
|
|
29
|
+
Even in cases where proxy patterns (e.g. `WorksheetProxy`) or OOXML builder mappings (e.g. `ChartBuilder`, `SeriesBuilder`) would traditionally benefit from dynamic delegation to avoid boilerplate, we explicitly generate and write out those delegations in the source code.
|
|
30
30
|
|
|
31
|
-
###
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
### 1. **`Xlsxrb` Module is the ONLY Entrypoint:**
|
|
32
|
+
The `Xlsxrb` module provides the top-level methods: `generate`, `build`, `read`, `foreach`, and `modify`. Users should **never** instantiate internal classes (like `Xlsxrb::Ooxml::WorkbookWriter`) directly.
|
|
33
|
+
|
|
34
|
+
2. **The `@api public` Contract (SemVer Guarantee):**
|
|
35
|
+
Any module, class, or method tagged with `# @api public` in its YARD documentation is guaranteed to follow Semantic Versioning.
|
|
36
|
+
- Patch versions (0.1.x -> 0.1.y) will not break these APIs.
|
|
37
|
+
- Minor versions (0.x.0 -> 0.y.0) will not break these APIs once 1.0.0 is released (during 0.x.x, it is a best-effort promise).
|
|
38
|
+
- Major versions (1.x -> 2.x) are the only time breaking changes to `@api public` components are permitted.
|
|
39
|
+
|
|
40
|
+
3. **Block-Yielded Objects are Public APIs:**
|
|
41
|
+
All builder objects yielded into blocks (e.g., `writer` in `Xlsxrb.generate { |writer| }`, `sheet` in `writer.sheet { |sheet| }`, `chart` in `sheet.chart { |chart| }`) are explicitly marked as `@api public`. Their exposed methods constitute the DSL and are strictly protected by the SemVer contract.
|
|
35
42
|
|
|
36
43
|
---
|
|
37
44
|
|
data/docs/wasm/ruby.wasm
CHANGED
|
Binary file
|
data/lib/xlsxrb/elements/cell.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Xlsxrb
|
|
|
7
7
|
# Represents a single cell in a worksheet.
|
|
8
8
|
# All indices are 0-based.
|
|
9
9
|
Cell = Data.define(:row_index, :column_index, :value, :formula, :style_index, :unmapped_data, :errors) do
|
|
10
|
-
def initialize(row_index:, column_index:, value: nil, formula: nil, style_index: nil, unmapped_data:
|
|
10
|
+
def initialize(row_index:, column_index:, value: nil, formula: nil, style_index: nil, unmapped_data: EMPTY_HASH, errors: nil)
|
|
11
11
|
computed_errors = errors || self.class.validate(row_index, column_index, value)
|
|
12
12
|
computed_errors = computed_errors.freeze unless computed_errors.frozen?
|
|
13
13
|
super(row_index: row_index, column_index: column_index, value: value, formula: formula,
|
|
@@ -23,6 +23,22 @@ module Xlsxrb
|
|
|
23
23
|
"#{self.class.column_letter(column_index)}#{row_index + 1}"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
def [](key)
|
|
27
|
+
case key
|
|
28
|
+
when :value then value
|
|
29
|
+
when :formula then formula
|
|
30
|
+
when :style_index then style_index
|
|
31
|
+
when :ref then ref
|
|
32
|
+
when :column_index then column_index
|
|
33
|
+
when :row_index then row_index
|
|
34
|
+
when :type
|
|
35
|
+
case value
|
|
36
|
+
when String then "s"
|
|
37
|
+
when true, false then "b"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
26
42
|
def content
|
|
27
43
|
value
|
|
28
44
|
end
|
|
@@ -122,19 +138,35 @@ module Xlsxrb
|
|
|
122
138
|
|
|
123
139
|
# Parses an Excel-style reference to [row_index, col_index] (both 0-based).
|
|
124
140
|
def self.parse_ref(ref)
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
return nil unless ref
|
|
142
|
+
|
|
143
|
+
bytes = ref.b
|
|
144
|
+
len = bytes.bytesize
|
|
145
|
+
col = 0
|
|
146
|
+
i = 0
|
|
147
|
+
while i < len
|
|
148
|
+
b = bytes.getbyte(i)
|
|
149
|
+
if b.between?(65, 90)
|
|
150
|
+
col = (col * 26) + (b - 64)
|
|
151
|
+
i += 1
|
|
152
|
+
elsif b.between?(97, 122)
|
|
153
|
+
col = (col * 26) + (b - 96)
|
|
154
|
+
i += 1
|
|
155
|
+
else
|
|
156
|
+
break
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
return nil if i.zero? || i == len
|
|
127
160
|
|
|
128
|
-
|
|
129
|
-
row
|
|
130
|
-
[row, col]
|
|
161
|
+
row = bytes.byteslice(i, len - i).to_i - 1
|
|
162
|
+
[row, col - 1]
|
|
131
163
|
end
|
|
132
164
|
|
|
133
165
|
def self.validate(row_index, column_index, value)
|
|
134
166
|
if row_index.is_a?(Integer) && row_index >= 0 && row_index < 1_048_576 &&
|
|
135
167
|
column_index.is_a?(Integer) && column_index >= 0 && column_index < 16_384 &&
|
|
136
168
|
(value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Date) || value.is_a?(Time) || value.is_a?(Formula) || (value.is_a?(Hash) && value.key?(:formula)) || value.is_a?(RichText) || value.is_a?(CellError))
|
|
137
|
-
return
|
|
169
|
+
return EMPTY_ERRORS
|
|
138
170
|
end
|
|
139
171
|
|
|
140
172
|
errs = []
|
data/lib/xlsxrb/elements/row.rb
CHANGED
|
@@ -9,8 +9,8 @@ module Xlsxrb
|
|
|
9
9
|
Row = Data.define(:index, :cells, :height, :hidden, :custom_height, :outline_level, :unmapped_data, :errors) do
|
|
10
10
|
include Enumerable
|
|
11
11
|
|
|
12
|
-
def initialize(index:, cells:
|
|
13
|
-
unmapped_data:
|
|
12
|
+
def initialize(index:, cells: EMPTY_CELLS, height: nil, hidden: false, custom_height: false, outline_level: nil,
|
|
13
|
+
unmapped_data: EMPTY_HASH, errors: nil)
|
|
14
14
|
computed_errors = errors || self.class.validate(index, cells)
|
|
15
15
|
computed_errors = computed_errors.freeze unless computed_errors.frozen?
|
|
16
16
|
cells = cells.freeze unless cells.frozen?
|
|
@@ -20,7 +20,20 @@ module Xlsxrb
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def [](col_index)
|
|
23
|
-
|
|
23
|
+
case col_index
|
|
24
|
+
when Symbol
|
|
25
|
+
case col_index
|
|
26
|
+
when :cells then cells
|
|
27
|
+
when :index then index
|
|
28
|
+
when :height then height
|
|
29
|
+
when :hidden then hidden
|
|
30
|
+
when :custom_height then custom_height
|
|
31
|
+
when :outline_level then outline_level
|
|
32
|
+
when :attrs then { height: height, hidden: hidden, custom_height: custom_height, outline_level: outline_level }
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
cells[col_index]
|
|
36
|
+
end
|
|
24
37
|
end
|
|
25
38
|
|
|
26
39
|
def each(&)
|
|
@@ -66,7 +79,7 @@ module Xlsxrb
|
|
|
66
79
|
end
|
|
67
80
|
|
|
68
81
|
def self.validate(index, cells)
|
|
69
|
-
return
|
|
82
|
+
return EMPTY_ERRORS if index.is_a?(Integer) && index >= 0 && index < 1_048_576 && cells.is_a?(Array)
|
|
70
83
|
|
|
71
84
|
errs = []
|
|
72
85
|
if !index.is_a?(Integer) || index.negative?
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
module Xlsxrb
|
|
6
6
|
module Elements
|
|
7
|
+
EMPTY_ERRORS = [].freeze
|
|
8
|
+
EMPTY_HASH = {}.freeze
|
|
9
|
+
EMPTY_CELLS = [].freeze
|
|
10
|
+
|
|
7
11
|
# Represents a formula with an optional cached value.
|
|
8
12
|
# Optional: type (:shared, :array), ref (range), shared_index (si for shared formulas)
|
|
9
13
|
Formula = Data.define(:expression, :cached_value, :type, :ref, :shared_index, :calculate_always, :aca, :bx, :dt2d, :dtr, :r1, :r2) do
|