xlsxrb 0.1.12 → 0.1.14

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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -42
  3. data/README.md +28 -22
  4. data/lib/xlsxrb/colors.rb +118 -0
  5. data/lib/xlsxrb/dsl_helpers.rb +59 -0
  6. data/lib/xlsxrb/elements/cell.rb +205 -16
  7. data/lib/xlsxrb/elements/column.rb +6 -4
  8. data/lib/xlsxrb/elements/coordinate_access.rb +32 -0
  9. data/lib/xlsxrb/elements/image.rb +128 -0
  10. data/lib/xlsxrb/elements/row.rb +206 -9
  11. data/lib/xlsxrb/elements/styles.rb +157 -0
  12. data/lib/xlsxrb/elements/types.rb +95 -1
  13. data/lib/xlsxrb/elements/workbook.rb +96 -4
  14. data/lib/xlsxrb/elements/worksheet.rb +424 -29
  15. data/lib/xlsxrb/elements.rb +2 -0
  16. data/lib/xlsxrb/number_formatter.rb +429 -0
  17. data/lib/xlsxrb/ooxml/cfb.rb +1 -1
  18. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +7 -5
  19. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +11 -4
  20. data/lib/xlsxrb/ooxml/reader.rb +12 -14
  21. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +10 -6
  22. data/lib/xlsxrb/ooxml/styles_parser.rb +39 -9
  23. data/lib/xlsxrb/ooxml/utils.rb +34 -18
  24. data/lib/xlsxrb/ooxml/workbook_parser.rb +53 -15
  25. data/lib/xlsxrb/ooxml/workbook_writer.rb +70 -21
  26. data/lib/xlsxrb/ooxml/worksheet_parser.rb +466 -63
  27. data/lib/xlsxrb/ooxml/worksheet_writer.rb +206 -61
  28. data/lib/xlsxrb/ooxml/writer/features_xml.rb +4 -2
  29. data/lib/xlsxrb/ooxml/writer.rb +16 -1
  30. data/lib/xlsxrb/ooxml/xml_builder.rb +20 -0
  31. data/lib/xlsxrb/ooxml/zip_reader.rb +452 -82
  32. data/lib/xlsxrb/ooxml/zip_writer.rb +32 -5
  33. data/lib/xlsxrb/stream_row.rb +243 -16
  34. data/lib/xlsxrb/stream_sheet.rb +504 -17
  35. data/lib/xlsxrb/stream_writer.rb +246 -83
  36. data/lib/xlsxrb/style_builder.rb +308 -22
  37. data/lib/xlsxrb/utils.rb +158 -0
  38. data/lib/xlsxrb/version.rb +1 -1
  39. data/lib/xlsxrb/workbook_builder.rb +74 -4
  40. data/lib/xlsxrb/worksheet_builder.rb +230 -47
  41. data/lib/xlsxrb.rb +1005 -229
  42. data/sig/generated/xlsxrb/colors.rbs +40 -0
  43. data/sig/generated/xlsxrb/dsl_helpers.rbs +32 -0
  44. data/sig/generated/xlsxrb/elements/cell.rbs +113 -8
  45. data/sig/generated/xlsxrb/elements/column.rbs +3 -2
  46. data/sig/generated/xlsxrb/elements/coordinate_access.rbs +18 -0
  47. data/sig/generated/xlsxrb/elements/image.rbs +82 -0
  48. data/sig/generated/xlsxrb/elements/row.rbs +83 -4
  49. data/sig/generated/xlsxrb/elements/styles.rbs +79 -0
  50. data/sig/generated/xlsxrb/elements/types.rbs +43 -0
  51. data/sig/generated/xlsxrb/elements/workbook.rbs +58 -2
  52. data/sig/generated/xlsxrb/elements/worksheet.rbs +231 -18
  53. data/sig/generated/xlsxrb/number_formatter.rbs +132 -0
  54. data/sig/generated/xlsxrb/ooxml/reader.rbs +6 -0
  55. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +2 -2
  56. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -2
  57. data/sig/generated/xlsxrb/ooxml/utils.rbs +17 -8
  58. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +9 -3
  59. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +31 -8
  60. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +3 -1
  61. data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
  62. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +11 -0
  63. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +106 -8
  64. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +5 -1
  65. data/sig/generated/xlsxrb/stream_row.rbs +100 -6
  66. data/sig/generated/xlsxrb/stream_sheet.rbs +288 -14
  67. data/sig/generated/xlsxrb/stream_writer.rbs +120 -14
  68. data/sig/generated/xlsxrb/style_builder.rbs +55 -18
  69. data/sig/generated/xlsxrb/utils.rbs +119 -0
  70. data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
  71. data/sig/generated/xlsxrb/worksheet_builder.rbs +107 -11
  72. data/sig/generated/xlsxrb.rbs +202 -36
  73. metadata +16 -146
  74. data/.devcontainer/Dockerfile +0 -65
  75. data/.devcontainer/devcontainer.json +0 -17
  76. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +0 -9
  77. data/.gem_rbs_collection/ast/2.4/ast.rbs +0 -73
  78. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +0 -9
  79. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +0 -4
  80. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +0 -16
  81. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +0 -96
  82. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +0 -4
  83. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +0 -68
  84. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +0 -249
  85. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +0 -4
  86. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +0 -47
  87. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +0 -5
  88. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +0 -9
  89. data/.gem_rbs_collection/csv/3.3/csv.rbs +0 -3871
  90. data/.gem_rbs_collection/csv/3.3/manifest.yaml +0 -3
  91. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +0 -9
  92. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +0 -48
  93. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +0 -9
  94. data/.gem_rbs_collection/listen/3.9/listen.rbs +0 -25
  95. data/.gem_rbs_collection/listen/3.9/listener.rbs +0 -24
  96. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +0 -9
  97. data/.gem_rbs_collection/logger/1.7/formatter.rbs +0 -45
  98. data/.gem_rbs_collection/logger/1.7/log_device.rbs +0 -100
  99. data/.gem_rbs_collection/logger/1.7/logger.rbs +0 -796
  100. data/.gem_rbs_collection/logger/1.7/manifest.yaml +0 -2
  101. data/.gem_rbs_collection/logger/1.7/period.rbs +0 -17
  102. data/.gem_rbs_collection/logger/1.7/severity.rbs +0 -34
  103. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +0 -9
  104. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +0 -2332
  105. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +0 -4
  106. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +0 -9
  107. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +0 -86
  108. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +0 -9
  109. data/.gem_rbs_collection/parser/3.2/manifest.yaml +0 -7
  110. data/.gem_rbs_collection/parser/3.2/parser.rbs +0 -194
  111. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +0 -4
  112. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +0 -9
  113. data/.gem_rbs_collection/rainbow/3.0/global.rbs +0 -7
  114. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +0 -209
  115. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +0 -5
  116. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +0 -9
  117. data/.gem_rbs_collection/rake/13.0/manifest.yaml +0 -2
  118. data/.gem_rbs_collection/rake/13.0/rake.rbs +0 -39
  119. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +0 -9
  120. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +0 -17
  121. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +0 -9
  122. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +0 -208
  123. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +0 -9
  124. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +0 -903
  125. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +0 -9
  126. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +0 -8
  127. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +0 -42
  128. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +0 -5
  129. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +0 -47
  130. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +0 -30
  131. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +0 -9
  132. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +0 -7
  133. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +0 -19
  134. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +0 -31
  135. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +0 -18
  136. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +0 -12
  137. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +0 -11
  138. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +0 -13
  139. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +0 -95
  140. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +0 -31
  141. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +0 -58
  142. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +0 -24
  143. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +0 -17
  144. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +0 -23
  145. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +0 -22
  146. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +0 -30
  147. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +0 -20
  148. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +0 -15
  149. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +0 -26
  150. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +0 -21
  151. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +0 -131
  152. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +0 -14
  153. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +0 -33
  154. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +0 -21
  155. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +0 -63
  156. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +0 -55
  157. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +0 -35
  158. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +0 -7
  159. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +0 -10
  160. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +0 -22
  161. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +0 -29
  162. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +0 -17
  163. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +0 -13
  164. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +0 -10
  165. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +0 -8
  166. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +0 -6
  167. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +0 -30
  168. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +0 -10
  169. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +0 -10
  170. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +0 -5
  171. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +0 -15
  172. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +0 -3
  173. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +0 -40
  174. data/.mutant.yml +0 -8
  175. data/Rakefile +0 -633
  176. data/Steepfile +0 -18
  177. data/benchmark.rb +0 -406
  178. data/docs/ARCHITECTURE.md +0 -514
  179. data/docs/DEVELOPMENT.md +0 -137
  180. data/docs/MUTATION_TESTING.md +0 -52
  181. data/docs/PEER_LIBRARIES.md +0 -121
  182. data/docs/QUALITY_ASSURANCE.md +0 -26
  183. data/docs/SPEC_SOURCES.md +0 -48
  184. data/docs/assets/benchmark_results.svg +0 -144
  185. data/docs/assets/lsp_autocompletion.png +0 -0
  186. data/docs/assets/playground_preview.png +0 -0
  187. data/docs/coi-serviceworker.js +0 -82
  188. data/docs/office_thread.js +0 -77
  189. data/docs/preview.html +0 -719
  190. data/docs/visual/VisualGallery.md +0 -4683
  191. data/docs/wasm/ruby.wasm +0 -0
  192. data/docs/wasm/wasm_doc_helper.css +0 -351
  193. data/docs/wasm/wasm_doc_helper.js +0 -439
  194. data/docs/zeta.js +0 -1107
  195. data/rbs_collection.lock.yaml +0 -252
  196. data/rbs_collection.yaml +0 -19
  197. data/vendor/sdk_runner/Program.cs +0 -93
  198. data/vendor/sdk_runner/sdk_runner.csproj +0 -13
