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
data/lib/xlsxrb.rb CHANGED
@@ -27,12 +27,15 @@ require_relative "xlsxrb/ooxml"
27
27
  require_relative "xlsxrb/elements"
28
28
  require_relative "xlsxrb/stream_row"
29
29
  require_relative "xlsxrb/stream_sheet"
30
+ require_relative "xlsxrb/colors"
30
31
  require_relative "xlsxrb/style_builder"
31
32
  require_relative "xlsxrb/chart_builder"
32
33
  require_relative "xlsxrb/dsl_helpers"
33
34
  require_relative "xlsxrb/worksheet_builder"
34
35
  require_relative "xlsxrb/workbook_builder"
36
+ require_relative "xlsxrb/number_formatter"
35
37
  require_relative "xlsxrb/stream_writer"
38
+ require_relative "xlsxrb/utils"
36
39
 
37
40
  # Modern, streaming-capable, low-memory XLSX reading and writing library for Ruby.
38
41
  #
@@ -56,6 +59,9 @@ module Xlsxrb
56
59
  # Raised when parsing malformed or invalid XML structures.
57
60
  class ParseError < Error; end
58
61
 
62
+ OMITTED_TARGET = Object.new.freeze
63
+ private_constant :OMITTED_TARGET
64
+
59
65
  # Raised when cell, row, or workbook parameters fail specification validation.
60
66
  class ValidationError < Error; end
61
67
 
@@ -73,23 +79,61 @@ module Xlsxrb
73
79
 
74
80
  # Helper to construct {Elements::RichText} objects with formatted text runs.
75
81
  #
76
- # @example Create multi-run rich text
82
+ # @example Create multi-run rich text with hashes
77
83
  # rt = Xlsxrb.rich_text({ text: "Total: ", font: { bold: true } }, { text: "$100" })
78
84
  #
85
+ # @example Create rich text with text/font pairs
86
+ # rt = Xlsxrb.rich_text("Normal text, ", ["bold text, ", { bold: true }], ["red text", { color: "FF0000" }])
87
+ #
88
+ # @example Create rich text with format objects implementing to_font_hash
89
+ # rt = Xlsxrb.rich_text("Normal text, ", bold_format, "bold text")
90
+ #
79
91
  # @example Create simple styled text
80
92
  # rt = Xlsxrb.rich_text(text: "Important Notice", bold: true, color: "FF0000")
81
93
  #
82
- # @param runs [Array<Hash, String>] Optional array of rich text run hashes or string.
94
+ # @param runs [Array<Hash, String, Array, Elements::RichTextRun, Object>] Optional array of rich text run items.
83
95
  # @param text [String, nil] Plain text string (convenience parameter).
84
96
  # @param font_props [Hash] Inline font styling options (e.g. bold: true, color: "FF0000").
85
97
  # @return [Elements::RichText] The compiled rich text element.
86
98
  # @api public
87
- #: (*(Hash[Symbol, untyped] | String) runs, ?text: String?, **untyped font_props) -> Elements::RichText
99
+ #: (*(Hash[Symbol, untyped] | Elements::RichTextRun | String | Array[untyped] | untyped) runs, ?text: String?, **untyped font_props) -> Elements::RichText
88
100
  def self.rich_text(*runs, text: nil, **font_props)
89
101
  if text
90
- runs = [{ text: text, font: font_props }]
102
+ runs = [Elements::RichTextRun.new(text: text, font: font_props.empty? ? nil : font_props)]
91
103
  elsif runs.size == 1 && runs.first.is_a?(String) && !font_props.empty?
92
- runs = [{ text: runs.first, font: font_props }]
104
+ runs = [Elements::RichTextRun.new(text: runs.first, font: font_props)]
105
+ else
106
+ normalized_runs = []
107
+ pending_font = nil
108
+
109
+ runs.each do |item|
110
+ if item.respond_to?(:to_font_hash)
111
+ pending_font = item.to_font_hash
112
+ elsif item.is_a?(Hash) && !item.key?(:text)
113
+ pending_font = item
114
+ elsif item.is_a?(Elements::RichTextRun)
115
+ normalized_runs << item
116
+ pending_font = nil
117
+ elsif item.is_a?(Hash)
118
+ f = item[:font] || pending_font
119
+ f = f.to_font_hash if f.respond_to?(:to_font_hash)
120
+ normalized_runs << Elements::RichTextRun.new(text: item[:text].to_s, font: f)
121
+ pending_font = nil
122
+ elsif item.is_a?(Array)
123
+ t = item[0].to_s
124
+ f = item[1] || pending_font
125
+ f = f.to_font_hash if f.respond_to?(:to_font_hash)
126
+ f = f.to_h if f.respond_to?(:to_h) && !f.is_a?(Hash)
127
+ normalized_runs << Elements::RichTextRun.new(text: t, font: f.is_a?(Hash) ? f : nil)
128
+ pending_font = nil
129
+ elsif pending_font
130
+ normalized_runs << Elements::RichTextRun.new(text: item.to_s, font: pending_font)
131
+ pending_font = nil
132
+ else
133
+ normalized_runs << Elements::RichTextRun.new(text: item.to_s)
134
+ end
135
+ end
136
+ runs = normalized_runs
93
137
  end
94
138
  Elements::RichText.new(runs: runs)
95
139
  end
@@ -104,35 +148,196 @@ module Xlsxrb
104
148
  #
105
149
  # @param expression [String] The formula expression without leading '=' (e.g. "SUM(A1:A10)").
106
150
  # @param cached_value [Object, nil] Optional precomputed value for readers that do not evaluate formulas.
151
+ # @param type [Symbol, String, nil] Formula type (:shared, :array, etc.).
152
+ # @param ref [String, nil] Target cell or range reference (e.g. "A1:C1").
153
+ # @return [Elements::Formula]
154
+ # @api public
155
+ #: (String expression, ?cached_value: (String | Numeric | bool | nil), ?type: (Symbol | String)?, ?ref: String?) -> Elements::Formula
156
+ def self.formula(expression, cached_value: nil, type: nil, ref: nil)
157
+ Elements::Formula.new(
158
+ expression: expression,
159
+ cached_value: cached_value,
160
+ type: type,
161
+ ref: ref,
162
+ calculate_always: cached_value.nil? || nil
163
+ )
164
+ end
165
+
166
+ # Creates an array or dynamic array {Elements::Formula} object.
167
+ #
168
+ # @example Create a multi-cell array formula
169
+ # formula = Xlsxrb.array_formula("SUM(B1:B10*C1:C10)", "A1:C1", cached_value: 0)
170
+ #
171
+ # @example Create a dynamic array formula
172
+ # formula = Xlsxrb.array_formula("SORT(UNIQUE(A1:A10))", "D1")
173
+ #
174
+ # @param expression [String] The formula expression without leading '='.
175
+ # @param ref [String, nil] Target cell or range reference (e.g. "A1:C1" or "D1").
176
+ # @param cached_value [Object, nil] Optional precomputed value.
107
177
  # @return [Elements::Formula]
