xlsxrb 0.1.13 → 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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/README.md +2 -0
  4. data/lib/xlsxrb/colors.rb +118 -0
  5. data/lib/xlsxrb/elements/cell.rb +205 -16
  6. data/lib/xlsxrb/elements/column.rb +6 -4
  7. data/lib/xlsxrb/elements/coordinate_access.rb +32 -0
  8. data/lib/xlsxrb/elements/image.rb +128 -0
  9. data/lib/xlsxrb/elements/row.rb +206 -9
  10. data/lib/xlsxrb/elements/styles.rb +157 -0
  11. data/lib/xlsxrb/elements/types.rb +95 -1
  12. data/lib/xlsxrb/elements/workbook.rb +85 -4
  13. data/lib/xlsxrb/elements/worksheet.rb +424 -29
  14. data/lib/xlsxrb/elements.rb +2 -0
  15. data/lib/xlsxrb/number_formatter.rb +429 -0
  16. data/lib/xlsxrb/ooxml/cfb.rb +1 -1
  17. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +7 -5
  18. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +11 -4
  19. data/lib/xlsxrb/ooxml/reader.rb +12 -14
  20. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +7 -1
  21. data/lib/xlsxrb/ooxml/styles_parser.rb +39 -9
  22. data/lib/xlsxrb/ooxml/utils.rb +34 -18
  23. data/lib/xlsxrb/ooxml/workbook_parser.rb +53 -15
  24. data/lib/xlsxrb/ooxml/workbook_writer.rb +65 -14
  25. data/lib/xlsxrb/ooxml/worksheet_parser.rb +319 -79
  26. data/lib/xlsxrb/ooxml/worksheet_writer.rb +206 -61
  27. data/lib/xlsxrb/ooxml/writer/features_xml.rb +4 -2
  28. data/lib/xlsxrb/ooxml/writer.rb +14 -0
  29. data/lib/xlsxrb/ooxml/zip_reader.rb +109 -25
  30. data/lib/xlsxrb/stream_row.rb +243 -16
  31. data/lib/xlsxrb/stream_sheet.rb +414 -15
  32. data/lib/xlsxrb/stream_writer.rb +213 -17
  33. data/lib/xlsxrb/style_builder.rb +308 -22
  34. data/lib/xlsxrb/utils.rb +158 -0
  35. data/lib/xlsxrb/version.rb +1 -1
  36. data/lib/xlsxrb/workbook_builder.rb +73 -3
  37. data/lib/xlsxrb/worksheet_builder.rb +227 -16
  38. data/lib/xlsxrb.rb +578 -79
  39. data/sig/generated/xlsxrb/colors.rbs +40 -0
  40. data/sig/generated/xlsxrb/elements/cell.rbs +113 -8
  41. data/sig/generated/xlsxrb/elements/column.rbs +3 -2
  42. data/sig/generated/xlsxrb/elements/coordinate_access.rbs +18 -0
  43. data/sig/generated/xlsxrb/elements/image.rbs +82 -0
  44. data/sig/generated/xlsxrb/elements/row.rbs +83 -4
  45. data/sig/generated/xlsxrb/elements/styles.rbs +79 -0
  46. data/sig/generated/xlsxrb/elements/types.rbs +43 -0
  47. data/sig/generated/xlsxrb/elements/workbook.rbs +51 -2
  48. data/sig/generated/xlsxrb/elements/worksheet.rbs +231 -18
  49. data/sig/generated/xlsxrb/number_formatter.rbs +132 -0
  50. data/sig/generated/xlsxrb/ooxml/reader.rbs +6 -0
  51. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +2 -0
  52. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -2
  53. data/sig/generated/xlsxrb/ooxml/utils.rbs +17 -8
  54. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +9 -3
  55. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +28 -6
  56. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +3 -1
  57. data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
  58. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +10 -0
  59. data/sig/generated/xlsxrb/stream_row.rbs +100 -6
  60. data/sig/generated/xlsxrb/stream_sheet.rbs +247 -13
  61. data/sig/generated/xlsxrb/stream_writer.rbs +120 -14
  62. data/sig/generated/xlsxrb/style_builder.rbs +55 -18
  63. data/sig/generated/xlsxrb/utils.rbs +119 -0
  64. data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
  65. data/sig/generated/xlsxrb/worksheet_builder.rbs +107 -11
  66. data/sig/generated/xlsxrb.rbs +186 -27
  67. metadata +11 -1
