xlsxrb 0.1.4 → 0.1.6

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +1 -1
  3. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +9 -0
  4. data/.gem_rbs_collection/ast/2.4/ast.rbs +73 -0
  5. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  6. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  7. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +16 -0
  8. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +96 -0
  9. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  10. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +68 -0
  11. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  12. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +4 -0
  13. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +47 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  15. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +9 -0
  16. data/.gem_rbs_collection/csv/3.3/csv.rbs +3871 -0
  17. data/.gem_rbs_collection/csv/3.3/manifest.yaml +3 -0
  18. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +9 -0
  19. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +48 -0
  20. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  21. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  22. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  23. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/logger/1.7/formatter.rbs +45 -0
  25. data/.gem_rbs_collection/logger/1.7/log_device.rbs +100 -0
  26. data/.gem_rbs_collection/logger/1.7/logger.rbs +796 -0
  27. data/.gem_rbs_collection/logger/1.7/manifest.yaml +2 -0
  28. data/.gem_rbs_collection/logger/1.7/period.rbs +17 -0
  29. data/.gem_rbs_collection/logger/1.7/severity.rbs +34 -0
  30. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +2332 -0
  32. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +4 -0
  33. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  35. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/parser/3.2/manifest.yaml +7 -0
  37. data/.gem_rbs_collection/parser/3.2/parser.rbs +194 -0
  38. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +4 -0
  39. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +9 -0
  40. data/.gem_rbs_collection/rainbow/3.0/global.rbs +7 -0
  41. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +209 -0
  42. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +5 -0
  43. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  44. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  45. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  46. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +9 -0
  47. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +17 -0
  48. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +9 -0
  49. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +208 -0
  50. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  51. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +903 -0
  52. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +9 -0
  53. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +8 -0
  54. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +42 -0
  55. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +5 -0
  56. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +47 -0
  57. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +30 -0
  58. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +9 -0
  59. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +7 -0
  60. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +19 -0
  61. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +31 -0
  62. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +18 -0
  63. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +12 -0
  64. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +11 -0
  65. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +13 -0
  66. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +95 -0
  67. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +31 -0
  68. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +58 -0
  69. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +24 -0
  70. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +17 -0
  71. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +23 -0
  72. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +22 -0
  73. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +30 -0
  74. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +20 -0
  75. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +15 -0
  76. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +26 -0
  77. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +21 -0
  78. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +131 -0
  79. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +14 -0
  80. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +33 -0
  81. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +21 -0
  82. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +63 -0
  83. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +55 -0
  84. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +35 -0
  85. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +7 -0
  86. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +10 -0
  87. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +22 -0
  88. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +29 -0
  89. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +17 -0
  90. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +13 -0
  91. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +10 -0
  92. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +8 -0
  93. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +6 -0
  94. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +30 -0
  95. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +10 -0
  96. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +10 -0
  97. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +5 -0
  98. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +15 -0
  99. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +3 -0
  100. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +40 -0
  101. data/CHANGELOG.md +42 -3
  102. data/README.md +130 -49
  103. data/Rakefile +23 -2
  104. data/Steepfile +17 -0
  105. data/benchmark.rb +324 -348
  106. data/docs/ARCHITECTURE.md +24 -0
  107. data/docs/DEVELOPMENT.md +58 -0
  108. data/docs/QUALITY_ASSURANCE.md +26 -0
  109. data/docs/visual/VisualGallery.md +893 -938
  110. data/docs/wasm/ruby.wasm +0 -0
  111. data/lib/xlsxrb/elements/cell.rb +113 -8
  112. data/lib/xlsxrb/elements/column.rb +2 -0
  113. data/lib/xlsxrb/elements/row.rb +52 -4
  114. data/lib/xlsxrb/elements/types.rb +6 -0
  115. data/lib/xlsxrb/elements/workbook.rb +29 -0
  116. data/lib/xlsxrb/elements/worksheet.rb +98 -1
  117. data/lib/xlsxrb/elements.rb +2 -0
  118. data/lib/xlsxrb/ooxml/reader.rb +8 -6
  119. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +106 -40
  120. data/lib/xlsxrb/ooxml/styles_parser.rb +2 -0
  121. data/lib/xlsxrb/ooxml/utils.rb +2 -0
  122. data/lib/xlsxrb/ooxml/workbook_parser.rb +6 -1
  123. data/lib/xlsxrb/ooxml/workbook_writer.rb +15 -4
  124. data/lib/xlsxrb/ooxml/worksheet_parser.rb +156 -23
  125. data/lib/xlsxrb/ooxml/worksheet_writer.rb +207 -152
  126. data/lib/xlsxrb/ooxml/writer.rb +217 -5
  127. data/lib/xlsxrb/ooxml/xml_builder.rb +18 -10
  128. data/lib/xlsxrb/ooxml/xml_parser.rb +2 -0
  129. data/lib/xlsxrb/ooxml/zip_generator.rb +5 -8
  130. data/lib/xlsxrb/ooxml/zip_reader.rb +179 -57
  131. data/lib/xlsxrb/ooxml/zip_writer.rb +65 -25
  132. data/lib/xlsxrb/ooxml.rb +3 -1
  133. data/lib/xlsxrb/style_builder.rb +284 -36
  134. data/lib/xlsxrb/version.rb +3 -1
  135. data/lib/xlsxrb.rb +1761 -275
  136. data/rbs_collection.lock.yaml +252 -0
  137. data/rbs_collection.yaml +19 -0
  138. data/sig/generated/xlsxrb/elements/cell.rbs +30 -0
  139. data/sig/generated/xlsxrb/elements/column.rbs +30 -0
  140. data/sig/generated/xlsxrb/elements/row.rbs +32 -0
  141. data/sig/generated/xlsxrb/elements/types.rbs +74 -0
  142. data/sig/generated/xlsxrb/elements/workbook.rbs +25 -0
  143. data/sig/generated/xlsxrb/elements/worksheet.rbs +27 -0
  144. data/sig/generated/xlsxrb/elements.rbs +8 -0
  145. data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
  146. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
  147. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
  148. data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
  149. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
  150. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
  151. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
  152. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
  153. data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
  154. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
  155. data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
  156. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
  157. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
  158. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
  159. data/sig/generated/xlsxrb/ooxml.rbs +23 -0
  160. data/sig/generated/xlsxrb/style_builder.rbs +250 -0
  161. data/sig/generated/xlsxrb/version.rbs +5 -0
  162. data/sig/generated/xlsxrb.rbs +1276 -0
  163. data/sig/rexml.rbs +4 -0
  164. metadata +145 -4
  165. data/measure_memory.rb +0 -42
  166. data/sig/xlsxrb.rbs +0 -23
