xlsxrb 0.1.13 → 0.1.15

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/README.md +17 -14
  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 +121 -4
  13. data/lib/xlsxrb/elements/worksheet.rb +519 -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/listeners/feature_listeners.rb +7 -1
  20. data/lib/xlsxrb/ooxml/reader.rb +14 -17
  21. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +7 -1
  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 +74 -16
  25. data/lib/xlsxrb/ooxml/workbook_writer.rb +82 -23
  26. data/lib/xlsxrb/ooxml/worksheet_parser.rb +319 -79
  27. data/lib/xlsxrb/ooxml/worksheet_writer.rb +263 -66
  28. data/lib/xlsxrb/ooxml/writer/features_xml.rb +5 -3
  29. data/lib/xlsxrb/ooxml/writer.rb +14 -0
  30. data/lib/xlsxrb/ooxml/xml_builder.rb +14 -2
  31. data/lib/xlsxrb/ooxml/zip_reader.rb +144 -28
  32. data/lib/xlsxrb/stream_row.rb +243 -16
  33. data/lib/xlsxrb/stream_sheet.rb +647 -16
  34. data/lib/xlsxrb/stream_writer.rb +375 -34
  35. data/lib/xlsxrb/style_builder.rb +308 -22
  36. data/lib/xlsxrb/utils.rb +211 -0
  37. data/lib/xlsxrb/version.rb +1 -1
  38. data/lib/xlsxrb/workbook_builder.rb +72 -11
  39. data/lib/xlsxrb/worksheet_builder.rb +738 -24
  40. data/lib/xlsxrb.rb +1073 -83
  41. data/sig/generated/xlsxrb/colors.rbs +40 -0
  42. data/sig/generated/xlsxrb/elements/cell.rbs +113 -8
  43. data/sig/generated/xlsxrb/elements/column.rbs +3 -2
  44. data/sig/generated/xlsxrb/elements/coordinate_access.rbs +18 -0
  45. data/sig/generated/xlsxrb/elements/image.rbs +82 -0
  46. data/sig/generated/xlsxrb/elements/row.rbs +83 -4
  47. data/sig/generated/xlsxrb/elements/styles.rbs +79 -0
  48. data/sig/generated/xlsxrb/elements/types.rbs +43 -0
  49. data/sig/generated/xlsxrb/elements/workbook.rbs +79 -2
  50. data/sig/generated/xlsxrb/elements/worksheet.rbs +282 -18
  51. data/sig/generated/xlsxrb/number_formatter.rbs +132 -0
  52. data/sig/generated/xlsxrb/ooxml/reader.rbs +6 -0
  53. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +2 -0
  54. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -2
  55. data/sig/generated/xlsxrb/ooxml/utils.rbs +17 -8
  56. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +9 -3
  57. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +1 -1
  58. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +28 -6
  59. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +13 -3
  60. data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
  61. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +3 -1
  62. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +13 -0
  63. data/sig/generated/xlsxrb/stream_row.rbs +100 -6
  64. data/sig/generated/xlsxrb/stream_sheet.rbs +358 -14
  65. data/sig/generated/xlsxrb/stream_writer.rbs +220 -30
  66. data/sig/generated/xlsxrb/style_builder.rbs +55 -18
  67. data/sig/generated/xlsxrb/utils.rbs +151 -0
  68. data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
  69. data/sig/generated/xlsxrb/worksheet_builder.rbs +356 -18
  70. data/sig/generated/xlsxrb.rbs +223 -27
  71. 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,225 @@ 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
+ # Converts a cell range reference string (e.g. "A1:C10", "A1") to 0-based [[top_row, left_col], [bottom_row, right_col]].
282
+ #
283
+ # @example
284
+ # Xlsxrb.parse_range("A1:C10") #=> [[0, 0], [9, 2]]
285
+ #
286
+ # @param range_ref [String, nil]
287
+ # @return [Array([Integer, Integer], [Integer, Integer]), nil]
288
+ # @api public
289
+ #: (String? range_ref) -> [[Integer, Integer], [Integer, Integer]]?
290
+ def self.parse_range(range_ref)
291
+ Utils.parse_range(range_ref)
292
+ end
293
+
294
+ # Converts 0-based bounding coordinates to an A1-style range reference string.
295
+ #
296
+ # @example
297
+ # Xlsxrb.range_to_ref(0, 0, 9, 2) #=> "A1:C10"
298
+ #
299
+ # @param top_row [Integer]
300
+ # @param left_col [Integer]
301
+ # @param bottom_row [Integer]
302
+ # @param right_col [Integer]
303
+ # @return [String]
304
+ # @api public
305
+ #: (Integer top_row, Integer left_col, Integer bottom_row, Integer right_col) -> String
306
+ def self.range_to_ref(top_row, left_col, bottom_row, right_col)
307
+ Utils.range_to_ref(top_row, left_col, bottom_row, right_col)
308
+ end
309
+
310
+ # Resolves an ECMA-376 indexed color palette index (0..63) to an RRGGBB hex string (or AARRGGBB if alpha: true).
311
+ #
312
+ # @example
313
+ # Xlsxrb.palette_color(12) #=> "0000FF"
314
+ # Xlsxrb.palette_color(12, alpha: true) #=> "FF0000FF"
315
+ #
316
+ # @param index [Object, nil] Palette index (0..63).
317
+ # @param alpha [Boolean] Whether to prefix "FF" alpha channel.
318
+ # @return [String, nil]
319
+ # @api public
320
+ #: (untyped index, ?alpha: bool) -> String?
321
+ def self.palette_color(index, alpha: false)
322
+ Colors.palette_color(index, alpha: alpha)
323
+ end
324
+
325
+ # Converts any supported color representation (palette index 0..63, CSS name, #RGB, #RRGGBB, #AARRGGBB, integer RGB)
326
+ # to a normalized hex string.
327
+ #
328
+ # @example
329
+ # Xlsxrb.to_hex_color(12) #=> "0000FF"
330
+ # Xlsxrb.to_hex_color(:navy) #=> "000080"
331
+ # Xlsxrb.to_hex_color("#FF0000") #=> "FF0000"
332
+ #
333
+ # @param color [Integer, String, Symbol, nil]
334
+ # @param alpha [Boolean] Whether to include the alpha channel (default false -> "RRGGBB").
335
+ # @return [String, nil]
336
+ # @api public
337
+ #: (Integer | String | Symbol | nil color, ?alpha: bool) -> String?
338
+ def self.to_hex_color(color, alpha: false)
339
+ Colors.to_hex(color, alpha: alpha)
340
+ end
341
+
342
+ class << self
343
+ alias color_to_hex to_hex_color
344
+ end
345
+
118
346
  # Reads an XLSX file (streaming and lazy-loaded by default) from a file path, IO stream, or binary String.
119
347
  #
120
348
  # Sheets and rows are streamed lazily with O(1) constant memory. If a block is given,
121
349
  # yields each {StreamSheet} sequentially. Call {#load} on the returned Workbook or Sheet
122
350
  # to convert to an in-memory representation for coordinate random access (`sheet["A1"]`).
123
351
  #
124
- # @overload read(source, password: nil, &block)
352
+ # @overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &block)
125
353
  # Yields each {StreamSheet} sequentially in streaming mode.
126
354
  # @param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
127
355
  # @param password [String, nil] Optional password to decrypt password-protected XLSX files.
356
+ # @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
357
+ # @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
358
+ # @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
128
359
  # @yield [sheet]
129
360
  # @yieldparam sheet [StreamSheet] The streaming worksheet object.
130
361
  # @return [void]
131
362
  #
132
- # @overload read(source, password: nil)
363
+ # @overload read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false)
133
364
  # Returns a lazy {Elements::Workbook} object.
134
365
  # @param source [String, IO, StringIO] File path, binary content string, or readable IO stream.
135
366
  # @param password [String, nil] Optional password to decrypt password-protected XLSX files.
367
+ # @param trim_empty_rows [Boolean] Whether to omit trailing empty rows during sheet iteration.
368
+ # @param pad_empty_rows [Boolean] Whether to yield empty rows for skipped row numbers.
369
+ # @param pad_empty_cells [Boolean] Whether to pad missing cells within rows.
136
370
  # @return [Elements::Workbook]
137
371
  #
138
372
  # @example Streaming read across sheets and rows (O(1) constant memory)
@@ -153,9 +387,9 @@ module Xlsxrb
153
387
  # @raise [InvalidPasswordError] If the supplied password is incorrect.
154
388
  # @raise [ParseError] If the spreadsheet XML structure is invalid.
155
389
  # @api public
