xlsxrb 0.1.12 → 0.1.13

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 (153) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -42
  3. data/README.md +26 -22
  4. data/lib/xlsxrb/dsl_helpers.rb +59 -0
  5. data/lib/xlsxrb/elements/workbook.rb +11 -0
  6. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +3 -5
  7. data/lib/xlsxrb/ooxml/workbook_writer.rb +5 -7
  8. data/lib/xlsxrb/ooxml/worksheet_parser.rb +172 -9
  9. data/lib/xlsxrb/ooxml/writer.rb +2 -1
  10. data/lib/xlsxrb/ooxml/xml_builder.rb +20 -0
  11. data/lib/xlsxrb/ooxml/zip_reader.rb +364 -78
  12. data/lib/xlsxrb/ooxml/zip_writer.rb +32 -5
  13. data/lib/xlsxrb/stream_sheet.rb +94 -6
  14. data/lib/xlsxrb/stream_writer.rb +33 -66
  15. data/lib/xlsxrb/version.rb +1 -1
  16. data/lib/xlsxrb/workbook_builder.rb +1 -1
  17. data/lib/xlsxrb/worksheet_builder.rb +3 -31
  18. data/lib/xlsxrb.rb +448 -171
  19. data/sig/generated/xlsxrb/dsl_helpers.rbs +32 -0
  20. data/sig/generated/xlsxrb/elements/workbook.rbs +7 -0
  21. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +0 -2
  22. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +5 -4
  23. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +11 -0
  24. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +96 -8
  25. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +5 -1
  26. data/sig/generated/xlsxrb/stream_sheet.rbs +43 -3
  27. data/sig/generated/xlsxrb.rbs +18 -11
  28. metadata +6 -146
  29. data/.devcontainer/Dockerfile +0 -65
  30. data/.devcontainer/devcontainer.json +0 -17
  31. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +0 -9
  32. data/.gem_rbs_collection/ast/2.4/ast.rbs +0 -73
  33. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +0 -9
  34. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +0 -4
  35. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +0 -16
  36. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +0 -96
  37. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +0 -4
  38. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +0 -68
  39. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +0 -249
  40. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +0 -4
  41. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +0 -47
  42. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +0 -5
  43. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +0 -9
  44. data/.gem_rbs_collection/csv/3.3/csv.rbs +0 -3871
  45. data/.gem_rbs_collection/csv/3.3/manifest.yaml +0 -3
  46. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +0 -9
  47. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +0 -48
  48. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +0 -9
  49. data/.gem_rbs_collection/listen/3.9/listen.rbs +0 -25
  50. data/.gem_rbs_collection/listen/3.9/listener.rbs +0 -24
  51. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +0 -9
  52. data/.gem_rbs_collection/logger/1.7/formatter.rbs +0 -45
  53. data/.gem_rbs_collection/logger/1.7/log_device.rbs +0 -100
  54. data/.gem_rbs_collection/logger/1.7/logger.rbs +0 -796
  55. data/.gem_rbs_collection/logger/1.7/manifest.yaml +0 -2
  56. data/.gem_rbs_collection/logger/1.7/period.rbs +0 -17
  57. data/.gem_rbs_collection/logger/1.7/severity.rbs +0 -34
  58. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +0 -9
  59. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +0 -2332
  60. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +0 -4
  61. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +0 -9
  62. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +0 -86
  63. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +0 -9
  64. data/.gem_rbs_collection/parser/3.2/manifest.yaml +0 -7
  65. data/.gem_rbs_collection/parser/3.2/parser.rbs +0 -194
  66. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +0 -4
  67. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +0 -9
  68. data/.gem_rbs_collection/rainbow/3.0/global.rbs +0 -7
  69. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +0 -209
  70. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +0 -5
  71. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +0 -9
  72. data/.gem_rbs_collection/rake/13.0/manifest.yaml +0 -2
  73. data/.gem_rbs_collection/rake/13.0/rake.rbs +0 -39
  74. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +0 -9
  75. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +0 -17
  76. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +0 -9
  77. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +0 -208
  78. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +0 -9
  79. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +0 -903
  80. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +0 -9
  81. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +0 -8
  82. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +0 -42
  83. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +0 -5
  84. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +0 -47
  85. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +0 -30
  86. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +0 -9
  87. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +0 -7
  88. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +0 -19
  89. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +0 -31
  90. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +0 -18
  91. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +0 -12
  92. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +0 -11
  93. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +0 -13
  94. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +0 -95
  95. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +0 -31
  96. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +0 -58
  97. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +0 -24
  98. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +0 -17
  99. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +0 -23
  100. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +0 -22
  101. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +0 -30
  102. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +0 -20
  103. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +0 -15
  104. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +0 -26
  105. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +0 -21
  106. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +0 -131
  107. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +0 -14
  108. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +0 -33
  109. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +0 -21
  110. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +0 -63
  111. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +0 -55
  112. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +0 -35
  113. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +0 -7
  114. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +0 -10
  115. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +0 -22
  116. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +0 -29
  117. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +0 -17
  118. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +0 -13
  119. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +0 -10
  120. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +0 -8
  121. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +0 -6
  122. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +0 -30
  123. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +0 -10
  124. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +0 -10
  125. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +0 -5
  126. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +0 -15
  127. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +0 -3
  128. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +0 -40
  129. data/.mutant.yml +0 -8
  130. data/Rakefile +0 -633
  131. data/Steepfile +0 -18
  132. data/benchmark.rb +0 -406
  133. data/docs/ARCHITECTURE.md +0 -514
  134. data/docs/DEVELOPMENT.md +0 -137
  135. data/docs/MUTATION_TESTING.md +0 -52
  136. data/docs/PEER_LIBRARIES.md +0 -121
  137. data/docs/QUALITY_ASSURANCE.md +0 -26
  138. data/docs/SPEC_SOURCES.md +0 -48
  139. data/docs/assets/benchmark_results.svg +0 -144
  140. data/docs/assets/lsp_autocompletion.png +0 -0
  141. data/docs/assets/playground_preview.png +0 -0
  142. data/docs/coi-serviceworker.js +0 -82
  143. data/docs/office_thread.js +0 -77
  144. data/docs/preview.html +0 -719
  145. data/docs/visual/VisualGallery.md +0 -4683
  146. data/docs/wasm/ruby.wasm +0 -0
  147. data/docs/wasm/wasm_doc_helper.css +0 -351
  148. data/docs/wasm/wasm_doc_helper.js +0 -439
  149. data/docs/zeta.js +0 -1107
  150. data/rbs_collection.lock.yaml +0 -252
  151. data/rbs_collection.yaml +0 -19
  152. data/vendor/sdk_runner/Program.cs +0 -93
  153. data/vendor/sdk_runner/sdk_runner.csproj +0 -13