@@ -17,23 +17,105 @@ module Xlsxrb
17
17
  class Worksheet
18
18
  [Enumerable, CoordinateAccess].each { |m| include m }
19
19
 
20
- attr_reader :name, :rows, :columns, :charts, :unmapped_data, :errors
20
+ attr_reader :name, :rows, :columns, :charts, :conditional_formatting, :data_validations, :unmapped_data, :errors, :state, :hyperlinks, :comments, :images, :styles, :date1904, :print_area, :print_titles, :sparkline_groups
21
+ attr_writer :dimension
22
+ alias conditional_formats conditional_formatting
21
23
 
22
24
  # @param name [String] The worksheet name (max 31 characters).
23
25
  # @param rows [Array<Elements::Row>] Rows in the sheet.
24
26
  # @param columns [Array<Elements::Column>] Column definitions.
25
27
  # @param charts [Array<Hash>] Charts in the sheet.
28
+ # @param conditional_formatting [Array<Hash>, nil] Conditional formatting rules.
29
+ # @param data_validations [Array<Hash>] Data validation rules.
26
30
  # @param unmapped_data [Hash] Additional metadata for round-tripping.
27
31
  # @param errors [Array<String>, nil] Validation errors.
28
- #: (name: String?, ?rows: Array[Elements::Row], ?columns: Array[Elements::Column], ?charts: Array[Hash[Symbol, untyped]], ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?) -> void
29
- def initialize(name:, rows: [], columns: [], charts: [], unmapped_data: {}, errors: nil)
32
+ # @param conditional_formats [Array<Hash>, nil] Alias for conditional_formatting.
33
+ # @param state [Symbol] Sheet visibility state (:visible, :hidden, or :very_hidden).
34
+ # @param hyperlinks [Hash{String => Hash}] Hyperlink mappings by cell reference.
35
+ # @param comments [Array<Hash>] Comments and notes in the sheet.
36
+ # @param images [Array<Elements::Image>] Embedded images in the sheet.
37
+ # @param styles [Hash, nil] Optional parsed styles hash.
38
+ # @param dimension [String, nil] Sheet dimension reference string (e.g. "A1:Z50000").
39
+ # @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during iteration.
40
+ # @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
41
+ # @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
42
+ # @param print_area [String, nil] Sheet print area range (e.g. "A1:H50").
43
+ # @param print_titles [Hash, nil] Repeating print title rows/cols (e.g. { rows: "1:2", cols: "A:B" }).
44
+ # @param sparkline_groups [Array<Hash>] Sparkline groups in the sheet.
45
+ #: (name: String?, ?rows: Array[Elements::Row], ?columns: Array[Elements::Column], ?charts: Array[Hash[Symbol, untyped]], ?conditional_formatting: Array[Hash[Symbol, untyped]]?, ?data_validations: Array[Hash[Symbol, untyped]], ?unmapped_data: Hash[untyped, untyped], ?errors: Array[String]?, ?conditional_formats: Array[Hash[Symbol, untyped]]?, ?state: Symbol, ?hyperlinks: Hash[String, Hash[Symbol, untyped]], ?comments: Array[Hash[Symbol, untyped]], ?images: Array[Elements::Image], ?styles: Hash[untyped, untyped]?, ?date1904: bool, ?dimension: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool, ?print_area: String?, ?print_titles: Hash[Symbol, untyped]?, ?sparkline_groups: Array[Hash[Symbol, untyped]]) -> void
46
+ def initialize(name:, rows: [], columns: [], charts: [], conditional_formatting: nil, data_validations: [], unmapped_data: {}, errors: nil, conditional_formats: nil, state: :visible, hyperlinks: {}, comments: [], images: [], styles: nil, date1904: false, dimension: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, print_area: nil, print_titles: nil, sparkline_groups: [])
30
47
  @name = name
31
48
  @rows = (rows || []).freeze
32
49
  @columns = (columns || []).freeze
33
50
  @charts = (charts || []).freeze
51
+ cf = conditional_formatting || conditional_formats || []
52
+ @conditional_formatting = cf.freeze
53
+ @data_validations = (data_validations || []).freeze
34
54
  @unmapped_data = (unmapped_data || {}).freeze
