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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +2332 -0
  4. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +4 -0
  5. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +9 -0
  6. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +8 -0
  7. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +42 -0
  8. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +5 -0
  9. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +47 -0
  10. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +30 -0
  11. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +9 -0
  12. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +7 -0
  13. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +19 -0
  14. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +31 -0
  15. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +18 -0
  16. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +12 -0
  17. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +11 -0
  18. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +13 -0
  19. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +95 -0
  20. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +31 -0
  21. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +58 -0
  22. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +24 -0
  23. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +17 -0
  24. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +23 -0
  25. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +22 -0
  26. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +30 -0
  27. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +20 -0
  28. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +15 -0
  29. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +26 -0
  30. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +21 -0
  31. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +131 -0
  32. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +14 -0
  33. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +33 -0
  34. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +21 -0
  35. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +63 -0
  36. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +55 -0
  37. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +35 -0
  38. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +7 -0
  39. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +10 -0
  40. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +22 -0
  41. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +29 -0
  42. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +17 -0
  43. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +13 -0
  44. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +10 -0
  45. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +8 -0
  46. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +6 -0
  47. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +30 -0
  48. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +10 -0
  49. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +10 -0
  50. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +5 -0
  51. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +15 -0
  52. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +3 -0
  53. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +40 -0
  54. data/CHANGELOG.md +18 -0
  55. data/README.md +39 -32
  56. data/Rakefile +2 -1
  57. data/Steepfile +0 -2
  58. data/benchmark.rb +376 -0
  59. data/docs/ARCHITECTURE.md +12 -5
  60. data/docs/wasm/ruby.wasm +0 -0
  61. data/lib/xlsxrb/elements/cell.rb +39 -7
  62. data/lib/xlsxrb/elements/row.rb +17 -4
  63. data/lib/xlsxrb/elements/types.rb +4 -0
  64. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +104 -40
  65. data/lib/xlsxrb/ooxml/worksheet_parser.rb +144 -23
  66. data/lib/xlsxrb/ooxml/worksheet_writer.rb +160 -145
  67. data/lib/xlsxrb/ooxml/xml_builder.rb +16 -10
  68. data/lib/xlsxrb/ooxml/zip_reader.rb +104 -18
  69. data/lib/xlsxrb/ooxml/zip_writer.rb +63 -25
  70. data/lib/xlsxrb/style_builder.rb +158 -30
  71. data/lib/xlsxrb/version.rb +1 -1
  72. data/lib/xlsxrb.rb +443 -236
  73. data/rbs_collection.lock.yaml +28 -0
  74. data/sig/generated/xlsxrb/elements/cell.rbs +30 -0
  75. data/sig/generated/xlsxrb/elements/column.rbs +30 -0
  76. data/sig/generated/xlsxrb/elements/row.rbs +32 -0
  77. data/sig/generated/xlsxrb/elements/types.rbs +74 -0
  78. data/sig/generated/xlsxrb/elements/workbook.rbs +25 -0
  79. data/sig/generated/xlsxrb/elements/worksheet.rbs +27 -0
  80. data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
  81. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
  82. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
  83. data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
  84. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
  85. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
  86. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
  87. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
  88. data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
  89. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
  90. data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
  91. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
  92. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
  93. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
  94. data/sig/generated/xlsxrb/style_builder.rbs +212 -50
  95. data/sig/generated/xlsxrb.rbs +1276 -0
  96. data/sig/rexml.rbs +4 -0
  97. 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 | Streaming (low memory) |
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
- | [rubyXL](https://rubygems.org/gems/rubyXL) | ✅ | ✅ | ❌ |
17
- | [fast_excel](https://rubygems.org/gems/fast_excel) | ❌ | ✅ | ✅ |
18
-
19
- Each of these libraries makes deliberate tradeoffs, and they do so thoughtfully. Some focus exclusively on highly efficient reading or writing by streaming data, while others provide a rich API for complex, in-memory document modifications.
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 and memory required to process a 1,000,000 cells (100,000 rows × 10 columns) spreadsheet, demonstrating `xlsxrb`'s memory consumption and processing times.
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 | Peak Memory | GC Count |
164
- | ---------------------- | ------- | ----------- | -------- |
165
- | xlsxtream (Streaming) | 0.12 s | 64.9 MB | 9.0 |
166
- | fast_excel (Streaming) | 1.34 s | 64.5 MB | 28.0 |
167
- | caxlsx (In-Memory) | 2.64 s | 142.7 MB | 16.0 |
168
- | xlsxrb (Streaming) | 3.32 s | 216.9 MB | 65.0 |
169
- | xlsxrb (In-Memory) | 6.23 s | 431.2 MB | 68.0 |
170
- | rubyXL (In-Memory) | 37.16 s | 2105.9 MB | 90.0 |
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 | Time | Peak Memory | GC Count |
175
- | ------------------ | ------- | ----------- | -------- |
176
- | xlsxrb (Streaming) | 5.38 s | 101.8 MB | 1429.0 |
177
- | creek (Streaming) | 7.05 s | 706.2 MB | 3985.0 |
178
- | roo (Streaming) | 7.24 s | 128.1 MB | 279.0 |
179
- | xlsxrb (In-Memory) | 8.62 s | 996.1 MB | 28.0 |
180
- | xsv (Streaming) | 14.41 s | 93.5 MB | 998.0 |
181
- | rubyXL (In-Memory) | 24.58 s | 1856.8 MB | 127.0 |
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 data is gathered using the bundled [benchmark.rb](file:///workspaces/xlsxrb/benchmark.rb) script, which runs each library's code in isolated subprocesses to ensure accurate memory and GC measurements.
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 locally for 1,000,000 cells (100,000 rows):
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
- sh "bundle exec rbs-inline --output lib/**/*.rb"
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
@@ -3,8 +3,6 @@
3
3
  target :lib do
4
4
  signature "sig"
5
5
 
6
- check "lib/xlsxrb.rb"
7
-
8
6
  library "date"
9
7
  library "time"
10
8
  library "securerandom"
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 method delegation to avoid boilerplate, we explicitly generate and write out those delegations in the source code.
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
- ### API Contract (SemVer)
32
- - Methods tagged with `@api public` in their YARD documentation are guaranteed to follow SemVer.
33
- - Methods without this tag (or tagged `@api private`) are internal and may change at any time.
34
- - Note that during the 0.x series, this is a "best effort" promise, but after 1.0.0, strict SemVer guarantees will apply to all `@api public` methods.
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
@@ -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: {}, errors: nil)
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
- match = ref.match(/\A([A-Z]+)(\d+)\z/)
126
- return nil unless match
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
- col = match[1].chars.reduce(0) { |acc, c| (acc * 26) + (c.ord - "A".ord + 1) } - 1
129
- row = match[2].to_i - 1
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 [].freeze
169
+ return EMPTY_ERRORS
138
170
  end
139
171
 
140
172
  errs = []
@@ -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: [], height: nil, hidden: false, custom_height: false, outline_level: nil,
13
- unmapped_data: {}, errors: nil)
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
- cells[col_index]
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 [].freeze if index.is_a?(Integer) && index >= 0 && index < 1_048_576 && cells.is_a?(Array)
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