108
178
  # @api public
109
- #: (String expression, ?cached_value: (String | Numeric | bool | nil)) -> Elements::Formula
110
- def self.formula(expression, cached_value: nil)
179
+ #: (String expression, ?String? ref, ?cached_value: (String | Numeric | bool | nil)) -> Elements::Formula
180
+ def self.array_formula(expression, ref = nil, cached_value: nil)
111
181
  Elements::Formula.new(
112
182
  expression: expression,
113
183
  cached_value: cached_value,
184
+ type: :array,
185
+ ref: ref,
114
186
  calculate_always: cached_value.nil? || nil
115
187
  )
116
188
  end
117
189
 
190
+ # Creates an {Elements::Hyperlink} object for use in cell hyperlinks.
191
+ #
192
+ # @example Create an external hyperlink with a ScreenTip tooltip
193
+ # link = Xlsxrb.hyperlink("https://example.com", tooltip: "Visit Example")
194
+ #
195
+ # @example Create an internal worksheet link
196
+ # link = Xlsxrb.hyperlink(location: "Sheet2!A1", tooltip: "Go to Sheet 2")
197
+ #
198
+ # @param url [String, nil] External hyperlink URL.
199
+ # @param display [String, nil] Optional display label.
200
+ # @param tooltip [String, nil] Optional hover ScreenTip text.
201
+ # @param location [String, nil] Optional internal sheet coordinate.
202
+ # @return [Elements::Hyperlink]
203
+ # @api public
204
+ #: (?String? url, ?display: String?, ?tooltip: String?, ?location: String?) -> Elements::Hyperlink
205
+ def self.hyperlink(url = nil, display: nil, tooltip: nil, location: nil)
206
+ Elements::Hyperlink.new(url: url, display: display, tooltip: tooltip, location: location)
207
+ end
208
+
209
+ # Formats a cell value according to an Excel format code string.
210
+ #
211
+ # @param value [Object, nil] Value to format.
212
+ # @param format_code [String, nil] Excel format code string.
213
+ # @param date1904 [Boolean] Whether the 1904 date system is active.
214
+ # @return [String] Formatted string representation.
215
+ # @api public
216
+ #: (untyped value, ?String? format_code, ?date1904: bool) -> String
217
+ def self.format(value, format_code = nil, date1904: false)
218
+ NumberFormatter.format(value, format_code, date1904: date1904)
219
+ end
220
+
221
+ # Converts a Date or Time to an Excel numeric serial number (1900 or 1904 system).
222
+ #
223
+ # @example
224
+ # Xlsxrb.date_to_serial(Date.new(2026, 1, 1)) #=> 46023
225
+ #
226
+ # @param date_or_time [Date, Time] Date or Time instance.
227
+ # @param date1904 [Boolean] Whether to use the 1904 date system.
228
+ # @return [Integer, Float] Serial number.
229
+ # @api public
230
+ #: (Date | Time date_or_time, ?date1904: bool) -> (Integer | Float)
231
+ def self.date_to_serial(date_or_time, date1904: false)
232
+ Utils.date_to_serial(date_or_time, date1904: date1904)
233
+ end
234
+
235
+ # Converts an Excel serial number to a Date.
236
+ #
237
+ # @example
238
+ # Xlsxrb.serial_to_date(46023) #=> #<Date: 2026-01-01>
239
+ #
240
+ # @param serial [Numeric] Excel serial number.
241
+ # @param date1904 [Boolean] Whether to use the 1904 date system.
242
+ # @return [Date]
243
+ # @api public
244
+ #: (Numeric serial, ?date1904: bool) -> Date
245
+ def self.serial_to_date(serial, date1904: false)
246
+ Utils.serial_to_date(serial, date1904: date1904)
247
+ end
248
+
249
+ # Converts a Time or Date to a fractional Excel serial number.
250
+ #
251
+ # @example
252
+ # Xlsxrb.datetime_to_serial(Time.utc(2026, 1, 1, 12, 0, 0)) #=> 46023.5
253
+ #
254
+ # @param time [Time, Date] Time or Date instance.
255
+ # @param date1904 [Boolean] Whether to use the 1904 date system.
256
+ # @return [Float] Fractional serial number.
257
+ # @api public
258
+ #: (Time | Date time, ?date1904: bool) -> Float
259
+ def self.datetime_to_serial(time, date1904: false)
260
+ Utils.datetime_to_serial(time, date1904: date1904)
261
+ end
262
+
263
+ # Converts an Excel serial number to a Time (UTC).
264
+ #
265
+ # @example
266
+ # Xlsxrb.serial_to_time(46023.5) #=> 2026-01-01 12:00:00 UTC
267
+ #
268
+ # @param serial [Numeric] Excel serial number.
269
+ # @param date1904 [Boolean] Whether to use the 1904 date system.
270
+ # @return [Time]
271
+ # @api public
272
+ #: (Numeric serial, ?date1904: bool) -> Time
273
+ def self.serial_to_time(serial, date1904: false)
274
+ Utils.serial_to_time(serial, date1904: date1904)
275
+ end
276
+
277
+ class << self
278
+ alias serial_to_datetime serial_to_time
279
+ end
280
+
281
+ # Resolves an ECMA-376 indexed color palette index (0..63) to an RRGGBB hex string (or AARRGGBB if alpha: true).
282
+ #
283
+ # @example
284
+ # Xlsxrb.palette_color(12) #=> "0000FF"
285
+ # Xlsxrb.palette_color(12, alpha: true) #=> "FF0000FF"
286
+ #
287
+ # @param index [Object, nil] Palette index (0..63).
288
+ # @param alpha [Boolean] Whether to prefix "FF" alpha channel.
289
+ # @return [String, nil]
290
+ # @api public
291
+ #: (untyped index, ?alpha: bool) -> String?
292
+ def self.palette_color(index, alpha: false)
293
+ Colors.palette_color(index, alpha: alpha)
294
+ end
295
+
296
+ # Converts any supported color representation (palette index 0..63, CSS name, #RGB, #RRGGBB, #AARRGGBB, integer RGB)
297
+ # to a normalized hex string.
298
+ #
299
+ # @example
300
+ # Xlsxrb.to_hex_color(12) #=> "0000FF"
301
+ # Xlsxrb.to_hex_color(:navy) #=> "000080"
302
+ # Xlsxrb.to_hex_color("#FF0000") #=> "FF0000"
303
+ #
304
+ # @param color [Integer, String, Symbol, nil]
305
+ # @param alpha [Boolean] Whether to include the alpha channel (default false -> "RRGGBB").
306
+ # @return [String, nil]
307
+ # @api public
308
+ #: (Integer | String | Symbol | nil color, ?alpha: bool) -> String?
309
+ def self.to_hex_color(color, alpha: false)
310
+ Colors.to_hex(color, alpha: alpha)
311
+ end
312
+
313
+ class << self
314
+ alias color_to_hex to_hex_color
315
+ end
316
+
118
317
  # Reads an XLSX file (streaming and lazy-loaded by default) from a file path, IO stream, or binary String.
