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
@@ -201,8 +201,12 @@ module Xlsxrb
201
201
  end
202
202
  end
203
203
 
204
- # Cache column letters up to Excel's limit (16,384)
205
- @column_letters = (0...16_384).map do |index|
204
+ # Pure calculation: converts a 0-based column index to an Excel letter.
205
+ #
206
+ # @param index [Integer] 0-based column index.
207
+ # @return [String] Excel column letter.
208
+ #: (Integer index) -> String
209
+ def self.calculate_column_letter(index)
206
210
  result = +""
207
211
  i = index
208
212
  loop do
@@ -210,8 +214,11 @@ module Xlsxrb
210
214
  i = (i / 26) - 1
211
215
  break if i.negative?
212
216
  end
213
- result.freeze
214
- end.freeze
217
+ result
218
+ end
219
+
220
+ # Cache column letters up to Excel's limit (16,384)
221
+ @column_letters = (0...16_384).map { |index| calculate_column_letter(index).freeze }.freeze
215
222
 
216
223
  # Converts a 0-based column index to an Excel letter (0 -> "A", 25 -> "Z", 26 -> "AA").
217
224
  #
@@ -226,16 +233,16 @@ module Xlsxrb
226
233
  def self.column_letter(index)
227
234
  raise ArgumentError, "Column index must be a non-negative Integer, got #{index.inspect}" unless index.is_a?(Integer) && index >= 0
228
235
 
229
- @column_letters[index] || begin
230
- result = +""
231
- i = index
232
- loop do
233
- result.prepend(("A".ord + (i % 26)).chr)
234
- i = (i / 26) - 1
235
- break if i.negative?
236
- end
237
- result
238
- end
236
+ @column_letters[index] || calculate_column_letter(index)
237
+ end
238
+
239
+ # Pure calculation: converts an uppercase column letter string to a 0-based index.
240
+ #
241
+ # @param letter [String] Uppercase column letter string (e.g. "A", "AA").
242
+ # @return [Integer] 0-based column index.
243
+ #: (String letter) -> Integer
244
+ def self.calculate_column_index(letter)
245
+ letter.bytes.reduce(0) { |acc, b| (acc * 26) + (b - 64) } - 1
239
246
  end
240
247
 
241
248
  # Converts a column letter (e.g. "A", :AA) to a 0-based column index.
@@ -265,7 +272,7 @@ module Xlsxrb
265
272
 
266
273
  raise ArgumentError, "Invalid column letter: #{letter.inspect}" unless str.match?(/\A[a-zA-Z]+\z/)
267
274
 
268
- str.upcase.chars.reduce(0) { |acc, c| (acc * 26) + (c.ord - "A".ord + 1) } - 1
275
+ calculate_column_index(str.upcase)
269
276
  end
270
277
 
271
278
  # Parses an Excel-style reference to [row_index, col_index] (both 0-based).
@@ -299,10 +306,40 @@ module Xlsxrb
299
306
  end
300
307
  return nil if i.zero? || i == len
301
308
 
302
- row = bytes.byteslice(i, len - i).to_i - 1
309
+ row_part = bytes.byteslice(i, len - i)
310
+ return nil unless row_part.match?(/\A[1-9]\d*\z/)
311
+
312
+ row = row_part.to_i - 1
303
313
  [row, col - 1]
304
314
  end
305
315
 
316
+ # Returns whether the coordinates are within OOXML limits (row: 0..1,048,575, col: 0..16,383).
317
+ #
318
+ # @param row_index [Integer] 0-based row index.
319
+ # @param column_index [Integer] 0-based column index.
320
+ # @return [Boolean]
321
+ #: (untyped row_index, untyped column_index) -> bool
322
+ def self.valid_coordinates?(row_index, column_index)
323
+ row_index.instance_of?(Integer) && row_index >= 0 && row_index < 1_048_576 &&
324
+ column_index.instance_of?(Integer) && column_index >= 0 && column_index < 16_384
325
+ end
326
+
327
+ # Returns whether the value type is supported by OOXML specifications.
328
+ #
329
+ # @param value [Object]
330
+ # @return [Boolean]
331
+ #: (untyped value) -> bool
332
+ def self.valid_value?(value)
333
+ case value
334
+ when nil, String, Numeric, true, false, Date, Time, Formula, RichText, CellError
335
+ true
336
+ when Hash
337
+ value.key?(:formula)
338
+ else
339
+ false
340
+ end
341
+ end
342
+
306
343
  # Validates cell coordinates and value type against OOXML specifications.