35
55
  computed_errors = errors || self.class.validate(@name, @rows)
36
56
  @errors = computed_errors.freeze
57
+ @state = state ? state.to_sym : :visible
58
+ @hyperlinks = (hyperlinks || {}).freeze
59
+ @comments = (comments || []).freeze
60
+ @images = (images || []).freeze
61
+ @styles = styles
62
+ @date1904 = date1904 ? true : false
63
+ @dimension = dimension
64
+ @print_area = print_area
65
+ @print_titles = print_titles&.freeze
66
+ @sparkline_groups = (sparkline_groups || []).freeze
67
+ @trim_empty_rows = trim_empty_rows ? true : false
68
+ @pad_empty_rows = pad_empty_rows ? true : false
69
+ @pad_empty_cells = pad_empty_cells ? true : false
70
+ end
71
+
72
+ # Returns whether trailing empty rows are omitted during iteration.
73
+ #
74
+ # @return [Boolean]
75
+ # @api public
76
+ #: () -> bool
77
+ def trim_empty_rows?
78
+ @trim_empty_rows ? true : false
79
+ end
80
+ alias trim_empty_rows trim_empty_rows?
81
+
82
+ # Returns whether skipped rows are padded during iteration.
83
+ #
84
+ # @return [Boolean]
85
+ # @api public
86
+ #: () -> bool
87
+ def pad_empty_rows?
88
+ @pad_empty_rows ? true : false
89
+ end
90
+ alias pad_empty_rows pad_empty_rows?
91
+
92
+ # Returns whether missing cells within rows are padded during iteration.
93
+ #
94
+ # @return [Boolean]
95
+ # @api public
96
+ #: () -> bool
97
+ def pad_empty_cells?
98
+ @pad_empty_cells ? true : false
99
+ end
100
+ alias pad_empty_cells pad_empty_cells?
101
+
102
+ # Returns whether the worksheet uses the 1904 date system.
103
+ #
104
+ # @return [Boolean]
105
+ # @api public
106
+ #: () -> bool
107
+ def date1904?
108
+ @date1904
109
+ end
110
+
111
+ # Returns the worksheet dimension reference string (e.g. "A1:Z50000"),
112
+ # or computes it dynamically from rows if not explicitly set.
113
+ #
114
+ # @return [String, nil]
115
+ # @api public
116
+ #: () -> String?
117
+ def dimension
118
+ @dimension ||= compute_dimension
37
119
  end
38
120
 
39
121
  # Iterate over rows in the worksheet.
@@ -43,16 +125,27 @@ module Xlsxrb
43
125
  # puts row.to_a.inspect
44
126
  # end
45
127
  #
46
- # @yield [row]
47
- # @yieldparam row [Elements::Row]
48
- # @return [Enumerator, void]
128
+ # @overload each(trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil, &block)
129
+ # @param trim_empty_rows [Boolean, nil] Whether to omit trailing empty rows.
130
+ # @param pad_empty_rows [Boolean, nil] Whether to yield empty rows for skipped row numbers.
131
+ # @param pad_empty_cells [Boolean, nil] Whether to pad missing cells within rows.
132
+ # @yield [row]
133
+ # @yieldparam row [Elements::Row]
134
+ # @return [void]
135
+ #
136
+ # @overload each(trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil)
137
+ # @param trim_empty_rows [Boolean, nil] Whether to omit trailing empty rows.
138
+ # @param pad_empty_rows [Boolean, nil] Whether to yield empty rows for skipped row numbers.
139
+ # @param pad_empty_cells [Boolean, nil] Whether to pad missing cells within rows.
140
+ # @return [Enumerator<Elements::Row, void>]
141
+ #
49
142
  # @api public
50
- #: () { (Elements::Row) -> void } -> void
51
- #: () -> Enumerator[Elements::Row, void]
52
- def each(&)
53
- return to_enum(:each) unless block_given?
143
+ #: (?trim_empty_rows: bool?, ?pad_empty_rows: bool?, ?pad_empty_cells: bool?) { (Elements::Row) -> void } -> void
144
+ #: (?trim_empty_rows: bool?, ?pad_empty_rows: bool?, ?pad_empty_cells: bool?) -> Enumerator[Elements::Row, void]
145
+ def each(trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil, &)
146
+ return to_enum(:each, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells) unless block_given?
54
147
 