@@ -14,7 +14,7 @@ module Xlsxrb
14
14
  #
15
15
  # @api public
16
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)
17
+ class Row < Data.define(:index, :cells, :height, :hidden, :custom_height, :collapsed, :outline_level, :unmapped_data, :errors)
18
18
  # rubocop:enable Style/DataInheritance
19
19
  [Enumerable].each { |m| include m }
20
20
 
@@ -23,17 +23,18 @@ module Xlsxrb
23
23
  # @param height [Float, Integer, nil] Row height in points.
24
24
  # @param hidden [Boolean] Whether the row is hidden.
25
25
  # @param custom_height [Boolean] Whether custom height is set.
26
+ # @param collapsed [Boolean] Whether the row is collapsed.
26
27
  # @param outline_level [Integer, nil] Grouping/outline level.
27
28
  # @param unmapped_data [Hash] Additional metadata.
28
29
  # @param errors [Array<String>, nil] Validation errors.
29
- #: (index: Integer, ?cells: Array[Elements::Cell], ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?outline_level: Integer | nil, ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
30
- def initialize(index:, cells: EMPTY_CELLS, height: nil, hidden: false, custom_height: false, outline_level: nil,
31
- unmapped_data: EMPTY_HASH, errors: nil)
30
+ #: (index: Integer, ?cells: Array[Elements::Cell], ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?collapsed: bool, ?outline_level: Integer | nil, ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
31
+ def initialize(index:, cells: EMPTY_CELLS, height: nil, hidden: false, custom_height: false, collapsed: false,
32
+ outline_level: nil, unmapped_data: EMPTY_HASH, errors: nil)
32
33
  computed_errors = errors || self.class.validate(index, cells)
