xlsxrb 0.1.5 → 0.1.7

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 (103) 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 +28 -0
  55. data/README.md +66 -44
  56. data/Rakefile +2 -1
  57. data/Steepfile +1 -2
  58. data/benchmark.rb +376 -0
  59. data/docs/ARCHITECTURE.md +12 -5
  60. data/docs/visual/VisualGallery.md +8 -0
  61. data/docs/wasm/ruby.wasm +0 -0
  62. data/lib/ruby_lsp/xlsxrb/addon.rb +43 -0
  63. data/lib/ruby_lsp/xlsxrb/completion_listener.rb +769 -0
  64. data/lib/xlsxrb/elements/cell.rb +137 -11
  65. data/lib/xlsxrb/elements/column.rb +22 -0
  66. data/lib/xlsxrb/elements/row.rb +98 -5
  67. data/lib/xlsxrb/elements/types.rb +40 -5
  68. data/lib/xlsxrb/elements/workbook.rb +73 -5
  69. data/lib/xlsxrb/elements/worksheet.rb +110 -1
  70. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +104 -40
  71. data/lib/xlsxrb/ooxml/worksheet_parser.rb +144 -23
  72. data/lib/xlsxrb/ooxml/worksheet_writer.rb +160 -145
  73. data/lib/xlsxrb/ooxml/xml_builder.rb +16 -10
  74. data/lib/xlsxrb/ooxml/zip_reader.rb +104 -18
  75. data/lib/xlsxrb/ooxml/zip_writer.rb +63 -25
  76. data/lib/xlsxrb/style_builder.rb +158 -30
  77. data/lib/xlsxrb/version.rb +1 -1
  78. data/lib/xlsxrb.rb +892 -336
  79. data/rbs_collection.lock.yaml +28 -0
  80. data/sig/generated/xlsxrb/elements/cell.rbs +39 -0
  81. data/sig/generated/xlsxrb/elements/column.rbs +35 -0
  82. data/sig/generated/xlsxrb/elements/row.rbs +39 -0
  83. data/sig/generated/xlsxrb/elements/types.rbs +82 -0
  84. data/sig/generated/xlsxrb/elements/workbook.rbs +32 -0
  85. data/sig/generated/xlsxrb/elements/worksheet.rbs +34 -0
  86. data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
  87. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
  88. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
  89. data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
  90. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
  91. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
  92. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
  93. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
  94. data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
  95. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
  96. data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
  97. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
  98. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
  99. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
  100. data/sig/generated/xlsxrb/style_builder.rbs +212 -50
  101. data/sig/generated/xlsxrb.rbs +1646 -0
  102. data/sig/rexml.rbs +4 -0
  103. metadata +78 -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
 
@@ -77,8 +82,8 @@ Generate large files efficiently by writing data directly to the file stream:
77
82
  ```ruby
78
83
  require "xlsxrb"
79
84
 
80
- Xlsxrb.generate("large_output.xlsx") do |wb|
81
- wb.sheet("Sales Data") do |sheet|
85
+ Xlsxrb.generate("large_output.xlsx") do |stream_writer|
86
+ stream_writer.sheet("Sales Data") do |sheet|
82
87
  sheet.row(["Date", "Amount", "Status"])
83
88
  sheet.row([Date.today, 100, true])
84
89
  sheet.column(0, width: 15.5)
@@ -92,7 +97,7 @@ Read rows one at a time without loading the entire file into memory:
92
97
  require "xlsxrb"
93
98
 
94
99
  Xlsxrb.foreach("large_file.xlsx") do |sheet|
95
- sheet.each do |row|
100
+ sheet.each_row do |row|
96
101
  puts "Row #{row.index}: #{row.cells.map(&:value).join(', ')}"
97
102
  end
98
103
  end
@@ -103,17 +108,17 @@ end
103
108
  `xlsxrb` provides a powerful, immutable-by-default API for modifying existing Excel files or building templates in-memory.
104
109
 
105
110
  #### Modifying an Existing File
106
- You can update specific cells or sheets using the functional `Xlsxrb.modify` API, which yields the parsed `Workbook`.
111
+ You can update specific cells or sheets using the functional `Xlsxrb.modify` API, which yields the parsed `Elements::Workbook`.
107
112
 
108
113
  ```ruby