55
- rows.each(&)
148
+ each_row(trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells, &)
56
149
  end
57
150
 
58
151
  # Iterate over rows in the worksheet.
@@ -62,16 +155,104 @@ module Xlsxrb
62
155
  # puts "Row #{row.index}: #{row.to_a.inspect}"
63
156
  # end
64
157
  #
65
- # @yield [row]
66
- # @yieldparam row [Elements::Row]
67
- # @return [Enumerator, void]
158
+ # @overload each_row(trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil, &block)
159
+ # @param trim_empty_rows [Boolean, nil] Whether to omit trailing empty rows.
160
+ # @param pad_empty_rows [Boolean, nil] Whether to yield empty rows for skipped row numbers.
161
+ # @param pad_empty_cells [Boolean, nil] Whether to pad missing cells within rows.
162
+ # @yield [row]
163
+ # @yieldparam row [Elements::Row]
164
+ # @return [void]
165
+ #
166
+ # @overload each_row(trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil)
167
+ # @param trim_empty_rows [Boolean, nil] Whether to omit trailing empty rows.
168
+ # @param pad_empty_rows [Boolean, nil] Whether to yield empty rows for skipped row numbers.
169
+ # @param pad_empty_cells [Boolean, nil] Whether to pad missing cells within rows.
170
+ # @return [Enumerator<Elements::Row, void>]
171
+ #
172
+ # @api public
173
+ #: (?trim_empty_rows: bool?, ?pad_empty_rows: bool?, ?pad_empty_cells: bool?) { (Elements::Row) -> void } -> void
174
+ #: (?trim_empty_rows: bool?, ?pad_empty_rows: bool?, ?pad_empty_cells: bool?) -> Enumerator[Elements::Row, void]
175
+ def each_row(trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil, &block)
176
+ return to_enum(:each_row, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells) unless block
177
+
178
+ trim = if trim_empty_rows.nil?
179
+ @trim_empty_rows
180
+ else
181
+ (trim_empty_rows ? true : false)
182
+ end
183
+ pad_rows = if pad_empty_rows.nil?
184
+ @pad_empty_rows
185
+ else
186
+ (pad_empty_rows ? true : false)
187
+ end
188
+ pad_cells = if pad_empty_cells.nil?
189
+ @pad_empty_cells
190
+ else
191
+ (pad_empty_cells ? true : false)
192
+ end
193
+
194
+ row_consumer = if trim
195
+ pending_empty = []
196
+ lambda do |row|
197
+ if row.empty?
198
+ pending_empty << row
199
+ else
200
+ pending_empty.each(&block)
201
+ pending_empty.clear
202
+ block.call(row)
203
+ end
204
+ end
205
+ else
206
+ block
207
+ end
208
+
209
+ if pad_rows
210
+ current_expected_idx = 0
211
+ inner_consumer = row_consumer
212
+ row_consumer = lambda do |row|
213
+ while current_expected_idx < row.index
214
+ empty_row = Row.new(index: current_expected_idx)
215
+ inner_consumer.call(pad_cells ? empty_row.pad_empty_cells : empty_row)
216
+ current_expected_idx += 1
217
+ end
218
+ inner_consumer.call(row)
219
+ current_expected_idx = row.index + 1
220
+ end
221
+ end
222
+
223
+ rows.each do |row|
224
+ row_to_yield = pad_cells ? row.pad_empty_cells : row
225
+ row_consumer.call(row_to_yield)
226
+ end
227
+ end
228
+
229
+ # Iterates over row values as Arrays.
230
+ #
231
+ # @overload each_row_values(type_cast: false, trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil, &block)
232
+ # @param type_cast [Boolean] Whether to coerce date/time serial numbers into Date/Time instances.
233
+ # @param trim_empty_rows [Boolean, nil] Whether to omit trailing empty rows.
234
+ # @param pad_empty_rows [Boolean, nil] Whether to yield empty rows for skipped row numbers.
235
+ # @param pad_empty_cells [Boolean, nil] Whether to pad missing cells within rows.
236
+ # @yield [values]
237
+ # @yieldparam values [Array<Object>] Row values array.
238
+ # @return [void]
239
+ #
240
+ # @overload each_row_values(type_cast: false, trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil)
241
+ # @param type_cast [Boolean] Whether to coerce date/time serial numbers into Date/Time instances.
242
+ # @param trim_empty_rows [Boolean, nil] Whether to omit trailing empty rows.
243
+ # @param pad_empty_rows [Boolean, nil] Whether to yield empty rows for skipped row numbers.
244
+ # @param pad_empty_cells [Boolean, nil] Whether to pad missing cells within rows.
245
+ # @return [Enumerator<Array<Object>, void>]
246
+ #
68
247
  # @api public
