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
@@ -0,0 +1,429 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require "date"
6
+ require "time"
7
+
8
+ module Xlsxrb
9
+ # Formats cell values according to OpenXML formatCode strings.
10
+ # Evaluates and converts numeric, currency, percentage, date/time, scientific,
11
+ # and accounting format patterns into string representations.
12
+ #
13
+ # @api public
14
+ module NumberFormatter
15
+ # Built-in date and time format code strings.
16
+ DATE_PATTERNS = [
17
+ "mm-dd-yy", "d-mmm-yy", "d-mmm", "mmm-yy", "h:mm AM/PM", "h:mm:ss AM/PM", "h:mm", "h:mm:ss", "m/d/yy h:mm", "mm:ss", "[h]:mm:ss", "mmss.0"
18
+ ].freeze
19
+
20
+ class << self
21
+ # Formats a cell value according to an Excel format code string.
22
+ #
23
+ # @param value [Object, nil] The cell's raw or typed value.
24
+ # @param format_code [String, nil] Excel format code pattern.
25
+ # @param date1904 [Boolean] Whether the 1904 date system is active.
26
+ # @return [String] Formatted string representation.
27
+ # @api public
28
+ #: (untyped value, ?String? format_code, ?date1904: bool) -> String
29
+ def format(value, format_code = nil, date1904: false)
30
+ return "" if value.nil?
31
+ return value.code.to_s if value.respond_to?(:code)
32
+ return value ? "TRUE" : "FALSE" if [true, false].include?(value)
33
+
34
+ code = format_code.to_s.strip
35
+ return default_format(value) if code.empty? || code.casecmp("general").zero?
36
+
37
+ return format_datetime(value, code) if value.is_a?(Date) || value.is_a?(DateTime) || value.is_a?(Time)
38
+
39
+ if value.is_a?(Numeric) && date_format?(code)
40
+ dt = if value.is_a?(Float) && (value % 1).positive?
41
+ Ooxml::Utils.serial_to_datetime(value, date1904: date1904)
42
+ else
43
+ Ooxml::Utils.serial_to_date(value.to_i, date1904: date1904)
44
+ end
45
+ return format_datetime(dt, code)
46
+ end
47
+
48
+ return format_number(value, code) if value.is_a?(Numeric)
49
+
50
+ if code.include?("@")
51
+ sections = split_sections(code)
52
+ pattern = sections[3] || sections[0]
53
+ pattern.gsub("@", value.to_s)
54
+ else
55
+ value.to_s
56
+ end
57
+ end
58
+
59
+ # Resolves the format code string for a given style index from a styles definition hash.
60
+ #
61
+ # @param style_index [Integer, nil] 0-based style index in cellXfs.
62
+ # @param styles [Hash, nil] Parsed styles hash.
63
+ # @return [String, nil] Format code string, or nil if not found.
64
+ # @api public
65
+ #: (Integer? style_index, untyped styles) -> String?
66
+ def format_code_for(style_index, styles)
67
+ return nil unless style_index && styles
68
+ return styles.number_format(style_index) if styles.respond_to?(:number_format)
69
+
70
+ cell_xfs = styles[:cell_xfs]
71
+ return nil unless cell_xfs.is_a?(Array) && style_index < cell_xfs.size
72
+
73
+ xf = cell_xfs[style_index]
74
+ return nil unless xf.is_a?(Hash)
75
+
76
+ num_fmt_id = xf[:num_fmt_id]
77
+ if (num_fmt_id.nil? || num_fmt_id.zero?) && xf[:xf_id] && styles[:cell_style_xfs].is_a?(Array)
78
+ parent_xf = styles[:cell_style_xfs][xf[:xf_id]]
79
+ num_fmt_id = parent_xf[:num_fmt_id] if parent_xf.is_a?(Hash)
80
+ end
81
+
82
+ return nil unless num_fmt_id
83
+
84
+ num_fmts = styles[:num_fmts] || {}
85
+ num_fmts[num_fmt_id] || Ooxml::Utils::BUILTIN_NUM_FMT_CODES[num_fmt_id]
86
+ end
87
+
88
+ # Returns the classified format category for a format code string.
89
+ #
90
+ # @param format_code [String, nil] Format code string.
91
+ # @return [Symbol, nil] :date, :datetime, :time, :number, :text, :general, or nil.
92
+ # @api public
93
+ #: (String? format_code) -> Symbol?
94
+ def format_type(format_code)
95
+ return nil if format_code.nil?
96
+
97
+ code = format_code.to_s.strip
98
+ return :general if code.empty? || code.casecmp("general").zero?
99
+ return :text if code == "@"
100
+
101
+ case code
102
+ when "mm-dd-yy", "d-mmm-yy", "d-mmm", "mmm-yy", "yyyy\\-mm\\-dd", "yyyy-mm-dd", "m/d/yy", "m/d/yyyy"
103
+ return :date
104
+ when "m/d/yy h:mm", "m/d/yyyy h:mm", "yyyy\\-mm\\-dd\\ hh:mm:ss", "yyyy-mm-dd hh:mm:ss"
105
+ return :datetime
106
+ when "h:mm AM/PM", "h:mm:ss AM/PM", "h:mm", "h:mm:ss", "mm:ss", "[h]:mm:ss", "mmss.0"
107
+ return :time
108
+ end
109
+
110
+ stripped = code.gsub(/"[^"]*"/, "")
111
+ .gsub(/\\[.]/, "")
112
+ .gsub(/\[(?!h\]|m\]|s\]|hh\]|mm\]|ss\])[^\]]*\]/i, "")
113
+
114
+ has_date = stripped.match?(/[ydYD]/) || (stripped.match?(/[mM]/) && !stripped.match?(/[hsHS]/))
115
+ has_time = stripped.match?(/[hsHS]/)
116
+
117
+ if has_date && has_time
118
+ :datetime
119
+ elsif has_date
120
+ :date
121
+ elsif has_time
122
+ :time
123
+ elsif stripped.include?("@")
124
+ :text
125
+ else
126
+ :number
127
+ end
128
+ end
129
+
130
+ # Checks whether a format code string represents a date, datetime, or time pattern.
131
+ #
132
+ # @param format_code [String, nil]
133
+ # @param num_fmt_id [Integer, nil]
134
+ # @return [Boolean]
135
+ #: (String? format_code, ?Integer? num_fmt_id) -> bool
136
+ def date_format?(format_code, num_fmt_id = nil)
137
+ code = format_code
138
+ code = Ooxml::Utils::BUILTIN_NUM_FMT_CODES[num_fmt_id] if (code.nil? || code.empty?) && num_fmt_id
139
+ return false if code.nil? || code.empty?
140
+
141
+ type = format_type(code)
142
+ %i[date datetime time].include?(type)
143
+ end
144
+
145
+ # Checks whether a format code string represents a date-only pattern.
146
+ #
147
+ # @param format_code [String, nil]
148
+ # @param num_fmt_id [Integer, nil]
149
+ # @return [Boolean]
150
+ # @api public
151
+ #: (String? format_code, ?Integer? num_fmt_id) -> bool
152
+ def date_only_format?(format_code, num_fmt_id = nil)
153
+ code = format_code
154
+ code = Ooxml::Utils::BUILTIN_NUM_FMT_CODES[num_fmt_id] if (code.nil? || code.empty?) && num_fmt_id
155
+ return false if code.nil? || code.empty?
156
+
157
+ format_type(code) == :date
158
+ end
159
+
160
+ # Checks whether a format code string represents a datetime pattern.
161
+ #
162
+ # @param format_code [String, nil]
163
+ # @param num_fmt_id [Integer, nil]
164
+ # @return [Boolean]
165
+ # @api public
166
+ #: (String? format_code, ?Integer? num_fmt_id) -> bool
167
+ def datetime_format?(format_code, num_fmt_id = nil)
168
+ code = format_code
169
+ code = Ooxml::Utils::BUILTIN_NUM_FMT_CODES[num_fmt_id] if (code.nil? || code.empty?) && num_fmt_id
170
+ return false if code.nil? || code.empty?
171
+
172
+ format_type(code) == :datetime
173
+ end
174
+
175
+ # Checks whether a format code string represents a time-only pattern.
176
+ #
177
+ # @param format_code [String, nil]
178
+ # @param num_fmt_id [Integer, nil]
179
+ # @return [Boolean]
180
+ # @api public
181
+ #: (String? format_code, ?Integer? num_fmt_id) -> bool
182
+ def time_format?(format_code, num_fmt_id = nil)
183
+ code = format_code
184
+ code = Ooxml::Utils::BUILTIN_NUM_FMT_CODES[num_fmt_id] if (code.nil? || code.empty?) && num_fmt_id
185
+ return false if code.nil? || code.empty?
186
+
187
+ format_type(code) == :time
188
+ end
189
+
190
+ # Returns the format category Symbol for a style index and styles definition.
191
+ #
192
+ # @param style_index [Integer, nil]
193
+ # @param styles [untyped]
194
+ # @return [Symbol, nil]
195
+ # @api public
196
+ #: (Integer? style_index, untyped styles) -> Symbol?
197
+ def format_type_for(style_index, styles)
198
+ return styles.format_type(style_index) if styles.respond_to?(:format_type)
199
+
200
+ code = format_code_for(style_index, styles)
201
+ format_type(code)
202
+ end
203
+
204
+ # Checks whether a style index represents a date, datetime, or time format.
205
+ #
206
+ # @param style_index [Integer, nil]
207
+ # @param styles [untyped]
208
+ # @return [Boolean]
209
+ # @api public
210
+ #: (Integer? style_index, untyped styles) -> bool
211
+ def date_format_for?(style_index, styles)
212
+ return styles.date_format?(style_index) if styles.respond_to?(:date_format?)
213
+
214
+ code = format_code_for(style_index, styles)
215
+ date_format?(code)
216
+ end
217
+
218
+ # Checks whether a style index represents a datetime format.
219
+ #
220
+ # @param style_index [Integer, nil]
221
+ # @param styles [untyped]
222
+ # @return [Boolean]
223
+ # @api public
224
+ #: (Integer? style_index, untyped styles) -> bool
225
+ def datetime_format_for?(style_index, styles)
226
+ return styles.datetime_format?(style_index) if styles.respond_to?(:datetime_format?)
227
+
228
+ code = format_code_for(style_index, styles)
229
+ datetime_format?(code)
230
+ end
231
+
232
+ # Checks whether a style index represents a time format.
233
+ #
234
+ # @param style_index [Integer, nil]
235
+ # @param styles [untyped]
236
+ # @return [Boolean]
237
+ # @api public
238
+ #: (Integer? style_index, untyped styles) -> bool
239
+ def time_format_for?(style_index, styles)
240
+ return styles.time_format?(style_index) if styles.respond_to?(:time_format?)
241
+
242
+ code = format_code_for(style_index, styles)
243
+ time_format?(code)
244
+ end
245
+
246
+ # Checks whether a style index represents a date-only format.
247
+ #
248
+ # @param style_index [Integer, nil]
249
+ # @param styles [untyped]
250
+ # @return [Boolean]
251
+ # @api public
252
+ #: (Integer? style_index, untyped styles) -> bool
253
+ def date_only_format_for?(style_index, styles)
254
+ return styles.date_only_format?(style_index) if styles.respond_to?(:date_only_format?)
255
+
256
+ code = format_code_for(style_index, styles)
257
+ date_only_format?(code)
258
+ end
259
+
260
+ private
261
+
262
+ def default_format(value)
263
+ if value.is_a?(Float)
264
+ value == value.to_i ? value.to_i.to_s : value.to_s
265
+ else
266
+ value.to_s
267
+ end
268
+ end
269
+
270
+ def split_sections(code)
271
+ sections = []
272
+ current = String.new
273
+ in_quotes = false
274
+ code.each_char do |ch|
275
+ if ch == "\""
276
+ in_quotes = !in_quotes
277
+ current << ch
278
+ elsif ch == ";" && !in_quotes
279
+ sections << current
280
+ current = String.new
281
+ else
282
+ current << ch
283
+ end
284
+ end
285
+ sections << current
286
+ sections
287
+ end
288
+
289
+ def format_number(value, code)
290
+ sections = split_sections(code)
291
+ if sections.size == 1
292
+ pattern = sections[0]
293
+ is_negative = value.negative?
294
+ num = value.abs
295
+ elsif sections.size >= 2
296
+ if value.negative?
297
+ pattern = sections[1]
298
+ is_negative = false
299
+ num = value.abs
300
+ elsif value.zero? && sections.size >= 3
301
+ pattern = sections[2]
302
+ is_negative = false
303
+ num = 0
304
+ else
305
+ pattern = sections[0]
306
+ is_negative = false
307
+ num = value
308
+ end
309
+ end
310
+
311
+ return (is_negative ? "-#{num}" : num.to_s) if pattern == "@"
312
+
313
+ format_single_number(num, pattern, is_negative)
314
+ end
315
+
316
+ def format_single_number(num, pattern, is_neg)
317
+ clean = pattern.strip
318
+ prefix = ""
319
+ suffix = ""
320
+
321
+ if clean =~ /\A\\-/ || clean =~ /\A-/
322
+ prefix = "-"
323
+ clean = clean.sub(/\A\\-/, "").sub(/\A-/, "")
324
+ end
325
+
326
+ clean = clean.gsub(/_[-_()€$]/, "").gsub(/\* */, "").gsub("\\ ", "").gsub(/"[^"]*"/, "").strip if clean.include?("_-") || clean.include?("*-") || clean.include?("* ")
327
+
328
+ case clean
329
+ when /\A(\[Red\])?\((.*)\)\z/
330
+ prefix = "#{::Regexp.last_match(1)}("
331
+ suffix = ")"
332
+ clean = ::Regexp.last_match(2)
333
+ when /\A"([^"]+)"(.*)\z/, /\A\[\$([^\]-]+)[^\]]*\]\s*(.*)\z/, /\A([$€£¥])(.*)\z/
334
+ prefix = ::Regexp.last_match(1)
335
+ clean = ::Regexp.last_match(2)
336
+ end
337
+
338
+ if clean =~ /0\.0+E\+00/i
339
+ dec = clean[/0\.(0+)E/i, 1].size
340
+ formatted = Kernel.format("%.#{dec}E", num)
341
+ return "#{prefix}#{formatted}#{suffix}"
342
+ elsif clean =~ /##0\.0+E\+0/i
343
+ dec = clean[/##0\.(0+)E/i, 1].size
344
+ formatted = Kernel.format("%.#{dec}E", num)
345
+ return "#{prefix}#{formatted}#{suffix}"
346
+ end
347
+
348
+ if clean.end_with?("%")
349
+ clean = clean.sub(/%+\z/, "")
350
+ suffix = "%#{suffix}"
351
+ num = num.to_f * 100
352
+ end
353
+
354
+ if clean =~ /\A(0+)\z/
355
+ zeros = ::Regexp.last_match(1).size
356
+ formatted = Kernel.format("%0#{zeros}d", num.round)
357
+ return "#{"-" if is_neg}#{prefix}#{formatted}#{suffix}"
358
+ end
359
+
360
+ decimals = 0
361
+ decimals = ::Regexp.last_match(1).size if clean =~ /\.(0+)/
362
+
363
+ has_commas = clean.include?(",")
364
+
365
+ str = Kernel.format("%.#{decimals}f", num)
366
+ if has_commas
367
+ parts = str.split(".")
368
+ parts[0] = parts[0].reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
369
+ str = parts.join(".")
370
+ end
371
+
372
+ "#{"-" if is_neg}#{prefix}#{str}#{suffix}"
373
+ end
374
+
375
+ def format_datetime(datetime, format_code)
376
+ strftime_pattern = excel_to_strftime(format_code)
377
+ datetime.strftime(strftime_pattern)
378
+ rescue StandardError
379
+ datetime.strftime("%Y-%m-%d %H:%M:%S")
380
+ end
381
+
382
+ def excel_to_strftime(format_code)
383
+ code = format_code.dup
384
+
385
+ code.gsub!(%r{am/pm}i, "%p")
386
+ code.gsub!(%r{a/p}i, "%p")
387
+
388
+ code.gsub!(/\[h+\]/i, "[%-k]")
389
+
390
+ code.gsub!(/(h+:)(mm)/i, "\\1%M")
391
+ code.gsub!(/(h+:)(m)(?!%)/i, "\\1%-M")
392
+ code.gsub!(/(mm)(:s+)/i, "%M\\2")
393
+ code.gsub!(/(?<!%)(m)(:s+)/i, "%-M\\2")
394
+
395
+ code.gsub!(/ss/i, "%S")
396
+ code.gsub!(/(?<!%)s/i, "%-S")
397
+
398
+ code.gsub!("000", "%3N")
399
+ code.gsub!("00", "%2N")
400
+ code.gsub!("0", "%1N")
401
+
402
+ code.gsub!(/mmmm/i, "%B")
403
+ code.gsub!(/mmm/i, "%^b")
404
+ code.gsub!(/mm/i, "%m")
405
+ code.gsub!(/(?<!%)(?<![a-zA-Z])m(?![a-zA-Z])/i, "%-m")
406
+
407
+ if code.include?("%p")
408
+ code.gsub!(/hh/i, "%I")
409
+ code.gsub!(/(?<!%)h/i, "%-l")
410
+ else
411
+ code.gsub!(/hh/i, "%H")
412
+ code.gsub!(/(?<!%)h/i, "%-k")
413
+ end
414
+
415
+ code.gsub!(/dddd/i, "%A")
416
+ code.gsub!(/ddd/i, "%^a")
417
+ code.gsub!(/dd/i, "%d")
418
+ code.gsub!(/(?<!%)d/i, "%-d")
419
+
420
+ code.gsub!(/yyyy/i, "%Y")
421
+ code.gsub!(/yy/i, "%y")
422
+
423
+ code.gsub!(/\\(.)/, "\\1")
424
+
425
+ code
426
+ end
427
+ end
428
+ end
429
+ end
@@ -63,7 +63,7 @@ module Xlsxrb
63
63
  def self.cfb?(data)
64
64
  case data
65
65
  when String
66
- data.start_with?(MAGIC)
66
+ data.b.start_with?(MAGIC)
67
67
  else
68
68
  false
69
69
  end
@@ -292,11 +292,11 @@ module Xlsxrb
292
292
 
293
293
  attrs = {}
294
294
  ht = attributes["ht"]
295
- attrs[:height] = ht.to_f if ht && attributes["customHeight"] == "1"
296
- attrs[:hidden] = true if attributes["hidden"] == "1"
295
+ attrs[:height] = ht.to_f if ht && %w[1 true].include?(attributes["customHeight"])
296
+ attrs[:hidden] = true if %w[1 true].include?(attributes["hidden"])
297
297
  ol = attributes["outlineLevel"]
298
298
  attrs[:outline_level] = ol.to_i if ol && ol != "0"
299
- attrs[:collapsed] = true if attributes["collapsed"] == "1"
299
+ attrs[:collapsed] = true if %w[1 true].include?(attributes["collapsed"])
300
300
  s = attributes["s"]
301
301
  attrs[:style] = s.to_i if s
302
302
  attrs[:thick_top] = true if attributes["thickTop"] == "1"
@@ -401,7 +401,8 @@ module Xlsxrb
401
401
  when "workbook"
402
402
  @conformance = attributes["conformance"] if attributes["conformance"]
403
403
  when "sheet"
404
- @sheets << { name: attributes["name"], rid: attributes["r:id"], state: attributes["state"] }
404
+ rid = attributes["r:id"] || attributes["id"] || attributes.find { |k, _| k.end_with?(":id") }&.last
405
+ @sheets << { name: attributes["name"], rid: rid, state: attributes["state"] }
405
406
  when "fileVersion"
406
407
  an = attributes["appName"]
407
408
  @file_version[:app_name] = an if an
@@ -736,7 +737,8 @@ module Xlsxrb
736
737
  return unless ref
737
738
 
738
739
  link = { ref: ref }
739
- link[:rid] = attributes["r:id"] if attributes["r:id"]
740
+ rid = attributes["r:id"] || attributes["id"] || attributes.find { |k, _| k.end_with?(":id") }&.last
741
+ link[:rid] = rid if rid
740
742
  link[:display] = attributes["display"] if attributes["display"]
741
743
  link[:tooltip] = attributes["tooltip"] if attributes["tooltip"]
742
744
  link[:location] = attributes["location"] if attributes["location"]
@@ -51,7 +51,7 @@ module Xlsxrb
51
51
  @current_image[:hidden] = %w[1 true].include?(attributes["hidden"]) if attributes["hidden"]
52
52
  end
53
53
  when "blip"
54
- rid = attributes["r:embed"] || attributes["embed"]
54
+ rid = attributes["r:embed"] || attributes["embed"] || attributes.find { |k, _| k.end_with?(":embed") }&.last
55
55
  @current_image[:embed_rid] = rid if @inside_pic && @current_image && rid
56
56
  when "alphaModFix"
57
57
  @current_image[:alpha_mod_fix] = attributes["amt"].to_i if @inside_pic && @current_image && attributes["amt"]
@@ -87,11 +87,14 @@ module Xlsxrb
87
87
  when "to"
88
88
  @inside_to = true if @inside_anchor
89
89
  when "ext"
90
+ cx = attributes["cx"]
91
+ cy = attributes["cy"]
90
92
  if @inside_pic && @current_image
91
- cx = attributes["cx"]
92
- cy = attributes["cy"]
93
93
  @current_image[:cx] = cx.to_i if cx
94
94
  @current_image[:cy] = cy.to_i if cy
95
+ elsif @inside_anchor
96
+ @anchor_cx = cx.to_i if cx
97
+ @anchor_cy = cy.to_i if cy
95
98
  end
96
99
  when "col", "colOff", "row", "rowOff"
97
100
  @current_field = name
@@ -116,6 +119,8 @@ module Xlsxrb
116
119
  @anchor_from.each { |k, v| @current_image[:"from_#{k}"] = v }
117
120
  @anchor_to.each { |k, v| @current_image[:"to_#{k}"] = v }
118
121
  @current_image[:edit_as] = @anchor_edit_as if @anchor_edit_as
122
+ @current_image[:cx] ||= @anchor_cx if @anchor_cx
123
+ @current_image[:cy] ||= @anchor_cy if @anchor_cy
119
124
  @images << @current_image
120
125
  end
121
126
  @current_image = nil
@@ -127,6 +132,8 @@ module Xlsxrb
127
132
  @inside_anchor = false
128
133
  @anchor_from = {}
129
134
  @anchor_to = {}
135
+ @anchor_cx = nil
136
+ @anchor_cy = nil
130
137
  @anchor_locks_with_sheet = nil
131
138
  @anchor_prints_with_sheet = nil
132
139
  @anchor_published = false
@@ -203,7 +210,7 @@ module Xlsxrb
203
210
  @current_chart[:frame_hidden] = %w[1 true].include?(attributes["hidden"]) if attributes["hidden"]
204
211
  end
205
212
  when "chart"
206
- rid = attributes["r:id"] || attributes["id"]
213
+ rid = attributes["r:id"] || attributes["id"] || attributes.find { |k, _| k.end_with?(":id") }&.last
207
214
  @current_chart[:rid] = rid if @inside_graphic_frame && @current_chart && rid
208
215
  when "graphicFrameLocks"
209
216
  @current_chart[:frame_no_grp] = true if @inside_graphic_frame && @current_chart && %w[1 true].include?(attributes["noGrp"])
@@ -89,18 +89,7 @@ module Xlsxrb
89
89
  return {} if worksheet_xml.nil? || worksheet_xml.empty?
90
90
 
91
91
  # Parse hyperlink elements from worksheet.
92
- links = []
93
- WorksheetParser.each_event(worksheet_xml, part_name: entry_path) do |event|
94
- next unless event.type == :hyperlink
95
-
96
- ref, rid, display, tooltip, location = event.args
97
- link = { ref: ref }
98
- link[:rid] = rid if rid
99
- link[:display] = display if display
100
- link[:tooltip] = tooltip if tooltip
101
- link[:location] = location if location
102
- links << link
103
- end
92
+ links = WorksheetParser.parse_hyperlinks(worksheet_xml)
104
93
 
105
94
  # Parse rels to resolve rId -> URL.
106
95
  rels_path = entry_path.sub(%r{([^/]+)$}, '_rels/\1.rels')
@@ -554,6 +543,14 @@ module Xlsxrb
554
543
  parse_workbook_metadata[:workbook_properties]
555
544
  end
556
545
 
546
+ # Returns whether the workbook uses the 1904 date system.
547
+ #
548
+ # @return [Boolean]
549
+ #: () -> bool
550
+ def date1904?
551
+ workbook_properties&.[](:date1904) ? true : false
552
+ end
553
+
557
554
  # Returns the workbook conformance class ("transitional" or "strict"), or nil if not set.
558
555
  def conformance
559
556
  parse_workbook_metadata[:conformance]
@@ -1227,6 +1224,7 @@ module Xlsxrb
1227
1224
  end
1228
1225
 
1229
1226
  def resolve_date_cells(raw_cells, cell_style_map, styles)
1227
+ is_date1904 = workbook_properties&.[](:date1904) || false
1230
1228
  raw_cells.each do |cell_ref, value|
1231
1229
  next unless value.is_a?(Numeric)
1232
1230
 
@@ -1240,9 +1238,9 @@ module Xlsxrb
1240
1238
  next unless date_format?(fmt_id, styles[:num_fmts])
1241
1239
 
1242
1240
  raw_cells[cell_ref] = if value.is_a?(Float) && (value % 1).positive?
1243
- Xlsxrb::Ooxml::Utils.serial_to_datetime(value)
1241
+ Xlsxrb::Ooxml::Utils.serial_to_datetime(value, date1904: is_date1904)
1244
1242
  else
1245
- Xlsxrb::Ooxml::Utils.serial_to_date(value.to_i)
1243
+ Xlsxrb::Ooxml::Utils.serial_to_date(value.to_i, date1904: is_date1904)
1246
1244
  end
1247
1245
  end
1248
1246
  raw_cells
@@ -101,7 +101,7 @@ module Xlsxrb
101
101
  str = ""
102
102
  end
103
103
 
104
- yield str.freeze
104
+ yield normalize_newlines(str).freeze
105
105
  pos = si_end + 5
106
106
  end
107
107
  end
@@ -134,6 +134,12 @@ module Xlsxrb
134
134
  end
135
135
 
136
136
  private_class_method :extract_multi_t
137
+
138
+ def self.normalize_newlines(str)
139
+ str.include?("\r") ? str.gsub("\r\n", "\n").tr("\r", "\n") : str
140
+ end
141
+
142
+ private_class_method :normalize_newlines
137
143
  end
138
144
  end
139
145
  end