xlsxrb 0.1.11 → 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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -34
  3. data/README.md +26 -21
  4. data/lib/xlsxrb/chart_builder.rb +18 -18
  5. data/lib/xlsxrb/dsl_helpers.rb +175 -0
  6. data/lib/xlsxrb/elements/cell.rb +56 -22
  7. data/lib/xlsxrb/elements/column.rb +27 -3
  8. data/lib/xlsxrb/elements/row.rb +27 -8
  9. data/lib/xlsxrb/elements/workbook.rb +27 -14
  10. data/lib/xlsxrb/elements/worksheet.rb +17 -1
  11. data/lib/xlsxrb/ooxml/cfb.rb +9 -6
  12. data/lib/xlsxrb/ooxml/crypto/agile.rb +5 -5
  13. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +784 -0
  14. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +3478 -0
  15. data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +1796 -0
  16. data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +428 -0
  17. data/lib/xlsxrb/ooxml/reader/listeners.rb +4 -6446
  18. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +3 -5
  19. data/lib/xlsxrb/ooxml/utils.rb +5 -6
  20. data/lib/xlsxrb/ooxml/workbook_writer.rb +5 -7
  21. data/lib/xlsxrb/ooxml/worksheet_parser.rb +172 -9
  22. data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +1602 -0
  23. data/lib/xlsxrb/ooxml/writer/features_xml.rb +731 -0
  24. data/lib/xlsxrb/ooxml/writer/styles_xml.rb +348 -0
  25. data/lib/xlsxrb/ooxml/writer.rb +9 -2646
  26. data/lib/xlsxrb/ooxml/xml_builder.rb +31 -2
  27. data/lib/xlsxrb/ooxml/zip_generator.rb +26 -5
  28. data/lib/xlsxrb/ooxml/zip_reader.rb +364 -78
  29. data/lib/xlsxrb/ooxml/zip_writer.rb +32 -5
  30. data/lib/xlsxrb/stream_sheet.rb +94 -6
  31. data/lib/xlsxrb/stream_writer.rb +48 -115
  32. data/lib/xlsxrb/style_builder.rb +10 -1
  33. data/lib/xlsxrb/version.rb +1 -1
  34. data/lib/xlsxrb/workbook_builder.rb +1 -1
  35. data/lib/xlsxrb/worksheet_builder.rb +19 -81
  36. data/lib/xlsxrb.rb +449 -171
  37. data/sig/generated/xlsxrb/chart_builder.rbs +36 -36
  38. data/sig/generated/xlsxrb/dsl_helpers.rbs +92 -0
  39. data/sig/generated/xlsxrb/elements/cell.rbs +29 -0
  40. data/sig/generated/xlsxrb/elements/column.rbs +32 -21
  41. data/sig/generated/xlsxrb/elements/row.rbs +94 -14
  42. data/sig/generated/xlsxrb/elements/workbook.rbs +102 -10
  43. data/sig/generated/xlsxrb/elements/worksheet.rbs +9 -0
  44. data/sig/generated/xlsxrb/ooxml/reader/listeners/core_listeners.rbs +173 -0
  45. data/sig/generated/xlsxrb/ooxml/reader/listeners/drawing_listeners.rbs +400 -0
  46. data/sig/generated/xlsxrb/ooxml/reader/listeners/feature_listeners.rbs +442 -0
  47. data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +80 -0
  48. data/sig/generated/xlsxrb/ooxml/reader/listeners.rbs +0 -1072
  49. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +0 -2
  50. data/sig/generated/xlsxrb/ooxml/utils.rbs +2 -0
  51. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +5 -4
  52. data/sig/generated/xlsxrb/ooxml/writer/drawing_xml.rbs +79 -0
  53. data/sig/generated/xlsxrb/ooxml/writer/features_xml.rbs +83 -0
  54. data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +53 -0
  55. data/sig/generated/xlsxrb/ooxml/writer.rbs +6 -185
  56. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +18 -0
  57. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +15 -0
  58. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +96 -8
  59. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +5 -1
  60. data/sig/generated/xlsxrb/stream_sheet.rbs +43 -3
  61. data/sig/generated/xlsxrb/style_builder.rbs +7 -0
  62. data/sig/generated/xlsxrb.rbs +18 -11
  63. metadata +22 -144
  64. data/.devcontainer/Dockerfile +0 -65
  65. data/.devcontainer/devcontainer.json +0 -17
  66. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +0 -9
  67. data/.gem_rbs_collection/ast/2.4/ast.rbs +0 -73
  68. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +0 -9
  69. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +0 -4
  70. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +0 -16
  71. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +0 -96
  72. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +0 -4
  73. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +0 -68
  74. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +0 -249
  75. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +0 -4
  76. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +0 -47
  77. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +0 -5
  78. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +0 -9
  79. data/.gem_rbs_collection/csv/3.3/csv.rbs +0 -3871
  80. data/.gem_rbs_collection/csv/3.3/manifest.yaml +0 -3
  81. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +0 -9
  82. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +0 -48
  83. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +0 -9
  84. data/.gem_rbs_collection/listen/3.9/listen.rbs +0 -25
  85. data/.gem_rbs_collection/listen/3.9/listener.rbs +0 -24
  86. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +0 -9
  87. data/.gem_rbs_collection/logger/1.7/formatter.rbs +0 -45
  88. data/.gem_rbs_collection/logger/1.7/log_device.rbs +0 -100
  89. data/.gem_rbs_collection/logger/1.7/logger.rbs +0 -796
  90. data/.gem_rbs_collection/logger/1.7/manifest.yaml +0 -2
  91. data/.gem_rbs_collection/logger/1.7/period.rbs +0 -17
  92. data/.gem_rbs_collection/logger/1.7/severity.rbs +0 -34
  93. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +0 -9
  94. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +0 -2332
  95. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +0 -4
  96. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +0 -9
  97. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +0 -86
  98. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +0 -9
  99. data/.gem_rbs_collection/parser/3.2/manifest.yaml +0 -7
  100. data/.gem_rbs_collection/parser/3.2/parser.rbs +0 -194
  101. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +0 -4
  102. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +0 -9
  103. data/.gem_rbs_collection/rainbow/3.0/global.rbs +0 -7
  104. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +0 -209
  105. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +0 -5
  106. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +0 -9
  107. data/.gem_rbs_collection/rake/13.0/manifest.yaml +0 -2
  108. data/.gem_rbs_collection/rake/13.0/rake.rbs +0 -39
  109. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +0 -9
  110. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +0 -17
  111. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +0 -9
  112. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +0 -208
  113. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +0 -9
  114. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +0 -903
  115. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +0 -9
  116. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +0 -8
  117. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +0 -42
  118. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +0 -5
  119. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +0 -47
  120. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +0 -30
  121. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +0 -9
  122. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +0 -7
  123. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +0 -19
  124. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +0 -31
  125. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +0 -18
  126. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +0 -12
  127. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +0 -11
  128. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +0 -13
  129. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +0 -95
  130. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +0 -31
  131. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +0 -58
  132. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +0 -24
  133. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +0 -17
  134. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +0 -23
  135. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +0 -22
  136. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +0 -30
  137. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +0 -20
  138. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +0 -15
  139. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +0 -26
  140. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +0 -21
  141. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +0 -131
  142. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +0 -14
  143. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +0 -33
  144. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +0 -21
  145. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +0 -63
  146. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +0 -55
  147. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +0 -35
  148. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +0 -7
  149. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +0 -10
  150. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +0 -22
  151. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +0 -29
  152. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +0 -17
  153. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +0 -13
  154. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +0 -10
  155. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +0 -8
  156. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +0 -6
  157. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +0 -30
  158. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +0 -10
  159. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +0 -10
  160. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +0 -5
  161. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +0 -15
  162. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +0 -3
  163. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +0 -40
  164. data/Rakefile +0 -567
  165. data/Steepfile +0 -18
  166. data/benchmark.rb +0 -406
  167. data/docs/ARCHITECTURE.md +0 -514
  168. data/docs/DEVELOPMENT.md +0 -133
  169. data/docs/PEER_LIBRARIES.md +0 -121
  170. data/docs/QUALITY_ASSURANCE.md +0 -25
  171. data/docs/SPEC_SOURCES.md +0 -48
  172. data/docs/assets/benchmark_results.svg +0 -144
  173. data/docs/assets/lsp_autocompletion.png +0 -0
  174. data/docs/assets/playground_preview.png +0 -0
  175. data/docs/coi-serviceworker.js +0 -82
  176. data/docs/office_thread.js +0 -77
  177. data/docs/preview.html +0 -719
  178. data/docs/visual/VisualGallery.md +0 -4672
  179. data/docs/wasm/ruby.wasm +0 -0
  180. data/docs/wasm/wasm_doc_helper.css +0 -351
  181. data/docs/wasm/wasm_doc_helper.js +0 -439
  182. data/docs/zeta.js +0 -1107
  183. data/rbs_collection.lock.yaml +0 -252
  184. data/rbs_collection.yaml +0 -19
  185. data/vendor/sdk_runner/Program.cs +0 -93
  186. 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"