Binary file
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents a single cell in a worksheet.
6
8
  # All indices are 0-based.
7
9
  Cell = Data.define(:row_index, :column_index, :value, :formula, :style_index, :unmapped_data, :errors) do
8
- def initialize(row_index:, column_index:, value: nil, formula: nil, style_index: nil, unmapped_data: {}, errors: nil)
10
+ def initialize(row_index:, column_index:, value: nil, formula: nil, style_index: nil, unmapped_data: EMPTY_HASH, errors: nil)
9
11
  computed_errors = errors || self.class.validate(row_index, column_index, value)
10
12
  computed_errors = computed_errors.freeze unless computed_errors.frozen?
11
13
  super(row_index: row_index, column_index: column_index, value: value, formula: formula,
@@ -21,6 +23,69 @@ module Xlsxrb
21
23
  "#{self.class.column_letter(column_index)}#{row_index + 1}"
22
24
  end
23
25
 
26
+ def [](key)
27
+ case key
28
+ when :value then value
29
+ when :formula then formula
30
+ when :style_index then style_index
31
+ when :ref then ref
32
+ when :column_index then column_index
33
+ when :row_index then row_index
34
+ when :type
35
+ case value
36
+ when String then "s"
37
+ when true, false then "b"
38
+ end
39
+ end
40
+ end
41
+
42
+ def content
43
+ value
44
+ end
45
+
46
+ def to_s
47
+ value.to_s
48
+ end
49
+
50
+ def to_i
51
+ value.to_i
52
+ end
53
+
54
+ def to_f
55
+ value.to_f
56
+ end
57
+
58
+ def to_date
59
+ return value if value.is_a?(Date)
60
+
61
+ # Excel epoch is 1899-12-30
62
+ if value.is_a?(Numeric)
63
+ Date.new(1899, 12, 30) + value.to_i
64
+ else
65
+ begin
66
+ Date.parse(value.to_s)
67
+ rescue StandardError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+
73
+ def to_time
74
+ return value if value.is_a?(Time)
75
+
76
+ if value.is_a?(Numeric)
77
+ days = value.to_f
78
+ base_time = Time.utc(1899, 12, 30)
79
+ base_time + (days * 86_400)
80
+ else
81
+ begin
82
+ Time.parse(value.to_s)
83
+ rescue StandardError
84
+ nil
85
+ end
86
+ end
87
+ end
88
+
24
89
  # Cache column letters up to Excel's limit (16,384)
25
90
  @column_letters = (0...16_384).map do |index|
26
91
  result = +""
@@ -35,6 +100,8 @@ module Xlsxrb
35
100
 
36
101
  # Converts a 0-based column index to a letter (0 -> "A", 25 -> "Z", 26 -> "AA").
37
102
  def self.column_letter(index)
103
+ raise ArgumentError, "Column index must be a non-negative Integer, got #{index.inspect}" unless index.is_a?(Integer) && index >= 0
104
+
38
105
  @column_letters[index] || begin
39
106
  result = +""
40
107
  i = index
@@ -47,21 +114,59 @@ module Xlsxrb
47
114
  end
48
115
  end
49
116
 
117
+ # Converts a column letter (e.g. "A", :AA) to a 0-based column index.
118
+ # If passed an integer or string/symbol representing an integer, it validates and returns the integer.
119
+ def self.column_index(letter)
120
+ if letter.is_a?(Integer)
121
+ raise ArgumentError, "Column index must be >= 0, got #{letter}" if letter.negative?
122
+
123
+ return letter
124
+ end
125
+
126
+ str = letter.to_s
127
+ if str.match?(/\A-?\d+\z/)
128
+ val = str.to_i
129
+ raise ArgumentError, "Column index must be >= 0, got #{val}" if val.negative?
130
+
131
+ return val
132
+ end
133
+
134
+ raise ArgumentError, "Invalid column letter: #{letter.inspect}" unless str.match?(/\A[a-zA-Z]+\z/)
135
+
136
+ str.upcase.chars.reduce(0) { |acc, c| (acc * 26) + (c.ord - "A".ord + 1) } - 1
137
+ end
138
+
50
139
  # Parses an Excel-style reference to [row_index, col_index] (both 0-based).
51
140
  def self.parse_ref(ref)
52
- match = ref.match(/\A([A-Z]+)(\d+)\z/)
53
- return nil unless match
141
+ return nil unless ref
142
+
143
+ bytes = ref.b
144
+ len = bytes.bytesize
145
+ col = 0
146
+ i = 0
147
+ while i < len
148
+ b = bytes.getbyte(i)
149
+ if b.between?(65, 90)
150
+ col = (col * 26) + (b - 64)
151
+ i += 1
152
+ elsif b.between?(97, 122)
153
+ col = (col * 26) + (b - 96)
154
+ i += 1
155
+ else
156
+ break
157
+ end
158
+ end
159
+ return nil if i.zero? || i == len
54
160
 
55
- col = match[1].chars.reduce(0) { |acc, c| (acc * 26) + (c.ord - "A".ord + 1) } - 1
56
- row = match[2].to_i - 1
57
- [row, col]
161
+ row = bytes.byteslice(i, len - i).to_i - 1
162
+ [row, col - 1]
58
163
  end
59
164
 
60
165
  def self.validate(row_index, column_index, value)
61
166
  if row_index.is_a?(Integer) && row_index >= 0 && row_index < 1_048_576 &&
62
167
  column_index.is_a?(Integer) && column_index >= 0 && column_index < 16_384 &&
63
- (value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Date) || value.is_a?(Time))
64
- return [].freeze
168
+ (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))
169
+ return EMPTY_ERRORS
65
170
  end