307
344
  #
308
345
  # @param row_index [Integer]
@@ -311,18 +348,15 @@ module Xlsxrb
311
348
  # @return [Array<String>] List of errors.
312
349
  #: (untyped row_index, untyped column_index, untyped value) -> Array[String]
313
350
  def self.validate(row_index, column_index, value)
314
- if row_index.is_a?(Integer) && row_index >= 0 && row_index < 1_048_576 &&
315
- column_index.is_a?(Integer) && column_index >= 0 && column_index < 16_384 &&
316
- (value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Date) || value.is_a?(Time) || value.is_a?(Formula) || (value.is_a?(Hash) && value.key?(:formula)) || value.is_a?(RichText) || value.is_a?(CellError))
317
- return EMPTY_ERRORS
318
- end
351
+ valid_val = valid_value?(value)
352
+ return EMPTY_ERRORS if valid_coordinates?(row_index, column_index) && valid_val
319
353
 
320
354
  errs = []
321
355
  errs << "row_index must be a non-negative Integer (got #{row_index.inspect})" if !row_index.is_a?(Integer) || row_index.negative?
322
356
  errs << "column_index must be a non-negative Integer (got #{column_index.inspect})" if !column_index.is_a?(Integer) || column_index.negative?
323
357
  errs << "row_index must be < 1048576 (got #{row_index}, max row is 1048575)" if row_index.is_a?(Integer) && row_index >= 1_048_576
324
358
  errs << "column_index must be < 16384 (got #{column_index}, max column is XFD=16383)" if column_index.is_a?(Integer) && column_index >= 16_384
325
- errs << "unsupported value type: #{value.class} (#{value.inspect}) — supported types: String, Numeric, true/false, Date, Time, or nil" unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Date) || value.is_a?(Time)
359
+ errs << "unsupported value type: #{value.class} (#{value.inspect}) — supported types: String, Numeric, true/false, Date, Time, Formula, RichText, CellError, or nil" unless valid_val
326
360
  errs
327
361
  end
328
362
  end
@@ -11,7 +11,9 @@ module Xlsxrb
11
11
  # col = Elements::Column.new(index: 0, width: 25.0)
12
12
  #
13
13
  # @api public
14
- Column = Data.define(:index, :width, :hidden, :custom_width, :outline_level, :unmapped_data, :errors) do
14
+ # rubocop:disable Style/DataInheritance -- Required as class syntax for mutant subject matcher
15
+ class Column < Data.define(:index, :width, :hidden, :custom_width, :outline_level, :unmapped_data, :errors)
16
+ # rubocop:enable Style/DataInheritance
15
17
  # @param index [Integer] 0-based column index.
16
18
  # @param width [Float, Integer, nil] Column width in characters.
17
19
  # @param hidden [Boolean] Whether the column is hidden.
@@ -36,6 +38,20 @@ module Xlsxrb
36
38
  errors.empty?
37
39
  end
38
40
 
41
+ # Returns whether the column index is within valid OOXML range (0..16383).
42
+ #
43
+ # @param index [Object]
44
+ # @return [Boolean]
45
+ #: (untyped index) -> bool
46
+ def self.valid_index?(index)
47
+ case index
48
+ when Integer
49
+ index >= 0 && index < 16_384
50
+ else
51
+ false
52
+ end
53
+ end
54
+
39
55
  # Validates column index against OOXML limits.
40
56
  #
41
57
  # @param index [Integer]
@@ -43,8 +59,16 @@ module Xlsxrb
43
59
  #: (untyped index) -> Array[String]
44
60
  def self.validate(index)
45
61
  errs = []
46
- errs << "index must be a non-negative Integer (got #{index.inspect})" if !index.is_a?(Integer) || index.negative?
47
- errs << "index must be < 16384 (got #{index}, max column is XFD=16383)" if index.is_a?(Integer) && index >= 16_384
62
+ case index
63
+ when Integer
64
+ if index.negative?
65
+ errs << "index must be a non-negative Integer (got #{index})"
66
+ elsif index >= 16_384
67
+ errs << "index must be < 16384 (got #{index}, max column is XFD=16383)"
68
+ end
69
+ else
70
+ errs << "index must be a non-negative Integer (got #{index.inspect})"
71
+ end
48
72
  errs
49
73
  end
50
74
  end