119
318
  #
120
319
  # Sheets and rows are streamed lazily with O(1) constant memory. If a block is given,
121
320
  # yields each {StreamSheet} sequentially. Call {#load} on the returned Workbook or Sheet
122
321
  # to convert to an in-memory representation for coordinate random access (`sheet["A1"]`).
123
322
  #
124
- # @overload read(source, password: nil, &block)
323
+ # @overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &block)
125
324
  # Yields each {StreamSheet} sequentially in streaming mode.
126
325
  # @param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
127
326
  # @param password [String, nil] Optional password to decrypt password-protected XLSX files.
327
+ # @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
328
+ # @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
329
+ # @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
128
330
  # @yield [sheet]
129
331
  # @yieldparam sheet [StreamSheet] The streaming worksheet object.
130
332
  # @return [void]
131
333
  #
132
- # @overload read(source, password: nil)
334
+ # @overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false)
133
335
  # Returns a lazy {Elements::Workbook} object.
134
336
  # @param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
135
337
  # @param password [String, nil] Optional password to decrypt password-protected XLSX files.
338
+ # @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
339
+ # @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
340
+ # @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
136
341
  # @return [Elements::Workbook]
137
342
  #
138
343
  # @example Streaming read across sheets and rows (O(1) constant memory)
@@ -153,9 +358,9 @@ module Xlsxrb
153
358
  # @raise [InvalidPasswordError] If the supplied password is incorrect.
154
359
  # @raise [ParseError] If the spreadsheet XML structure is invalid.
155
360
  # @api public
156
- #: (untyped source, ?password: String?) { (StreamSheet) -> void } -> void
157
- #: (untyped source, ?password: String?) -> Elements::Workbook
158
- def self.read(source, password: nil, &)
361
+ #: (untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) { (StreamSheet) -> void } -> void
362
+ #: (untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) -> Elements::Workbook
363
+ def self.read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &)
159
364
  prepared_source = prepare_source_io(source, password)
160
365
  zip_reader = Ooxml::ZipReader.open(prepared_source)
161
366
  begin
@@ -163,7 +368,9 @@ module Xlsxrb
163
368
  shared_strings = Ooxml::SharedStringsParser.parse(shared_strings_xml)
164
369
 
165
370
  workbook_xml = zip_reader.read_entry("xl/workbook.xml")
166
- workbook_sheets = Ooxml::WorkbookParser.parse(workbook_xml)
371
+ wb_parsed = Ooxml::WorkbookParser.parse_with_properties(workbook_xml)
372
+ workbook_sheets = wb_parsed[:sheets]
373
+ is_date1904 = wb_parsed[:date1904]
167
374
 
168
375
  rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
169
376
  rels = Ooxml::RelationshipsParser.parse(rels_xml)
@@ -186,11 +393,25 @@ module Xlsxrb
186
393
  shared_strings,
187
394
  styles,
188
395
  zip_reader: zip_reader,
189
- entry_name: sheet_path
396
+ entry_name: sheet_path,
397
+ state: sheet_info[:state] || :visible,
398
+ date1904: is_date1904,
399
+ trim_empty_rows: trim_empty_rows,
400
+ pad_empty_rows: pad_empty_rows,
401
+ pad_empty_cells: pad_empty_cells
190
402
  )
191
403
  end.compact
192
404
 
193
- wb = Elements::Workbook.new(sheets: sheets, shared_strings: shared_strings, styles: styles)
405
+ wb_unmapped = {}
406
+ wb_unmapped[:workbook_properties] = { date1904: true } if is_date1904
407
+ defined_names = wb_parsed[:defined_names] || []
408
+ wb = Elements::Workbook.new(
409
+ sheets: sheets,
410
+ shared_strings: shared_strings,
411
+ styles: styles,
412
+ unmapped_data: wb_unmapped,
413
+ defined_names: defined_names
414
+ )
194
415
 
195
416
  if block_given?
196
417
  sheets.each(&)
@@ -203,17 +424,22 @@ module Xlsxrb
203
424
  end
204
425
  end
205
426
 
427
+ class << self
428
+ alias stream read
429
+ end
430
+
206
431
  # Writes an XLSX file or IO stream (streaming or in-memory), or returns a binary string.
207
432
  #
208
- # @overload write(target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
433
+ # @overload write(?target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
209
434
  # Streaming write: yields a {StreamWriter} context for high-speed, zero-allocation XLSX generation.
210
- # @param target [String, IO, StringIO] Destination file path or writable IO object.
435
+ # If target is omitted, returns the generated XLSX as an in-memory binary String.
436
+ # @param target [String, IO, StringIO, nil] Optional destination file path or writable IO object.
211
437
  # @param password [String, nil] Optional password to encrypt the generated XLSX file.
212
438
  # @param encryption_mode [Symbol] Encryption algorithm (:standard or :agile).
213
439
  # @param strict_excel_mode [Boolean] Whether to enforce Microsoft Excel specification limits.
214
440
  # @yield [stream_writer]