66
171
 
67
172
  errs = []
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents column formatting in a worksheet.
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents a single row in a worksheet.
6
8
  # index is 0-based.
7
9
  Row = Data.define(:index, :cells, :height, :hidden, :custom_height, :outline_level, :unmapped_data, :errors) do
8
- def initialize(index:, cells: [], height: nil, hidden: false, custom_height: false, outline_level: nil,
9
- unmapped_data: {}, errors: nil)
10
+ include Enumerable
11
+
12
+ def initialize(index:, cells: EMPTY_CELLS, height: nil, hidden: false, custom_height: false, outline_level: nil,
13
+ unmapped_data: EMPTY_HASH, errors: nil)
10
14
  computed_errors = errors || self.class.validate(index, cells)
11
15
  computed_errors = computed_errors.freeze unless computed_errors.frozen?
12
16
  cells = cells.freeze unless cells.frozen?
@@ -15,6 +19,46 @@ module Xlsxrb
15
19
  unmapped_data: unmapped_data, errors: computed_errors)
16
20
  end
17
21
 
22
+ def [](col_index)
23
+ case col_index
24
+ when Symbol
25
+ case col_index
26
+ when :cells then cells
27
+ when :index then index
28
+ when :height then height
29
+ when :hidden then hidden
30
+ when :custom_height then custom_height
31
+ when :outline_level then outline_level
32
+ when :attrs then { height: height, hidden: hidden, custom_height: custom_height, outline_level: outline_level }
33
+ end
34
+ else
35
+ cells[col_index]
36
+ end
37
+ end
38
+
39
+ def each(&)
40
+ return to_enum(:each) unless block_given?
41
+
42
+ cells.each(&)
43
+ end
44
+
45
+ def each_cell(&)
46
+ return to_enum(:each_cell) unless block_given?
47
+
48
+ cells.each(&)
49
+ end
50
+
51
+ def to_a
52
+ return [] if cells.empty?
53
+
54
+ max_col = cells.map(&:column_index).max
55
+ arr = Array.new(max_col + 1)
56
+ cells.each do |cell|
57
+ arr[cell.column_index] = cell.value
58
+ end
59
+ arr
60
+ end
61
+
18
62
  def valid?