@@ -29,6 +29,7 @@ require_relative "xlsxrb/stream_row"
29
29
  require_relative "xlsxrb/stream_sheet"
30
30
  require_relative "xlsxrb/style_builder"
31
31
  require_relative "xlsxrb/chart_builder"
32
+ require_relative "xlsxrb/dsl_helpers"
32
33
  require_relative "xlsxrb/worksheet_builder"
33
34
  require_relative "xlsxrb/workbook_builder"
34
35
  require_relative "xlsxrb/stream_writer"
@@ -70,28 +71,6 @@ module Xlsxrb
70
71
  # Raised when encrypted package stream decryption fails.
71
72
  class DecryptionError < EncryptedFileError; end
72
73
 
73
- TRACER = OpenTelemetry.tracer_provider.tracer("xlsxrb", Xlsxrb::VERSION)
74
-
75
- # Executes the block within an OpenTelemetry tracer span if tracing is configured.
76
- #
77
- # @param name [String] The span name.
78
- # @param attributes [Hash, nil] Optional telemetry attributes.
79
- # @yield Block to execute inside the tracing span.
80
- # @return [Object] The result of the block.
81
- #: (String name, ?attributes: Hash[String, untyped]?) { (*untyped) -> untyped } -> untyped
82
- def self.in_span(name, attributes: nil, &)
83
- if defined?(Ractor) && Ractor.current != Ractor.main
84
- # simplecov:disable
85
- # Test suite runs in the main Ractor. This branch is for multi-threaded usage via Ractors.
86
- yield
87
- # simplecov:enable
88
- elsif attributes
89
- TRACER.in_span(name, attributes: attributes, &)
90
- else
91
- TRACER.in_span(name, &)
92
- end
93
- end
94
-
95
74
  # Helper to construct {Elements::RichText} objects with formatted text runs.