data/lib/xlsxrb.rb CHANGED
@@ -7,6 +7,7 @@ require "time"
7
7
  require "openssl"
8
8
  require "securerandom"
9
9
  require "tempfile"
10
+ require "fileutils"
10
11
  begin
11
12
  require "bigdecimal"
12
13
  rescue LoadError
@@ -17,7 +18,6 @@ rescue LoadError
17
18
  Object.const_set(:BigDecimal, Class.new)
18
19
  # simplecov:enable
19
20
  end
20
- require "opentelemetry"
21
21
  require_relative "xlsxrb/version"
22
22
  require_relative "xlsxrb/ooxml/zip_generator"
23
23
  require_relative "xlsxrb/ooxml/writer"
@@ -71,28 +71,6 @@ module Xlsxrb
71
71
  # Raised when encrypted package stream decryption fails.
72
72
  class DecryptionError < EncryptedFileError; end
73
73
 
74
- TRACER = OpenTelemetry.tracer_provider.tracer("xlsxrb", Xlsxrb::VERSION)
75
-
76
- # Executes the block within an OpenTelemetry tracer span if tracing is configured.
77
- #
78
- # @param name [String] The span name.
79
- # @param attributes [Hash, nil] Optional telemetry attributes.
80
- # @yield Block to execute inside the tracing span.
81
- # @return [Object] The result of the block.
82
- #: (String name, ?attributes: Hash[String, untyped]?) { (*untyped) -> untyped } -> untyped
83
- def self.in_span(name, attributes: nil, &)
84
- if defined?(Ractor) && Ractor.current != Ractor.main
85
- # simplecov:disable
86
- # Test suite runs in the main Ractor. This branch is for multi-threaded usage via Ractors.
87
- yield
88
- # simplecov:enable
89
- elsif attributes
90
- TRACER.in_span(name, attributes: attributes, &)
91
- else
92
- TRACER.in_span(name, &)
93
- end
94
- end
95
-
96
74
  # Helper to construct {Elements::RichText} objects with formatted text runs.
97
75
  #
98
76
  # @example Create multi-run rich text
@@ -178,62 +156,37 @@ module Xlsxrb
178
156
  #: (untyped source, ?password: String?) { (StreamSheet) -> void } -> void
179
157
  #: (untyped source, ?password: String?) -> Elements::Workbook
180
158
  def self.read(source, password: nil, &)