19
63
  errors.empty?
20
64
  end
@@ -35,10 +79,14 @@ module Xlsxrb
35
79
  end
36
80
 
37
81
  def self.validate(index, cells)
38
- return [].freeze if index.is_a?(Integer) && index >= 0 && cells.is_a?(Array)
82
+ return EMPTY_ERRORS if index.is_a?(Integer) && index >= 0 && index < 1_048_576 && cells.is_a?(Array)
39
83
 
40
84
  errs = []
41
- errs << "index must be a non-negative Integer (got #{index.inspect})" if !index.is_a?(Integer) || index.negative?
85
+ if !index.is_a?(Integer) || index.negative?
86
+ errs << "index must be a non-negative Integer (got #{index.inspect})"
87
+ elsif index >= 1_048_576
88
+ errs << "index must be < 1048576 (got #{index}, max row is 1048576)"
89
+ end
42
90
  errs << "cells must be an Array (got #{cells.class})" unless cells.is_a?(Array)
43
91
  errs
44
92
  end
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
7
+ EMPTY_ERRORS = [].freeze
8
+ EMPTY_HASH = {}.freeze
9
+ EMPTY_CELLS = [].freeze
10
+
5
11
  # Represents a formula with an optional cached value.
6
12
  # Optional: type (:shared, :array), ref (range), shared_index (si for shared formulas)
7
13
  Formula = Data.define(:expression, :cached_value, :type, :ref, :shared_index, :calculate_always, :aca, :bx, :dt2d, :dtr, :r1, :r2) do
@@ -1,15 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents an entire XLSX workbook.
6
8
  Workbook = Data.define(:sheets, :shared_strings, :styles, :unmapped_data, :errors) do
9
+ include Enumerable
10
+
7
11
  def initialize(sheets: [], shared_strings: [], styles: {}, unmapped_data: {}, errors: nil)
8
12
  computed_errors = errors || self.class.validate(sheets)
9
13
  super(sheets: sheets.freeze, shared_strings: shared_strings.freeze, styles: styles,
10
14
  unmapped_data: unmapped_data, errors: computed_errors.freeze)
11
15
  end
12
16
 
17
+ def each(&)
18
+ sheets.each(&)
19
+ end
20
+
13
21
  def valid?
14
22
  errors.empty?
15
23
  end
@@ -23,12 +31,33 @@ module Xlsxrb
23
31
  sheets.find { |s| s.name == identifier }
24
32
  end
25
33
  end