156
- #: (untyped source, ?password: String?) { (StreamSheet) -> void } -> void
157
- #: (untyped source, ?password: String?) -> Elements::Workbook
158
- def self.read(source, password: nil, &)
390
+ #: (untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) { (StreamSheet) -> void } -> void
391
+ #: (untyped source, ?password: String?, ?trim_empty_rows: bool, ?pad_empty_rows: bool, ?pad_empty_cells: bool) -> Elements::Workbook
392
+ def self.read(source, password: nil, trim_empty_rows: false, pad_empty_rows: false, pad_empty_cells: false, &)
159
393
  prepared_source = prepare_source_io(source, password)
160
394
  zip_reader = Ooxml::ZipReader.open(prepared_source)
161
395
  begin
@@ -163,7 +397,9 @@ module Xlsxrb
163
397
  shared_strings = Ooxml::SharedStringsParser.parse(shared_strings_xml)
164
398
 
165
399
  workbook_xml = zip_reader.read_entry("xl/workbook.xml")
166
- workbook_sheets = Ooxml::WorkbookParser.parse(workbook_xml)
400
+ wb_parsed = Ooxml::WorkbookParser.parse_with_properties(workbook_xml)
401
+ workbook_sheets = wb_parsed[:sheets]
402
+ is_date1904 = wb_parsed[:date1904]
167
403
 
168
404
  rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
169
405
  rels = Ooxml::RelationshipsParser.parse(rels_xml)
@@ -186,11 +422,56 @@ module Xlsxrb
186
422
  shared_strings,
187
423
  styles,
188
424
  zip_reader: zip_reader,
189
- entry_name: sheet_path
425
+ entry_name: sheet_path,
426
+ state: sheet_info[:state] || :visible,
427
+ date1904: is_date1904,
428
+ trim_empty_rows: trim_empty_rows,
429
+ pad_empty_rows: pad_empty_rows,
430
+ pad_empty_cells: pad_empty_cells
190
431
  )
191
432
  end.compact
192
433
 
193
- wb = Elements::Workbook.new(sheets: sheets, shared_strings: shared_strings, styles: styles)
434
+ wb_unmapped = {}
435
+ wb_unmapped[:workbook_properties] = { date1904: true } if is_date1904
436
+
437
+ if workbook_xml&.include?("<workbookProtection")
438
+ prot_match = workbook_xml.match(%r{<workbookProtection\b([^>]*)/>})
439
+ if prot_match
440
+ prot = {}
441
+ prot[:lock_structure] = true if prot_match[1].include?('lockStructure="1"') || prot_match[1].include?('lockStructure="true"')
442
+ prot[:lock_windows] = true if prot_match[1].include?('lockWindows="1"') || prot_match[1].include?('lockWindows="true"')
443
+ wb_unmapped[:workbook_protection] = prot unless prot.empty?
444
+ end
445
+ end
446
+
447
+ core_xml = zip_reader.read_entry("docProps/core.xml")
448
+ if core_xml && !core_xml.empty?
449
+ c_listener = Ooxml::Reader::CorePropertiesListener.new
450
+ Ooxml::XmlParser.parse(core_xml, c_listener)
451
+ wb_unmapped[:core_properties] = c_listener.properties unless c_listener.properties.empty?
452
+ end
453
+
454
+ app_xml = zip_reader.read_entry("docProps/app.xml")
455
+ if app_xml && !app_xml.empty?
456
+ a_listener = Ooxml::Reader::AppPropertiesListener.new
457
+ Ooxml::XmlParser.parse(app_xml, a_listener)
458
+ wb_unmapped[:app_properties] = a_listener.properties unless a_listener.properties.empty?
459
+ end
460
+
461
+ custom_xml = zip_reader.read_entry("docProps/custom.xml")
462
+ if custom_xml && !custom_xml.empty?
463
+ cu_listener = Ooxml::Reader::CustomPropertiesListener.new
464
+ Ooxml::XmlParser.parse(custom_xml, cu_listener)
465
+ wb_unmapped[:custom_properties] = cu_listener.properties unless cu_listener.properties.empty?
466
+ end
467
+ defined_names = wb_parsed[:defined_names] || []
468
+ wb = Elements::Workbook.new(
469
+ sheets: sheets,
470
+ shared_strings: shared_strings,
471
+ styles: styles,
472
+ unmapped_data: wb_unmapped,
473
+ defined_names: defined_names
474
+ )
194
475
 
195
476
  if block_given?
196
477
  sheets.each(&)
@@ -203,17 +484,22 @@ module Xlsxrb
203
484
  end
204
485
  end
205
486
 
487
+ class << self
488
+ alias stream read
489
+ end
490
+
206
491
  # Writes an XLSX file or IO stream (streaming or in-memory), or returns a binary string.
207
492
  #
208
- # @overload write(target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
493
+ # @overload write(?target, password: nil, encryption_mode: :standard, strict_excel_mode: true, &block)
209
494
  # 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.
495
+ # If target is omitted, returns the generated XLSX as an in-memory binary String.
496
+ # @param target [String, IO, StringIO, nil] Optional destination file path or writable IO object.
211
497
  # @param password [String, nil] Optional password to encrypt the generated XLSX file.
212
498
  # @param encryption_mode [Symbol] Encryption algorithm (:standard or :agile).
213
499
  # @param strict_excel_mode [Boolean] Whether to enforce Microsoft Excel specification limits.
214
500
  # @yield [stream_writer]
215
501
  # @yieldparam stream_writer [Xlsxrb::StreamWriter]
216
- # @return [void]
502
+ # @return [String, void] Binary data if target is omitted, or void if written to target.
217
503
  #
218
504
  # @overload write(workbook, password: nil, encryption_mode: :standard)
219
505
  # In-memory write: exports the workbook to an in-memory binary String.
@@ -235,6 +521,11 @@ module Xlsxrb
235
521
  # writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
236
522
  # end
237
523
  #
524
+ # @example Streaming write to in-memory binary string (target omitted)
525
+ # binary_data = Xlsxrb.write do |writer|
526
+ # writer.sheet("Sheet1") { |s| s.row(["Hello", "World"]) }
527
+ # end
528
+ #
238
529
  # @example Password-protected streaming write
239
530
  # Xlsxrb.write("protected.xlsx", password: "SecretPassword123") do |writer|
240
531
  # writer.sheet("Confidential") { |s| s.row(["Private Data", 100]) }
@@ -246,9 +537,16 @@ module Xlsxrb
246
537
  # @api public
247
538
  #: (Elements::Workbook workbook, ?password: String?, ?encryption_mode: Symbol) -> String
248
539
  #: (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)
540
+ #: (?untyped target_or_workbook, ?Elements::Workbook | untyped workbook_or_nil, ?password: String?, ?encryption_mode: Symbol, ?strict_excel_mode: bool) ?{ (StreamWriter) -> void } -> untyped
541
+ def self.write(target_or_workbook = OMITTED_TARGET, workbook_or_nil = nil, password: nil, encryption_mode: :standard, strict_excel_mode: true, &)
251
542
  if block_given?
543
+ if target_or_workbook.equal?(OMITTED_TARGET)
544
+ io = StringIO.new
545
+ io.binmode
546
+ write(io, password: password, encryption_mode: encryption_mode, strict_excel_mode: strict_excel_mode, &)
547
+ return io.string.b
548
+ end
549
+
252
550
  target = target_or_workbook
253
551
  raise Error, "target is required" if target.nil?
254
552
 
@@ -282,6 +580,8 @@ module Xlsxrb
282
580
  end
283
581
 
284
582
  if workbook_or_nil.nil?
583
+ raise Error, "workbook must be an Elements::Workbook" if target_or_workbook.equal?(OMITTED_TARGET)
584
+
285
585
  wb = target_or_workbook
286
586
  raise Error, "workbook must be an Elements::Workbook" unless wb.is_a?(Elements::Workbook)
287
587
 
@@ -299,6 +599,15 @@ module Xlsxrb
299
599
  sst = []
300
600
  sst_index = {}
301
601
 
602
+ if workbook.shared_strings && !workbook.shared_strings.empty?
603
+ workbook.shared_strings.each do |val|
604
+ if (val.is_a?(String) || val.is_a?(Elements::RichText)) && !sst_index.key?(val)
605
+ sst << val
606
+ sst_index[val] = sst.size - 1
607
+ end
608
+ end
609
+ end
610
+
302
611
  # Collect shared strings and build index without allocating new Hashes
303
612
  sheet_data = workbook.sheets.map do |raw_ws|
304
613
  ws = raw_ws.respond_to?(:load) ? raw_ws.load : raw_ws
@@ -314,21 +623,63 @@ module Xlsxrb
314
623
  columns = ws.columns.map do |col|
315
624
  # simplecov:disable
316
625
  # 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 }
