xlsxrb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +64 -0
  3. data/.devcontainer/devcontainer.json +17 -0
  4. data/CHANGELOG.md +12 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +384 -0
  8. data/Rakefile +290 -0
  9. data/benchmark.rb +390 -0
  10. data/docs/ARCHITECTURE.md +488 -0
  11. data/docs/SPEC_SOURCES.md +42 -0
  12. data/docs/visual/VisualGallery.md +4684 -0
  13. data/docs/wasm/wasm_doc_helper.css +189 -0
  14. data/docs/wasm/wasm_doc_helper.js +320 -0
  15. data/lib/xlsxrb/elements/cell.rb +77 -0
  16. data/lib/xlsxrb/elements/column.rb +28 -0
  17. data/lib/xlsxrb/elements/row.rb +47 -0
  18. data/lib/xlsxrb/elements/types.rb +36 -0
  19. data/lib/xlsxrb/elements/workbook.rb +47 -0
  20. data/lib/xlsxrb/elements/worksheet.rb +50 -0
  21. data/lib/xlsxrb/elements.rb +15 -0
  22. data/lib/xlsxrb/ooxml/reader.rb +8048 -0
  23. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
  24. data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
  25. data/lib/xlsxrb/ooxml/utils.rb +122 -0
  26. data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
  27. data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
  28. data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
  29. data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
  30. data/lib/xlsxrb/ooxml/writer.rb +5450 -0
  31. data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
  32. data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
  33. data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
  34. data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
  35. data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
  36. data/lib/xlsxrb/ooxml.rb +22 -0
  37. data/lib/xlsxrb/style_builder.rb +241 -0
  38. data/lib/xlsxrb/version.rb +5 -0
  39. data/lib/xlsxrb.rb +1427 -0
  40. data/measure_memory.rb +42 -0
  41. data/sig/xlsxrb.rbs +23 -0
  42. data/vendor/sdk_runner/Program.cs +91 -0
  43. data/vendor/sdk_runner/sdk_runner.csproj +13 -0
  44. metadata +144 -0