34
+ alias_method :[], :sheet
35
+
36
+ # Returns a new Workbook with the specified sheet updated.
37
+ # If a block is given, it yields the matched sheet and expects a new Worksheet back.
38
+ def update_sheet(identifier)
39
+ raise ArgumentError, "block is required" unless block_given?
40
+
41
+ sheet_to_update = sheet(identifier)
42
+ raise ArgumentError, "sheet not found: #{identifier}" unless sheet_to_update
43
+
44
+ new_sheet = yield sheet_to_update
45
+ raise TypeError, "block must return a Worksheet" unless new_sheet.is_a?(Worksheet)
46
+
47
+ new_sheets = sheets.map { |s| s == sheet_to_update ? new_sheet : s }
48
+ with(sheets: new_sheets)
49
+ end
26
50
 
27
51
  # Returns sheet names.
28
52
  def sheet_names
29
53
  sheets.map(&:name)
30
54
  end
31
55
 
56
+ # Save the workbook to a file.
57
+ def save(filepath)
58
+ Xlsxrb.write(filepath, self)
59
+ end
60
+
32
61
  def self.validate(sheets)
33
62
  errs = []
34
63
  errs << "sheets must be an Array (got #{sheets.class})" unless sheets.is_a?(Array)
@@ -1,15 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents a single worksheet in a workbook.
6
8
  Worksheet = Data.define(:name, :rows, :columns, :charts, :unmapped_data, :errors) do
9
+ include Enumerable
10
+
7
11
  def initialize(name:, rows: [], columns: [], charts: [], unmapped_data: {}, errors: nil)
8
12
  computed_errors = errors || self.class.validate(name, rows)
9
13
  super(name: name, rows: rows.freeze, columns: columns.freeze, charts: charts.freeze,
10
14
  unmapped_data: unmapped_data, errors: computed_errors.freeze)
11
15
  end
12
16
 
17
+ def cells_hash
18
+ h = {}
19
+ rows.each do |r|
20
+ r.cells.each do |c|
21
+ ref = "#{Cell.column_letter(c.column_index)}#{c.row_index + 1}"
22
+ h[ref] = c
23
+ end
24
+ end
25
+ h
26
+ end
27
+
28
+ def cells
29
+ # Ensure ordered traversal
30
+ cells_hash.values.sort_by { |c| [c.row_index, c.column_index] }
31
+ end
32
+
33
+ def [](ref)
34
+ cells_hash[ref.to_s.upcase]
35
+ end
36
+
37
+ def each(&)
38
+ return to_enum(:each) unless block_given?
39
+
40
+ cells.each(&)
41
+ end
42
+
43
+ def each_cell(&)
44
+ return to_enum(:each_cell) unless block_given?
45
+
46
+ cells.each(&)
47
+ end
48
+
49
+ def each_row(&)
50
+ return to_enum(:each_row) unless block_given?
51
+
52
+ rows.each(&)
53
+ end
54
+
13
55
  def valid?
14
56
  errors.empty?
15
57
  end
@@ -19,6 +61,14 @@ module Xlsxrb
19
61
  rows.find { |r| r.index == index }
20
62
  end
21
63
 
64
+ def first_row
65
+ rows.min_by(&:index)
66
+ end
67
+
68
+ def last_row
69
+ rows.max_by(&:index)
70
+ end
71
+
22
72
  # Returns cell value at Excel-style reference (e.g. "A1").
23
73
  def cell_value(ref)
24
74
  parsed = Cell.parse_ref(ref)
@@ -32,9 +82,56 @@ module Xlsxrb
32
82
  cell&.value
33
83
  end
34
84
 
85
+ # Returns a new Worksheet with the specified cell updated.
86
+ #
87
+ # @param ref [String] The cell reference (e.g. "B1").
88
+ # @param value [Object] The new cell value.
89
+ # @param style_index [Integer, String, nil] Optional new style index.
90
+ # @param formula [Elements::Formula, nil] Optional new formula.
91
+ # @return [Worksheet] A new Worksheet instance.
92
+ def update_cell(ref, value: nil, style_index: nil, formula: nil)
93
+ parsed = Cell.parse_ref(ref)
94
+ raise ArgumentError, "invalid cell reference: #{ref}" unless parsed
95
+
96
+ row_idx, col_idx = parsed
97
+ existing_row = row_at(row_idx)
98
+
99
+ if existing_row
100
+ existing_cell = existing_row.cell_at(col_idx)
101
+ new_cell = if existing_cell
102
+ existing_cell.with(
103
+ value: value || existing_cell.value,
104
+ style_index: style_index || existing_cell.style_index,
105
+ formula: formula || existing_cell.formula
106
+ )
107
+ else
108
+ Cell.new(row_index: row_idx, column_index: col_idx, value: value, style_index: style_index, formula: formula)
109
+ end
110
+
111
+ # Replace cell in the existing row
112
+ new_cells = existing_row.cells.reject { |c| c.column_index == col_idx }
113
+ new_cells << new_cell
114
+ new_cells.sort_by!(&:column_index)
115
+
116
+ new_row = existing_row.with(cells: new_cells)
117
+ new_rows = rows.map { |r| r.index == row_idx ? new_row : r }
118
+ else
119
+ # Row doesn't exist, create it
120
+ new_cell = Cell.new(row_index: row_idx, column_index: col_idx, value: value, style_index: style_index, formula: formula)
121
+ new_row = Row.new(index: row_idx, cells: [new_cell])
122
+ new_rows = (rows + [new_row]).sort_by!(&:index)
123
+ end
124
+ with(rows: new_rows)
125
+ end
126
+
35
127
  def self.validate(name, rows)