626
+ style_idx = col.unmapped_data&.[](:style_index) || col.unmapped_data&.[](:style) || (col.respond_to?(:style_index) && col.style_index)
627
+ { index: col.index, width: col.width, hidden: col.hidden, custom_width: col.custom_width, outline_level: col.outline_level, style_index: style_idx }
318
628
  # simplecov:enable
319
629
  end
320
630
  sd = { name: ws.name, rows: ws.rows, columns: columns }
321
631
  sd[:charts] = ws.charts unless ws.charts.empty?
632
+ sd[:conditional_formats] = ws.conditional_formatting unless ws.conditional_formatting.empty?
633
+ sd[:data_validations] = ws.data_validations unless ws.data_validations.empty?
322
634
 
323
635
  # Extract facade metadata from unmapped_data
324
636
  facade = ws.unmapped_data[:facade]
325
- facade&.each { |key, val| sd[key] = val }
637
+ facade&.each { |key, val| sd[key] ||= val }
638
+
639
+ sd[:state] ||= ws.state if ws.respond_to?(:state) && ws.state && ws.state != :visible
640
+ sd[:comments] ||= ws.comments if ws.respond_to?(:comments) && !ws.comments.empty?
641
+ if ws.respond_to?(:hyperlinks) && !ws.hyperlinks.empty?
642
+ sd[:hyperlinks] ||= if ws.hyperlinks.is_a?(Hash)
643
+ ws.hyperlinks.map do |cell_ref, link|
644
+ entry = link.is_a?(Hash) ? link.dup : { url: link }
645
+ entry[:ref] ||= cell_ref
646
+ entry
647
+ end
648
+ else
649
+ ws.hyperlinks
650
+ end
651
+ end
652
+ sd[:sparkline_groups] ||= ws.sparkline_groups if ws.respond_to?(:sparkline_groups) && !ws.sparkline_groups.empty?
653
+ sd[:sheet_properties] ||= ws.sheet_properties if ws.respond_to?(:sheet_properties) && !ws.sheet_properties.empty?
654
+ sd[:sheet_format] ||= ws.sheet_format if ws.respond_to?(:sheet_format) && !ws.sheet_format.empty?
655
+ sd[:protected_ranges] ||= ws.protected_ranges if ws.respond_to?(:protected_ranges) && !ws.protected_ranges.empty?
656
+ sd[:cell_watches] ||= ws.cell_watches if ws.respond_to?(:cell_watches) && !ws.cell_watches.empty?
326
657
 
327
658
  sd
328
659
  end
329
660
 
330
661
  # Extract workbook-level facade metadata
331
662
  wb_facade = workbook.unmapped_data[:facade] || {}
663
+ wb_properties = workbook.unmapped_data[:workbook_properties] || wb_facade[:workbook_properties]
664
+ raw_dns = wb_facade[:defined_names] || (workbook.respond_to?(:defined_names) && !workbook.defined_names.empty? ? workbook.defined_names : nil)
665
+ defined_names = raw_dns ? raw_dns.map(&:dup) : []
666
+
667
+ # Auto-generate _xlnm.Print_Area and _xlnm.Print_Titles from worksheet definitions if not already present
668
+ workbook.sheets.each_with_index do |ws, idx|
669
+ sheet_name = ws.name || "Sheet#{idx + 1}"
670
+ 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) }
671
+ val = "'#{sheet_name}'!#{DslHelpers.absolute_range(ws.print_area)}"
672
+ defined_names << { name: "_xlnm.Print_Area", value: val, local_sheet_id: idx }
673
+ end
674
+ 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) }
675
+
676
+ pt = ws.print_titles
677
+ parts = []
678
+ parts << "'#{sheet_name}'!$#{pt[:cols].sub(":", ":$")}" if pt[:cols]
679
+ parts << "'#{sheet_name}'!$#{pt[:rows].sub(":", ":$")}" if pt[:rows]
680
+ defined_names << { name: "_xlnm.Print_Titles", value: parts.join(","), local_sheet_id: idx } unless parts.empty?
681
+ end
682
+ defined_names = nil if defined_names.empty?
332
683
 
333
684
  if password && !password.empty?
334
685
  buf = StringIO.new
@@ -339,12 +690,12 @@ module Xlsxrb
339
690
  shared_strings: sst,
340
691
  shared_strings_index: sst_index,
341
692
  styles: workbook.styles,
342
- defined_names: wb_facade[:defined_names],
693
+ defined_names: defined_names,
343
694
  core_properties: wb_facade[:core_properties],
344
695
  app_properties: wb_facade[:app_properties],
345
696
  custom_properties: wb_facade[:custom_properties],
346
697
  workbook_protection: wb_facade[:workbook_protection],
347
- workbook_properties: wb_facade[:workbook_properties]
698
+ workbook_properties: wb_properties
348
699
  )
349
700
  encrypted_bytes = Ooxml::Crypto.encrypt(buf.string.b, password, mode: encryption_mode)
350
701
  if target.is_a?(String)
@@ -359,12 +710,12 @@ module Xlsxrb
359
710
  shared_strings: sst,
360
711
  shared_strings_index: sst_index,
361
712
  styles: workbook.styles,
362
- defined_names: wb_facade[:defined_names],
713
+ defined_names: defined_names,
363
714
  core_properties: wb_facade[:core_properties],
364
715
  app_properties: wb_facade[:app_properties],
365
716
  custom_properties: wb_facade[:custom_properties],
366
717
  workbook_protection: wb_facade[:workbook_protection],
367
- workbook_properties: wb_facade[:workbook_properties]
718
+ workbook_properties: wb_properties
368
719
  )
369
720
  end
370
721
  end
@@ -422,7 +773,8 @@ module Xlsxrb
422
773
  initial_sst_size = shared_strings.size
423
774
 
424
775
  workbook_xml = zip_reader.read_entry("xl/workbook.xml")
425
- workbook_sheets = workbook_xml ? Ooxml::WorkbookParser.parse(workbook_xml) : []
776
+ wb_parsed = workbook_xml ? Ooxml::WorkbookParser.parse_with_properties(workbook_xml) : { sheets: [], date1904: false, defined_names: [] }
777
+ workbook_sheets = wb_parsed[:sheets]
426
778
 
427
779
  rels_xml = zip_reader.read_entry("xl/_rels/workbook.xml.rels")
428
780
  rels = rels_xml ? Ooxml::RelationshipsParser.parse(rels_xml) : {}
@@ -440,13 +792,14 @@ module Xlsxrb
440
792
 
441
793
  sheet_entry_map[sheet_info[:name]] = sheet_path
442
794
  sheet_xml = zip_reader.read_entry(sheet_path) || ""
443
- build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles)
795
+ build_worksheet(sheet_info[:name], sheet_xml, shared_strings, styles, state: sheet_info[:state] || :visible, zip_reader: zip_reader, entry_name: sheet_path)
444
796
  end.compact
445
797
 
446
798
  original_workbook = Elements::Workbook.new(
447
799
  sheets: loaded_sheets,
448
800
  shared_strings: shared_strings,
449
- styles: styles
801
+ styles: styles,
802
+ defined_names: wb_parsed[:defined_names] || []
450
803
  )
451
804
  mutable_shared_strings = shared_strings.dup
452
805
 
@@ -475,6 +828,51 @@ module Xlsxrb
475
828
  )
476
829
  updated_sheet_xml = replace_sheet_data(orig_sheet_xml, new_sheet_data)
477
830
  updated_sheet_xml = update_dimension(updated_sheet_xml, new_sheet.rows)
831
+
832
+ all_hyperlinks = new_sheet.hyperlinks.dup
833
+ new_sheet.rows.each do |row|
834
+ row.cells.each do |cell|
835
+ all_hyperlinks[cell.ref] ||= cell.hyperlink if cell.hyperlink
836
+ end
837
+ end
838
+
839
+ if all_hyperlinks != orig_sheet.hyperlinks
840
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
841
+ orig_rels_xml = replaced_entries[rels_path] || zip_reader.read_entry(rels_path)
842
+ updated_sheet_xml, updated_rels_xml = update_sheet_hyperlinks(
843
+ updated_sheet_xml,
844
+ orig_rels_xml,
845
+ all_hyperlinks
846
+ )
847
+ replaced_entries[rels_path] = updated_rels_xml if updated_rels_xml
848
+ end
849
+
850
+ all_comments = new_sheet.comments.dup
851
+ new_sheet.rows.each do |row|
852
+ row.cells.each do |cell|
853
+ if cell.comment && all_comments.none? { |c| (c[:ref] || c[:cell])&.to_s&.upcase == cell.ref.upcase }
854
+ entry = cell.comment.is_a?(Hash) ? cell.comment.merge(ref: cell.ref) : { ref: cell.ref, text: cell.comment }
855
+ all_comments << entry
856
+ end
857
+ end
858
+ end
859
+
860
+ if all_comments != orig_sheet.comments
861
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
862
+ orig_rels_xml = replaced_entries[rels_path] || zip_reader.read_entry(rels_path)
863
+ updated_sheet_xml, updated_rels_xml = update_sheet_comments(
864
+ updated_sheet_xml,
865
+ rels_path,
866
+ orig_rels_xml,
867
+ all_comments,
868
+ zip_reader,
869
+ idx,
870
+ entry_name,
871
+ replaced_entries
872
+ )
873
+ replaced_entries[rels_path] = updated_rels_xml if updated_rels_xml
874
+ end
875
+
478
876
  replaced_entries[entry_name] = updated_sheet_xml