33
34
  computed_errors = computed_errors.freeze unless computed_errors.frozen?
34
35
  cells = cells.freeze unless cells.frozen?
35
36
  super(index: index, cells: cells, height: height, hidden: hidden,
36
- custom_height: custom_height, outline_level: outline_level,
37
+ custom_height: custom_height, collapsed: collapsed, outline_level: outline_level,
37
38
  unmapped_data: unmapped_data, errors: computed_errors)
38
39
  end
39
40
 
@@ -53,18 +54,144 @@ module Xlsxrb
53
54
  when Symbol
54
55
  case col_index
55
56
  when :cells then cells
57
+ when :values then values
58
+ when :raw_values then raw_values
59
+ when :formatted_values then formatted_values
60
+ when :formulas then formulas
56
61
  when :index then index
57
62
  when :height then height
58
63
  when :hidden then hidden
59
64
  when :custom_height then custom_height
65
+ when :collapsed then collapsed
60
66
  when :outline_level then outline_level
61
- when :attrs then { height: height, hidden: hidden, custom_height: custom_height, outline_level: outline_level }
67
+ when :style_index then style_index
68
+ when :hidden? then hidden?
69
+ when :collapsed? then collapsed?
70
+ when :custom_height? then custom_height?
71
+ when :attributes then attributes
72
+ when :cells_hash then cells_hash
73
+ when :attrs
74
+ h = { height: height, hidden: hidden, custom_height: custom_height, collapsed: collapsed, outline_level: outline_level }
75
+ h[:style_index] = style_index if style_index
76
+ h
62
77
  end
63
78
  else
64
79
  cells[col_index]
65
80
  end
66
81
  end
67
82
 