96
75
  #
97
76
  # @example Create multi-run rich text
@@ -177,62 +156,37 @@ module Xlsxrb
177
156
  #: (untyped source, ?password: String?) { (StreamSheet) -> void } -> void
178
157
  #: (untyped source, ?password: String?) -> Elements::Workbook
179
158
  def self.read(source, password: nil, &)
180
- if source.is_a?(String)
181
- if source.start_with?("PK\x03\x04") || source.include?("\x00") || Ooxml::Cfb::Reader.cfb?(source)
182
- if Ooxml::Cfb::Reader.cfb?(source)
183
- decrypted_zip = Ooxml::Crypto.decrypt(source, password)
184
- source = StringIO.new(decrypted_zip)
185
- else
186
- source = StringIO.new(source)
187
- end
188
- elsif File.file?(source)
189
- first_bytes = begin
190
- File.binread(source, 8)
191
- rescue StandardError
192
- nil
193
- end
194
- if Ooxml::Cfb::Reader.cfb?(first_bytes)
195
- encrypted_data = File.binread(source)
196
- decrypted_zip = Ooxml::Crypto.decrypt(encrypted_data, password)
197
- source = StringIO.new(decrypted_zip)
198
- end
199
- end
200
- elsif source.respond_to?(:read) && source.respond_to?(:pos) && source.respond_to?(:seek)
201
- begin
202
- cur_pos = source.pos
203
- first_bytes = source.read(8)
204
- source.seek(cur_pos)
205
- if Ooxml::Cfb::Reader.cfb?(first_bytes)
206
- full_data = source.read
207
- decrypted_zip = Ooxml::Crypto.decrypt(full_data, password)
208
- source = StringIO.new(decrypted_zip)
209
- end
210
- rescue StandardError
211
- # Fall through to standard reader if seeking fails
212
- end
213
- 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)
214
170
 