69
- #: () { (Elements::Row) -> void } -> void
70
- #: () -> Enumerator[Elements::Row, void]
71
- def each_row(&)
72
- return to_enum(:each_row) unless block_given?
248
+ #: (?type_cast: bool, ?trim_empty_rows: bool?, ?pad_empty_rows: bool?, ?pad_empty_cells: bool?) { (Array[untyped]) -> void } -> void
249
+ #: (?type_cast: bool, ?trim_empty_rows: bool?, ?pad_empty_rows: bool?, ?pad_empty_cells: bool?) -> Enumerator[Array[untyped], void]
250
+ def each_row_values(type_cast: false, trim_empty_rows: nil, pad_empty_rows: nil, pad_empty_cells: nil, &block)
251
+ return to_enum(:each_row_values, type_cast: type_cast, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells) unless block
73
252
 
74
- rows.each(&)
253
+ each_row(trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells) do |row|
254
+ block.call(row.values(type_cast: type_cast))
255
+ end
75
256
  end
76
257
 
77
258
  # Iterate over all cells across rows.
@@ -88,6 +269,39 @@ module Xlsxrb
88
269
  cells.each(&)
89
270
  end
90
271
 
272
+ # Returns all images anchored at a specific cell reference or 0-based coordinate.
273
+ #
274
+ # @param ref_or_row [String, Symbol, Integer] Cell reference (e.g. "A1") or 0-based row index.
275
+ # @param col [Integer, nil] Optional 0-based column index.
276
+ # @return [Array<Elements::Image>]
277
+ # @api public
278
+ #: (String | Symbol | Integer ref_or_row, ?Integer? col) -> Array[Elements::Image]
279
+ def images_at(ref_or_row, col = nil)
280
+ ref = if col
281
+ "#{Cell.column_letter(col)}#{ref_or_row.to_i + 1}"
282
+ else
283
+ ref_or_row.to_s.upcase
284
+ end
285
+ images.select { |img| img.cell_ref == ref }
286
+ end
287
+
288
+ # Yields self with images loaded or returns self.
289
+ #
290
+ # @yield [sheet]
291
+ # @yieldparam sheet [Elements::Worksheet]
292
+ # @return [Elements::Worksheet, void]
293
+ # @api public
294
+ #: () { (Elements::Worksheet) -> void } -> void
295
+ #: () -> Elements::Worksheet
296
+ def with_images
297
+ if block_given?
298
+ yield self
299
+ nil
300
+ else
301
+ self
302
+ end
303
+ end
304
+
91
305
  # Returns whether the worksheet is valid according to OOXML specifications.
92
306
  #
93
307
  # @return [Boolean]
@@ -96,6 +310,95 @@ module Xlsxrb
96
310
  errors.empty?
97
311
  end
98
312
 