215
441
  # @yieldparam stream_writer [Xlsxrb::StreamWriter]
216
- # @return [void]
442
+ # @return [String, void] Binary data if target is omitted, or void if written to target.
217
443
  #
218
444
  # @overload write(workbook, password: nil, encryption_mode: :standard)
219
445
  # In-memory write: exports the workbook to an in-memory binary String.
@@ -235,6 +461,11 @@ module Xlsxrb
235
461
  # writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
236
462
  # end
237
463
  #
464
+ # @example Streaming write to in-memory binary string (target omitted)
465
+ # binary_data = Xlsxrb.write do |writer|
466
+ # writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
467
+ # end
468
+ #
238
469
  # @example Password-protected streaming write
239
470
  # Xlsxrb.write("protected.xlsx", password: "SecretPassword123") do |writer|
240
471
  # writer.sheet("Confidential") { |s| s.row(["Private Data", 100]) }
@@ -246,9 +477,16 @@ module Xlsxrb
246
477
  # @api public
247
478
  #: (Elements::Workbook workbook, ?password: String?, ?encryption_mode: Symbol) -> String
248
479
  #: (untyped target, Elements::Workbook | untyped workbook, ?password: String?, ?encryption_mode: Symbol) -> void
249
- #: (untyped target_or_workbook, ?Elements::Workbook | untyped workbook_or_nil, ?password: String?, ?encryption_mode: Symbol, ?strict_excel_mode: bool) ?{ (StreamWriter) -> void } -> untyped
250
- def self.write(target_or_workbook, workbook_or_nil = nil, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
480
+ #: (?untyped target_or_workbook, ?Elements::Workbook | untyped workbook_or_nil, ?password: String?, ?encryption_mode: Symbol, ?strict_excel_mode: bool) ?{ (StreamWriter) -> void } -> untyped
481
+ def self.write(target_or_workbook = OMITTED_TARGET, workbook_or_nil = nil, password: nil, encryption_mode: :standard, strict_excel_mode: true, &)
251
482
  if block_given?
483
+ if target_or_workbook.equal?(OMITTED_TARGET)
484
+ io = StringIO.new
485
+ io.binmode
486
+ write(io, password: password, encryption_mode: encryption_mode, strict_excel_mode: strict_excel_mode, &)
487
+ return io.string.b
488
+ end
489
+
252
490
  target = target_or_workbook
253
491
  raise Error, "target is required" if target.nil?
254
492
 
@@ -282,6 +520,8 @@ module Xlsxrb
282
520
  end
283
521
 
284
522
  if workbook_or_nil.nil?
523
+ raise Error, "workbook must be an Elements::Workbook" if target_or_workbook.equal?(OMITTED_TARGET)
524
+
285
525
  wb = target_or_workbook
286
526
  raise Error, "workbook must be an Elements::Workbook" unless wb.is_a?(Elements::Workbook)
287
527
 
@@ -299,6 +539,15 @@ module Xlsxrb
299
539
  sst = []
300
540
  sst_index = {}
301
541
 
542
+ if workbook.shared_strings && !workbook.shared_strings.empty?
543
+ workbook.shared_strings.each do |val|
544
+ if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
545
+ sst << val
546
+ sst_index[val] = sst.size - 1
547
+ end
548
+ end
549
+ end
550
+
302
551
  # Collect shared strings and build index without allocating new Hashes
303
552
  sheet_data = workbook.sheets.map do |raw_ws|
304
553
  ws = raw_ws.respond_to?(:load) ? raw_ws.load : raw_ws
@@ -314,21 +563,51 @@ module Xlsxrb
314
563
  columns = ws.columns.map do |col|
315
564
  # simplecov:disable
316
565
  # Edge case / untested delegation block
317
- { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level }
566
+ style_idx = col.unmapped_data&.[](:style_index) || col.unmapped_data&.[](:style) || (col.respond_to?(:style_index) && col.style_index)
567
+ { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level, style_index: style_idx }
318
568
  # simplecov:enable
319
569
  end
320
570
  sd = { name: ws.name, rows: ws.rows, columns: columns }
321
571
  sd[:charts] = ws.charts unless ws.charts.empty?
572
+ sd[:conditional_formats] = ws.conditional_formatting unless ws.conditional_formatting.empty?
573
+ sd[:data_validations] = ws.data_validations unless ws.data_validations.empty?
322
574
 
323
575
  # Extract facade metadata from unmapped_data
324
576
  facade = ws.unmapped_data[:facade]
325
- facade&.each { |key, val| sd[key] = val }
577
+ facade&.each { |key, val| sd[key] ||= val }
578
+
579
+ sd[:state] ||= ws.state if ws.respond_to?(:state) && ws.state && ws.state != :visible
580
+ sd[:comments] ||= ws.comments if ws.respond_to?(:comments) && !ws.comments.empty?
581
+ if ws.respond_to?(:hyperlinks) && !ws.hyperlinks.empty?
582
+ sd[:hyperlinks] ||= ws.hyperlinks.is_a?(Hash) ? ws.hyperlinks.values : ws.hyperlinks
583
+ end
584
+ sd[:sparkline_groups] ||= ws.sparkline_groups if ws.respond_to?(:sparkline_groups) && !ws.sparkline_groups.empty?
326
585
 
327
586
  sd
328
587
  end
329
588
 
330
589
  # Extract workbook-level facade metadata
331
590
  wb_facade = workbook.unmapped_data[:facade] || {}
591
+ wb_properties = workbook.unmapped_data[:workbook_properties] || wb_facade[:workbook_properties]
592
+ raw_dns = wb_facade[:defined_names] || (workbook.respond_to?(:defined_names) && !workbook.defined_names.empty? ? workbook.defined_names : nil)
593
+ defined_names = raw_dns ? raw_dns.map(&:dup) : []
594
+
595
+ # Auto-generate _xlnm.Print_Area and _xlnm.Print_Titles from worksheet definitions if not already present
596
+ workbook.sheets.each_with_index do |ws, idx|
597
+ sheet_name = ws.name || "Sheet#{idx + 1}"
598
+ if ws.respond_to?(:print_area) && ws.print_area && defined_names.none? { |dn| dn[:name] == "_xlnm.Print_Area" && (dn[:local_sheet_id] == idx || dn[:local_sheet_name] == sheet_name) }
599
+ val = "'#{sheet_name}'!#{DslHelpers.absolute_range(ws.print_area)}"
600
+ defined_names << { name: "_xlnm.Print_Area", value: val, local_sheet_id: idx }
601
+ end
602
+ next unless ws.respond_to?(:print_titles) && ws.print_titles && defined_names.none? { |dn| dn[:name] == "_xlnm.Print_Titles" && (dn[:local_sheet_id] == idx || dn[:local_sheet_name] == sheet_name) }
603
+
604
+ pt = ws.print_titles
605
+ parts = []
606
+ parts << "'#{sheet_name}'!$#{pt[:cols].sub(":", ":$")}" if pt[:cols]
607
+ parts << "'#{sheet_name}'!$#{pt[:rows].sub(":", ":$")}" if pt[:rows]
608
+ defined_names << { name: "_xlnm.Print_Titles", value: parts.join(","), local_sheet_id: idx } unless parts.empty?
609
+ end
610
+ defined_names = nil if defined_names.empty?
332
611
 
333
612
  if password && !password.empty?
334
613
  buf = StringIO.new
@@ -339,12 +618,12 @@ module Xlsxrb
339
618
  shared_strings: sst,
340
619
  shared_strings_index: sst_index,
341
620
  styles: workbook.styles,
342
- defined_names: wb_facade[:defined_names],
621
+ defined_names: defined_names,
343
622
  core_properties: wb_facade[:core_properties],
344
623
  app_properties: wb_facade[:app_properties],
345
624
  custom_properties: wb_facade[:custom_properties],
346
625
  workbook_protection: wb_facade[:workbook_protection],
347
- workbook_properties: wb_facade[:workbook_properties]
626
+ workbook_properties: wb_properties
348
627
  )