109
114
  require "xlsxrb"
110
115
 
111
- # Create a dummy template.xlsx for this example
112
- Xlsxrb.build { |w| w.sheet("Invoice") }.write("template.xlsx")
116
+ # Create a template.xlsx for this example
117
+ Xlsxrb.build { |builder| builder.sheet("Invoice") }.write("template.xlsx")
113
118
 
114
- Xlsxrb.modify("template.xlsx", "output.xlsx") do |wb|
115
- wb.update_sheet("Invoice") do |sheet|
116
- # Update a specific cell
119
+ Xlsxrb.modify("template.xlsx", "output.xlsx") do |workbook|
120
+ workbook.update_sheet("Invoice") do |sheet|
121
+ # Update specific cells (returns updated sheet)
117
122
  sheet = sheet.update_cell("C4", value: "INV-10042")
118
123
  sheet = sheet.update_cell("C5", value: Date.today)
119
124
 
@@ -129,16 +134,31 @@ end
129
134
  You can directly apply inline styles or use Ranges for multiple columns without boilerplate:
130
135
 
131
136
  ```ruby
132
- Xlsxrb.build do |wb|
137
+ Xlsxrb.build do |builder|
133
138
  # Use [] accessor for sheets
134
- wb["Report"].row(
139
+ builder["Report"].row(
135
140
  ["ID", "Name", "Score", "Rank"],
136
141
  # Apply 'header' style to first two columns, and bold inline style to the third
137
142
  styles: { 0..1 => "header", 2 => { font: { bold: true, color: "red" } } }
138
143
  )
139
144
 
140
145
  # Set multiple column widths at once using Ranges
141
- wb["Report"].column("A".."D", width: 15.0)
146
+ builder["Report"].column("A".."D", width: 15.0)
147
+ end
148
+ ```
149
+
150
+ ### IDE Autocompletion & Ruby LSP Support
151
+
152
+ `xlsxrb` bundles a native **Ruby LSP Add-on** (`RubyLsp::Xlsxrb::Addon`) and full **RBS signatures**, enabling zero-configuration method autocompletion and rich Markdown documentation in VS Code and other LSP-enabled editors.
153
+
154
+ Whether you use standard descriptive block variable names (`|stream_writer|`, `|sheet|`, `|workbook|`) or short names (`|wb|`, `|s|`), your editor will automatically provide complete method suggestions and parameter hints:
155
+
156
+ ```ruby
157
+ Xlsxrb.generate("output.xlsx") do |stream_writer| # or |wb|
158
+ stream_writer.sheet("Data") do |sheet| # or |s|
159
+ sheet.row(["Product", "Price"], styles: :bold)
160
+ sheet.auto_filter("A1:B100")
161
+ end
142
162
  end
143
163
  ```
144
164
 