@@ -13,8 +13,10 @@ module Xlsxrb
13
13
  # row.to_a # array of cell values
14
14
  #
15
15
  # @api public
16
- Row = Data.define(:index, :cells, :height, :hidden, :custom_height, :outline_level, :unmapped_data, :errors) do
17
- include Enumerable
16
+ # rubocop:disable Style/DataInheritance -- Required as class syntax for mutant subject matcher
17
+ class Row < Data.define(:index, :cells, :height, :hidden, :custom_height, :outline_level, :unmapped_data, :errors)
18
+ # rubocop:enable Style/DataInheritance
19
+ [Enumerable].each { |m| include m }
18
20
 
19
21
  # @param index [Integer] 0-based row index.
20
22
  # @param cells [Array<Elements::Cell>] Cells in this row.
@@ -152,6 +154,20 @@ module Xlsxrb
152
154
  result
153
155
  end
154
156
 
157
+ # Returns whether the row index is within valid OOXML range (0..1048575).
158
+ #
159
+ # @param index [Object]
160
+ # @return [Boolean]
161
+ #: (untyped index) -> bool
162
+ def self.valid_index?(index)
163
+ case index
164
+ when Integer
165
+ index >= 0 && index < 1_048_576
166
+ else
167
+ false
168
+ end
169
+ end
170
+
155
171
  # Validates row index and cells against OOXML limits.
156
172
  #
157
173
  # @param index [Integer]
@@ -159,16 +175,19 @@ module Xlsxrb
159
175
  # @return [Array<String>] List of errors.
160
176
  #: (untyped index, untyped cells) -> Array[String]
161
177
  def self.validate(index, cells)
162
- return EMPTY_ERRORS if index.is_a?(Integer) && index >= 0 && index < 1_048_576 && cells.is_a?(Array)
163
-
164
178
  errs = []
165
- if !index.is_a?(Integer) || index.negative?
179
+ case index
180
+ when Integer
181
+ if index.negative?
182
+ errs << "index must be a non-negative Integer (got #{index})"
183
+ elsif index >= 1_048_576
184
+ errs << "index must be < 1048576 (got #{index}, max row is 1048575)"
185
+ end
186
+ else
166
187
  errs << "index must be a non-negative Integer (got #{index.inspect})"
167
- elsif index >= 1_048_576
168
- errs << "index must be < 1048576 (got #{index}, max row is 1048576)"
169
188
  end
170
189
  errs << "cells must be an Array (got #{cells.class})" unless cells.is_a?(Array)
171
- errs
190
+ errs.empty? ? EMPTY_ERRORS : errs
172
191
  end
173
192
  end
174
193
  end
@@ -12,15 +12,17 @@ module Xlsxrb
12
12
  # workbook.each { |s| puts s.name }
13
13
  #
14
14
  # @api public
15
- Workbook = Data.define(:sheets, :shared_strings, :styles, :unmapped_data, :errors) do
16
- include Enumerable
15
+ # rubocop:disable Style/DataInheritance -- Required as class syntax for mutant subject matcher
16
+ class Workbook < Data.define(:sheets, :shared_strings, :styles, :unmapped_data, :errors)
17
+ # rubocop:enable Style/DataInheritance
18
+ [Enumerable].each { |m| include m }
17
19
 
18
- # @param sheets [Array<Elements::Worksheet>] Worksheets in the workbook.
20
+ # @param sheets [Array<Elements::Worksheet, StreamSheet>] Worksheets in the workbook.
19
21
  # @param shared_strings [Array<String>] Shared strings table.
20
22
  # @param styles [Hash] Styles definition.
21
23
  # @param unmapped_data [Hash] Additional metadata for round-tripping.
22
24
  # @param errors [Array<String>, nil] Validation errors.
23
- #: (?sheets: Array[Elements::Worksheet], ?shared_strings: Array[String], ?styles: Hash[untyped, untyped], ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
25
+ #: (?sheets: Array[Elements::Worksheet | StreamSheet], ?shared_strings: Array[String], ?styles: Hash[untyped, untyped], ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
24
26
  def initialize(sheets: [], shared_strings: [], styles: {}, unmapped_data: {}, errors: nil)
25
27
  computed_errors = errors || self.class.validate(sheets)