181
- if source.is_a?(String)
182
- if source.start_with?("PK\x03\x04") || source.include?("\x00") || Ooxml::Cfb::Reader.cfb?(source)
183
- if Ooxml::Cfb::Reader.cfb?(source)
184
- decrypted_zip = Ooxml::Crypto.decrypt(source, password)
185
- source = StringIO.new(decrypted_zip)
186
- else
187
- source = StringIO.new(source)
188
- end
189
- elsif File.file?(source)
190
- first_bytes = begin
191
- File.binread(source, 8)
192
- rescue StandardError
193
- nil
194
- end
195
- if Ooxml::Cfb::Reader.cfb?(first_bytes)
196
- encrypted_data = File.binread(source)
197
- decrypted_zip = Ooxml::Crypto.decrypt(encrypted_data, password)
198
- source = StringIO.new(decrypted_zip)
199
- end
200
- end
201
- elsif source.respond_to?(:read) && source.respond_to?(:pos) && source.respond_to?(:seek)
202
- begin
203
- cur_pos = source.pos
204
- first_bytes = source.read(8)
205
- source.seek(cur_pos)
206
- if Ooxml::Cfb::Reader.cfb?(first_bytes)
207
- full_data = source.read
208
- decrypted_zip = Ooxml::Crypto.decrypt(full_data, password)
209
- source = StringIO.new(decrypted_zip)
210
- end
211
- rescue StandardError
212
- # Fall through to standard reader if seeking fails
213
- end
214
- end
159
+ prepared_source = prepare_source_io(source, password)
160
+ zip_reader = Ooxml::ZipReader.open(prepared_source)
161
+ begin
162
+ shared_strings_xml = zip_reader.read_entry("xl/sharedStrings.xml")
163
+ shared_strings = Ooxml::SharedStringsParser.parse(shared_strings_xml)
164
+
165
+ workbook_xml = zip_reader.read_entry("xl/workbook.xml")
166
+ workbook_sheets = Ooxml::WorkbookParser.parse(workbook_xml)
167
+
168
+ rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
169
+ rels = Ooxml::RelationshipsParser.parse(rels_xml)
215
170
 
216
- attributes = source.is_a?(String) ? { "filepath" => source } : {}
217
- Xlsxrb.in_span("Xlsxrb.read", attributes: attributes) do
218
- entries = Ooxml::ZipReader.open(source, &:read_all)
219
- shared_strings = Ooxml::SharedStringsParser.parse(entries["xl/sharedStrings.xml"])
220
- workbook_sheets = Ooxml::WorkbookParser.parse(entries["xl/workbook.xml"])
221
- rels = Ooxml::RelationshipsParser.parse(entries["xl/_rels/workbook.xml.rels"])
222
- styles = Ooxml::StylesParser.parse(entries["xl/styles.xml"])
171
+ styles_xml = zip_reader.read_entry("xl/styles.xml")
172
+ styles = Ooxml::StylesParser.parse(styles_xml)
223
173
 
224
174
  sheets = workbook_sheets.map do |sheet_info|
225
175
  target = rels[sheet_info[:r_id]]
226
176
  next nil unless target
227
177
 
228
178
  sheet_path = target.start_with?("/") ? target.delete_prefix("/") : "xl/#{target}"
229
- sheet_xml = entries[sheet_path]
230
- next nil if sheet_xml.nil? || sheet_xml.empty?
179
+ next nil unless zip_reader.entry?(sheet_path)
180
+
181
+ chunk_supplier = ->(&blk) { zip_reader.each_entry_chunk(sheet_path, &blk) }
231
182
 
232
183
  StreamSheet.new(
233
184
  sheet_info[:name],
234
- sheet_xml,
185
+ chunk_supplier,
235
186
  shared_strings,
236
- styles
187
+ styles,
188
+ zip_reader: zip_reader,
189
+ entry_name: sheet_path
237
190
  )
238
191
  end.compact
239
192
 
@@ -245,6 +198,8 @@ module Xlsxrb
245
198
  else
246
199
  wb
247
200
  end
201
+ ensure
202
+ zip_reader.close if block_given?
248
203
  end
249
204
  end
250
205
 
@@ -297,35 +252,33 @@ module Xlsxrb
297
252
  target = target_or_workbook
298
253
  raise Error, "target is required" if target.nil?
299
254
 