83
+ # Returns a new Row with missing cells padded up to the maximum column index.
84
+ #
85
+ # @return [Elements::Row]
86
+ # @api public
87
+ #: () -> Elements::Row
88
+ def pad_empty_cells
89
+ return self if cells.empty?
90
+
91
+ max_col = cells.map(&:column_index).max || 0
92
+ return self if cells.size == max_col + 1
93
+
94
+ is_d1904 = cells.first ? cells.first.date1904? : false
95
+ cell_map = {}
96
+ cells.each { |c| cell_map[c.column_index] = c }
97
+ padded = Array.new(max_col + 1)
98
+ (0..max_col).each do |c_idx|
99
+ padded[c_idx] = cell_map[c_idx] || Cell.fast_create(index, c_idx, nil, nil, nil, nil, nil, is_d1904)
100
+ end
101
+ Row.new(
102
+ index: index,
103
+ cells: padded,
104
+ height: height,
105
+ hidden: hidden,
106
+ custom_height: custom_height,
107
+ collapsed: collapsed,
108
+ outline_level: outline_level,
109
+ unmapped_data: unmapped_data,
110
+ errors: errors
111
+ )
112
+ end
113
+
114
+ # Returns a coordinate-keyed Hash of cell values (e.g. {"A1" => "value", "B1" => 123}).
115
+ #
116
+ # @param type_cast [Boolean] Whether to coerce date/time serial numbers into Date/Time instances.
117
+ # @return [Hash{String => Object}]
118
+ # @api public
119
+ #: (?type_cast: bool) -> Hash[String, untyped]
120
+ def cells_hash(type_cast: false)
121
+ result = {}
122
+ cells.each do |c|
123
+ val = c.value
124
+ if type_cast && val.is_a?(Numeric) && c.format_code
125
+ fmt_type = NumberFormatter.format_type(c.format_code)
126
+ begin
127
+ case fmt_type
128
+ when :date
129
+ val = Ooxml::Utils.serial_to_date(val, date1904: c.date1904?)
130
+ when :datetime, :time
131
+ val = Ooxml::Utils.serial_to_datetime(val, date1904: c.date1904?)
132
+ end
133
+ rescue StandardError
134
+ # Keep val numeric on failure
135
+ end
136
+ end
137
+ result[c.ref] = val
138
+ end
139
+ result
140
+ end
141
+
142
+ # Returns whether the row is hidden.
143
+ #
144
+ # @return [Boolean]
145
+ # @api public
146
+ #: () -> bool
147
+ def hidden?
148
+ hidden == true
149
+ end
150
+
151
+ # Returns whether the row is collapsed.
152
+ #
153
+ # @return [Boolean]
154
+ # @api public
155
+ #: () -> bool
156
+ def collapsed?
157
+ collapsed == true
158
+ end
159
+
160
+ # Returns whether custom height is set.
161
+ #
162
+ # @return [Boolean]
163
+ # @api public
164
+ #: () -> bool
165
+ def custom_height?
166
+ custom_height == true
167
+ end
168
+
169
+ # Returns a frozen Hash of row attributes.
170
+ #
171
+ # @return [Hash{Symbol => Object}]
172
+ # @api public
173
+ #: () -> Hash[Symbol, untyped]
174
+ def attributes
175
+ h = {
176
+ index: index,
177
+ height: height,
178
+ hidden: hidden,
179
+ custom_height: custom_height,
180
+ collapsed: collapsed,
181
+ outline_level: outline_level
182
+ }
183
+ h[:style_index] = style_index if style_index
184
+ h.freeze
185
+ end
186
+
187
+ # Returns style_index if present in unmapped_data.
188
+ #
189
+ # @return [Integer, nil]
190
+ #: () -> Integer?
191
+ def style_index
192
+ unmapped_data[:style_index]
193
+ end
194
+
68
195
  # Iterate over cells in this row.
69
196
  #
70
197
  # @example
@@ -130,6 +257,17 @@ module Xlsxrb
130
257
  errors.empty?
131
258
  end
132
259
 
260
+ # Returns whether the row contains no cell values (or all cell values are nil or empty strings).
261
+ #
262
+ # @return [Boolean]
263
+ # @api public
264
+ #: () -> bool
265
+ def empty?
266
+ return true if cells.empty?
267
+
268
+ cells.all? { |c| c.value.nil? || (c.value.is_a?(String) && c.value.empty?) }
269
+ end
270
+
133
271
  # Returns the cell at the given 0-based column index, or nil.
134
272
  #
135
273
  # @param column_index [Integer] 0-based column index.
@@ -142,15 +280,74 @@ module Xlsxrb
142
280
 
143
281
  # Returns cell values as an Array (sparse columns get nil).
144
282
  #
283
+ # @param type_cast [Boolean] Whether to coerce date/time serial numbers into Date/Time instances.
145
284
  # @return [Array<Object>]
146
285
  # @api public