479
877
  end
480
878
 
@@ -546,59 +944,445 @@ module Xlsxrb
546
944
  class << self
547
945
  private
548
946
 
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?
947
+ #: (String name, String? sheet_xml, Array[String] shared_strings, untyped styles, ?state: Symbol, ?zip_reader: Ooxml::ZipReader?, ?entry_name: String?, ?hyperlinks: Hash[String, 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, ?merged_cells: Array[String]?, ?auto_filter: String?, ?tables: Array[Hash[Symbol, untyped]]?, ?shapes: Array[Hash[Symbol, untyped]]?, ?freeze_pane: Hash[Symbol, untyped]?, ?page_setup: Hash[Symbol, untyped]?, ?page_margins: Hash[Symbol, untyped]?, ?header_footer: Hash[Symbol, untyped]?, ?print_options: Hash[Symbol, untyped]?, ?sheet_protection: Hash[Symbol, untyped]?, ?sheet_view: Hash[Symbol, untyped]?, ?sheet_properties: Hash[Symbol, untyped]?, ?sheet_format: Hash[Symbol, untyped]?, ?row_breaks: Array[untyped]?, ?col_breaks: Array[untyped]?, ?protected_ranges: Array[Hash[Symbol, untyped]]?, ?cell_watches: Array[String]?) -> Elements::Worksheet
948
+ 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, merged_cells: nil, auto_filter: nil, tables: nil, shapes: nil, freeze_pane: nil, page_setup: nil, page_margins: nil, header_footer: nil, print_options: nil, sheet_protection: nil, sheet_view: nil, sheet_properties: nil, sheet_format: nil, row_breaks: nil, col_breaks: nil, protected_ranges: nil, cell_watches: nil)
949
+ sheet_dimension = dimension || sheet_xml&.slice(/<(?:[a-zA-Z0-9_]+:)?dimension\b[^>]*\bref=["']([^"']+)["']/, 1)
950
+ if sheet_xml.nil? || sheet_xml.empty?
951
+ return Elements::Worksheet.new(
952
+ name: name, state: state, styles: styles, images: images || [], date1904: date1904,
953
+ dimension: sheet_dimension, trim_empty_rows: trim_empty_rows, pad_empty_rows: pad_empty_rows, pad_empty_cells: pad_empty_cells,
954
+ merged_cells: merged_cells || [], auto_filter: auto_filter,
955
+ tables: tables || [], shapes: shapes || [], freeze_pane: freeze_pane,
956
+ page_setup: page_setup || {}, page_margins: page_margins, header_footer: header_footer || {},
957
+ print_options: print_options || {}, sheet_protection: sheet_protection, sheet_view: sheet_view || {},
958
+ sheet_properties: sheet_properties || {}, sheet_format: sheet_format || {}, row_breaks: row_breaks || [], col_breaks: col_breaks || [],
959
+ protected_ranges: protected_ranges || [], cell_watches: cell_watches || []
960
+ )
961
+ end
552
962
 
553
- raw_rows = Ooxml::WorksheetParser.parse(sheet_xml, shared_strings: shared_strings)
963
+ sheet_hyperlinks = hyperlinks || resolve_hyperlinks(sheet_xml, zip_reader: zip_reader, entry_name: entry_name)
964
+ sheet_comments = comments || resolve_comments(zip_reader: zip_reader, entry_name: entry_name)
965
+ sheet_images = images || resolve_images(zip_reader: zip_reader, entry_name: entry_name)
966
+ raw_rows = Ooxml::WorksheetParser.parse(sheet_xml, shared_strings: shared_strings, styles: styles)
554
967
  raw_columns = Ooxml::WorksheetParser.parse_columns(sheet_xml)
555
968
 
556
- rows = raw_rows.map { |rr| build_row_from_raw(rr) }
969
+ rows = raw_rows.map { |rr| build_row_from_raw(rr, sheet_hyperlinks, sheet_comments, styles, date1904: date1904) }
557
970
  columns = raw_columns.map do |rc|
558
971
  # Columns from OOXML are 1-based min/max ranges; convert to 0-based