300
- attributes = target.is_a?(String) ? { "filepath" => target } : {}
301
- return Xlsxrb.in_span("Xlsxrb.write", attributes: attributes) do
302
- if password && !password.empty?
303
- buf = StringIO.new
304
- buf.binmode
305
- stream_writer = StreamWriter.new(buf, strict_excel_mode: strict_excel_mode)
306
- begin
307
- yield stream_writer
308
- stream_writer.close
309
- plain_bytes = buf.string.b
310
- encrypted_bytes = Ooxml::Crypto.encrypt(plain_bytes, password, mode: encryption_mode)
311
- if target.is_a?(String)
312
- File.binwrite(target, encrypted_bytes)
313
- elsif target.respond_to?(:write)
314
- target.write(encrypted_bytes)
315
- end
316
- ensure
317
- stream_writer.cleanup!
318
- end
319
- else
320
- stream_writer = StreamWriter.new(target, strict_excel_mode: strict_excel_mode)
321
- begin
322
- yield stream_writer
323
- stream_writer.close
324
- ensure
325
- stream_writer.cleanup!
255
+ if password && !password.empty?
256
+ buf = StringIO.new
257
+ buf.binmode
258
+ stream_writer = StreamWriter.new(buf, strict_excel_mode: strict_excel_mode)
259
+ begin
260
+ yield stream_writer
261
+ stream_writer.close
262
+ plain_bytes = buf.string.b
263
+ encrypted_bytes = Ooxml::Crypto.encrypt(plain_bytes, password, mode: encryption_mode)
264
+ if target.is_a?(String)
265
+ File.binwrite(target, encrypted_bytes)
266
+ elsif target.respond_to?(:write)
267
+ target.write(encrypted_bytes)
326
268
  end
269
+ ensure
270
+ stream_writer.cleanup!
271
+ end
272
+ else
273
+ stream_writer = StreamWriter.new(target, strict_excel_mode: strict_excel_mode)
274
+ begin
275
+ yield stream_writer
276
+ stream_writer.close
277
+ ensure
278
+ stream_writer.cleanup!
327
279
  end
328
280
  end
281
+ return
329
282
  end
330
283
 
331
284
  if workbook_or_nil.nil?
@@ -343,79 +296,76 @@ module Xlsxrb
343
296
  raise Error, "target is required" if target.nil?
344
297
  raise Error, "workbook must be an Elements::Workbook" unless workbook.is_a?(Elements::Workbook)
345
298
 
346
- attributes = target.is_a?(String) ? { "filepath" => target } : {}
347
- Xlsxrb.in_span("Xlsxrb.write", attributes: attributes) do
348
- sst = []
349
- sst_index = {}
350
-
351
- # Collect shared strings and build index without allocating new Hashes
352
- sheet_data = workbook.sheets.map do |raw_ws|
353
- ws = raw_ws.respond_to?(:load) ? raw_ws.load : raw_ws
354
- ws.rows.each do |row|
355
- row.cells.each do |cell|
356
- val = cell.value
357
- if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
358
- sst << val
359
- sst_index[val] = sst.size - 1
360
- end
299
+ sst = []
300
+ sst_index = {}
301
+
302
+ # Collect shared strings and build index without allocating new Hashes
303
+ sheet_data = workbook.sheets.map do |raw_ws|
304
+ ws = raw_ws.respond_to?(:load) ? raw_ws.load : raw_ws
305
+ ws.rows.each do |row|
306
+ row.cells.each do |cell|
307
+ val = cell.value
308
+ if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
309
+ sst << val
310
+ sst_index[val] = sst.size - 1
361
311
  end
362
312
  end
363
- columns = ws.columns.map do |col|
364
- # simplecov:disable
365
- # Edge case / untested delegation block
366
- { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level }
367
- # simplecov:enable
368
- end
369
- sd = { name: ws.name, rows: ws.rows, columns: columns }
370
- sd[:charts] = ws.charts unless ws.charts.empty?
371
-
372
- # Extract facade metadata from unmapped_data
373
- facade = ws.unmapped_data[:facade]
374
- facade&.each { |key, val| sd[key] = val }
375
-
376
- sd
377
313
  end
314
+ columns = ws.columns.map do |col|
315
+ # simplecov:disable
316
+ # Edge case / untested delegation block
317
+ { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level }
318
+ # simplecov:enable
319
+ end
320
+ sd = { name: ws.name, rows: ws.rows, columns: columns }
321
+ sd[:charts] = ws.charts unless ws.charts.empty?
378
322
 
379
- # Extract workbook-level facade metadata
380
- wb_facade = workbook.unmapped_data[:facade] || {}
323
+ # Extract facade metadata from unmapped_data
324
+ facade = ws.unmapped_data[:facade]
325
+ facade&.each { |key, val| sd[key] = val }
381
326
 