36
128
  errs = []
37
- errs << "worksheet name must be a non-empty String (got #{name.inspect})" if name.nil? || (name.is_a?(String) && name.empty?)
129
+ if name.nil? || !name.is_a?(String) || name.empty?
130
+ errs << "worksheet name must be a non-empty String (got #{name.inspect})"
131
+ else
132
+ errs << "worksheet name cannot exceed 31 characters (got #{name.size})" if name.size > 31
133
+ errs << "worksheet name cannot contain \\, /, ?, *, [, or ]" if name.match?(%r{[\\/?*\[\]]})
134
+ end
38
135
  errs << "rows must be an Array (got #{rows.class})" unless rows.is_a?(Array)
39
136
  if rows.is_a?(Array)
40
137
  indices = rows.map(&:index)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require_relative "elements/types"
4
6
  require_relative "elements/cell"
5
7
  require_relative "elements/row"
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "zlib"
4
6
  require "rexml/parsers/sax2parser"
5
7
  require "rexml/sax2listener"
@@ -1294,23 +1296,23 @@ module Xlsxrb
1294
1296
  signature_value = signature.unpack1("V")
1295
1297
  break if [0x02014b50, 0x06054b50].include?(signature_value)
1296
1298
 
1297
- raise Error, "invalid ZIP local header signature" unless signature_value == 0x04034b50
1299
+ raise ZipError, "invalid ZIP local header signature" unless signature_value == 0x04034b50
1298
1300
 
1299
1301
  header = file.read(26)
1300
- raise Error, "truncated ZIP local header" if header.nil? || header.bytesize < 26
1302
+ raise ZipError, "truncated ZIP local header" if header.nil? || header.bytesize < 26
1301
1303
 
1302
1304
  _version, flags, compression_method, _mtime, _mdate, _crc32, compressed_size,
1303
1305
  _uncompressed_size, file_name_length, extra_field_length = header.unpack("v v v v v V V V v v")
1304
1306
 
1305
- raise Error, "ZIP data descriptor is not supported" if flags.anybits?(0x0008)
1307
+ raise ZipError, "ZIP data descriptor is not supported" if flags.anybits?(0x0008)
1306
1308
 
1307
1309
  file_name = file.read(file_name_length)
1308
- raise Error, "truncated ZIP file name" if file_name.nil? || file_name.bytesize < file_name_length
1310
+ raise ZipError, "truncated ZIP file name" if file_name.nil? || file_name.bytesize < file_name_length
1309
1311
 
1310
1312
  file.read(extra_field_length)
1311
1313
 
1312
1314
  compressed_data = file.read(compressed_size)
1313
- raise Error, "truncated ZIP entry data" if compressed_data.nil? || compressed_data.bytesize < compressed_size
1315
+ raise ZipError, "truncated ZIP entry data" if compressed_data.nil? || compressed_data.bytesize < compressed_size
1314
1316
 
1315
1317
  next unless file_name == entry_name
1316
1318
 
@@ -1325,7 +1327,7 @@ module Xlsxrb
1325
1327
  inflater.close
1326
1328
  end
1327
1329
  else
1328
- raise Error, "unsupported ZIP compression method: #{compression_method}"
1330
+ raise ZipError, "unsupported ZIP compression method: #{compression_method}"
1329
1331
  end
1330
1332
  end
1331
1333
  end