972
+ col_unmapped = {}
973
+ col_unmapped[:style_index] = rc[:style_index] if rc[:style_index]
559
974
  Elements::Column.new(
560
975
  index: (rc[:min] || 1) - 1,
561
976
  width: rc[:width],
562
977
  hidden: rc[:hidden] || false,
563
978
  custom_width: rc[:custom_width] || false,
564
- outline_level: rc[:outline_level]
979
+ outline_level: rc[:outline_level],
980
+ style_index: rc[:style_index],
981
+ unmapped_data: col_unmapped
982
+ )
983
+ end
984
+
985
+ cfs = if sheet_xml.include?("<conditionalFormatting")
986
+ listener = Ooxml::Reader::ConditionalFormattingListener.new
987
+ Ooxml::XmlParser.parse(sheet_xml, listener)
988
+ listener.rules
989
+ else
990
+ []
991
+ end
992
+
993
+ dvs = if sheet_xml.include?("<dataValidation")
994
+ listener = Ooxml::Reader::DataValidationsListener.new
995
+ Ooxml::XmlParser.parse(sheet_xml, listener)
996
+ listener.validations
997
+ else
998
+ []
999
+ end
1000
+
1001
+ sheet_merged_cells = merged_cells || if sheet_xml.include?("<mergeCell")
1002
+ listener = Ooxml::Reader::MergeCellsListener.new
1003
+ Ooxml::XmlParser.parse(sheet_xml, listener)
1004
+ listener.ranges
1005
+ else
1006
+ []
1007
+ end
1008
+
1009
+ sheet_auto_filter = auto_filter || if sheet_xml.include?("<autoFilter")
1010
+ listener = Ooxml::Reader::AutoFilterListener.new
1011
+ Ooxml::XmlParser.parse(sheet_xml, listener)
1012
+ listener.ref
1013
+ end
1014
+
1015
+ sheet_tables = tables || resolve_tables(zip_reader: zip_reader, entry_name: entry_name)
1016
+ sheet_shapes = shapes || resolve_shapes(zip_reader: zip_reader, entry_name: entry_name)
1017
+
1018
+ sheet_view_data = if freeze_pane.nil? || sheet_view.nil?
1019
+ if sheet_xml.include?("<sheetView")
1020
+ v_listener = Ooxml::Reader::SheetViewListener.new
1021
+ Ooxml::XmlParser.parse(sheet_xml, v_listener)
1022
+ { view: v_listener.view, pane: v_listener.pane }
1023
+ else
1024
+ { view: {}, pane: nil }
1025
+ end
1026
+ else
1027
+ { view: sheet_view, pane: freeze_pane }
1028
+ end
1029
+ sheet_freeze_pane = freeze_pane || sheet_view_data[:pane]
1030
+ sheet_view_val = sheet_view || sheet_view_data[:view] || {}
1031
+
1032
+ sheet_prot = sheet_protection || if sheet_xml.include?("<sheetProtection")
1033
+ prot_listener = Ooxml::Reader::SheetProtectionListener.new
1034
+ Ooxml::XmlParser.parse(sheet_xml, prot_listener)
1035
+ prot_listener.protection
1036
+ end
1037
+
1038
+ sheet_props = sheet_properties || if sheet_xml.include?("<sheetPr")
1039
+ pr_listener = Ooxml::Reader::SheetPropertiesListener.new
1040
+ Ooxml::XmlParser.parse(sheet_xml, pr_listener)
1041
+ pr_listener.properties
1042
+ else
1043
+ {}
1044
+ end
1045
+
1046
+ sheet_fmt = sheet_format || if sheet_xml.include?("<sheetFormatPr")
1047
+ fmt_listener = Ooxml::Reader::SheetFormatListener.new
1048
+ Ooxml::XmlParser.parse(sheet_xml, fmt_listener)
1049
+ fmt_listener.properties
1050
+ else
1051
+ {}
1052
+ end
1053
+
1054
+ print_data = if page_setup.nil? || page_margins.nil? || header_footer.nil? || print_options.nil? || row_breaks.nil? || col_breaks.nil?
1055
+ if sheet_xml.match?(/<(?:pageSetup|pageMargins|headerFooter|printOptions|rowBreaks|colBreaks)\b/)
1056
+ pg_listener = Ooxml::Reader::PrintPageListener.new
1057
+ Ooxml::XmlParser.parse(sheet_xml, pg_listener)
1058
+ {
1059
+ page_margins: pg_listener.page_margins,
1060
+ page_setup: pg_listener.page_setup,
1061
+ header_footer: pg_listener.header_footer,
1062
+ print_options: pg_listener.print_options,
1063
+ row_breaks: pg_listener.row_breaks,
1064
+ col_breaks: pg_listener.col_breaks
1065
+ }
1066
+ else
1067
+ { page_margins: nil, page_setup: {}, header_footer: {}, print_options: {}, row_breaks: [], col_breaks: [] }
1068
+ end
1069
+ else
1070
+ { page_margins: page_margins, page_setup: page_setup, header_footer: header_footer, print_options: print_options, row_breaks: row_breaks, col_breaks: col_breaks }
1071
+ end
1072
+
1073
+ sheet_prot_ranges = protected_ranges || if sheet_xml.include?("<protectedRanges")
1074
+ pr_listener = Ooxml::Reader::ProtectedRangesListener.new
1075
+ Ooxml::XmlParser.parse(sheet_xml, pr_listener)
1076
+ pr_listener.ranges
1077
+ else
1078
+ []
1079
+ end
1080
+
1081
+ sheet_watches = cell_watches || if sheet_xml.include?("<cellWatches")
1082
+ cw_listener = Ooxml::Reader::CellWatchesListener.new
1083
+ Ooxml::XmlParser.parse(sheet_xml, cw_listener)
1084
+ cw_listener.watches
1085
+ else
1086
+ []
1087
+ end
1088
+
1089
+ Elements::Worksheet.new(
1090
+ name: name,
1091
+ rows: rows,
1092
+ columns: columns,
1093
+ conditional_formatting: cfs,
1094
+ data_validations: dvs,
1095
+ state: state,
1096
+ hyperlinks: sheet_hyperlinks,
1097
+ comments: sheet_comments,
1098
+ images: sheet_images,
1099
+ styles: styles,
1100
+ date1904: date1904,
1101
+ dimension: sheet_dimension,
1102
+ trim_empty_rows: trim_empty_rows,
1103
+ pad_empty_rows: pad_empty_rows,
1104
+ pad_empty_cells: pad_empty_cells,
1105
+ merged_cells: sheet_merged_cells,
1106
+ auto_filter: sheet_auto_filter,
1107
+ tables: sheet_tables,
1108
+ shapes: sheet_shapes,
1109
+ freeze_pane: sheet_freeze_pane,
1110
+ page_setup: page_setup || print_data[:page_setup] || {},
1111
+ page_margins: page_margins || print_data[:page_margins],
1112
+ header_footer: header_footer || print_data[:header_footer] || {},
1113
+ print_options: print_options || print_data[:print_options] || {},
1114
+ sheet_protection: sheet_prot,
1115
+ sheet_view: sheet_view_val,
1116
+ sheet_properties: sheet_props,
1117
+ sheet_format: sheet_fmt,
1118
+ row_breaks: row_breaks || print_data[:row_breaks] || [],
1119
+ col_breaks: col_breaks || print_data[:col_breaks] || [],
1120
+ protected_ranges: sheet_prot_ranges,
1121
+ cell_watches: sheet_watches
1122
+ )
1123
+ end
1124
+
1125
+ #: (String? sheet_xml, ?zip_reader: Ooxml::ZipReader?, ?entry_name: String?, ?rels_xml: String?) -> Hash[String, Hash[Symbol, untyped]]
1126
+ def resolve_hyperlinks(sheet_xml, zip_reader: nil, entry_name: nil, rels_xml: nil)
1127
+ return {} if sheet_xml.nil? || !sheet_xml.include?("hyperlink")
1128
+
1129
+ links = Ooxml::WorksheetParser.parse_hyperlinks(sheet_xml)
1130
+ return {} if links.empty?
1131
+
1132
+ rid_to_url = {}
1133
+ if rels_xml && !rels_xml.empty?
1134
+ rid_to_url = Ooxml::RelationshipsParser.parse(rels_xml)
1135
+ elsif zip_reader && entry_name
1136
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
1137
+ rels_content = zip_reader.read_entry(rels_path)
1138
+ rid_to_url = Ooxml::RelationshipsParser.parse(rels_content) if rels_content && !rels_content.empty?
1139
+ end
1140
+
1141
+ result = {}
1142
+ links.each do |link|
1143
+ entry = {}
1144
+ if link[:rid]
1145
+ url = rid_to_url[link[:rid]]
1146
+ entry[:url] = url if url
1147
+ end
1148
+ entry[:display] = link[:display] if link[:display]
1149
+ entry[:tooltip] = link[:tooltip] if link[:tooltip]
1150
+ entry[:location] = link[:location] if link[:location]
1151
+ result[link[:ref]] = entry unless entry.empty?
1152
+ end
1153
+ result
1154
+ end
1155
+
1156
+ #: (String base_dir, String target) -> String
1157
+ def resolve_relative_path(base_dir, target)
1158
+ if target.start_with?("/")
1159
+ target.delete_prefix("/")
1160
+ elsif target.start_with?("..")
1161
+ parts = base_dir.split("/") + target.split("/")
1162
+ resolved = []
1163
+ parts.each { |p| p == ".." ? resolved.pop : resolved << p }
1164
+ resolved.join("/")
1165
+ else
1166
+ "#{base_dir}/#{target}"
1167
+ end
1168
+ end
1169
+
1170
+ #: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Hash[Symbol, untyped]]
1171
+ def resolve_comments(zip_reader: nil, entry_name: nil)
1172
+ return [] unless zip_reader && entry_name
1173
+
1174
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
1175
+ rels_xml = zip_reader.read_entry(rels_path)
1176
+ return [] if rels_xml.nil? || rels_xml.empty?
1177
+
1178
+ parser = REXML::Parsers::SAX2Parser.new(rels_xml)
1179
+ listener = Ooxml::Reader::RelsListener.new
1180
+ parser.listen(listener)
1181
+ parser.parse
1182
+
1183
+ rel = listener.relationships.find { |r| r[:type]&.end_with?("/comments") }
1184
+ return [] unless rel
1185
+
1186
+ base_dir = File.dirname(entry_name)
1187
+ comments_path = resolve_relative_path(base_dir, rel[:target])
1188
+
1189
+ xml = zip_reader.read_entry(comments_path)
1190
+ return [] if xml.nil? || xml.empty?
1191
+
1192
+ c_parser = REXML::Parsers::SAX2Parser.new(xml)
1193
+ c_listener = Ooxml::Reader::CommentsListener.new
1194
+ c_parser.listen(c_listener)
1195
+ c_parser.parse
1196
+ c_listener.comments
1197
+ end
1198
+
1199
+ #: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Elements::Image]
1200
+ def resolve_images(zip_reader: nil, entry_name: nil)
1201
+ return [] unless zip_reader && entry_name
1202
+
1203
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
1204
+ rels_xml = zip_reader.read_entry(rels_path)
1205
+ return [] if rels_xml.nil? || rels_xml.empty?
1206
+
1207
+ parser = REXML::Parsers::SAX2Parser.new(rels_xml)
1208
+ listener = Ooxml::Reader::RelsListener.new
1209
+ parser.listen(listener)
1210
+ parser.parse
1211
+
1212
+ drawing_rel = listener.relationships.find { |r| r[:type]&.end_with?("/drawing") }
1213
+ return [] unless drawing_rel
1214
+
1215
+ base_dir = File.dirname(entry_name)
1216
+ target = drawing_rel[:target]
1217
+ drawing_path = resolve_relative_path(base_dir, target)
1218
+ drawing_xml = zip_reader.read_entry(drawing_path)
1219
+ return [] if drawing_xml.nil? || drawing_xml.empty?
1220
+
1221
+ drawing_rels_path = drawing_path.sub(%r{([^/]+)$}, '_rels/\1.rels')
1222
+ drawing_rels_xml = zip_reader.read_entry(drawing_rels_path)
1223
+ drawing_rels = {}
1224
+ if drawing_rels_xml && !drawing_rels_xml.empty?
1225
+ d_parser = REXML::Parsers::SAX2Parser.new(drawing_rels_xml)
1226
+ d_listener = Ooxml::Reader::RelsListener.new
1227
+ d_parser.listen(d_listener)
1228
+ d_parser.parse
1229
+ d_listener.relationships.each do |r|
1230
+ drawing_rels[r[:id]] = r[:target]
1231
+ end
1232
+ end
1233
+
1234
+ p_parser = REXML::Parsers::SAX2Parser.new(drawing_xml)
1235
+ img_listener = Ooxml::Reader::DrawingImagesListener.new
1236
+ p_parser.listen(img_listener)
1237
+ p_parser.parse
1238
+
1239
+ drawing_dir = File.dirname(drawing_path)
1240
+ img_listener.images.map do |raw_img|
1241
+ rid = raw_img[:embed_rid]
1242
+ media_target = drawing_rels[rid]
1243
+ img_data = nil
1244
+ filename = nil
1245
+ if media_target
1246
+ media_path = resolve_relative_path(drawing_dir, media_target)
1247
+ filename = File.basename(media_path)
1248
+ img_data = zip_reader.read_entry(media_path)
1249
+ end
1250
+
1251
+ from_col = raw_img[:from_col] || 0
1252
+ from_row = raw_img[:from_row] || 0
1253
+ to_col = raw_img[:to_col]
1254
+ to_row = raw_img[:to_row]
1255
+ cx = raw_img[:cx]
1256
+ cy = raw_img[:cy]
1257
+ name = raw_img[:name]
1258
+
1259
+ Elements::Image.new(
1260
+ filename: filename || "image.png",
1261
+ name: name,
1262
+ data: img_data,
1263
+ from_col: from_col,
1264
+ from_row: from_row,
1265
+ to_col: to_col,
1266
+ to_row: to_row,
1267
+ cx: cx,
1268
+ cy: cy,
1269
+ unmapped_data: raw_img
565
1270
  )