382
- if password && !password.empty?
383
- buf = StringIO.new
384
- buf.binmode
385
- Ooxml::WorkbookWriter.write(
386
- buf,
387
- sheets: sheet_data,
388
- shared_strings: sst,
389
- shared_strings_index: sst_index,
390
- styles: workbook.styles,
391
- defined_names: wb_facade[:defined_names],
392
- core_properties: wb_facade[:core_properties],
393
- app_properties: wb_facade[:app_properties],
394
- custom_properties: wb_facade[:custom_properties],
395
- workbook_protection: wb_facade[:workbook_protection],
396
- workbook_properties: wb_facade[:workbook_properties]
397
- )
398
- encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password, mode: encryption_mode)
399
- if target.is_a?(String)
400
- File.binwrite(target, encrypted_bytes)
401
- elsif target.respond_to?(:write)
402
- target.write(encrypted_bytes)
403
- end
404
- else
405
- Ooxml::WorkbookWriter.write(
406
- target,
407
- sheets: sheet_data,
408
- shared_strings: sst,
409
- shared_strings_index: sst_index,
410
- styles: workbook.styles,
411
- defined_names: wb_facade[:defined_names],
412
- core_properties: wb_facade[:core_properties],
413
- app_properties: wb_facade[:app_properties],
414
- custom_properties: wb_facade[:custom_properties],
415
- workbook_protection: wb_facade[:workbook_protection],
416
- workbook_properties: wb_facade[:workbook_properties]
417
- )
327
+ sd
328
+ end
329
+
330
+ # Extract workbook-level facade metadata
331
+ wb_facade = workbook.unmapped_data[:facade] || {}
332
+
333
+ if password && !password.empty?
334
+ buf = StringIO.new
335
+ buf.binmode
336
+ Ooxml::WorkbookWriter.write(
337
+ buf,
338
+ sheets: sheet_data,
339
+ shared_strings: sst,
340
+ shared_strings_index: sst_index,
341
+ styles: workbook.styles,
342
+ defined_names: wb_facade[:defined_names],
343
+ core_properties: wb_facade[:core_properties],
344
+ app_properties: wb_facade[:app_properties],
345
+ custom_properties: wb_facade[:custom_properties],
346
+ workbook_protection: wb_facade[:workbook_protection],
347
+ workbook_properties: wb_facade[:workbook_properties]
348
+ )
349
+ encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password, mode: encryption_mode)
350
+ if target.is_a?(String)
351
+ File.binwrite(target, encrypted_bytes)
352
+ elsif target.respond_to?(:write)
353
+ target.write(encrypted_bytes)
418
354
  end
355
+ else
356
+ Ooxml::WorkbookWriter.write(
357
+ target,
358
+ sheets: sheet_data,
359
+ shared_strings: sst,
360
+ shared_strings_index: sst_index,
361
+ styles: workbook.styles,
362
+ defined_names: wb_facade[:defined_names],
363
+ core_properties: wb_facade[:core_properties],
364
+ app_properties: wb_facade[:app_properties],
365
+ custom_properties: wb_facade[:custom_properties],
366
+ workbook_protection: wb_facade[:workbook_protection],
367
+ workbook_properties: wb_facade[:workbook_properties]
368
+ )
419
369
  end
420
370
  end
421
371
 
@@ -445,12 +395,128 @@ module Xlsxrb
445
395
  raise Error, "source is required" if source.nil?
446
396
  raise Error, "block is required" unless block_given?
447
397
 