data/Rakefile ADDED
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "etc"
6
+ require "fileutils"
7
+ require "open3"
8
+ require "tmpdir"
9
+
10
+ desc "Build the Open XML SDK runner"
11
+ task :build_sdk_runner do
12
+ sh "dotnet build vendor/sdk_runner/sdk_runner.csproj -c Release"
13
+ end
14
+
15
+ def reader_fixture_dir
16
+ File.expand_path("test/fixtures/reader_generated", __dir__)
17
+ end
18
+
19
+ def sdk_scenario_dir
20
+ File.expand_path("test/fixtures/sdk_scenarios", __dir__)
21
+ end
22
+
23
+ def sdk_runner_dll
24
+ File.expand_path("vendor/sdk_runner/bin/Release/net8.0/sdk_runner.dll", __dir__)
25
+ end
26
+
27
+ def reader_fixture_specs
28
+ Dir.glob(File.join(sdk_scenario_dir, "reader_*_generated_by_sdk.cs")).map do |scenario_path|
29
+ scenario_name = File.basename(scenario_path, ".cs")
30
+ [scenario_name, File.join(reader_fixture_dir, "#{scenario_name}.xlsx")]
31
+ end
32
+ end
33
+
34
+ def reader_fixture_workers
35
+ Integer(ENV.fetch("READER_FIXTURE_WORKERS", Etc.nprocessors))
36
+ rescue ArgumentError
37
+ Etc.nprocessors
38
+ end
39
+
40
+ desc "Ensure SDK-generated reader fixtures exist"
41
+ task ensure_reader_fixtures: :build_sdk_runner do
42
+ missing_specs = reader_fixture_specs.reject { |_scenario_name, fixture_path| File.exist?(fixture_path) }
43
+ next if missing_specs.empty?
44
+
45
+ FileUtils.mkdir_p(reader_fixture_dir)
46
+
47
+ queue = Queue.new
48
+ missing_specs.each { |spec| queue << spec }
49
+ failures = Queue.new
50
+
51
+ worker_count = [reader_fixture_workers, missing_specs.size].min
52
+ threads = Array.new(worker_count) do
53
+ Thread.new do
54
+ loop do
55
+ scenario_name, fixture_path = queue.pop(true)
56
+ scenario_path = File.join(sdk_scenario_dir, "#{scenario_name}.cs")
57
+ FileUtils.touch(fixture_path)
58
+
59
+ stdout, stderr, status = Open3.capture3(
60
+ "dotnet", sdk_runner_dll, scenario_path, fixture_path
61
+ )
62
+
63
+ next if status.success?
64
+
65
+ FileUtils.rm_f(fixture_path)
66
+ message = stderr.to_s.strip.empty? ? stdout : stderr
67
+ failures << "Failed to generate reader fixture #{scenario_name}: #{message}"
68
+ rescue ThreadError
69
+ break
70
+ end
71
+ end
72
+ end
73
+
74
+ threads.each(&:join)
75
+ next if failures.empty?
76
+
77
+ raise failures.pop
78
+ end
79
+
80
+ Rake::TestTask.new(:test) do |t|
81
+ t.libs << "test"
82
+ t.libs << "lib"
83
+ t.test_files = FileList["test/**/*_test.rb"]
84
+ workers = ENV.fetch("TEST_WORKERS", Etc.nprocessors)
85
+ t.options = "--parallel --n-workers=#{workers}"
86
+ end
87
+
88
+ task test: %i[build_sdk_runner ensure_reader_fixtures]
89
+
90
+ namespace :test do
91
+ Rake::TestTask.new(:unit) do |t|
92
+ t.libs << "test"
93
+ t.libs << "lib"
94
+ t.test_files = FileList["test/xlsxrb/**/*_test.rb", "test/*_test.rb"]
95
+ workers = ENV.fetch("TEST_WORKERS", Etc.nprocessors)
96
+ t.options = "--parallel --n-workers=#{workers}"
97
+ end
98
+
99
+ Rake::TestTask.new(:contract) do |t|
100
+ t.libs << "test"
101
+ t.libs << "lib"
102
+ t.test_files = FileList["test/contract/**/*_test.rb"]
103
+ workers = ENV.fetch("TEST_WORKERS", Etc.nprocessors)
104
+ t.options = "--parallel --n-workers=#{workers}"
105
+ end
106
+
107
+ Rake::TestTask.new(:e2e) do |t|
108
+ t.libs << "test"
109
+ t.libs << "lib"
110
+ t.test_files = FileList["test/e2e/**/*_test.rb"]
111
+ workers = ENV.fetch("TEST_WORKERS", Etc.nprocessors)
112
+ t.options = "--parallel --n-workers=#{workers}"
113
+ end
114
+
115
+ Rake::TestTask.new(:visual) do |t|
116
+ t.libs << "test"
117
+ t.libs << "lib"
118
+ t.test_files = FileList["test/visual/**/*_test.rb"]
119
+ workers = ENV.fetch("TEST_WORKERS", Etc.nprocessors)
120
+ t.options = "--parallel --n-workers=#{workers}"
121
+ end
122
+
123
+ namespace :fixtures do
124
+ namespace :reader do
125
+ desc "Generate reader fixture XLSX files from SDK scenarios"
126
+ task generate: :ensure_reader_fixtures
127
+
128
+ desc "Remove generated reader fixture XLSX files"
129
+ task :clean do
130
+ FileUtils.rm_rf(reader_fixture_dir)
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ task "test:e2e" => %i[build_sdk_runner ensure_reader_fixtures]
137
+
138
+ require "rubocop/rake_task"
139
+
140
+ RuboCop::RakeTask.new
141
+
142
+ task default: %i[test rubocop]
143
+
144
+ namespace :visual do
145
+ desc "Generate docs/visual/ README.md explanation gallery"
146
+ task :gallery do
147
+ require_relative "test/visual/support/gallery_generator"
148
+ Xlsxrb::Visual::GalleryGenerator.generate
149
+ end
150
+
151
+ desc "Generate / update VRT baselines in test/visual/baselines/"
152
+ task :baseline do
153
+ require_relative "test/visual/support/renderer"
154
+ require "securerandom"
155
+ examples = Dir.glob(File.expand_path("examples/visual/*.rb", __dir__))
156
+ baselines_dir = File.expand_path("test/visual/baselines", __dir__)
157
+
158
+ FileUtils.rm_rf(baselines_dir)
159
+ FileUtils.mkdir_p(baselines_dir)
160
+
161
+ examples.each do |example_path|
162
+ name = File.basename(example_path, ".rb")
163
+ puts "Generating baseline for #{name}..."
164
+
165
+ example_tmp_dir = File.join(Dir.tmpdir, "xlsxrb_baselines_build_#{name}_#{SecureRandom.hex(4)}")
166
+ FileUtils.mkdir_p(example_tmp_dir)
167
+
168
+ xlsx_path = File.join(example_tmp_dir, "#{name}.xlsx")
169
+ system("ruby", "-Ilib", example_path, xlsx_path)
170
+
171
+ dest_dir = File.join(baselines_dir, name)
172
+ begin
173
+ Xlsxrb::Visual::Renderer.render(xlsx_path, dest_dir)
174
+ ensure
175
+ FileUtils.rm_rf(example_tmp_dir)
176
+ end
177
+ end
178
+ puts "Baselines generated successfully."
179
+ end
180
+ end
181
+
182
+ desc "Build custom packed ruby.wasm from staging bundle"
183
+ task :wasm do
184
+ original_wasm_cache = File.expand_path("tmp/test_env/ruby.wasm", __dir__)
185
+ packed_wasm_path = File.expand_path("docs/wasm/ruby.wasm", __dir__)
186
+ wasm_bundle_dir = File.expand_path("tmp/wasm_bundle", __dir__)
187
+
188
+ # 1. Download original base Wasm if not cached locally
189
+ unless File.exist?(original_wasm_cache)
190
+ puts "Downloading original ruby.wasm for caching..."
191
+ require "open-uri"
192
+ wasm_url = "https://cdn.jsdelivr.net/npm/@ruby/4.0-wasm-wasi@2.9.3-2.9.4/dist/ruby.wasm"
193
+ FileUtils.mkdir_p(File.dirname(original_wasm_cache))
194
+ URI.open(wasm_url) do |stream|
195
+ File.open(original_wasm_cache, "wb") do |file|
196
+ IO.copy_stream(stream, file)
197
+ end
198
+ end
199
+ puts "Original ruby.wasm cached successfully."
200
+ end
201
+
202
+ # 2. Synchronize latest lib/xlsxrb.rb to staging bundle directory before packing
203
+ FileUtils.mkdir_p(File.dirname(packed_wasm_path))
204
+ FileUtils.cp(File.expand_path("lib/xlsxrb.rb", __dir__), File.join(wasm_bundle_dir, "xlsxrb.rb"))
205
+
206
+ # 3. Compile custom ruby.wasm packaging staging libs
207
+ puts "Building packed ruby.wasm from staging bundle..."
208
+ cmd = "bundle exec rbwasm pack #{original_wasm_cache} --dir #{wasm_bundle_dir}::/usr/local/lib/ruby/site_ruby -o #{packed_wasm_path}"
209
+ puts "Executing: #{cmd}"
210
+ unless system(cmd)
211
+ raise "Failed to build packed ruby.wasm using rbwasm pack!"
212
+ end
213
+ end
214
+
215
+ desc "Generate RDoc documentation including Visual Gallery"
216
+ task doc: :wasm do
217
+ FileUtils.rm_rf("doc")
218
+ sh "bundle exec rdoc --title 'xlsxrb Documentation' --main README.md README.md \"docs/visual/VisualGallery.md\" lib/"
219
+
220
+ # Copy visual gallery images and files so they are available in RDoc output
221
+ FileUtils.mkdir_p("doc/test/visual/baselines")
222
+ FileUtils.cp_r("test/visual/baselines", "doc/test/visual")
223
+ FileUtils.mkdir_p("doc/test/visual/support/illustrations")
224
+ FileUtils.cp_r(Dir.glob("test/visual/support/illustrations/*.png"), "doc/test/visual/support/illustrations")
225
+
226
+ FileUtils.mkdir_p("doc/docs/visual/files")
227
+ FileUtils.cp_r(Dir.glob("docs/visual/files/*"), "doc/docs/visual/files")
228
+
229
+ # --- WebAssembly Playground Integration ---
230
+ puts "Integrating WebAssembly Playground..."
231
+
232
+ # Copy playground helper assets and custom Wasm binary to doc directory
233
+ FileUtils.mkdir_p("doc/css")
234
+ FileUtils.mkdir_p("doc/js")
235
+ FileUtils.mkdir_p("doc/wasm")
236
+ FileUtils.cp("docs/wasm/wasm_doc_helper.js", "doc/js/wasm_doc_helper.js")
237
+ FileUtils.cp("docs/wasm/wasm_doc_helper.css", "doc/css/wasm_doc_helper.css")
238
+ FileUtils.cp("docs/wasm/ruby.wasm", "doc/wasm/ruby.wasm")
239
+
240
+ # Inject stylesheet and javascript loading tags to all generated HTML docs
241
+ Dir.glob("doc/**/*.html").each do |html_path|
242
+ html_content = File.read(html_path)
243
+ depth = html_path.sub(%r{\Adoc/}, "").count("/")
244
+ rel_prefix = "../" * depth
245
+
246
+ js_tag = %Q{<script src="#{rel_prefix}js/wasm_doc_helper.js" defer></script>}
247
+ css_tag = %Q{<link href="#{rel_prefix}css/wasm_doc_helper.css" rel="stylesheet">}
248
+
249
+ if html_content.include?("<body")
250
+ modified = html_content.sub("<body", "#{js_tag}\n#{css_tag}\n<body")
251
+ File.write(html_path, modified)
252
+ end
253
+ end
254
+ puts "WebAssembly Playground integrated successfully!"
255
+ puts "RDoc documentation generated successfully at doc/"
256
+ end
257
+
258
+ namespace :doc do
259
+ desc "Build and preview RDoc documentation locally"
260
+ task preview: :doc do
261
+ require "webrick"
262
+
263
+ port = ENV.fetch("PORT", "8000").to_i
264
+
265
+ # Configure WEBrick to serve 'doc' directory
266
+ server = WEBrick::HTTPServer.new(
267
+ Port: port,
268
+ DocumentRoot: File.expand_path("doc", __dir__),
269
+ Logger: WEBrick::Log.new(nil, WEBrick::BasicLog::WARN),
270
+ AccessLog: []
271
+ )
272
+
273
+ puts "=================================================="
274
+ puts " Starting local documentation server at:"
275
+ puts " http://localhost:#{port}/"
276
+ puts " Press Ctrl+C to stop the server"
277
+ puts "=================================================="
278
+
279
+ trap("INT") { server.shutdown }
280
+
281
+ begin
282
+ server.start
283
+ rescue Errno::EADDRINUSE
284
+ warn "\n[Error] Port #{port} is already in use by another process."
285
+ warn "Please stop the other process or specify a different port."
286
+ warn "Example: PORT=8080 bundle exec rake doc:preview\n\n"
287
+ exit 1
288
+ end
289
+ end
290
+ end
data/benchmark.rb ADDED
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["GEM_HOME"] = File.expand_path("tmp/vendor/bundle", __dir__)
4
+ ENV["GEM_PATH"] = ENV.fetch("GEM_HOME", nil)
5
+
6
+ require "bundler/inline"
7
+
8
+ gemfile do
9
+ source "https://rubygems.org"
10
+ gem "benchmark", "0.5.0"
11
+ gem "rexml", "3.4.4"
12
+ gem "zlib", "3.2.3"
13
+ gem "xlsxtream", "3.1.0"
14
+ gem "caxlsx", "4.4.2"
15
+ gem "rubyXL", "3.4.35"
16
+ gem "fast_excel", "0.5.0"
17
+ gem "roo", "3.0.0"
18
+ gem "creek", "2.6.3"
19
+ gem "csv", "3.3.2"
20
+ gem "xsv", "1.4.0"
21
+ gem "base64", "0.2.0"
22
+ gem "opentelemetry-sdk", "1.11.0"
23
+ end
24
+
25
+ require "json"
26
+ require "benchmark"
27
+ require "fileutils"
28
+ require "objspace"
29
+ require_relative "lib/xlsxrb"
30
+
31
+ args = ARGV.dup
32
+ KEEP_FILES = args.delete("--keep-files")
33
+
34
+ ROWS = (args[0] || 10_000).to_i
35
+ COLS = 10
36
+ ITERATIONS = 5
37
+ READ_TEST_FILE = "tmp/benchmark_read_test.xlsx"
38
+
39
+ OUTPUT_FILES = {
40
+ "xlsxrb (Streaming)" => "tmp/write_xlsxrb_stream.xlsx",
41
+ "xlsxrb (In-Memory)" => "tmp/write_xlsxrb_mem.xlsx",
42
+ "caxlsx (In-Memory)" => "tmp/write_caxlsx.xlsx",
43
+ "xlsxtream (Streaming)" => "tmp/write_xlsxtream.xlsx",
44
+ "fast_excel (Streaming)" => "tmp/write_fast_excel.xlsx",
45
+ "rubyXL (In-Memory)" => "tmp/write_rubyXL.xlsx",
46
+ "Read source file" => READ_TEST_FILE
47
+ }.freeze
48
+
49
+ puts "Target: #{ROWS} rows x #{COLS} columns (#{ROWS * COLS} cells)"
50
+ puts "Iterations: #{ITERATIONS}"
51
+ puts "Keep generated files: #{KEEP_FILES ? "yes" : "no"}"
52
+ puts "Preparing test file for read benchmarks..."
53
+
54
+ # Prepare test file for read benchmarks
55
+ Xlsxrb.generate(READ_TEST_FILE) do |w|
56
+ w.add_sheet("Test") do |s|
57
+ ROWS.times do |r|
58
+ row_data = Array.new(COLS) { |c| (r * COLS) + c }
59
+ s.add_row(row_data)
60
+ end
61
+ end
62
+ end
63
+
64
+ def run_in_subprocess(_name, &block)
65
+ File.write("tmp/bench_task.rb", <<~RUBY)
66
+ ENV['BUNDLE_GEMFILE'] = ''
67
+ ENV['BUNDLE_IGNORE_CONFIG'] = '1'
68
+ ENV['GEM_HOME'] = File.expand_path('tmp/vendor/bundle', __dir__)
69
+ ENV['GEM_PATH'] = ENV['GEM_HOME']
70
+
71
+ require 'bundler/inline'
72
+ gemfile do
73
+ source 'https://rubygems.org'
74
+ gem 'benchmark', '0.5.0'
75
+ gem 'rexml', '3.4.4'
76
+ gem 'zlib', '3.2.3'
77
+ gem 'xlsxtream', '3.1.0'
78
+ gem 'caxlsx', '4.4.2'
79
+ gem 'rubyXL', '3.4.35'
80
+ gem 'fast_excel', '0.5.0'
81
+ gem 'roo', '3.0.0'
82
+ gem 'creek', '2.6.3'
83
+ gem 'csv', '3.3.2'
84
+ gem 'xsv', '1.4.0'
85
+ gem 'base64', '0.2.0'
86
+ gem 'opentelemetry-sdk', '1.11.0'
87
+ end
88
+ require 'csv'
89
+ require 'base64'
90
+ require 'json'
91
+ require 'benchmark'
92
+ require 'opentelemetry/sdk'
93
+ require_relative '../lib/xlsxrb'
94
+
95
+ ROWS = #{ROWS}
96
+ COLS = 10
97
+ READ_TEST_FILE = "tmp/benchmark_read_test.xlsx"
98
+
99
+ def get_io_stats
100
+ return { rchar: 0, wchar: 0, read_bytes: 0, write_bytes: 0 } unless File.exist?('/proc/self/io')
101
+
102
+ stats = {}
103
+ File.readlines('/proc/self/io').each do |line|
104
+ key, value = line.split(':')
105
+ stats[key.strip.to_sym] = value.to_i if key && value
106
+ end
107
+ stats
108
+ end
109
+
110
+ class BenchmarkSpanProcessor < OpenTelemetry::SDK::Trace::SpanProcessor
111
+ def on_start(span, _parent_context)
112
+ return unless span.name == 'benchmark_task'
113
+ #{" "}
114
+ span.set_attribute('bench.gc.count.start', GC.stat[:count])
115
+ span.set_attribute('bench.alloc.objects.start', GC.stat[:total_allocated_objects])
116
+ span.set_attribute('bench.time.start', Process.clock_gettime(Process::CLOCK_MONOTONIC))
117
+ span.set_attribute('bench.cpu.utime.start', Process.times.utime)
118
+ span.set_attribute('bench.cpu.stime.start', Process.times.stime)
119
+ @io_start = get_io_stats
120
+ #{" "}
121
+ @max_mem_mb = 0.0
122
+ @watcher = Thread.new do
123
+ loop do
124
+ mem = `ps -o rss= -p \#{Process.pid}`.to_f / 1024.0
125
+ @max_mem_mb = mem if mem > @max_mem_mb
126
+ sleep 0.05
127
+ end
128
+ end
129
+ end
130
+
131
+ def on_finish(span)
132
+ return unless span.name == 'benchmark_task'
133
+ #{" "}
134
+ @watcher.kill
135
+ mem = `ps -o rss= -p \#{Process.pid}`.to_f / 1024.0
136
+ @max_mem_mb = mem if mem > @max_mem_mb
137
+
138
+ gc_start = span.attributes['bench.gc.count.start']
139
+ alloc_start = span.attributes['bench.alloc.objects.start']
140
+ time_start = span.attributes['bench.time.start']
141
+ utime_start = span.attributes['bench.cpu.utime.start']
142
+ stime_start = span.attributes['bench.cpu.stime.start']
143
+ io_end = get_io_stats
144
+ #{" "}
145
+ gc_diff = GC.stat[:count] - gc_start
146
+ alloc_diff = GC.stat[:total_allocated_objects] - alloc_start
147
+
148
+ written_bytes = io_end[:wchar] - @io_start[:wchar]
149
+ read_bytes = io_end[:rchar] - @io_start[:rchar]
150
+
151
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
+ end_times = Process.times
153
+
154
+ elapsed = end_time - time_start
155
+ cpu_time = (end_times.utime - utime_start) + (end_times.stime - stime_start)
156
+ cpu_percent = elapsed > 0 ? (cpu_time / elapsed) * 100 : 0.0
157
+
158
+ puts JSON.dump({
159
+ time: elapsed,
160
+ cpu: cpu_percent,
161
+ memory: @max_mem_mb,
162
+ gc_count: gc_diff,
163
+ alloc_objects: alloc_diff,
164
+ rchar: read_bytes,
165
+ wchar: written_bytes
166
+ })
167
+ end
168
+ end
169
+
170
+ OpenTelemetry::SDK.configure do |c|
171
+ c.add_span_processor(BenchmarkSpanProcessor.new)
172
+ end
173
+
174
+ tracer = OpenTelemetry.tracer_provider.tracer('benchmark')
175
+ GC.start
176
+
177
+ tracer.in_span('benchmark_task') do
178
+ # --- TASK START ---
179
+ #{block.call}
180
+ # --- TASK END ---
181
+ end
182
+ RUBY
183
+
184
+ output = Bundler.with_unbundled_env do
185
+ `ruby tmp/bench_task.rb 2>/dev/null`
186
+ end
187
+ begin
188
+ JSON.parse(output, symbolize_names: true)
189
+ rescue StandardError => e
190
+ puts "Error: #{e.message} with output: #{output.inspect}"
191
+ nil
192
+ end
193
+ end
194
+
195
+ def run_benchmark(name, snippet)
196
+ print format("%-25s", name)
197
+ results = ITERATIONS.times.map do
198
+ print "."
199
+ run_in_subprocess(name) { snippet }
200
+ end
201
+ puts
202
+
203
+ avg_time = results.compact.map { |r| r[:time] }.compact.sum / results.compact.size.to_f
204
+ avg_cpu = results.compact.map { |r| r[:cpu] }.compact.sum / results.compact.size.to_f
205
+ avg_mem = results.compact.map { |r| r[:memory] }.compact.sum / results.compact.size.to_f
206
+ avg_gc = results.compact.map { |r| r[:gc_count] }.compact.sum / results.compact.size.to_f
207
+ avg_alloc = results.compact.map { |r| r[:alloc_objects] }.compact.sum / results.compact.size.to_f
208
+ avg_wchar = results.compact.map { |r| r[:wchar] }.compact.sum / results.compact.size.to_f
209
+ avg_rchar = results.compact.map { |r| r[:rchar] }.compact.sum / results.compact.size.to_f
210
+
211
+ {
212
+ name: name,
213
+ time: avg_time,
214
+ cpu: avg_cpu,
215
+ memory: avg_mem,
216
+ gc_count: avg_gc,
217
+ alloc_m: avg_alloc / 1_000_000.0,
218
+ wchar_mb: avg_wchar / 1_048_576.0,
219
+ rchar_mb: avg_rchar / 1_048_576.0
220
+ }
221
+ end
222
+
223
+ def format_row(result)
224
+ format("| %-25<name>s | %8.2<time>f s | %9.1<memory>f MB | %8.1<gc_count>f |", result)
225
+ end
226
+
227
+ puts "\n--- Write Benchmarks ---"
228
+ write_results = []
229
+
230
+ write_results << run_benchmark("xlsxrb (Streaming)", <<~RUBY
231
+ Xlsxrb.generate("tmp/write_xlsxrb_stream.xlsx") do |writer|
232
+ writer.add_sheet("Sheet1") do |sheet|
233
+ ROWS.times do |r|
234
+ row_data = Array.new(COLS) { |c| r * COLS + c }
235
+ sheet.add_row(row_data)
236
+ end
237
+ end
238
+ end
239
+ RUBY
240
+ )
241
+
242
+ write_results << run_benchmark("xlsxrb (In-Memory)", <<~RUBY
243
+ workbook = Xlsxrb.build do |w|
244
+ w.add_sheet("Sheet1") do |sheet|
245
+ ROWS.times do |r|
246
+ row_data = Array.new(COLS) { |c| r * COLS + c }
247
+ sheet.add_row(row_data)
248
+ end
249
+ end
250
+ end
251
+ Xlsxrb.write("tmp/write_xlsxrb_mem.xlsx", workbook)
252
+ RUBY
253
+ )
254
+
255
+ write_results << run_benchmark("caxlsx (In-Memory)", <<~RUBY
256
+ begin
257
+ p = Axlsx::Package.new
258
+ sheet = p.workbook.add_worksheet(name: "Sheet1")
259
+ ROWS.times do |r|
260
+ sheet.add_row(Array.new(COLS) { |c| r * COLS + c })
261
+ end
262
+ p.serialize("tmp/write_caxlsx.xlsx")
263
+ rescue Zlib::BufError => e
264
+ File.write("tmp/write_caxlsx.xlsx", "")
265
+ end
266
+ RUBY
267
+ )
268
+
269
+ write_results << run_benchmark("xlsxtream (Streaming)", <<~RUBY
270
+ begin
271
+ Xlsxtream::Workbook.open("tmp/write_xlsxtream.xlsx") do |workbook|
272
+ workbook.write_worksheet("Sheet1") do |sheet|
273
+ ROWS.times do |r|
274
+ sheet << Array.new(COLS) { |c| r * COLS + c }
275
+ end
276
+ end
277
+ end
278
+ rescue Zlib::BufError => e
279
+ File.write("tmp/write_xlsxtream.xlsx", "")
280
+ end
281
+ RUBY
282
+ )
283
+
284
+ write_results << run_benchmark("fast_excel (Streaming)", <<~RUBY
285
+ File.delete("tmp/write_fast_excel.xlsx") if File.exist?("tmp/write_fast_excel.xlsx")
286
+ workbook = FastExcel.open("tmp/write_fast_excel.xlsx", constant_memory: true)
287
+ sheet = workbook.add_worksheet("Sheet1")
288
+ ROWS.times do |r|
289
+ sheet.append_row(Array.new(COLS) { |c| r * COLS + c })
290
+ end
291
+ workbook.close
292
+ RUBY
293
+ )
294
+
295
+ write_results << run_benchmark("rubyXL (In-Memory)", <<~RUBY
296
+ workbook = RubyXL::Workbook.new
297
+ sheet = workbook.worksheets[0]
298
+ ROWS.times do |r|
299
+ COLS.times do |c|
300
+ sheet.add_cell(r, c, r * COLS + c)
301
+ end
302
+ end
303
+ workbook.write("tmp/write_rubyXL.xlsx")
304
+ RUBY
305
+ )
306
+
307
+ puts "\n--- Read Benchmarks ---"
308
+ read_results = []
309
+
310
+ read_results << run_benchmark("xlsxrb (Streaming)", <<~RUBY
311
+ count = 0
312
+ Xlsxrb.foreach(READ_TEST_FILE) do |row|
313
+ count += row.cells.size
314
+ end
315
+ RUBY
316
+ )
317
+
318
+ read_results << run_benchmark("xlsxrb (In-Memory)", <<~RUBY
319
+ workbook = Xlsxrb.read(READ_TEST_FILE)
320
+ count = 0
321
+ workbook.sheets[0].rows.each do |row|
322
+ count += row.cells.size
323
+ end
324
+ RUBY
325
+ )
326
+
327
+ read_results << run_benchmark("creek (Streaming)", <<~RUBY
328
+ creek = Creek::Book.new(READ_TEST_FILE)
329
+ sheet = creek.sheets[0]
330
+ count = 0
331
+ sheet.simple_rows.each do |row|
332
+ count += row.size
333
+ end
334
+ RUBY
335
+ )
336
+
337
+ read_results << run_benchmark("roo (Streaming)", <<~RUBY
338
+ xlsx = Roo::Excelx.new(READ_TEST_FILE)
339
+ count = 0
340
+ xlsx.each_row_streaming do |row|
341
+ count += row.size
342
+ end
343
+ RUBY
344
+ )
345
+
346
+ read_results << run_benchmark("rubyXL (In-Memory)", <<~RUBY
347
+ workbook = RubyXL::Parser.parse(READ_TEST_FILE)
348
+ count = 0
349
+ workbook.worksheets[0].each do |row|
350
+ count += row.cells.size if row
351
+ end
352
+ RUBY
353
+ )
354
+
355
+ read_results << run_benchmark("xsv (Streaming)", <<~RUBY
356
+ workbook = Xsv.open(READ_TEST_FILE)
357
+ count = 0
358
+ workbook[0].each do |row|
359
+ count += row.size
360
+ end
361
+ RUBY
362
+ )
363
+
364
+ # Print markdown tables
365
+ puts "\n\n### Write Performance (#{ROWS * COLS} cells)"
366
+ puts "| Library | Time | Peak Memory | GC Count |"
367
+ puts "|---------------------------|------------|--------------|----------|"
368
+ write_results.sort_by { |r| r[:time] }.each { |r| puts format_row(r) }
369
+
370
+ puts "\n### Read Performance (#{ROWS * COLS} cells)"
371
+ puts "| Library | Time | Peak Memory | GC Count |"
372
+ puts "|---------------------------|------------|--------------|----------|"
373
+ read_results.sort_by { |r| r[:time] }.each { |r| puts format_row(r) }
374
+
375
+ puts "\n### Generated Files"
376
+ puts "| Producer | File path | Status |"
377
+ puts "|---------------------------|-------------------------------|----------|"
378
+ OUTPUT_FILES.each do |name, path|
379
+ status = File.exist?(path) ? "exists" : "missing"
380
+ puts format("| %-25<name>s | %-29<path>s | %-8<status>s |", name: name, path: path, status: status)
381
+ end
382
+
383
+ # Cleanup
384
+ if KEEP_FILES
385
+ puts "\nKeeping generated files because --keep-files was specified."
386
+ else
387
+ Dir.glob("tmp/write_*.xlsx").each { |f| FileUtils.rm(f) }
388
+ FileUtils.rm(READ_TEST_FILE)
389
+ end
390
+ FileUtils.rm_f("tmp/bench_task.rb")