566
1271
  end
1272
+ end
567
1273
 
568
- Elements::Worksheet.new(name: name, rows: rows, columns: columns)
1274
+ #: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Hash[Symbol, untyped]]
1275
+ def resolve_tables(zip_reader: nil, entry_name: nil)
1276
+ return [] unless zip_reader && entry_name
1277
+
1278
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
1279
+ rels_xml = zip_reader.read_entry(rels_path)
1280
+ return [] if rels_xml.nil? || rels_xml.empty?
1281
+
1282
+ parser = REXML::Parsers::SAX2Parser.new(rels_xml)
1283
+ listener = Ooxml::Reader::RelsListener.new
1284
+ parser.listen(listener)
1285
+ parser.parse
1286
+
1287
+ table_rels = listener.relationships.select { |r| r[:type]&.end_with?("/table") }
1288
+ return [] if table_rels.empty?
1289
+
1290
+ base_dir = File.dirname(entry_name)
1291
+ table_rels.filter_map do |rel|
1292
+ tbl_path = resolve_relative_path(base_dir, rel[:target])
1293
+ tbl_xml = zip_reader.read_entry(tbl_path)
1294
+ next if tbl_xml.nil? || tbl_xml.empty?
1295
+
1296
+ t_parser = REXML::Parsers::SAX2Parser.new(tbl_xml)
1297
+ t_listener = Ooxml::Reader::TableListener.new
1298
+ t_parser.listen(t_listener)
1299
+ t_parser.parse
1300
+ t_listener.table
1301
+ end
569
1302
  end
570
1303
 
571
- #: (untyped raw_row) -> (Elements::Row | untyped)
572
- def build_row_from_raw(raw_row)
1304
+ #: (?zip_reader: Ooxml::ZipReader?, ?entry_name: String?) -> Array[Hash[Symbol, untyped]]
1305
+ def resolve_shapes(zip_reader: nil, entry_name: nil)
1306
+ return [] unless zip_reader && entry_name
1307
+
1308
+ rels_path = entry_name.sub(%r{([^/]+)$}, '_rels/\1.rels')
1309
+ rels_xml = zip_reader.read_entry(rels_path)
1310
+ return [] if rels_xml.nil? || rels_xml.empty?
1311
+
1312
+ parser = REXML::Parsers::SAX2Parser.new(rels_xml)
1313
+ listener = Ooxml::Reader::RelsListener.new
1314
+ parser.listen(listener)
1315
+ parser.parse
1316
+
1317
+ drawing_rel = listener.relationships.find { |r| r[:type]&.end_with?("/drawing") }
1318
+ return [] unless drawing_rel
1319
+
1320
+ base_dir = File.dirname(entry_name)
1321
+ drawing_path = resolve_relative_path(base_dir, drawing_rel[:target])
1322
+ drawing_xml = zip_reader.read_entry(drawing_path)
1323
+ return [] if drawing_xml.nil? || drawing_xml.empty?
1324
+
1325
+ d_parser = REXML::Parsers::SAX2Parser.new(drawing_xml)
1326
+ d_listener = Ooxml::Reader::DrawingShapesListener.new
1327
+ d_parser.listen(d_listener)
1328
+ d_parser.parse
1329
+ d_listener.shapes
1330
+ end
1331
+
1332
+ #: (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)
1333
+ def build_row_from_raw(raw_row, hyperlinks = {}, comments = [], styles = nil, date1904: false)
573
1334
  return raw_row if raw_row.is_a?(Elements::Row)
574
1335
 
1336
+ comments_map = if comments.is_a?(Hash)
1337
+ comments
1338
+ else
1339
+ comments.each_with_object({}) { |c, acc| (r = c[:ref] || c[:cell]) && (acc[r.to_s.upcase] = c) }
1340
+ end
1341
+
575
1342
  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
1343
+ cells = raw_cells.map do |rc|
1344
+ if rc.is_a?(Elements::Cell)
1345
+ hl = hyperlinks && !hyperlinks.empty? ? hyperlinks[rc.ref] : nil
1346
+ cm = comments_map && !comments_map.empty? ? comments_map[rc.ref] : nil
1347
+ fmt = rc.format_code || (NumberFormatter.format_code_for(rc.style_index, styles) if styles && rc.style_index)
1348
+ if hl || cm || (fmt && rc.format_code != fmt) || (date1904 && !rc.date1904?)
1349
+ rc.with(hyperlink: hl || rc.hyperlink, comment: cm || rc.comment, format_code: fmt || rc.format_code, date1904: date1904 || rc.date1904?)
1350
+ else
1351
+ rc
1352
+ end
1353
+ else
1354
+ parsed = Elements::Cell.parse_ref(rc[:ref]) if rc[:ref]
1355
+ row_idx = parsed ? parsed[0] : raw_row[:index]
1356
+ col_idx = parsed ? parsed[1] : 0
1357
+ ref = rc[:ref] || "#{Elements::Cell.column_letter(col_idx)}#{row_idx + 1}"
1358
+ hl = hyperlinks ? hyperlinks[ref] : nil
1359
+ cm = comments_map ? comments_map[ref] : nil
1360
+ fmt = rc[:format_code] || (NumberFormatter.format_code_for(rc[:style_index], styles) if styles && rc[:style_index])
1361
+
1362
+ val = rc[:value]
1363
+ raw_val = rc[:raw_value] || val&.to_s
1364
+ cell_errors = Elements::Cell.validate(row_idx, col_idx, val)
1365
+ if !cell_errors.empty? && rc[:source]
1366
+ cell_errors = cell_errors.map do |err|
1367
+ "#{err} (at #{rc[:source][:part]} row #{rc[:source][:row] + 1} cell #{rc[:ref] || "unknown"})"
1368
+ end
1369
+ end
1370
+
1371
+ Elements::Cell.new(
1372
+ row_index: row_idx,
1373
+ column_index: col_idx,
1374
+ value: val,
1375
+ formula: rc[:formula],
1376
+ style_index: rc[:style_index],
1377
+ hyperlink: hl,
1378
+ comment: cm,
1379
+ raw_value: raw_val,
1380
+ format_code: fmt,
1381
+ errors: cell_errors,
1382
+ date1904: date1904
1383
+ )
1384
+ end
1385
+ end
602
1386
  attrs = raw_row[:attrs] || {}
603
1387
  row_errors = Elements::Row.validate(raw_row[:index], cells)
604
1388
  if !row_errors.empty? && raw_row[:source]
@@ -609,19 +1393,23 @@ module Xlsxrb
609
1393
  # simplecov:enable
610
1394
  end
611
1395
  end
1396
+ row_unmapped = {}
1397
+ row_unmapped[:style_index] = attrs[:style_index] if attrs[:style_index]
612
1398
  Elements::Row.new(
613
1399
  index: raw_row[:index],
614
1400
  cells: cells,
615
1401
  height: attrs[:height],
616
1402
  hidden: attrs[:hidden] || false,
617
1403
  custom_height: attrs[:custom_height] || false,
1404
+ collapsed: attrs[:collapsed] || false,
618
1405
  outline_level: attrs[:outline_level],
1406
+ unmapped_data: row_unmapped,
619
1407
  errors: row_errors
620
1408
  )