147
- #: () -> Array[untyped]
148
- def values
286
+ #: (?type_cast: bool) -> Array[untyped]
287
+ def values(type_cast: false)
288
+ return [] if cells.empty?
289
+
290
+ max_col = cells.max_by(&:column_index)&.column_index || 0
291
+ result = Array.new(max_col + 1)
292
+ cells.each do |c|
293
+ val = c.value
294
+ if type_cast && val.is_a?(Numeric) && c.format_code
295
+ fmt_type = NumberFormatter.format_type(c.format_code)
296
+ begin
297
+ case fmt_type
298
+ when :date
299
+ val = Ooxml::Utils.serial_to_date(val, date1904: c.date1904?)
300
+ when :datetime, :time
301
+ val = Ooxml::Utils.serial_to_datetime(val, date1904: c.date1904?)
302
+ end
303
+ rescue StandardError
304
+ # Keep val numeric on failure
305
+ end
306
+ end
307
+ result[c.column_index] = val
308
+ end
309
+ result
310
+ end
311
+
312
+ # Returns unparsed raw cell values as an Array (sparse columns get nil).
313
+ #
314
+ # @return [Array<String, nil>]
315
+ # @api public
316
+ #: () -> Array[String?]
317
+ def raw_values
318
+ return [] if cells.empty?
319
+
320
+ max_col = cells.max_by(&:column_index).column_index
321
+ result = Array.new(max_col + 1)
322
+ cells.each { |c| result[c.column_index] = c.raw_value }
323
+ result
324
+ end
325
+
326
+ # Returns formatted string representations of cell values as an Array (sparse columns get nil).
327
+ #
328
+ # @return [Array<String, nil>]
329
+ # @api public
330
+ #: () -> Array[String?]
331
+ def formatted_values
332
+ return [] if cells.empty?
333
+
334
+ max_col = cells.max_by(&:column_index).column_index
335
+ result = Array.new(max_col + 1)
336
+ cells.each { |c| result[c.column_index] = c.formatted_value }
337
+ result
338
+ end
339
+
340
+ # Returns formula expressions without leading '=' as an Array (sparse columns get nil).
341
+ #
342
+ # @return [Array<String, nil>]
343
+ # @api public
344
+ #: () -> Array[String?]
345
+ def formulas
149
346
  return [] if cells.empty?
150
347
 
151
348
  max_col = cells.max_by(&:column_index).column_index
152
349
  result = Array.new(max_col + 1)
153
- cells.each { |c| result[c.column_index] = c.value }
350
+ cells.each { |c| result[c.column_index] = c.formula_expression }
154
351
  result
155
352
  end