215
- attributes = source.is_a?(String) ? { "filepath" => source } : {}
216
- Xlsxrb.in_span("Xlsxrb.read", attributes: attributes) do
217
- entries = Ooxml::ZipReader.open(source, &:read_all)
218
- shared_strings = Ooxml::SharedStringsParser.parse(entries["xl/sharedStrings.xml"])
219
- workbook_sheets = Ooxml::WorkbookParser.parse(entries["xl/workbook.xml"])
220
- rels = Ooxml::RelationshipsParser.parse(entries["xl/_rels/workbook.xml.rels"])
221
- 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)
222
173
 
223
174
  sheets = workbook_sheets.map do |sheet_info|
224
175
  target = rels[sheet_info[:r_id]]
225
176
  next nil unless target
226
177
 
227
178
  sheet_path = target.start_with?("/") ? target.delete_prefix("/") : "xl/#{target}"
228
- sheet_xml = entries[sheet_path]
229
- 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) }
230
182
 
231
183
  StreamSheet.new(
232
184
  sheet_info[:name],
233
- sheet_xml,
185
+ chunk_supplier,
234
186
  shared_strings,
235
- styles
187
+ styles,
188
+ zip_reader: zip_reader,
189
+ entry_name: sheet_path
236
190
  )
237
191
  end.compact
238
192
 
@@ -244,6 +198,8 @@ module Xlsxrb
244
198
  else
245
199
  wb
246
200
  end
201
+ ensure
202
+ zip_reader.close if block_given?
247
203
  end
248
204
  end
249
205
 
@@ -296,35 +252,33 @@ module Xlsxrb
296
252
  target = target_or_workbook
297
253
  raise Error, "target is required" if target.nil?
298
254
 
299
- attributes = target.is_a?(String) ? { "filepath" => target } : {}
300
- return Xlsxrb.in_span("Xlsxrb.write", attributes: attributes) do
301
- if password && !password.empty?
302
- buf = StringIO.new
303
- buf.binmode
304
- stream_writer = StreamWriter.new(buf, strict_excel_mode: strict_excel_mode)
305
- begin
306
- yield stream_writer
307
- stream_writer.close
308
- plain_bytes = buf.string.b
309
- encrypted_bytes = Ooxml::Crypto.encrypt(plain_bytes, password, mode: encryption_mode)
310
- if target.is_a?(String)
311
- File.binwrite(target, encrypted_bytes)
312
- elsif target.respond_to?(:write)
313
- target.write(encrypted_bytes)
314
- end
315
- ensure
316
- stream_writer.cleanup!
317
- end
318
- else
319
- stream_writer = StreamWriter.new(target, strict_excel_mode: strict_excel_mode)
320
- begin
321
- yield stream_writer
322
- stream_writer.close
323
- ensure
324
- 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)
325
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!
326
279
  end
327
280
  end
281
+ return
328
282
  end
329
283
 
330
284
  if workbook_or_nil.nil?
@@ -342,79 +296,76 @@ module Xlsxrb
342
296
  raise Error, "target is required" if target.nil?
343
297
  raise Error, "workbook must be an Elements::Workbook" unless workbook.is_a?(Elements::Workbook)
344
298
 