349
628
  encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password, mode: encryption_mode)
350
629
  if target.is_a?(String)
@@ -359,12 +638,12 @@ module Xlsxrb
359
638
  shared_strings: sst,
360
639
  shared_strings_index: sst_index,
361
640
  styles: workbook.styles,
362
- defined_names: wb_facade[:defined_names],
641
+ defined_names: defined_names,
363
642
  core_properties: wb_facade[:core_properties],
364
643
  app_properties: wb_facade[:app_properties],
365
644
  custom_properties: wb_facade[:custom_properties],
366
645
  workbook_protection: wb_facade[:workbook_protection],
367
- workbook_properties: wb_facade[:workbook_properties]
646
+ workbook_properties: wb_properties
368
647
  )
369
648
  end
370
649
  end
@@ -422,7 +701,8 @@ module Xlsxrb
422
701
  initial_sst_size = shared_strings.size
423
702
 
424
703
  workbook_xml = zip_reader.read_entry("xl/workbook.xml")
425
- workbook_sheets = workbook_xml ? Ooxml::WorkbookParser.parse(workbook_xml) : []
704
+ wb_parsed = workbook_xml ? Ooxml::WorkbookParser.parse_with_properties(workbook_xml) : { sheets: [], date1904: false, defined_names: [] }
705
+ workbook_sheets = wb_parsed[:sheets]
426
706
 
427
707
  rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
428
708
  rels = rels_xml ? Ooxml::RelationshipsParser.parse(rels_xml) : {}
@@ -440,13 +720,14 @@ module Xlsxrb
440
720
 
441
721
  sheet_entry_map[sheet_info[:name]] = sheet_path
442
722
  sheet_xml = zip_reader.read_entry(sheet_path) || ""
443
- build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles)
723
+ build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles, state: sheet_info[:state] || :visible, zip_reader: zip_reader, entry_name: sheet_path)
444
724
  end.compact
445
725
 
446
726
  original_workbook = Elements::Workbook.new(
447
727
  sheets: loaded_sheets,
448
728
  shared_strings: shared_strings,
449
- styles: styles
729
+ styles: styles,
730
+ defined_names: wb_parsed[:defined_names] || []
450
731
  )
451
732
  mutable_shared_strings = shared_strings.dup
452
733
 
@@ -546,59 +827,271 @@ module Xlsxrb
546
827
  class << self
547
828
  private
548
829
 
549
- #: (String name, String? sheet_xml, Array[String] shared_strings, untyped _styles) -> Elements::Worksheet
550
- def build_worksheet(name, sheet_xml, shared_strings, _styles)
551
- return Elements::Worksheet.new(name: name) if sheet_xml.nil? || sheet_xml.empty?
830
+ #: (String name, String? sheet_xml, Array[String] shared_strings, untyped styles, ?state: Symbol, ?zip_reader: Ooxml::ZipReader?, ?entry_name: String?, ?hyperlinks: Hash[String, Hash[Symbol, untyped]]?, ?comments: Array[Hash[Symbol, untyped]]?, ?images: Array[Elements::Image]?, ?date1904: bool, ?dimension: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) -> Elements::Worksheet
831
+ def build_worksheet(name, sheet_xml, shared_strings, styles, state: :visible, zip_reader: nil, entry_name: nil, hyperlinks: nil, comments: nil, images: nil, date1904: false, dimension: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false)
832
+ sheet_dimension = dimension || sheet_xml&.slice(/<(?:[a-zA-Z0-9_]+:)?dimension\b[^>]*\bref=["']([^"']+)["']/, 1)
833
+ return Elements::Worksheet.new(name: name, state: state, styles: styles, images: images || [], date1904: date1904, dimension: sheet_dimension, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells) if sheet_xml.nil? || sheet_xml.empty?
552
834
 
553
- raw_rows = Ooxml::WorksheetParser.parse(sheet_xml, shared_strings: shared_strings)
835
+ sheet_hyperlinks = hyperlinks || resolve_hyperlinks(sheet_xml, zip_reader: zip_reader, entry_name: entry_name)
836
+ sheet_comments = comments || resolve_comments(zip_reader: zip_reader, entry_name: entry_name)
837
+ sheet_images = images || resolve_images(zip_reader: zip_reader, entry_name: entry_name)
838
+ raw_rows = Ooxml::WorksheetParser.parse(sheet_xml, shared_strings: shared_strings, styles: styles)
554
839
  raw_columns = Ooxml::WorksheetParser.parse_columns(sheet_xml)
555
840
 
556
- rows = raw_rows.map { |rr| build_row_from_raw(rr) }
841
+ rows = raw_rows.map { |rr| build_row_from_raw(rr, sheet_hyperlinks, sheet_comments, styles, date1904: date1904) }
557
842
  columns = raw_columns.map do |rc|