621
1409
  end
622
1410
 
623
- #: (Elements::Cell cell, Array[String] sst, Hash[String, Integer] sst_index) -> Hash[Symbol, untyped]
624
- def build_raw_cell(cell, sst, sst_index)
1411
+ #: (Elements::Cell cell, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) -> Hash[Symbol, untyped]
1412
+ def build_raw_cell(cell, sst, sst_index, date1904: false)
625
1413
  # simplecov:disable
626
1414
  # Edge case / untested delegation block
627
1415
  ref = cell.ref
@@ -645,9 +1433,9 @@ module Xlsxrb
645
1433
  result[:value] = value.code
646
1434
  result[:type] = "e"
647
1435
  when Date
648
- result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
1436
+ result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value, date1904: date1904)
649
1437
  when Time
650
- result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
1438
+ result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904)
651
1439
  # simplecov:enable
652
1440
  when NilClass
653
1441
  # empty cell
@@ -687,6 +1475,7 @@ module Xlsxrb
687
1475
  attrs[:height] = row.height if row.height
688
1476
  attrs[:hidden] = true if row.hidden
689
1477
  attrs[:custom_height] = true if row.custom_height
1478
+ attrs[:collapsed] = true if row.collapsed
690
1479
  attrs[:outline_level] = row.outline_level if row.outline_level
691
1480
  attrs
692
1481
  # simplecov:enable
@@ -695,12 +1484,13 @@ module Xlsxrb
695
1484
  #: (untyped source, String? password) -> untyped
696
1485
  def prepare_source_io(source, password)
697
1486
  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)
1487
+ bin_source = source.encoding == Encoding::BINARY ? source : source.b
1488
+ if bin_source.start_with?("PK\x03\x04".b) || bin_source.include?("\x00".b) || Ooxml::Cfb::Reader.cfb?(bin_source)
1489
+ if Ooxml::Cfb::Reader.cfb?(bin_source)
1490
+ decrypted_zip = Ooxml::Crypto.decrypt(bin_source, password)
701
1491
  StringIO.new(decrypted_zip)
702
1492
  else
703
- StringIO.new(source)
1493
+ StringIO.new(bin_source)
704
1494
  end
705
1495
  elsif File.file?(source)
706
1496
  first_bytes = begin
@@ -823,20 +1613,27 @@ module Xlsxrb
823
1613
 
824
1614
  #: (String sheet_xml, String new_sheet_data_xml) -> String
825
1615
  def replace_sheet_data(sheet_xml, new_sheet_data_xml)
826
- sheet_data_start = sheet_xml.index("<sheetData")
1616
+ sd_term = sheet_xml.index("sheetData")
1617
+ return sheet_xml unless sd_term
1618
+
1619
+ sheet_data_start = sheet_xml.rindex("<", sd_term)
827
1620
  return sheet_xml unless sheet_data_start
828
1621
 
829
- open_tag_end = sheet_xml.index(">", sheet_data_start)
1622
+ prefix_len = sd_term - (sheet_data_start + 1)
1623
+ tag_prefix = sheet_xml[sheet_data_start + 1, prefix_len]
1624
+
1625
+ open_tag_end = sheet_xml.index(">", sd_term)
830
1626
  return sheet_xml unless open_tag_end
831
1627
 
832
1628
  prefix = sheet_xml[0...sheet_data_start]
833
1629
  suffix = if sheet_xml.getbyte(open_tag_end - 1) == 47
834
1630
  sheet_xml[(open_tag_end + 1)..]
835
1631
  else
836
- close_tag_start = sheet_xml.index("</sheetData>", open_tag_end)
1632
+ close_tag = "</#{tag_prefix}sheetData>"
1633
+ close_tag_start = sheet_xml.index(close_tag, open_tag_end)
837
1634
  return sheet_xml unless close_tag_start
838
1635
 
839
- sheet_xml[(close_tag_start + 12)..]
1636
+ sheet_xml[(close_tag_start + close_tag.bytesize)..]
840
1637
  end
841
1638
 
842
1639
  "#{prefix}#{new_sheet_data_xml}#{suffix}"
@@ -861,11 +1658,204 @@ module Xlsxrb
861
1658
  sheet_xml.sub(/<dimension\b([^>]*)\bref="[^"]*"/, "<dimension\\1ref=\"#{dim_ref}\"")
862
1659
  end