26
28
  super(sheets: sheets.freeze, shared_strings: shared_strings.freeze, styles: styles,
@@ -35,14 +37,14 @@ module Xlsxrb
35
37
  # end
36
38
  #
37
39
  # @yield [sheet]
38
- # @yieldparam sheet [Elements::Worksheet]
40
+ # @yieldparam sheet [Elements::Worksheet, StreamSheet]
39
41
  # @return [Enumerator, void]
40
- #: () { (Elements::Worksheet) -> void } -> void
41
- #: () -> Enumerator[Elements::Worksheet, void]
42
+ #: () { (Elements::Worksheet | StreamSheet) -> void } -> void
43
+ #: () -> Enumerator[Elements::Worksheet | StreamSheet, void]
42
44
  def each(&)
43
45
  sheets.each(&)
44
46
  end
45
- alias_method :each_sheet, :each
47
+ alias each_sheet each
46
48
 
47
49
  # Returns whether the workbook is valid according to ECMA-376 rules.
48
50
  #
@@ -58,10 +60,10 @@ module Xlsxrb
58
60
  # wb.sheet(0)
59
61
  # wb.sheet("Sales")
60
62
  #
61
- # @param identifier [Integer, String] 0-based index or sheet name.
62
- # @return [Elements::Worksheet, nil]
63
+ # @param identifier [Integer, String, untyped] 0-based index or sheet name.
64
+ # @return [Elements::Worksheet, StreamSheet, nil]
63
65
  # @api public
64
- #: (?Integer | String identifier) -> Elements::Worksheet?
66
+ #: (?Integer | String | untyped identifier) -> (Elements::Worksheet | StreamSheet)?
65
67
  def sheet(identifier = 0)
66
68
  case identifier
67
69
  when Integer
@@ -70,7 +72,7 @@ module Xlsxrb
70
72
  sheets.find { |s| s.name == identifier }
71
73
  end
72
74
  end
73
- alias_method :[], :sheet
75
+ alias [] sheet
74
76
 
75
77
  # Loads all sheets into memory, returning an Elements::Workbook where every
76
78
  # worksheet is a fully-parsed Elements::Worksheet supporting coordinate random access.
@@ -84,9 +86,20 @@ module Xlsxrb
84
86
  #: () -> Elements::Workbook
85
87
  def load
86
88
  loaded_sheets = sheets.map { |s| s.respond_to?(:load) ? s.load : s }
89
+ close
87
90
  with(sheets: loaded_sheets)
88
91
  end
89
- alias_method :to_workbook, :load
92
+ alias to_workbook load
93
+
94
+ # Closes any streaming resources associated with worksheets.
95
+ #
96
+ # @return [void]
97
+ # @api public
98
+ #: () -> void
99
+ def close
100
+ sheets.each { |s| s.close if s.respond_to?(:close) }
101
+ nil
102
+ end
90
103
 
91
104
  # Returns a new Workbook with the specified sheet updated.
92
105
  # Yields the matched worksheet to the block, which must return a new Worksheet.
@@ -102,7 +115,7 @@ module Xlsxrb
102
115
  # @yieldreturn [Elements::Worksheet] The modified worksheet.
103
116
  # @return [Elements::Workbook] A new Workbook instance.
104
117
  # @api public
105
- #: (Integer | String identifier) { (Elements::Worksheet) -> Elements::Worksheet } -> Elements::Workbook
118
+ #: (Integer | String identifier) ?{ (Elements::Worksheet) -> Elements::Worksheet } -> Elements::Workbook
106
119
  def update_sheet(identifier)
107
120
  raise ArgumentError, "block is required" unless block_given?
108
121
 
@@ -197,6 +197,22 @@ module Xlsxrb
197
197
  end
198
198
  alias to_worksheet load
199
199
 
200
+ FORBIDDEN_NAME_CHARS = %r{[\\/?*\[\]]}
201
+
202
+ # Returns whether the worksheet name is valid according to OOXML specifications (1..31 chars, no forbidden chars).
203
+ #
204
+ # @param name [Object]
205
+ # @return [Boolean]
206
+ #: (untyped name) -> bool
207
+ def self.valid_name?(name)
208
+ case name
209
+ when String
210
+ !name.empty? && name.size <= 31 && !name.match?(FORBIDDEN_NAME_CHARS)
211
+ else
212
+ false
213
+ end
214
+ end
215
+
200
216
  # Validates worksheet name and rows against OOXML limits.
201
217
  #
202
218
  # @param name [String]
@@ -209,7 +225,7 @@ module Xlsxrb
209
225
  errs << "worksheet name must be a non-empty String (got #{name.inspect})"
210
226
  else
211
227
  errs << "worksheet name cannot exceed 31 characters (got #{name.size})" if name.size > 31
212
- errs << "worksheet name cannot contain \\, /, ?, *, [, or ]" if name.match?(%r{[\\/?*\[\]]})
228
+ errs << "worksheet name cannot contain \\, /, ?, *, [, or ]" if name.match?(FORBIDDEN_NAME_CHARS)
213
229
  end
214
230
  errs << "rows must be an Array (got #{rows.class})" unless rows.is_a?(Array)
215
231
  if rows.is_a?(Array)
@@ -6,7 +6,7 @@ module Xlsxrb
6
6
  module Ooxml
7
7
  # Pure-Ruby Compound File Binary (CFB / OLE Structured Storage) implementation for [MS-CFB] / [MS-OFFCRYPTO].
8
8
  module Cfb
9
- MAGIC = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b.freeze
9
+ MAGIC = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1].pack("C*").freeze
10
10
 