448
- workbook = read(source, password: password).load
449
- result_workbook = yield workbook
450
- result_workbook = workbook unless result_workbook.is_a?(Elements::Workbook)
398
+ prepared_source = prepare_source_io(source, password)
399
+ in_place = target.nil? || target == source || (source.is_a?(String) && target.is_a?(String) && File.file?(target) && File.identical?(source, target))
400
+ temp_target_path = nil
401
+
402
+ begin
403
+ write_dest = if in_place && source.is_a?(String)
404
+ temp_target = Tempfile.new(["xlsxrb_mod", ".xlsx"], File.dirname(source))
405
+ temp_target_path = temp_target.path
406
+ temp_target.close
407
+ temp_target_path
408
+ elsif in_place && source.is_a?(StringIO)
409
+ StringIO.new
410
+ else
411
+ target || source
412
+ end
413
+
414
+ replaced_entries = {}
415
+ structural_change = false
416
+
417
+ Ooxml::ZipReader.open(prepared_source) do |zip_reader|
418
+ shared_strings_xml = zip_reader.read_entry("xl/sharedStrings.xml")
419
+ shared_strings = shared_strings_xml ? Ooxml::SharedStringsParser.parse(shared_strings_xml) : []
420
+ sst_index = {}
421
+ shared_strings.each_with_index { |s, idx| sst_index[s] ||= idx }
422
+ initial_sst_size = shared_strings.size
423
+
424
+ workbook_xml = zip_reader.read_entry("xl/workbook.xml")
425
+ workbook_sheets = workbook_xml ? Ooxml::WorkbookParser.parse(workbook_xml) : []
426
+
427
+ rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
428
+ rels = rels_xml ? Ooxml::RelationshipsParser.parse(rels_xml) : {}
429
+
430
+ styles_xml = zip_reader.read_entry("xl/styles.xml")
431
+ styles = styles_xml ? Ooxml::StylesParser.parse(styles_xml) : {}
432
+
433
+ sheet_entry_map = {}
434
+ loaded_sheets = workbook_sheets.map do |sheet_info|
435
+ target_path = rels[sheet_info[:r_id]]
436
+ next nil unless target_path
437
+
438
+ sheet_path = target_path.start_with?("/") ? target_path.delete_prefix("/") : "xl/#{target_path}"
439
+ next nil unless zip_reader.entry?(sheet_path)
440
+
441
+ sheet_entry_map[sheet_info[:name]] = sheet_path
442
+ sheet_xml = zip_reader.read_entry(sheet_path) || ""
443
+ build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles)
444
+ end.compact
445
+
446
+ original_workbook = Elements::Workbook.new(
447
+ sheets: loaded_sheets,
448
+ shared_strings: shared_strings,
449
+ styles: styles
450
+ )
451
+ mutable_shared_strings = shared_strings.dup
452
+
453
+ result_workbook = yield original_workbook
454
+ result_workbook = original_workbook unless result_workbook.is_a?(Elements::Workbook)
455
+
456
+ structural_change = result_workbook.sheets.size != original_workbook.sheets.size ||
457
+ result_workbook.sheets.map(&:name) != original_workbook.sheets.map(&:name)
458
+
459
+ if structural_change
460
+ write_target = target || source
461
+ write(write_target, result_workbook, password: password)
462
+ else
463
+ result_workbook.sheets.each_with_index do |new_sheet, idx|
464
+ orig_sheet = original_workbook.sheets[idx]
465
+ next if new_sheet.equal?(orig_sheet) || new_sheet == orig_sheet
466
+
467
+ entry_name = sheet_entry_map[new_sheet.name] || "xl/worksheets/sheet#{idx + 1}.xml"
468
+ orig_sheet_xml = zip_reader.read_entry(entry_name) || ""
469
+
470
+ new_sheet_data = serialize_sheet_data_xml(
471
+ new_sheet.rows,
472
+ mutable_shared_strings,
473
+ sst_index,
474
+ use_sst: !shared_strings_xml.nil?
475
+ )
476
+ updated_sheet_xml = replace_sheet_data(orig_sheet_xml, new_sheet_data)
477
+ updated_sheet_xml = update_dimension(updated_sheet_xml, new_sheet.rows)
478
+ replaced_entries[entry_name] = updated_sheet_xml
479
+ end
480
+
481
+ if mutable_shared_strings.size > initial_sst_size && shared_strings_xml
482
+ new_strings = mutable_shared_strings[initial_sst_size..]
483
+ replaced_entries["xl/sharedStrings.xml"] = append_to_shared_strings_xml(
484
+ shared_strings_xml,
485
+ new_strings
486
+ )
487
+ end
488
+
489
+ perform_modify_write(
490
+ write_dest,
491
+ zip_reader,
492
+ replaced_entries,
493
+ password: password
494
+ )
495
+ end
496
+ end
451
497
 
452
- write_target = target || source
453
- write(write_target, result_workbook, password: password)
498
+ if in_place && !structural_change
499
+ if source.is_a?(String) && temp_target_path
500
+ FileUtils.mv(temp_target_path, source)
501
+ temp_target_path = nil
502
+ elsif source.is_a?(StringIO) && write_dest.is_a?(StringIO)
503
+ source.string = write_dest.string
504
+ elsif source.respond_to?(:write) && source.respond_to?(:seek) && write_dest.is_a?(StringIO)
505
+ source.seek(0)
506
+ source.write(write_dest.string)
507
+ source.truncate(source.pos) if source.respond_to?(:truncate)
508
+ source.rewind
509
+ end
510
+ end
511
+ ensure
512
+ if temp_target_path && File.exist?(temp_target_path)
513
+ begin
514
+ File.unlink(temp_target_path)
515
+ rescue SystemCallError
516
+ nil
517
+ end
518
+ end
519
+ end
454
520
  end
455
521
 
456
522
  # Builds an in-memory {Elements::Workbook} using a declarative DSL.
@@ -472,11 +538,9 @@ module Xlsxrb
472
538
  def self.build(strict_excel_mode: true)
473
539
  raise Error, "block is required" unless block_given?
474
540
 
475
- Xlsxrb.in_span("Xlsxrb.build") do
476
- builder = WorkbookBuilder.new(strict_excel_mode: strict_excel_mode)
477
- yield builder
478
- builder.build
479
- end
541
+ builder = WorkbookBuilder.new(strict_excel_mode: strict_excel_mode)
542
+ yield builder
543
+ builder.build
480
544
  end
481
545
 
482
546
  class << self
@@ -627,6 +691,219 @@ module Xlsxrb
627
691
  attrs
628
692
  # simplecov:enable