863
1660
 
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)
1661
+ #: (String sheet_xml, String? orig_rels_xml, Hash[String, untyped] new_hyperlinks) -> [String, String?]
1662
+ def update_sheet_hyperlinks(sheet_xml, orig_rels_xml, new_hyperlinks)
1663
+ existing_rels = []
1664
+ max_rid_num = 0
1665
+ if orig_rels_xml && !orig_rels_xml.empty?
1666
+ orig_rels_xml.scan(%r{<Relationship\b([^>]*)/?>}) do |m|
1667
+ attrs = m[0]
1668
+ id = attrs[/Id="([^"]*)"/, 1]
1669
+ type = attrs[/Type="([^"]*)"/, 1]
1670
+ target = attrs[/Target="([^"]*)"/, 1]
1671
+ target_mode = attrs[/TargetMode="([^"]*)"/, 1]
1672
+ if id =~ /\ArId(\d+)\z/
1673
+ num = Regexp.last_match(1).to_i
1674
+ max_rid_num = num if num > max_rid_num
1675
+ end
1676
+ existing_rels << { id: id, type: type, target: target, target_mode: target_mode } unless type == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
1677
+ end
1678
+ end
1679
+
1680
+ sheet_rels = existing_rels.dup
1681
+ cur_rid = max_rid_num
1682
+ hl_elements = []
1683
+
1684
+ new_hyperlinks.each do |cell_ref, link|
1685
+ entry = link.is_a?(Hash) ? link.dup : { url: link.to_s }
1686
+ url = entry[:url]
1687
+ loc = entry[:location]
1688
+ if url&.start_with?("#")
1689
+ loc ||= url.delete_prefix("#")
1690
+ url = nil
1691
+ end
1692
+ display = entry[:display]
1693
+ tooltip = entry[:tooltip]
1694
+
1695
+ attrs = if url && !url.empty?
1696
+ cur_rid += 1
1697
+ rid = "rId#{cur_rid}"
1698
+ sheet_rels << {
1699
+ id: rid,
1700
+ type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
1701
+ target: url,
1702
+ target_mode: "External"
1703
+ }
1704
+ %(ref="#{cell_ref}" r:id="#{rid}")
1705
+ elsif loc && !loc.empty?
1706
+ %(ref="#{cell_ref}" location="#{Ooxml::XmlBuilder.escape(loc)}")
1707
+ else
1708
+ next
1709
+ end
1710
+
1711
+ attrs << %( display="#{Ooxml::XmlBuilder.escape(display)}") if display
1712
+ attrs << %( tooltip="#{Ooxml::XmlBuilder.escape(tooltip)}") if tooltip
1713
+ hl_elements << "<hyperlink #{attrs}/>"
1714
+ end
1715
+
1716
+ updated_sheet_xml = sheet_xml.dup
1717
+ updated_sheet_xml.sub!(/<worksheet\b/, '<worksheet xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"') if hl_elements.any? && !updated_sheet_xml.include?('xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"')
1718
+
1719
+ new_hyperlinks_xml = hl_elements.empty? ? "" : "<hyperlinks>#{hl_elements.join}</hyperlinks>"
1720
+
1721
+ if updated_sheet_xml.include?("<hyperlinks")
1722
+ updated_sheet_xml.sub!(%r{<hyperlinks\b[^>]*/\s*>|<hyperlinks\b[^>]*>.*?</hyperlinks>}m, new_hyperlinks_xml)
1723
+ elsif hl_elements.any?
1724
+ insert_pattern = %r{<(?:printOptions|pageMargins|pageSetup|headerFooter|rowBreaks|colBreaks|customProperties|cellWatches|ignoredErrors|smartTags|drawing|legacyDrawing|legacyDrawingHF|picture|oleObjects|controls|webPublishItems|tableParts|extLst)\b|</worksheet>}
1725
+ match = updated_sheet_xml.match(insert_pattern)
1726
+ if match
1727
+ updated_sheet_xml.insert(match.begin(0), new_hyperlinks_xml)
1728
+ else
1729
+ updated_sheet_xml << new_hyperlinks_xml
1730
+ end
1731
+ end
1732
+
1733
+ updated_rels_xml = if sheet_rels.empty? && (orig_rels_xml.nil? || orig_rels_xml.empty?)
1734
+ nil
1735
+ else
1736
+ io = StringIO.new
1737
+ b = Ooxml::XmlBuilder.new(io)
1738
+ b.declaration
1739
+ b.open_tag("Relationships", { xmlns: "http://schemas.openxmlformats.org/package/2006/relationships" })
1740
+ sheet_rels.each do |rel|
1741
+ rel_attrs = { Id: rel[:id], Type: rel[:type], Target: rel[:target] }
1742
+ rel_attrs[:TargetMode] = rel[:target_mode] if rel[:target_mode]
1743
+ b.empty_tag("Relationship", rel_attrs)
1744
+ end
1745
+ b.close_tag("Relationships")
1746
+ io.string
1747
+ end
1748
+
1749
+ [updated_sheet_xml, updated_rels_xml]
1750
+ end
1751
+
1752
+ #: (String sheet_xml, String _rels_path, String? orig_rels_xml, Array[Hash[Symbol, untyped]] new_comments, Ooxml::ZipReader zip_reader, Integer sheet_idx, String entry_name, Hash[String, String] replaced_entries) -> [String, String?]
1753
+ def update_sheet_comments(sheet_xml, _rels_path, orig_rels_xml, new_comments, zip_reader, sheet_idx, entry_name, replaced_entries)
1754
+ existing_rels = []
1755
+ max_rid_num = 0
1756
+ if orig_rels_xml && !orig_rels_xml.empty?
1757
+ orig_rels_xml.scan(%r{<Relationship\b([^>]*)/?>}) do |m|
1758
+ attrs = m[0]
1759
+ id = attrs[/Id="([^"]*)"/, 1]
1760
+ type = attrs[/Type="([^"]*)"/, 1]
1761
+ target = attrs[/Target="([^"]*)"/, 1]
1762
+ target_mode = attrs[/TargetMode="([^"]*)"/, 1]
1763
+ if id =~ /\ArId(\d+)\z/
1764
+ num = Regexp.last_match(1).to_i
1765
+ max_rid_num = num if num > max_rid_num
1766
+ end
1767
+ existing_rels << { id: id, type: type, target: target, target_mode: target_mode }
1768
+ end
1769
+ end
1770
+
1771
+ comments_rel = existing_rels.find { |r| r[:type]&.end_with?("/comments") }
1772
+ vml_rel = existing_rels.find { |r| r[:type]&.end_with?("/vmlDrawing") }
1773
+
1774
+ writer = Ooxml::Writer.new
1775
+ new_comments.each do |c|
1776
+ writer.add_comment(c[:cell] || c[:ref], c[:text], author: c[:author] || "Author")
1777
+ end
1778
+ comments_xml = writer.send(:generate_comments_xml, writer.comments)
1779
+ vml_xml = writer.send(:generate_vml_drawing_xml, writer.comments)
1780
+
1781
+ updated_sheet_xml = sheet_xml.dup
1782
+
1783
+ if comments_rel
1784
+ base_dir = File.dirname(entry_name)
1785
+ comments_path = resolve_relative_path(base_dir, comments_rel[:target])
1786
+ replaced_entries[comments_path] = comments_xml
1787
+ if vml_rel
1788
+ vml_path = resolve_relative_path(base_dir, vml_rel[:target])
1789
+ replaced_entries[vml_path] = vml_xml
1790
+ end
1791
+ elsif new_comments.any?
1792
+ existing_comment_count = zip_reader.entry_names.count { |n| n =~ %r{\Axl/comments\d+\.xml\z} }
1793
+ comment_file_num = existing_comment_count + sheet_idx + 1
1794
+ comments_path = "xl/comments#{comment_file_num}.xml"
1795
+ vml_path = "xl/drawings/vmlDrawing#{comment_file_num}.vml"
1796
+ replaced_entries[comments_path] = comments_xml
1797
+ replaced_entries[vml_path] = vml_xml
1798
+
1799
+ max_rid_num += 1
1800
+ c_rid = "rId#{max_rid_num}"
1801
+ existing_rels << {
1802
+ id: c_rid,
1803
+ type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
1804
+ target: "../comments#{comment_file_num}.xml"
1805
+ }
1806
+
1807
+ max_rid_num += 1
1808
+ vml_rid = "rId#{max_rid_num}"
1809
+ existing_rels << {
1810
+ id: vml_rid,
1811
+ type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing",
1812
+ target: "../drawings/vmlDrawing#{comment_file_num}.vml"
1813
+ }
1814
+
1815
+ unless updated_sheet_xml.include?("<legacyDrawing")
1816
+ legacy_tag = %(<legacyDrawing r:id="#{vml_rid}"/>)
1817
+ insert_pattern = %r{<(?:picture|oleObjects|controls|webPublishItems|tableParts|extLst)\b|</worksheet>}
1818
+ match = updated_sheet_xml.match(insert_pattern)
1819
+ if match
1820
+ updated_sheet_xml.insert(match.begin(0), legacy_tag)
1821
+ else
1822
+ updated_sheet_xml << legacy_tag
1823
+ end
1824
+ end
1825
+
1826
+ updated_sheet_xml.sub!(/<worksheet\b/, '<worksheet xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"') unless updated_sheet_xml.include?('xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"')
1827
+
1828
+ ct_xml = replaced_entries["[Content_Types].xml"] || zip_reader.read_entry("[Content_Types].xml")
1829
+ if ct_xml && !ct_xml.include?("comments#{comment_file_num}.xml")
1830
+ override_tag = %(<Override PartName="/#{comments_path}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/>)
1831
+ ct_xml = ct_xml.sub(%r{</Types>}, "#{override_tag}</Types>")
1832
+ unless ct_xml.include?('Extension="vml"')
1833
+ default_vml = %(<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>)
1834
+ ct_xml = ct_xml.sub(%r{</Types>}, "#{default_vml}</Types>")
1835
+ end
1836
+ replaced_entries["[Content_Types].xml"] = ct_xml
1837
+ end
1838
+ end
1839
+
1840
+ io = StringIO.new
1841
+ b = Ooxml::XmlBuilder.new(io)
1842
+ b.declaration
1843
+ b.open_tag("Relationships", { xmlns: "http://schemas.openxmlformats.org/package/2006/relationships" })
1844
+ existing_rels.each do |rel|
1845
+ rel_attrs = { Id: rel[:id], Type: rel[:type], Target: rel[:target] }
1846
+ rel_attrs[:TargetMode] = rel[:target_mode] if rel[:target_mode]
1847
+ b.empty_tag("Relationship", rel_attrs)
1848
+ end
1849
+ b.close_tag("Relationships")
1850
+
1851
+ [updated_sheet_xml, io.string]
1852
+ end
1853
+
1854
+ #: (Array[Elements::Row] rows, Array[untyped] shared_strings, Hash[untyped, Integer] sst_index, ?use_sst: bool, ?date1904: bool) -> String
1855
+ def serialize_sheet_data_xml(rows, shared_strings, sst_index, use_sst: true, date1904: false)
866
1856
  io = StringIO.new
867
1857
  io.write("<sheetData>")
868
- ws = Ooxml::WorksheetWriter.new(io)
1858
+ ws = Ooxml::WorksheetWriter.new(io, date1904: date1904)
869
1859
  ws.instance_variable_set(:@started, true)
870
1860
 
871
1861
  rows.each do |row|
@@ -915,8 +1905,8 @@ module Xlsxrb
915
1905
  # @param sst_index [Hash{String => Integer}] Shared strings index mapping.
916
1906
  # @return [Hash{Symbol => Object}] Raw cell hash.
917
1907
  # @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)
1908
+ #: (Integer row_index, Integer col_index, untyped value, Array[String] sst, Hash[String, Integer] sst_index, ?date1904: bool) -> Hash[Symbol, untyped]
1909
+ def self.build_raw_cell_from_value(row_index, col_index, value, sst, sst_index, date1904: false)
920
1910
  # simplecov:disable
921
1911
  # Edge case / untested delegation block
922
1912
  ref = "#{Elements::Cell.column_letter(col_index)}#{row_index + 1}"
@@ -943,9 +1933,9 @@ module Xlsxrb
943
1933
  result[:value] = value.code
944
1934
  result[:type] = "e"
945
1935
  when Date
946
- result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value)
1936
+ result[:value] = Xlsxrb::Ooxml::Utils.date_to_serial(value, date1904: date1904)
947
1937
  when Time
948
- result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value)
1938
+ result[:value] = Xlsxrb::Ooxml::Utils.datetime_to_serial(value, date1904: date1904)
949
1939
  # simplecov:enable
950
1940
  when NilClass
951
1941
  # empty cell