156
353
 
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require_relative "../number_formatter"
6
+ require_relative "../ooxml/utils"
7
+
8
+ module Xlsxrb
9
+ module Elements
10
+ # Represents the workbook stylesheet definitions (fonts, fills, borders, cellXfs, and numFmts).
11
+ # Inherits from Hash for backward compatibility with existing styles hash structures,
12
+ # while providing precomputed O(1) number format classifications.
13
+ #
14
+ # @api public
15
+ class Styles < Hash #[untyped, untyped]
16
+ # Initializes the styles container, optionally wrapping an existing styles hash.
17
+ #
18
+ # @param initial_hash [Hash, nil] Initial styles dictionary.
19
+ #: (?Hash[untyped, untyped]? initial_hash) -> void
20
+ def initialize(initial_hash = nil)
21
+ super()
22
+ merge!(initial_hash) if initial_hash.is_a?(Hash)
23
+ @format_codes = nil
24
+ @format_types = nil
25
+ @date_flags = nil
26
+ @datetime_flags = nil
27
+ @time_flags = nil
28
+ end
29
+
30
+ # Returns the format code string for a given style index.
31
+ #
32
+ # @param style_index [Integer, nil]
33
+ # @return [String, nil]
34
+ # @api public
35
+ #: (Integer? style_index) -> String?
36
+ def number_format(style_index)
37
+ return nil unless style_index.is_a?(Integer) && style_index >= 0
38
+
39
+ precompute! unless @format_codes
40
+ @format_codes[style_index]
41
+ end
42
+ alias format_code number_format
43
+ alias format_code_for number_format
44
+
45
+ # Returns the classified format category (:date, :datetime, :time, :number, :text, :general) for a style index.
46
+ #
47
+ # @param style_index [Integer, nil]
48
+ # @return [Symbol, nil]
49
+ # @api public
50
+ #: (Integer? style_index) -> Symbol?
51
+ def format_type(style_index)
52
+ return nil unless style_index.is_a?(Integer) && style_index >= 0
53
+
54
+ precompute! unless @format_types
55
+ @format_types[style_index]
56
+ end
57
+ alias classification_for format_type
58
+
59
+ # Returns whether the style index represents a date, datetime, or time pattern.
60
+ #
61
+ # @param style_index [Integer, nil]
62
+ # @return [Boolean]
63
+ # @api public
64
+ #: (Integer? style_index) -> bool
65
+ def date_format?(style_index)
66
+ return false unless style_index.is_a?(Integer) && style_index >= 0
67
+
68
+ precompute! unless @date_flags
69
+ @date_flags[style_index] || false
70
+ end
71
+
72
+ # Returns whether the style index represents a datetime pattern.
73
+ #
74
+ # @param style_index [Integer, nil]
75
+ # @return [Boolean]
76
+ # @api public
77
+ #: (Integer? style_index) -> bool
78
+ def datetime_format?(style_index)
79
+ return false unless style_index.is_a?(Integer) && style_index >= 0
80
+
81
+ precompute! unless @datetime_flags
82
+ @datetime_flags[style_index] || false
83
+ end
84
+
85
+ # Returns whether the style index represents a time pattern.
86
+ #
87
+ # @param style_index [Integer, nil]
88
+ # @return [Boolean]
89
+ # @api public
90
+ #: (Integer? style_index) -> bool
91
+ def time_format?(style_index)
92
+ return false unless style_index.is_a?(Integer) && style_index >= 0
93
+
94
+ precompute! unless @time_flags
95
+ @time_flags[style_index] || false
96
+ end
97
+
98
+ # Returns whether the style index represents a date-only pattern.
99
+ #
100
+ # @param style_index [Integer, nil]
101
+ # @return [Boolean]
102
+ # @api public
103
+ #: (Integer? style_index) -> bool
104
+ def date_only_format?(style_index)
105
+ format_type(style_index) == :date
106
+ end
107
+
108
+ # Precomputes format code and type classification lookups for all cellXfs.
109
+ #
110
+ # @return [self]
111
+ # @api public
112
+ #: () -> self
113
+ def precompute!
114
+ cell_xfs = self[:cell_xfs]
115
+ size = cell_xfs.is_a?(Array) ? cell_xfs.size : 0
116
+
117
+ codes = Array.new(size)
118
+ types = Array.new(size)
119
+ d_flags = Array.new(size, false)
120
+ dt_flags = Array.new(size, false)
121
+ t_flags = Array.new(size, false)
122
+
123
+ if size.positive?
124
+ cell_style_xfs = self[:cell_style_xfs]
125
+ num_fmts = self[:num_fmts] || {}
126
+
127
+ size.times do |i|
128
+ xf = cell_xfs[i]
129
+ next unless xf.is_a?(Hash)
130
+
131
+ num_fmt_id = xf[:num_fmt_id]
132
+ if (num_fmt_id.nil? || num_fmt_id.zero?) && xf[:xf_id] && cell_style_xfs.is_a?(Array)
133
+ parent_xf = cell_style_xfs[xf[:xf_id]]
134
+ num_fmt_id = parent_xf[:num_fmt_id] if parent_xf.is_a?(Hash)
135
+ end
136
+
137
+ code = num_fmt_id ? (num_fmts[num_fmt_id] || Ooxml::Utils::BUILTIN_NUM_FMT_CODES[num_fmt_id]) : nil
138
+
139
+ codes[i] = code
140
+ type = NumberFormatter.format_type(code)
141
+ types[i] = type
142
+ d_flags[i] = %i[date datetime time].include?(type)
143
+ dt_flags[i] = (type == :datetime)
144
+ t_flags[i] = (type == :time)
145
+ end
146
+ end
147
+
148
+ @format_codes = codes
149
+ @format_types = types
150
+ @date_flags = d_flags
151
+ @datetime_flags = dt_flags
152
+ @time_flags = t_flags
153
+ self
154
+ end
155
+ end
156
+ end
157
+ end
@@ -53,6 +53,43 @@ module Xlsxrb
53
53
  end
54
54
  end
55
55
 
56
+ # Represents a formatted text run within a rich text string.
57
+ #
58
+ # @example
59
+ # run = Elements::RichTextRun.new(text: "Hello", font: { bold: true })
60
+ #
61
+ # @api public
62
+ RichTextRun = Data.define(:text, :font) do
63
+ # @param text [String] Plain text for this run.
64
+ # @param font [Hash, nil] Optional font styling properties.
65
+ #: (?text: String, ?font: Hash[Symbol, untyped]?) -> void
66
+ def initialize(text: "", font: nil)
67
+ super
68
+ end
69
+
70
+ # Supports hash-like key access for backwards compatibility.
71
+ #
72
+ # @param key [Symbol]
73
+ # @return [Object, nil]
74
+ # @api public
75
+ #: (Symbol key) -> untyped
76
+ def [](key)
77
+ case key
78
+ when :text then text
79
+ when :font then font
80
+ end
81
+ end
82
+
83
+ # Returns the plain text of this run.
84
+ #
85
+ # @return [String]
86
+ # @api public
87
+ #: () -> String
88
+ def to_s
89
+ text
90
+ end
91
+ end
92
+
56
93
  # Represents a rich text string with multiple formatting runs.
57
94
  #
58
95
  # @example
@@ -66,7 +103,64 @@ module Xlsxrb
66
103
  # @api public
67
104
  #: () -> String
68
105
  def to_s