313
+ # Returns whether the worksheet is hidden (:hidden or :very_hidden).
314
+ #
315
+ # @return [Boolean]
316
+ # @api public
317
+ #: () -> bool
318
+ def hidden?
319
+ @state == :hidden || @state == :very_hidden
320
+ end
321
+
322
+ # Returns whether the worksheet is visible.
323
+ #
324
+ # @return [Boolean]
325
+ # @api public
326
+ #: () -> bool
327
+ def visible?
328
+ @state == :visible
329
+ end
330
+
331
+ # Returns hyperlink metadata for the given cell reference, or nil.
332
+ #
333
+ # @param ref_or_row [String, Symbol, Integer] Cell reference (e.g. "A1") or 0-based row index.
334
+ # @param col [Integer, nil] Optional 0-based column index if ref_or_row is a row index.
335
+ # @return [Hash, nil]
336
+ # @api public
337
+ #: (String | Symbol | Integer ref_or_row, ?Integer? col) -> Hash[Symbol, untyped]?
338
+ def hyperlink(ref_or_row, col = nil)
339
+ ref = if col
340
+ "#{Cell.column_letter(col)}#{ref_or_row.to_i + 1}"
341
+ else
342
+ ref_or_row.to_s.upcase
343
+ end
344
+ @hyperlinks[ref]
345
+ end
346
+
347
+ # Returns comment metadata for the given cell reference, or nil.
348
+ #
349
+ # @param ref_or_row [String, Symbol, Integer] Cell reference (e.g. "A1") or 0-based row index.
350
+ # @param col [Integer, nil] Optional 0-based column index if ref_or_row is a row index.
351
+ # @return [Hash, nil]
352
+ # @api public
353
+ #: (String | Symbol | Integer ref_or_row, ?Integer? col) -> Hash[Symbol, untyped]?
354
+ def comment(ref_or_row, col = nil)
355
+ ref = if col
356
+ "#{Cell.column_letter(col)}#{ref_or_row.to_i + 1}"
357
+ else
358
+ ref_or_row.to_s.upcase
359
+ end
360
+ comments_by_ref[ref]
361
+ end
362
+
363
+ # Returns comments indexed by cell reference.
364
+ #
365
+ # @return [Hash<String, Hash[Symbol, untyped]>]
366
+ # @api public
367
+ #: () -> Hash[String, Hash[Symbol, untyped]]
368
+ def comments_by_ref
369
+ @comments_by_ref ||= @comments.each_with_object({}) do |c, acc|
370
+ r = c[:ref] || c[:cell]
371
+ acc[r.to_s.upcase] = c if r
372
+ end
373
+ end
374
+
375
+ # Access a cell by reference or 0-based coordinates.
376
+ #
377
+ # @param ref_or_row [String, Symbol, Integer] Cell reference (e.g. "A1") or 0-based row index.
378
+ # @param col [Integer, nil] Optional 0-based column index.
379
+ # @return [Elements::Cell, nil]
380
+ # @api public
381
+ #: (String | Symbol | Integer ref_or_row, ?Integer? col) -> Elements::Cell?
382
+ def cell(ref_or_row, col = nil)
383
+ if col
384
+ row = row_at(ref_or_row.to_i)
385
+ row&.cell_at(col.to_i)
386
+ else
387
+ self[ref_or_row]
388
+ end
389
+ end
390
+
391
+ # Returns the formatted string representation of a cell's value.
392
+ #
393
+ # @param ref_or_row [String, Symbol, Integer] Cell reference (e.g. "A1") or 0-based row index.
394
+ # @param col [Integer, nil] Optional 0-based column index.
395
+ # @return [String, nil]
396
+ # @api public
397
+ #: (String | Symbol | Integer ref_or_row, ?Integer? col) -> String?
398
+ def formatted_value(ref_or_row, col = nil)
399
+ cell(ref_or_row, col)&.formatted_value
400
+ end
401
+
99
402
  # Returns a new Worksheet with the specified cell updated.
100
403
  #
101
404
  # @example
@@ -105,10 +408,12 @@ module Xlsxrb
105
408
  # @param value [Object] The new cell value.
106
409
  # @param style_index [Integer, String, nil] Optional new style index.
107
410
  # @param formula [Elements::Formula, nil] Optional new formula.
411
+ # @param hyperlink [Hash, String, nil] Optional new hyperlink.
412
+ # @param comment [Hash, String, nil] Optional new comment.
108
413
  # @return [Worksheet] A new Worksheet instance.
109
414
  # @api public
110
- #: (String ref, ?value: untyped, ?style_index: Integer | String | nil, ?formula: Elements::Formula?) -> Elements::Worksheet
111
- def update_cell(ref, value: nil, style_index: nil, formula: nil)
415
+ #: (String ref, ?value: untyped, ?style_index: Integer | String | nil, ?formula: Elements::Formula?, ?hyperlink: (Hash[Symbol, untyped] | String)?, ?comment: (Hash[Symbol, untyped] | String)?) -> Elements::Worksheet
416
+ def update_cell(ref, value: nil, style_index: nil, formula: nil, hyperlink: nil, comment: nil)
112
417
  parsed = Cell.parse_ref(ref)
113
418
  raise ArgumentError, "invalid cell reference: #{ref}" unless parsed