558
843
  # Columns from OOXML are 1-based min/max ranges; convert to 0-based
844
+ col_unmapped = {}
845
+ col_unmapped[:style_index] = rc[:style_index] if rc[:style_index]
559
846
  Elements::Column.new(
560
847
  index: (rc[:min] || 1) - 1,
561
848
  width: rc[:width],
562
849
  hidden: rc[:hidden] || false,
563
850
  custom_width: rc[:custom_width] || false,
564
- outline_level: rc[:outline_level]
851
+ outline_level: rc[:outline_level],
852
+ style_index: rc[:style_index],
853
+ unmapped_data: col_unmapped
565
854
  )
566
855
  end
567
856
 
568
- Elements::Worksheet.new(name: name, rows: rows, columns: columns)
857
+ cfs = if sheet_xml.include?("<conditionalFormatting")
858
+ listener = Ooxml::Reader::ConditionalFormattingListener.new
859
+ Ooxml::XmlParser.parse(sheet_xml, listener)
860
+ listener.rules
861
+ else
862
+ []
863
+ end
864
+
865
+ dvs = if sheet_xml.include?("<dataValidation")
866
+ listener = Ooxml::Reader::DataValidationsListener.new
867
+ Ooxml::XmlParser.parse(sheet_xml, listener)
868
+ listener.validations
869
+ else
870
+ []
871
+ end
872
+
873
+ Elements::Worksheet.new(
874
+ name: name,
875
+ rows: rows,
876
+ columns: columns,
877
+ conditional_formatting: cfs,
878
+ data_validations: dvs,
879
+ state: state,
880
+ hyperlinks: sheet_hyperlinks,
881
+ comments: sheet_comments,
882
+ images: sheet_images,
883
+ styles: styles,
884
+ date1904: date1904,
885
+ dimension: sheet_dimension,
886
+ trim_empty_rows: trim_empty_rows,
887
+ pad_empty_rows: pad_empty_rows,
888
+ pad_empty_cells: pad_empty_cells
889
+ )
890
+ end
891
+
892
+ #: (String? sheet_xml, ?zip_reader: Ooxml::ZipReader?, ?entry_name: String?, ?rels_xml: String?) -> Hash[String, Hash[Symbol, untyped]]
893
+ def resolve_hyperlinks(sheet_xml, zip_reader: nil, entry_name: nil, rels_xml: nil)
894
+ return {} if sheet_xml.nil? || !sheet_xml.include?("hyperlink")
895
+
896
+ links = Ooxml::WorksheetParser.parse_hyperlinks(sheet_xml)
897
+ return {} if links.empty?
898
+
899
+ rid_to_url = {}
900
+ if rels_xml && !rels_xml.empty?
901
+ rid_to_url = Ooxml::RelationshipsParser.parse(rels_xml)
902
+ elsif zip_reader && entry_name
903
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
904
+ rels_content = zip_reader.read_entry(rels_path)
905
+ rid_to_url = Ooxml::RelationshipsParser.parse(rels_content) if rels_content && !rels_content.empty?
906
+ end
907
+
908
+ result = {}
909
+ links.each do |link|
910
+ entry = {}
911
+ if link[:rid]
912
+ url = rid_to_url[link[:rid]]
913
+ entry[:url] = url if url
914
+ end
915
+ entry[:display] = link[:display] if link[:display]
916
+ entry[:tooltip] = link[:tooltip] if link[:tooltip]
917
+ entry[:location] = link[:location] if link[:location]
918
+ result[link[:ref]] = entry unless entry.empty?
919
+ end
920
+ result
569
921
  end
570
922
 