@@ -156,37 +176,39 @@ For detailed specification references and policies, see [SPEC_SOURCES.md](docs/S
156
176
 
157
177
  ## Benchmarks
158
178
 
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.
179
+ 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
180
 
161
181
  ### Write Performance (1,000,000 cells)
162
182
 
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 |
183
+ | Library | Model | Write String Storage | Time (Median) | Time (Mean) | Peak Memory | GC Count |
184
+ | ---------------------- | ----------- | -------------------- | ------------- | ----------- | ----------- | -------- |
185
+ | xlsxtream 3.1.0 | Streaming | Inline String | 1.23 s | 1.25 s | 18.1 MB | 1061.0 |
186
+ | xlsxrb (Streaming) | Streaming | SST (Shared) | 1.62 s | 1.62 s | 94.5 MB | 39.0 |
187
+ | fast_excel 0.5.0 (C) | Streaming | SST (Shared) | 2.03 s | 2.03 s | 147.9 MB | 263.0 |
188
+ | xlsxrb (In-Memory) | In-Memory | SST (Shared) | 4.06 s | 4.06 s | 280.3 MB | 32.0 |
189
+ | caxlsx 4.5.0 | In-Memory | Inline String | 5.36 s | 5.35 s | 188.7 MB | 23.0 |
190
+ | rubyXL 3.4.38 | In-Memory | Inline String | 38.02 s | 38.03 s | 2166.0 MB | 104.0 |
191
+
192
+ > **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
193
 
172
194
  ### Read Performance (1,000,000 cells)
173
195
 
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 |
196
+ | Library | Model | Time (Median) | Time (Mean) | Peak Memory | GC Count |
197
+ | ---------------------- | ----------- | ------------- | ----------- | ----------- | -------- |
198
+ | xlsxrb (Streaming) | Streaming | 3.38 s | 3.37 s | 90.6 MB | 40.0 |
199
+ | xlsxrb (In-Memory) | In-Memory | 5.75 s | 5.94 s | 250.9 MB | 55.0 |
200
+ | creek 2.6.3 | Streaming | 7.90 s | 7.93 s | 835.7 MB | 481.0 |
201
+ | roo 3.0.0 | Streaming | 10.34 s | 10.28 s | 139.0 MB | 107.0 |
202
+ | xsv 1.4.1 | Streaming | 16.39 s | 17.28 s | 75.4 MB | 2215.0 |
203
+ | rubyXL 3.4.38 | In-Memory | 35.45 s | 35.38 s | 2281.3 MB | 146.0 |
182
204
 
183
- ### Running the Benchmarks Locally
205
+ ### Running the Benchmarks Locally (Reproducibility)
184
206
 
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.
207
+ 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
208
 
187
- To run the benchmark locally for 1,000,000 cells (100,000 rows):
209
+ To run the complete benchmark suite:
188
210
  ```bash
189
- ruby benchmark.rb 100000
211
+ ruby benchmark.rb 100000 10
190
212
  ```
191
213
 
192
214
  ## 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
@@ -2,8 +2,7 @@
2
2
 
3
3
  target :lib do
4
4
  signature "sig"
5
-
6
- check "lib/xlsxrb.rb"
5
+ check "lib"
7
6
 
8
7
  library "date"
9
8
  library "time"
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
 
@@ -1426,6 +1426,14 @@ sheet.rows.each do |row|
1426
1426
  end
1427
1427
  ```
1428
1428
 
1429
+ ### Console Output
1430
+
1431
+ ```text
1432
+ === Read Validation ===
1433
+ Row 0: A1: "Format" (String), B1: "Value" (String)
1434
+ Row 1: A2: "Rich Text Cell" (String), B2: "Normal BOLD RED ITALIC BLUE" (String)
1435
+ ```
1436
+
1429
1437
  <hr/>
1430
1438
 
1431
1439
  ## Cell Times
data/docs/wasm/ruby.wasm CHANGED
Binary file
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_lsp/addon"
4
+ require_relative "completion_listener"
5
+ require_relative "../../xlsxrb/version"
6
+
7
+ module RubyLsp
8
+ module Xlsxrb
9
+ # Ruby LSP Add-on for xlsxrb.
10
+ #
11
+ # [Context & Lifecycle Note]
12
+ # This add-on serves as a bridge/polyfill for current Ruby LSP environments.
13
+ # While xlsxrb ships with complete RBS signatures (`sig/generated/`), Ruby LSP's
14
+ # type inferrer does not yet perform automatic static type inference from method
15
+ # block signatures to block parameters (e.g., `Xlsxrb.generate do |wb|`).
16
+ #
17
+ # This add-on enables immediate out-of-the-box autocompletion and rich Markdown
18
+ # documentation across all public block arguments.
19
+ #
20
+ # As the Ruby tooling ecosystem evolves and Ruby LSP gains native block argument
21
+ # type resolution from gem RBS signatures in the future, this add-on will become
22
+ # redundant and can eventually be deprecated or removed.
23
+ class Addon < ::RubyLsp::Addon
24
+ def activate(global_state, _message_queue)
25
+ @global_state = global_state
26
+ end
27
+
28
+ def deactivate; end
29
+
30
+ def name
31
+ "xlsxrb"
32
+ end
33
+
34
+ def version
35
+ ::Xlsxrb::VERSION
36
+ end
37
+
38
+ def create_completion_listener(response_builder, node_context, dispatcher, _uri = nil)
39
+ CompletionListener.new(response_builder, node_context, dispatcher, @global_state)
40
+ end
41
+ end
42
+ end
43
+ end