114
419
 
@@ -121,10 +426,12 @@ module Xlsxrb
121
426
  existing_cell.with(
122
427
  value: value || existing_cell.value,
123
428
  style_index: style_index || existing_cell.style_index,
124
- formula: formula || existing_cell.formula
429
+ formula: formula || existing_cell.formula,
430
+ hyperlink: hyperlink || existing_cell.hyperlink,
431
+ comment: comment || existing_cell.comment
125
432
  )
126
433
  else
127
- Cell.new(row_index: row_idx, column_index: col_idx, value: value, style_index: style_index, formula: formula)
434
+ Cell.new(row_index: row_idx, column_index: col_idx, value: value, style_index: style_index, formula: formula, hyperlink: hyperlink, comment: comment)
128
435
  end
129
436
 
130
437
  # Replace cell in the existing row
@@ -136,11 +443,18 @@ module Xlsxrb
136
443
  new_rows = rows.map { |r| r.index == row_idx ? new_row : r }
137
444
  else
138
445
  # Row doesn't exist, create it
139
- new_cell = Cell.new(row_index: row_idx, column_index: col_idx, value: value, style_index: style_index, formula: formula)
446
+ new_cell = Cell.new(row_index: row_idx, column_index: col_idx, value: value, style_index: style_index, formula: formula, hyperlink: hyperlink, comment: comment)
140
447
  new_row = Row.new(index: row_idx, cells: [new_cell])
141
448
  new_rows = (rows + [new_row]).sort_by!(&:index)
142
449
  end
143
- with(rows: new_rows)
450
+ new_hyperlinks = hyperlink ? hyperlinks.merge(ref.upcase => hyperlink) : hyperlinks
451
+ new_comments = if comment
452
+ entry = comment.is_a?(Hash) ? comment.merge(ref: ref.upcase) : { ref: ref.upcase, text: comment }
453
+ @comments.reject { |c| (c[:ref] || c[:cell])&.to_s&.upcase == ref.upcase } + [entry]
454
+ else
455
+ @comments
456
+ end
457
+ with(rows: new_rows, hyperlinks: new_hyperlinks, comments: new_comments)
144
458
  end
145
459
 
146
460
  # Returns a new Worksheet with attributes replaced (Data-like behavior).
@@ -154,23 +468,73 @@ module Xlsxrb
154
468
  new_rows = changes.key?(:rows) ? changes[:rows] : rows
155
469
  new_cols = changes.key?(:columns) ? changes[:columns] : columns
156
470
  new_charts = changes.key?(:charts) ? changes[:charts] : charts
471
+ new_cf = if changes.key?(:conditional_formatting)
472
+ changes[:conditional_formatting]
473
+ elsif changes.key?(:conditional_formats)
474
+ changes[:conditional_formats]
475
+ else
476
+ conditional_formatting
477
+ end
478
+ new_dv = changes.key?(:data_validations) ? changes[:data_validations] : data_validations
157
479
  new_unmapped = changes.key?(:unmapped_data) ? changes[:unmapped_data] : unmapped_data
158
480
  new_errors = changes.key?(:errors) ? changes[:errors] : errors
481
+ new_state = changes.key?(:state) ? changes[:state] : state
482
+ new_hyperlinks = changes.key?(:hyperlinks) ? changes[:hyperlinks] : hyperlinks
483
+ new_comments = changes.key?(:comments) ? changes[:comments] : comments
484
+ new_styles = changes.key?(:styles) ? changes[:styles] : styles
485
+ new_date1904 = changes.key?(:date1904) ? changes[:date1904] : date1904
486
+ new_dim = changes.key?(:dimension) ? changes[:dimension] : @dimension
487
+ new_print_area = changes.key?(:print_area) ? changes[:print_area] : print_area
488
+ new_print_titles = changes.key?(:print_titles) ? changes[:print_titles] : print_titles
489
+ new_sparklines = changes.key?(:sparkline_groups) ? changes[:sparkline_groups] : sparkline_groups
490
+ new_trim = changes.key?(:trim_empty_rows) ? changes[:trim_empty_rows] : @trim_empty_rows
159
491
 