571
- #: (untyped raw_row) -> (Elements::Row | untyped)
572
- def build_row_from_raw(raw_row)
923
+ #: (String base_dir, String target) -> String
924
+ def resolve_relative_path(base_dir, target)
925
+ if target.start_with?("/")
926
+ target.delete_prefix("/")
927
+ elsif target.start_with?("..")
928
+ parts = base_dir.split("/") + target.split("/")
929
+ resolved = []
930
+ parts.each { |p| p == ".." ? resolved.pop : resolved << p }
931
+ resolved.join("/")
932
+ else
933
+ "#{base_dir}/#{target}"
934
+ end
935
+ end
936
+
937
+ #: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Hash[Symbol, untyped]]
938
+ def resolve_comments(zip_reader: nil, entry_name: nil)
939
+ return [] unless zip_reader && entry_name
940
+
941
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
942
+ rels_xml = zip_reader.read_entry(rels_path)
943
+ return [] if rels_xml.nil? || rels_xml.empty?
944
+
945
+ parser = REXML::Parsers::SAX2Parser.new(rels_xml)
946
+ listener = Ooxml::Reader::RelsListener.new
947
+ parser.listen(listener)
948
+ parser.parse
949
+
950
+ rel = listener.relationships.find { |r| r[:type]&.end_with?("/comments") }
951
+ return [] unless rel
952
+
953
+ base_dir = File.dirname(entry_name)
954
+ comments_path = resolve_relative_path(base_dir, rel[:target])
955
+
956
+ xml = zip_reader.read_entry(comments_path)
957
+ return [] if xml.nil? || xml.empty?
958
+
959
+ c_parser = REXML::Parsers::SAX2Parser.new(xml)
960
+ c_listener = Ooxml::Reader::CommentsListener.new
961
+ c_parser.listen(c_listener)
962
+ c_parser.parse
963
+ c_listener.comments
964
+ end
965
+
966
+ #: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Elements::Image]
967
+ def resolve_images(zip_reader: nil, entry_name: nil)
968
+ return [] unless zip_reader && entry_name
969
+
970
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
971
+ rels_xml = zip_reader.read_entry(rels_path)
972
+ return [] if rels_xml.nil? || rels_xml.empty?
973
+
974
+ parser = REXML::Parsers::SAX2Parser.new(rels_xml)
975
+ listener = Ooxml::Reader::RelsListener.new
976
+ parser.listen(listener)
977
+ parser.parse
978
+
979
+ drawing_rel = listener.relationships.find { |r| r[:type]&.end_with?("/drawing") }
980
+ return [] unless drawing_rel
981
+
982
+ base_dir = File.dirname(entry_name)
983
+ target = drawing_rel[:target]
984
+ drawing_path = resolve_relative_path(base_dir, target)
985
+ drawing_xml = zip_reader.read_entry(drawing_path)
986
+ return [] if drawing_xml.nil? || drawing_xml.empty?
987
+
988
+ drawing_rels_path = drawing_path.sub(%r{([^/]+)$}, '_rels/\1.rels')
989
+ drawing_rels_xml = zip_reader.read_entry(drawing_rels_path)
990
+ drawing_rels = {}
991
+ if drawing_rels_xml && !drawing_rels_xml.empty?
992
+ d_parser = REXML::Parsers::SAX2Parser.new(drawing_rels_xml)
993
+ d_listener = Ooxml::Reader::RelsListener.new
994
+ d_parser.listen(d_listener)
995
+ d_parser.parse
996
+ d_listener.relationships.each do |r|
997
+ drawing_rels[r[:id]] = r[:target]
998
+ end
999
+ end
1000
+
1001
+ p_parser = REXML::Parsers::SAX2Parser.new(drawing_xml)
1002
+ img_listener = Ooxml::Reader::DrawingImagesListener.new
1003
+ p_parser.listen(img_listener)
1004
+ p_parser.parse
1005
+
1006
+ drawing_dir = File.dirname(drawing_path)
1007
+ img_listener.images.map do |raw_img|
1008
+ rid = raw_img[:embed_rid]
1009
+ media_target = drawing_rels[rid]
1010
+ img_data = nil
1011
+ filename = nil
1012
+ if media_target
1013
+ media_path = resolve_relative_path(drawing_dir, media_target)
1014
+ filename = File.basename(media_path)
1015
+ img_data = zip_reader.read_entry(media_path)
1016
+ end
1017
+
1018
+ from_col = raw_img[:from_col] || 0
1019
+ from_row = raw_img[:from_row] || 0
1020
+ to_col = raw_img[:to_col]
1021
+ to_row = raw_img[:to_row]
1022
+ cx = raw_img[:cx]
1023
+ cy = raw_img[:cy]
1024
+ name = raw_img[:name]
1025
+
1026
+ Elements::Image.new(
1027
+ filename: filename || "image.png",
1028
+ name: name,
1029
+ data: img_data,
1030
+ from_col: from_col,
1031
+ from_row: from_row,
1032
+ to_col: to_col,
1033
+ to_row: to_row,
1034
+ cx: cx,
1035
+ cy: cy,
1036
+ unmapped_data: raw_img
1037
+ )
1038
+ end
1039
+ end
1040
+
1041
+ #: (untyped raw_row, ?Hash[String, Hash[Symbol, untyped]] hyperlinks, ?(Array[Hash[Symbol, untyped]] | Hash[String, Hash[Symbol, untyped]]) comments, ?Hash[untyped, untyped]? styles, ?date1904: bool) -> (Elements::Row | untyped)
1042
+ def build_row_from_raw(raw_row, hyperlinks = {}, comments = [], styles = nil, date1904: false)
573
1043
  return raw_row if raw_row.is_a?(Elements::Row)
574
1044
 
1045
+ comments_map = if comments.is_a?(Hash)
1046
+ comments
1047
+ else
1048
+ comments.each_with_object({}) { |c, acc| (r = c[:ref] || c[:cell]) && (acc[r.to_s.upcase] = c) }
1049
+ end
1050
+
575
1051
  raw_cells = raw_row[:cells]
576
- cells = if raw_cells.empty? || raw_cells.first.is_a?(Elements::Cell)
577
- raw_cells
578
- else
579
- raw_cells.map do |rc|
580
- parsed = Elements::Cell.parse_ref(rc[:ref]) if rc[:ref]
581
- row_idx = parsed ? parsed[0] : raw_row[:index]
582
- col_idx = parsed ? parsed[1] : 0
583
-
584
- val = rc[:value]
585
- cell_errors = Elements::Cell.validate(row_idx, col_idx, val)
586
- if !cell_errors.empty? && rc[:source]
587
- cell_errors = cell_errors.map do |err|
588
- "#{err} (at #{rc[:source][:part]} row #{rc[:source][:row] + 1} cell #{rc[:ref] || "unknown"})"
589
- end
590
- end
591
-
592
- Elements::Cell.new(
593
- row_index: row_idx,
594
- column_index: col_idx,
595
- value: val,
596
- formula: rc[:formula],
597
- style_index: rc[:style_index],
598
- errors: cell_errors
599
- )
600
- end
601
- end
1052
+ cells = raw_cells.map do |rc|
1053
+ if rc.is_a?(Elements::Cell)
1054
+ hl = hyperlinks && !hyperlinks.empty? ? hyperlinks[rc.ref] : nil
1055
+ cm = comments_map && !comments_map.empty? ? comments_map[rc.ref] : nil
1056
+ fmt = rc.format_code || (NumberFormatter.format_code_for(rc.style_index, styles) if styles && rc.style_index)
1057
+ if hl || cm || (fmt && rc.format_code != fmt) || (date1904 && !rc.date1904?)
1058
+ rc.with(hyperlink: hl || rc.hyperlink, comment: cm || rc.comment, format_code: fmt || rc.format_code, date1904: date1904 || rc.date1904?)
1059
+ else
1060
+ rc
1061
+ end
1062
+ else
1063
+ parsed = Elements::Cell.parse_ref(rc[:ref]) if rc[:ref]
1064
+ row_idx = parsed ? parsed[0] : raw_row[:index]
1065
+ col_idx = parsed ? parsed[1] : 0
1066
+ ref = rc[:ref] || "#{Elements::Cell.column_letter(col_idx)}#{row_idx + 1}"
1067
+ hl = hyperlinks ? hyperlinks[ref] : nil
1068
+ cm = comments_map ? comments_map[ref] : nil
1069
+ fmt = rc[:format_code] || (NumberFormatter.format_code_for(rc[:style_index], styles) if styles && rc[:style_index])
1070
+
1071
+ val = rc[:value]
1072
+ raw_val = rc[:raw_value] || val&.to_s
1073
+ cell_errors = Elements::Cell.validate(row_idx, col_idx, val)
1074
+ if !cell_errors.empty? && rc[:source]
1075
+ cell_errors = cell_errors.map do |err|
1076
+ "#{err} (at #{rc[:source][:part]} row #{rc[:source][:row] + 1} cell #{rc[:ref] || "unknown"})"
1077
+ end
1078
+ end
1079
+
1080
+ Elements::Cell.new(
1081
+ row_index: row_idx,
1082
+ column_index: col_idx,
1083
+ value: val,
1084
+ formula: rc[:formula],
1085
+ style_index: rc[:style_index],
1086
+ hyperlink: hl,
1087
+ comment: cm,
1088
+ raw_value: raw_val,
1089
+ format_code: fmt,
1090
+ errors: cell_errors,
1091
+ date1904: date1904
1092
+ )
1093
+ end
1094
+ end
602
1095
  attrs = raw_row[:attrs] || {}