69
- runs.map { |r| r[:text] }.join
106
+ runs.map { |r| r[:text] || r.to_s }.join
107
+ end
108
+ end
109
+
110
+ # Represents a worksheet hyperlink with an optional target URL, display text, tooltip (ScreenTip), and internal location.
111
+ #
112
+ # @example Create a hyperlink
113
+ # link = Elements::Hyperlink.new(url: "https://example.com", tooltip: "Visit website")
114
+ #
115
+ # @api public
116
+ # rubocop:disable Lint/DataDefineOverride -- :display represents the hyperlink display text attribute in OOXML
117
+ Hyperlink = Data.define(:url, :display, :tooltip, :location) do
118
+ # rubocop:enable Lint/DataDefineOverride
119
+ # @param url [String, nil] Target external URL or URI.
120
+ # @param display [String, nil] Optional friendly display label.
121
+ # @param tooltip [String, nil] Optional hover ScreenTip text.
122
+ # @param location [String, nil] Optional internal sheet/cell reference.
123
+ #: (?url: String?, ?display: String?, ?tooltip: String?, ?location: String?) -> void
124
+ def initialize(url: nil, display: nil, tooltip: nil, location: nil)
125
+ super
126
+ end
127
+
128
+ # Supports hash-like key access for backwards compatibility.
129
+ #
130
+ # @param key [Symbol]
131
+ # @return [Object, nil]
132
+ # @api public
133
+ #: (Symbol key) -> untyped
134
+ def [](key)
135
+ case key
136
+ when :url then url
137
+ when :display then display
138
+ when :tooltip then tooltip
139
+ when :location then location
140
+ end
141
+ end
142
+
143
+ # Supports Hash conversion.
144
+ #
145
+ # @return [Hash{Symbol => String}]
146
+ # @api public
147
+ #: () -> Hash[Symbol, String]
148
+ def to_h
149
+ h = {}
150
+ h[:url] = url if url
151
+ h[:display] = display if display
152
+ h[:tooltip] = tooltip if tooltip
153
+ h[:location] = location if location
154
+ h
155
+ end
156
+
157
+ # Returns the target URL or location string.
158
+ #
159
+ # @return [String, nil]
160
+ # @api public
161
+ #: () -> String?
162
+ def to_s
163
+ url || location
70
164
  end
71
165
  end
72
166
  end
@@ -13,7 +13,7 @@ module Xlsxrb
13
13
  #
14
14
  # @api public
15
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)
16
+ class Workbook < Data.define(:sheets, :shared_strings, :styles, :unmapped_data, :errors, :defined_names)
17
17
  # rubocop:enable Style/DataInheritance
18
18
  [Enumerable].each { |m| include m }
19
19
 
@@ -22,11 +22,19 @@ module Xlsxrb
22
22
  # @param styles [Hash] Styles definition.
23
23
  # @param unmapped_data [Hash] Additional metadata for round-tripping.
24
24
  # @param errors [Array<String>, nil] Validation errors.
25
- #: (?sheets: Array[Elements::Worksheet | StreamSheet], ?shared_strings: Array[String], ?styles: Hash[untyped, untyped], ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
26
- def initialize(sheets: [], shared_strings: [], styles: {}, unmapped_data: {}, errors: nil)
25
+ # @param defined_names [Array<Hash>, nil] Defined names list.
26
+ # @param date1904 [Boolean] Whether the workbook uses the 1904 date system.
27
+ #: (?sheets: Array[Elements::Worksheet | StreamSheet], ?shared_strings: Array[String], ?styles: Hash[untyped, untyped], ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?, ?defined_names: Array[Hash[Symbol, untyped]]?, ?date1904: bool) -> void
28
+ def initialize(sheets: [], shared_strings: [], styles: {}, unmapped_data: {}, errors: nil, defined_names: nil, date1904: false)
29
+ dns = defined_names || unmapped_data[:defined_names] || unmapped_data.dig(:facade, :defined_names) || []
27
30
  computed_errors = errors || self.class.validate(sheets)
31
+ unmapped = unmapped_data
32
+ if date1904 && !unmapped.dig(:workbook_properties, :date1904) && !unmapped.dig(:facade, :workbook_properties, :date1904)
33
+ unmapped = unmapped.dup
34
+ unmapped[:workbook_properties] = (unmapped[:workbook_properties] || {}).merge(date1904: true)
35
+ end
28
36
  super(sheets: sheets.freeze, shared_strings: shared_strings.freeze, styles: styles,
29
- unmapped_data: unmapped_data, errors: computed_errors.freeze)
37
+ unmapped_data: unmapped, errors: computed_errors.freeze, defined_names: dns.freeze)
30
38
  end
31
39
 
32
40
  # Iterate over worksheets.
@@ -54,6 +62,41 @@ module Xlsxrb
54
62
  errors.empty?
55
63
  end
56
64
 