629
693
  end
694
+
695
+ #: (untyped source, String? password) -> untyped
696
+ def prepare_source_io(source, password)
697
+ if source.is_a?(String)
698
+ if source.start_with?("PK\x03\x04") || source.include?("\x00") || Ooxml::Cfb::Reader.cfb?(source)
699
+ if Ooxml::Cfb::Reader.cfb?(source)
700
+ decrypted_zip = Ooxml::Crypto.decrypt(source, password)
701
+ StringIO.new(decrypted_zip)
702
+ else
703
+ StringIO.new(source)
704
+ end
705
+ elsif File.file?(source)
706
+ first_bytes = begin
707
+ File.binread(source, 8)
708
+ rescue StandardError
709
+ nil
710
+ end
711
+ if Ooxml::Cfb::Reader.cfb?(first_bytes)
712
+ encrypted_data = File.binread(source)
713
+ decrypted_zip = Ooxml::Crypto.decrypt(encrypted_data, password)
714
+ StringIO.new(decrypted_zip)
715
+ else
716
+ source
717
+ end
718
+ else
719
+ source
720
+ end
721
+ elsif source.respond_to?(:read) && source.respond_to?(:pos) && source.respond_to?(:seek)
722
+ begin
723
+ cur_pos = source.pos
724
+ first_bytes = source.read(8)
725
+ source.seek(cur_pos)
726
+ if Ooxml::Cfb::Reader.cfb?(first_bytes)
727
+ full_data = source.read
728
+ decrypted_zip = Ooxml::Crypto.decrypt(full_data, password)
729
+ StringIO.new(decrypted_zip)
730
+ else
731
+ source
732
+ end
733
+ rescue StandardError
734
+ source
735
+ end
736
+ else
737
+ source
738
+ end
739
+ end
740
+
741
+ #: (untyped dest, Ooxml::ZipReader zip_reader, Hash[String, String] replaced_entries, ?password: String?) -> void
742
+ def perform_modify_write(dest, zip_reader, replaced_entries, password: nil)
743
+ write_zip = lambda do |zip_out|
744
+ Ooxml::ZipWriter.open(zip_out) do |zip_writer|
745
+ zip_reader.entry_names.each do |name|
746
+ if replaced_entries.key?(name)
747
+ zip_writer.add_entry(name, replaced_entries[name])
748
+ else
749
+ zip_reader.copy_to_writer(name, zip_writer)
750
+ end
751
+ end
752
+ replaced_entries.each do |name, content|
753
+ next if zip_reader.entry?(name)
754
+
755
+ zip_writer.add_entry(name, content)
756
+ end
757
+ end
758
+ end
759
+
760
+ if password && !password.empty?
761
+ buf = StringIO.new
762
+ buf.binmode
763
+ write_zip.call(buf)
764
+ encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password)
765
+ if dest.is_a?(String)
766
+ File.binwrite(dest, encrypted_bytes)
767
+ elsif dest.respond_to?(:write)
768
+ dest.write(encrypted_bytes)
769
+ end
770
+ else
771
+ write_zip.call(dest)
772
+ end
773
+ end
774
+
775
+ #: (String sst_xml, Array[untyped] new_strings) -> String
776
+ def append_to_shared_strings_xml(sst_xml, new_strings)
777
+ return sst_xml if new_strings.nil? || new_strings.empty?
778
+
779
+ new_si_tags = +""
780
+ new_strings.each do |str|
781
+ s = str.to_s
782
+ needs_space = s.start_with?(" ", "\t", "\n") || s.end_with?(" ", "\t", "\n")
783
+ space_attr = needs_space ? ' xml:space="preserve"' : ""
784
+ new_si_tags << "<si><t#{space_attr}>#{Ooxml::XmlBuilder.escape(s)}</t></si>"
785
+ end
786
+
787
+ sst_close = sst_xml.rindex("</sst>")
788
+ updated_xml = if sst_close
789
+ prefix = sst_xml[0...sst_close]
790
+ suffix = sst_xml[sst_close..]
791
+ "#{prefix}#{new_si_tags}#{suffix}"
792
+ else
793
+ sst_start = sst_xml.index("<sst")
794
+ return sst_xml unless sst_start
795
+
796
+ sst_open_end = sst_xml.index("/>", sst_start)
797
+ return sst_xml unless sst_open_end
798
+
799
+ prefix = sst_xml[0...sst_open_end]
800
+ suffix = sst_xml[(sst_open_end + 2)..]
801
+ "#{prefix}>#{new_si_tags}</sst#{suffix}"
802
+ end
803
+
804
+ updated_xml.sub(/<sst\b([^>]*)>/) do
805
+ attrs = Regexp.last_match(1)
806
+ if attrs =~ /\bcount="(\d+)"/
807
+ old_count = Regexp.last_match(1).to_i
808
+ new_count = old_count + new_strings.size
809
+ attrs = attrs.sub(/\bcount="\d+"/, "count=\"#{new_count}\"")
810
+ else
811
+ attrs = "#{attrs} count=\"#{new_strings.size}\""
812
+ end
813
+ if attrs =~ /\buniqueCount="(\d+)"/
814
+ old_ucount = Regexp.last_match(1).to_i
815
+ new_ucount = old_ucount + new_strings.size
816
+ attrs = attrs.sub(/\buniqueCount="\d+"/, "uniqueCount=\"#{new_ucount}\"")
817
+ else
818
+ attrs = "#{attrs} uniqueCount=\"#{new_strings.size}\""
819
+ end
820
+ "<sst#{attrs}>"
821
+ end
822
+ end
823
+
824
+ #: (String sheet_xml, String new_sheet_data_xml) -> String
825
+ def replace_sheet_data(sheet_xml, new_sheet_data_xml)
826
+ sheet_data_start = sheet_xml.index("<sheetData")
827
+ return sheet_xml unless sheet_data_start
828
+
829
+ open_tag_end = sheet_xml.index(">", sheet_data_start)
830
+ return sheet_xml unless open_tag_end
831
+
832
+ prefix = sheet_xml[0...sheet_data_start]
833
+ suffix = if sheet_xml.getbyte(open_tag_end - 1) == 47
834
+ sheet_xml[(open_tag_end + 1)..]
835
+ else
836
+ close_tag_start = sheet_xml.index("</sheetData>", open_tag_end)
837
+ return sheet_xml unless close_tag_start
838
+
839
+ sheet_xml[(close_tag_start + 12)..]
840
+ end
841
+
842
+ "#{prefix}#{new_sheet_data_xml}#{suffix}"
843
+ end
844
+
845
+ #: (String sheet_xml, Array[Elements::Row] rows) -> String
846
+ def update_dimension(sheet_xml, rows)
847
+ return sheet_xml if rows.empty?
848
+
849
+ min_row = rows.first.index
850
+ max_row = rows.last.index
851
+ min_col = rows.map { |r| r.cells.first&.column_index }.compact.min || 0
852
+ max_col = rows.map { |r| r.cells.last&.column_index }.compact.max || 0
853
+ min_col_letter = Elements::Cell.column_letter(min_col)
854
+ max_col_letter = Elements::Cell.column_letter(max_col)
855
+ dim_ref = if min_row == max_row && min_col == max_col
856
+ "#{min_col_letter}#{min_row + 1}"
857
+ else
858
+ "#{min_col_letter}#{min_row + 1}:#{max_col_letter}#{max_row + 1}"
859
+ end
860
+
861
+ sheet_xml.sub(/<dimension\b([^>]*)\bref="[^"]*"/, "<dimension\\1ref=\"#{dim_ref}\"")
862
+ end
863
+
864
+ #: (Array[Elements::Row] rows, Array[untyped] shared_strings, Hash[untyped, Integer] sst_index, ?use_sst: bool) -> String
865
+ def serialize_sheet_data_xml(rows, shared_strings, sst_index, use_sst: true)
866
+ io = StringIO.new
867
+ io.write("<sheetData>")
868
+ ws = Ooxml::WorksheetWriter.new(io)
869
+ ws.instance_variable_set(:@started, true)
870
+
871
+ rows.each do |row|
872
+ attrs = {}
873
+ attrs[:height] = row.height if row.height
874
+ attrs[:hidden] = true if row.hidden
875
+ attrs[:custom_height] = true if row.custom_height
876
+ attrs[:outline_level] = row.outline_level if row.outline_level
877
+
878
+ if use_sst
879
+ row.cells.each do |cell|
880
+ val = cell.value
881
+ next unless val.is_a?(String) || val.is_a?(Elements::RichText)
882
+ next if val.is_a?(String) && val.start_with?("=")
883
+ next if sst_index.key?(val)
884
+
885
+ shared_strings << val
886
+ sst_index[val] = shared_strings.size - 1
887
+ end
888
+ end
889
+
890
+ ws.write_row(
891
+ row.index,
892
+ row.cells,
893
+ attrs: attrs,
894
+ unmapped: row.unmapped_data || [],
895
+ sst_index: use_sst ? sst_index : nil
896
+ )
897
+ end
898
+
899
+ buf = ws.instance_variable_get(:@row_buffer)
900
+ if buf && !buf.empty?
901
+ io.write(buf)
902
+ buf.clear
903
+ end
904
+ io.write("</sheetData>")
905
+ io.string
906
+ end
630
907
  end
631
908
 
632
909
  # Builds a raw cell hash from a value for streaming writes.