603
1096
  row_errors = Elements::Row.validate(raw_row[:index], cells)
604
1097
  if !row_errors.empty? && raw_row[:source]
@@ -609,19 +1102,23 @@ module Xlsxrb
609
1102
  # simplecov:enable
610
1103
  end
611
1104
  end
1105
+ row_unmapped = {}
1106
+ row_unmapped[:style_index] = attrs[:style_index] if attrs[:style_index]
612
1107
  Elements::Row.new(
613
1108
  index: raw_row[:index],
614
1109
  cells: cells,
615
1110
  height: attrs[:height],
616
1111
  hidden: attrs[:hidden] || false,
617
1112
  custom_height: attrs[:custom_height] || false,
1113
+ collapsed: attrs[:collapsed] || false,
618
1114
  outline_level: attrs[:outline_level],
1115
+ unmapped_data: row_unmapped,
619
1116
  errors: row_errors
620
1117
  )
621
1118
  end
622
1119
 
623
- #: (Elements::Cell cell, Array[String] sst, Hash[String, Integer] sst_index) -> Hash[Symbol, untyped]
624
- def build_raw_cell(cell, sst, sst_index)
1120
+ #: (Elements::Cell cell, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) -> Hash[Symbol, untyped]
1121
+ def build_raw_cell(cell, sst, sst_index, date1904: false)
625
1122
  # simplecov:disable
626
1123
  # Edge case / untested delegation block
627
1124
  ref = cell.ref
@@ -645,9 +1142,9 @@ module Xlsxrb
645
1142
  result[:value] = value.code
646
1143
  result[:type] = "e"
647
1144
  when Date
648
- result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
1145
+ result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value, date1904: date1904)
649
1146
  when Time
650
- result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
1147
+ result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904)
651
1148
  # simplecov:enable
652
1149
  when NilClass
653
1150
  # empty cell
@@ -687,6 +1184,7 @@ module Xlsxrb
687
1184
  attrs[:height] = row.height if row.height
688
1185
  attrs[:hidden] = true if row.hidden
689
1186
  attrs[:custom_height] = true if row.custom_height
1187
+ attrs[:collapsed] = true if row.collapsed
690
1188
  attrs[:outline_level] = row.outline_level if row.outline_level
691
1189
  attrs
692
1190
  # simplecov:enable
@@ -695,12 +1193,13 @@ module Xlsxrb
695
1193
  #: (untyped source, String? password) -> untyped
696
1194
  def prepare_source_io(source, password)
697
1195
  if source.is_a?(String)
698
- if source.start_with?("PK\x03\x04") || source.include?("\x00") || Ooxml::Cfb::Reader.cfb?(source)
699
- if Ooxml::Cfb::Reader.cfb?(source)
700
- decrypted_zip = Ooxml::Crypto.decrypt(source, password)
1196
+ bin_source = source.encoding == Encoding::BINARY ? source : source.b
1197
+ if bin_source.start_with?("PK\x03\x04".b) || bin_source.include?("\x00".b) || Ooxml::Cfb::Reader.cfb?(bin_source)
1198
+ if Ooxml::Cfb::Reader.cfb?(bin_source)
1199
+ decrypted_zip = Ooxml::Crypto.decrypt(bin_source, password)
701
1200
  StringIO.new(decrypted_zip)
702
1201
  else
703
- StringIO.new(source)
1202
+ StringIO.new(bin_source)
704
1203
  end
705
1204
  elsif File.file?(source)
706
1205
  first_bytes = begin
@@ -861,11 +1360,11 @@ module Xlsxrb
861
1360
  sheet_xml.sub(/<dimension\b([^>]*)\bref="[^"]*"/, "<dimension\\1ref=\"#{dim_ref}\"")
862
1361
  end
863
1362
 
864
- #: (Array[Elements::Row] rows, Array[untyped] shared_strings, Hash[untyped, Integer] sst_index, ?use_sst: bool) -> String
865
- def serialize_sheet_data_xml(rows, shared_strings, sst_index, use_sst: true)
1363
+ #: (Array[Elements::Row] rows, Array[untyped] shared_strings, Hash[untyped, Integer] sst_index, ?use_sst: bool, ?date1904: bool) -> String
1364
+ def serialize_sheet_data_xml(rows, shared_strings, sst_index, use_sst: true, date1904: false)
866
1365
  io = StringIO.new
867
1366
  io.write("<sheetData>")
868
- ws = Ooxml::WorksheetWriter.new(io)
1367
+ ws = Ooxml::WorksheetWriter.new(io, date1904: date1904)
869
1368
  ws.instance_variable_set(:@started, true)
870
1369
 
871
1370
  rows.each do |row|
@@ -915,8 +1414,8 @@ module Xlsxrb
915
1414
  # @param sst_index [Hash{String => Integer}] Shared strings index mapping.
916
1415
  # @return [Hash{Symbol => Object}] Raw cell hash.
917
1416
  # @api public
918
- #: (Integer row_index, Integer col_index, untyped value, Array[String] sst, Hash[String, Integer] sst_index) -> Hash[Symbol, untyped]
919
- def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index)
1417
+ #: (Integer row_index, Integer col_index, untyped value, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) -> Hash[Symbol, untyped]
1418
+ def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index, date1904: false)
920
1419
  # simplecov:disable
921
1420
  # Edge case / untested delegation block
922
1421
  ref = "#{Elements::Cell.column_letter(col_index)}#{row_index + 1}"
@@ -943,9 +1442,9 @@ module Xlsxrb
943
1442
  result[:value] = value.code
944
1443
  result[:type] = "e"
945
1444
  when Date
946
- result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
1445
+ result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value, date1904: date1904)
947
1446
  when Time
948
- result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
1447
+ result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904)
949
1448
  # simplecov:enable
950
1449
  when NilClass
951
1450
  # empty cell