345
- attributes = target.is_a?(String) ? { "filepath" => target } : {}
346
- Xlsxrb.in_span("Xlsxrb.write", attributes: attributes) do
347
- sst = []
348
- sst_index = {}
349
-
350
- # Collect shared strings and build index without allocating new Hashes
351
- sheet_data = workbook.sheets.map do |raw_ws|
352
- ws = raw_ws.respond_to?(:load) ? raw_ws.load : raw_ws
353
- ws.rows.each do |row|
354
- row.cells.each do |cell|
355
- val = cell.value
356
- if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
357
- sst << val
358
- sst_index[val] = sst.size - 1
359
- 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
360
311
  end
361
312
  end
362
- columns = ws.columns.map do |col|
363
- # simplecov:disable
364
- # Edge case / untested delegation block
365
- { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level }
366
- # simplecov:enable
367
- end
368
- sd = { name: ws.name, rows: ws.rows, columns: columns }
369
- sd[:charts] = ws.charts unless ws.charts.empty?
370
-
371
- # Extract facade metadata from unmapped_data
372
- facade = ws.unmapped_data[:facade]
373
- facade&.each { |key, val| sd[key] = val }
374
-
375
- sd
376
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?
377
322
 
378
- # Extract workbook-level facade metadata
379
- 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 }
380
326
 
381
- if password && !password.empty?
382
- buf = StringIO.new
383
- buf.binmode
384
- Ooxml::WorkbookWriter.write(
385
- buf,
386
- sheets: sheet_data,
387
- shared_strings: sst,
388
- shared_strings_index: sst_index,
389
- styles: workbook.styles,
390
- defined_names: wb_facade[:defined_names],
391
- core_properties: wb_facade[:core_properties],
392
- app_properties: wb_facade[:app_properties],
393
- custom_properties: wb_facade[:custom_properties],
394
- workbook_protection: wb_facade[:workbook_protection],
395
- workbook_properties: wb_facade[:workbook_properties]
396
- )
397
- encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password, mode: encryption_mode)
398
- if target.is_a?(String)
399
- File.binwrite(target, encrypted_bytes)
400
- elsif target.respond_to?(:write)
401
- target.write(encrypted_bytes)
402
- end
403
- else
404
- Ooxml::WorkbookWriter.write(
405
- target,
406
- sheets: sheet_data,
407
- shared_strings: sst,
408
- shared_strings_index: sst_index,
409
- styles: workbook.styles,
410
- defined_names: wb_facade[:defined_names],
411
- core_properties: wb_facade[:core_properties],
412
- app_properties: wb_facade[:app_properties],
413
- custom_properties: wb_facade[:custom_properties],
414
- workbook_protection: wb_facade[:workbook_protection],
415
- workbook_properties: wb_facade[:workbook_properties]
416
- )
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)
417
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
+ )
418
369
  end
419
370
  end
420
371
 
@@ -444,12 +395,128 @@ module Xlsxrb
444
395
  raise Error, "source is required" if source.nil?
445
396
  raise Error, "block is required" unless block_given?
446
397
 
447
- workbook = read(source, password: password).load
448
- result_workbook = yield workbook
449
- 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
450
497
 
451
- write_target = target || source
452
- 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
453
520
  end
454
521
 
455
522
  # Builds an in-memory {Elements::Workbook} using a declarative DSL.
@@ -471,11 +538,9 @@ module Xlsxrb
471
538
  def self.build(strict_excel_mode: true)
472
539
  raise Error, "block is required" unless block_given?
473
540
 
474
- Xlsxrb.in_span("Xlsxrb.build") do
475
- builder = WorkbookBuilder.new(strict_excel_mode: strict_excel_mode)
476
- yield builder
477
- builder.build
478
- end
541
+ builder = WorkbookBuilder.new(strict_excel_mode: strict_excel_mode)
542
+ yield builder
543
+ builder.build
479
544
  end
480
545
 
481
546
  class << self
@@ -626,6 +691,219 @@ module Xlsxrb
626
691
  attrs
627
692
  # simplecov:enable
628
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
629
907
  end
630
908
 
631
909
  # Builds a raw cell hash from a value for streaming writes.