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.
- checksums.yaml +7 -0
- data/.devcontainer/Dockerfile +64 -0
- data/.devcontainer/devcontainer.json +17 -0
- data/CHANGELOG.md +12 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/Rakefile +290 -0
- data/benchmark.rb +390 -0
- data/docs/ARCHITECTURE.md +488 -0
- data/docs/SPEC_SOURCES.md +42 -0
- data/docs/visual/VisualGallery.md +4684 -0
- data/docs/wasm/wasm_doc_helper.css +189 -0
- data/docs/wasm/wasm_doc_helper.js +320 -0
- data/lib/xlsxrb/elements/cell.rb +77 -0
- data/lib/xlsxrb/elements/column.rb +28 -0
- data/lib/xlsxrb/elements/row.rb +47 -0
- data/lib/xlsxrb/elements/types.rb +36 -0
- data/lib/xlsxrb/elements/workbook.rb +47 -0
- data/lib/xlsxrb/elements/worksheet.rb +50 -0
- data/lib/xlsxrb/elements.rb +15 -0
- data/lib/xlsxrb/ooxml/reader.rb +8048 -0
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +75 -0
- data/lib/xlsxrb/ooxml/styles_parser.rb +194 -0
- data/lib/xlsxrb/ooxml/utils.rb +122 -0
- data/lib/xlsxrb/ooxml/workbook_parser.rb +78 -0
- data/lib/xlsxrb/ooxml/workbook_writer.rb +1000 -0
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +455 -0
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +1008 -0
- data/lib/xlsxrb/ooxml/writer.rb +5450 -0
- data/lib/xlsxrb/ooxml/xml_builder.rb +113 -0
- data/lib/xlsxrb/ooxml/xml_parser.rb +68 -0
- data/lib/xlsxrb/ooxml/zip_generator.rb +162 -0
- data/lib/xlsxrb/ooxml/zip_reader.rb +158 -0
- data/lib/xlsxrb/ooxml/zip_writer.rb +213 -0
- data/lib/xlsxrb/ooxml.rb +22 -0
- data/lib/xlsxrb/style_builder.rb +241 -0
- data/lib/xlsxrb/version.rb +5 -0
- data/lib/xlsxrb.rb +1427 -0
- data/measure_memory.rb +42 -0
- data/sig/xlsxrb.rbs +23 -0
- data/vendor/sdk_runner/Program.cs +91 -0
- data/vendor/sdk_runner/sdk_runner.csproj +13 -0
- 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")
|