160
492
  self.class.new(
161
493
  name: new_name,
162
494
  rows: new_rows,
163
495
  columns: new_cols,
164
496
  charts: new_charts,
497
+ conditional_formatting: new_cf,
498
+ data_validations: new_dv,
165
499
  unmapped_data: new_unmapped,
166
- errors: new_errors
500
+ errors: new_errors,
501
+ state: new_state,
502
+ hyperlinks: new_hyperlinks,
503
+ comments: new_comments,
504
+ styles: new_styles,
505
+ date1904: new_date1904,
506
+ dimension: new_dim,
507
+ print_area: new_print_area,
508
+ print_titles: new_print_titles,
509
+ sparkline_groups: new_sparklines,
510
+ trim_empty_rows: new_trim
167
511
  )
168
512
  end
169
513
 
170
514
  # Support pattern matching.
171
515
  #: (Array[Symbol]?) -> Hash[Symbol, untyped]
172
516
  def deconstruct_keys(_keys)
173
- { name: name, rows: rows, columns: columns, charts: charts, unmapped_data: unmapped_data, errors: errors }
517
+ {
518
+ name: name,
519
+ rows: rows,
520
+ columns: columns,
521
+ charts: charts,
522
+ conditional_formatting: conditional_formatting,
523
+ conditional_formats: conditional_formatting,
524
+ data_validations: data_validations,
525
+ unmapped_data: unmapped_data,
526
+ errors: errors,
527
+ state: state,
528
+ hyperlinks: hyperlinks,
529
+ comments: comments,
530
+ styles: styles,
531
+ date1904: date1904,
532
+ dimension: dimension,
533
+ print_area: print_area,
534
+ print_titles: print_titles,
535
+ sparkline_groups: sparkline_groups,
536
+ trim_empty_rows: @trim_empty_rows
537
+ }
174
538
  end
175
539
 
176
540
  # Compare worksheets for equality.
@@ -178,13 +542,15 @@ module Xlsxrb
178
542
  def ==(other)
179
543
  return false unless other.is_a?(Worksheet)
180
544
 
181
- name == other.name && rows == other.rows && columns == other.columns && charts == other.charts
545
+ name == other.name && rows == other.rows && columns == other.columns && charts == other.charts &&
546
+ conditional_formatting == other.conditional_formatting && data_validations == other.data_validations &&
547
+ state == other.state && hyperlinks == other.hyperlinks && comments == other.comments
182
548
  end
183
549
  alias eql? ==
184
550
 
185
551
  #: () -> Integer
186
552
  def hash
187
- [self.class, name, rows, columns, charts].hash
553
+ [self.class, name, rows, columns, charts, conditional_formatting, data_validations, state, hyperlinks, comments].hash
188
554
  end
189
555
 
190
556
  # Returns self when load is called on an already in-memory Worksheet.
@@ -237,6 +603,35 @@ module Xlsxrb
237
603
  end
238
604
  errs
239
605
  end
606
+
607
+ private
608
+
609
+ #: () -> String?
610
+ def compute_dimension
611
+ return nil if rows.empty?
612
+
613
+ min_r = nil
614
+ max_r = nil
615
+ min_c = nil
616
+ max_c = nil
617
+
618
+ rows.each do |r|
619
+ next if r.cells.empty?
620
+
621
+ min_r = r.index if min_r.nil? || r.index < min_r
622
+ max_r = r.index if max_r.nil? || r.index > max_r
623
+ r.cells.each do |c|
624
+ min_c = c.column_index if min_c.nil? || c.column_index < min_c
625
+ max_c = c.column_index if max_c.nil? || c.column_index > max_c
626
+ end
627
+ end
628
+
629
+ return nil if min_r.nil? || min_c.nil?
630
+
631
+ start_cell = "#{Elements::Cell.column_letter(min_c)}#{min_r + 1}"
632
+ end_cell = "#{Elements::Cell.column_letter(max_c)}#{max_r + 1}"
633
+ start_cell == end_cell ? start_cell : "#{start_cell}:#{end_cell}"
634
+ end
240
635
  end
241
636
  end
242
637
  end
@@ -9,6 +9,8 @@ require_relative "elements/column"
9
9
  require_relative "elements/coordinate_access"
10
10
  require_relative "elements/worksheet"
11
11
  require_relative "elements/workbook"
12
+ require_relative "elements/styles"
13
+ require_relative "elements/image"
12
14
 
13
15
  module Xlsxrb
14
16
  # High-level domain model layer.