65
+ # Returns whether the workbook uses the 1904 date system.
66
+ #
67
+ # @return [Boolean]
68
+ # @api public
69
+ #: () -> bool
70
+ def date1904?
71
+ props = unmapped_data[:workbook_properties] || unmapped_data.dig(:facade, :workbook_properties)
72
+ props&.[](:date1904) ? true : false
73
+ end
74
+
75
+ # Returns the defined name matching name (and optionally sheet), or nil.
76
+ #
77
+ # @example
78
+ # wb.defined_name("SalesTotal")
79
+ # wb.defined_name("Print_Area", sheet: "Sheet1")
80
+ #
81
+ # @param name [String] The defined name.
82
+ # @param sheet [String, Integer, nil] Optional sheet name or 0-based index.
83
+ # @return [Hash[Symbol, untyped], nil]
84
+ # @api public
85
+ #: (String name, ?sheet: (String | Integer)?) -> Hash[Symbol, untyped]?
86
+ def defined_name(name, sheet: nil)
87
+ sheet_idx = case sheet
88
+ when Integer then sheet
89
+ when String then sheets.find_index { |s| s.name == sheet }
90
+ end
91
+
92
+ if sheet_idx
93
+ defined_names.find { |dn| dn[:name] == name && dn[:local_sheet_id] == sheet_idx }
94
+ else
95
+ defined_names.find { |dn| dn[:name] == name && dn[:local_sheet_id].nil? } ||
96
+ defined_names.find { |dn| dn[:name] == name }
97
+ end
98
+ end
99
+
57
100
  # Returns the worksheet at the given 0-based index or by name.
58
101
  #
59
102
  # @example
@@ -74,6 +117,44 @@ module Xlsxrb
74
117
  end
75
118
  alias [] sheet
76
119
 
120
+ # Returns the formatted string representation of a cell's value in the specified sheet.
121
+ #
122
+ # @param sheet_identifier [Integer, String, untyped] 0-based index or sheet name.
123
+ # @param ref_or_row [String, Symbol, Integer] Cell reference (e.g. "A1") or 0-based row index.
124
+ # @param col [Integer, nil] Optional 0-based column index.
125
+ # @return [String, nil]
126
+ # @api public
127
+ #: (Integer | String | untyped sheet_identifier, String | Symbol | Integer ref_or_row, ?Integer? col) -> String?
128
+ def formatted_value(sheet_identifier, ref_or_row, col = nil)
129
+ target_sheet = sheet(sheet_identifier)
130
+ target_sheet&.formatted_value(ref_or_row, col)
131
+ end
132
+
133
+ # Returns all embedded images across all worksheets.
134
+ #
135
+ # @return [Array<Elements::Image>]
136
+ # @api public
137
+ #: () -> Array[Elements::Image]
138
+ def images
139
+ sheets.flat_map(&:images)
140
+ end
141
+
142
+ # Returns all images anchored at a specific cell in the given worksheet.
143
+ #
144
+ # @param sheet_identifier [Integer, String, untyped] Sheet index or sheet name.
145
+ # @param ref_or_row [String, Symbol, Integer] Cell reference (e.g. "A1") or 0-based row index.
146
+ # @param col [Integer, nil] Optional 0-based column index.
147
+ # @return [Array<Elements::Image>]
148
+ # @api public
149
+ #: (Integer | String | untyped sheet_identifier, String | Symbol | Integer ref_or_row, ?Integer? col) -> Array[Elements::Image]
150
+ def images_at(sheet_identifier, ref_or_row, col = nil)
151
+ ident = sheet_identifier.is_a?(Symbol) ? sheet_identifier.to_s : sheet_identifier
152
+ target_sheet = sheet(ident)
153
+ return [] unless target_sheet
154
+
155
+ target_sheet.images_at(ref_or_row, col)
156
+ end
157
+
77
158
  # Loads all sheets into memory, returning an Elements::Workbook where every
78
159
  # worksheet is a fully-parsed Elements::Worksheet supporting coordinate random access.
79
160
  #
@@ -86,10 +167,21 @@ module Xlsxrb
86
167
  #: () -> Elements::Workbook
87
168
  def load
88
169
  loaded_sheets = sheets.map { |s| s.respond_to?(:load) ? s.load : s }
170
+ close
89
171
  with(sheets: loaded_sheets)
90
172
  end
91
173
  alias to_workbook load
92
174
 
175
+ # Closes any streaming resources associated with worksheets.
176
+ #
177
+ # @return [void]
178
+ # @api public
179
+ #: () -> void
180
+ def close
181
+ sheets.each { |s| s.close if s.respond_to?(:close) }
182
+ nil
183
+ end
184
+
93
185
  # Returns a new Workbook with the specified sheet updated.
94
186
  # Yields the matched worksheet to the block, which must return a new Worksheet.
95
187
  #