11
11
  FREESECT = 0xFFFFFFFF
12
12
  ENDOFCHAIN = 0xFFFFFFFE
@@ -61,9 +61,12 @@ module Xlsxrb
61
61
  attr_reader :entries
62
62
 
63
63
  def self.cfb?(data)
64
- return false if data.nil? || data.bytesize < 8
65
-
66
- data[0, 8] == MAGIC
64
+ case data
65
+ when String
66
+ data.start_with?(MAGIC)
67
+ else
68
+ false
69
+ end
67
70
  end
68
71
 
69
72
  def initialize(data)
@@ -372,7 +375,7 @@ module Xlsxrb
372
375
  num_minifat_sec = (minifat_bytes.bytesize + sector_size - 1) / sector_size
373
376
  num_minifat_sec.times do |s_idx|
374
377
  chunk = minifat_bytes[s_idx * sector_size, sector_size] || "".b
375
- chunk = chunk.ljust(sector_size, "\xFF".b) if chunk.bytesize < sector_size
378
+ chunk = chunk.ljust(sector_size, 255.chr(Encoding::BINARY)) if chunk.bytesize < sector_size
376
379
  allocated_sectors << chunk
377
380
  end
378
381
  sector_chains << [first_minifat_sec, num_minifat_sec]
@@ -400,7 +403,7 @@ module Xlsxrb
400
403
  fat[dir_sector_id] = ENDOFCHAIN
401
404
  fat[fat_sector_id] = FATSECT
402
405
 
403
- fat_bytes = fat.pack("V*").ljust(sector_size, "\xFF".b)
406
+ fat_bytes = fat.pack("V*").ljust(sector_size, 255.chr(Encoding::BINARY))
404
407
  allocated_sectors << fat_bytes
405
408
 
406
409
  # Build Header (512 bytes)
@@ -11,11 +11,11 @@ module Xlsxrb
11
11
  module Crypto
12
12
  # Implements Microsoft Office Agile Encryption specified in [MS-OFFCRYPTO] Section 2.3.4.
13
13
  class Agile
14
- BLOCK_KEY_VERIFIER_INPUT = "\xfe\xa7\xd2\x76\x3b\x4b\x9e\x79".b.freeze
15
- BLOCK_KEY_VERIFIER_VALUE = "\xd7\xaa\x0f\x6d\x30\x61\x34\x4e".b.freeze
16
- BLOCK_KEY_KEY = "\x14\x6e\x0b\xe7\xab\xac\xd0\xd6".b.freeze
17
- BLOCK_KEY_INTEGRITY_KEY = "\x5f\xb2\xad\x01\x0c\xb9\xe1\xf6".b.freeze
18
- BLOCK_KEY_INTEGRITY_VALUE = "\xa0\x67\x7f\x02\xb2\x2c\x84\x33".b.freeze
14
+ BLOCK_KEY_VERIFIER_INPUT = [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79].pack("C*").freeze
15
+ BLOCK_KEY_VERIFIER_VALUE = [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e].pack("C*").freeze
16
+ BLOCK_KEY_KEY = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6].pack("C*").freeze
17
+ BLOCK_KEY_INTEGRITY_KEY = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6].pack("C*").freeze
18
+ BLOCK_KEY_INTEGRITY_VALUE = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33].pack("C*").freeze
19
19
 
20
20
  SEGMENT_SIZE = 4096
21
21