xlsxrb 0.1.14 → 0.1.16

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +37 -17
  4. data/benchmark/micro_ips.rb +70 -0
  5. data/lib/xlsxrb/elements/styles.rb +427 -15
  6. data/lib/xlsxrb/elements/workbook.rb +67 -4
  7. data/lib/xlsxrb/elements/worksheet.rb +117 -7
  8. data/lib/xlsxrb/ooxml/cfb.rb +10 -0
  9. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +56 -38
  10. data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +14 -2
  11. data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +12 -7
  12. data/lib/xlsxrb/ooxml/reader.rb +167 -139
  13. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +83 -28
  14. data/lib/xlsxrb/ooxml/styles_parser.rb +57 -14
  15. data/lib/xlsxrb/ooxml/utils.rb +59 -2
  16. data/lib/xlsxrb/ooxml/workbook_parser.rb +63 -4
  17. data/lib/xlsxrb/ooxml/workbook_writer.rb +232 -59
  18. data/lib/xlsxrb/ooxml/worksheet_parser.rb +192 -123
  19. data/lib/xlsxrb/ooxml/worksheet_writer.rb +307 -125
  20. data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +16 -12
  21. data/lib/xlsxrb/ooxml/writer/features_xml.rb +1 -1
  22. data/lib/xlsxrb/ooxml/writer/styles_xml.rb +64 -15
  23. data/lib/xlsxrb/ooxml/writer.rb +31 -28
  24. data/lib/xlsxrb/ooxml/xml_builder.rb +22 -2
  25. data/lib/xlsxrb/ooxml/xml_parser.rb +45 -0
  26. data/lib/xlsxrb/ooxml/zip_reader.rb +83 -32
  27. data/lib/xlsxrb/stream_row.rb +4 -3
  28. data/lib/xlsxrb/stream_sheet.rb +259 -7
  29. data/lib/xlsxrb/stream_writer.rb +197 -31
  30. data/lib/xlsxrb/utils.rb +68 -0
  31. data/lib/xlsxrb/version.rb +1 -1
  32. data/lib/xlsxrb/workbook_builder.rb +110 -19
  33. data/lib/xlsxrb/worksheet_builder.rb +526 -15
  34. data/lib/xlsxrb.rb +889 -90
  35. data/sig/generated/xlsxrb/elements/styles.rbs +271 -5
  36. data/sig/generated/xlsxrb/elements/workbook.rbs +54 -3
  37. data/sig/generated/xlsxrb/elements/worksheet.rbs +63 -2
  38. data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +2 -0
  39. data/sig/generated/xlsxrb/ooxml/reader.rbs +12 -0
  40. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +3 -1
  41. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +2 -0
  42. data/sig/generated/xlsxrb/ooxml/utils.rbs +22 -0
  43. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +7 -0
  44. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +13 -1
  45. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +18 -2
  46. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +13 -3
  47. data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +5 -2
  48. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +6 -1
  49. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +9 -6
  50. data/sig/generated/xlsxrb/stream_row.rbs +3 -2
  51. data/sig/generated/xlsxrb/stream_sheet.rbs +132 -6
  52. data/sig/generated/xlsxrb/stream_writer.rbs +118 -27
  53. data/sig/generated/xlsxrb/utils.rbs +45 -0
  54. data/sig/generated/xlsxrb/worksheet_builder.rbs +252 -9
  55. data/sig/generated/xlsxrb.rbs +84 -10
  56. metadata +2 -1
@@ -5,6 +5,7 @@
5
5
  require "zlib"
6
6
  require "rexml/parsers/sax2parser"
7
7
  require "rexml/sax2listener"
8
+ require_relative "../number_formatter"
8
9
 
9
10
  module Xlsxrb
10
11
  module Ooxml
@@ -73,18 +74,7 @@ module Xlsxrb
73
74
 
74
75
  # Returns hyperlinks as { "A1" => "https://example.com" }.
75
76
  def hyperlinks(sheet: nil)
76
- sheets = discover_sheets
77
- raise ArgumentError, "workbook has no sheets" if sheets.empty?
78
-
79
- target = resolve_sheet_target(sheets, sheet)
80
- raise ArgumentError, "sheet not found: #{sheet.inspect}" if target.nil?
81
-
82
- entry_path = if target.start_with?("/")
83
- target.delete_prefix("/")
84
- else
85
- "xl/#{target}"
86
- end
87
-
77
+ entry_path = resolve_sheet_entry_path(sheet)
88
78
  worksheet_xml = extract_zip_entry(entry_path)
89
79
  return {} if worksheet_xml.nil? || worksheet_xml.empty?
90
80
 
@@ -128,14 +118,17 @@ module Xlsxrb
128
118
  parser.parse
129
119
 
130
120
  result = {}
121
+ cell_xfs = styles.respond_to?(:cell_xfs) ? styles.cell_xfs : styles[:cell_xfs]
122
+ cell_style_xfs = styles.respond_to?(:cell_style_xfs) ? styles.cell_style_xfs : styles[:cell_style_xfs]
123
+ n_fmts = styles.respond_to?(:num_fmts) ? styles.num_fmts : styles[:num_fmts]
131
124
  listener.cell_style_indices.each do |cell_ref, xf_index|
132
- xf = resolve_effective_xf(styles[:cell_xfs][xf_index], styles[:cell_style_xfs])
125
+ xf = resolve_effective_xf(cell_xfs ? cell_xfs[xf_index] : nil, cell_style_xfs)
133
126
  next unless xf
134
127
 
135
128
  fmt_id = xf[:num_fmt_id]
136
129
  next unless fmt_id && fmt_id != 0
137
130
 
138
- format_code = resolve_num_fmt_code(fmt_id, styles[:num_fmts])
131
+ format_code = resolve_num_fmt_code(fmt_id, n_fmts)
139
132
  result[cell_ref] = format_code if format_code
140
133
  end
141
134
  result
@@ -151,16 +144,23 @@ module Xlsxrb
151
144
 
152
145
  indices = parse_cell_style_indices(worksheet_xml)
153
146
  result = {}
147
+ cell_xfs = styles.respond_to?(:cell_xfs) ? styles.cell_xfs : styles[:cell_xfs]
148
+ cell_style_xfs = styles.respond_to?(:cell_style_xfs) ? styles.cell_style_xfs : styles[:cell_style_xfs]
149
+ fonts_arr = styles.respond_to?(:fonts) ? styles.fonts : styles[:fonts]
150
+ fills_arr = styles.respond_to?(:fills) ? styles.fills : styles[:fills]
151
+ borders_arr = styles.respond_to?(:borders) ? styles.borders : styles[:borders]
152
+ n_fmts = styles.respond_to?(:num_fmts) ? styles.num_fmts : styles[:num_fmts]
153
+
154
154
  indices.each do |cell_ref, xf_index|
155
- xf = resolve_effective_xf(styles[:cell_xfs][xf_index], styles[:cell_style_xfs])
155
+ xf = resolve_effective_xf(cell_xfs ? cell_xfs[xf_index] : nil, cell_style_xfs)
156
156
  next unless xf
157
157
 
158
158
  entry = {}
159
- entry[:font] = styles[:fonts][xf[:font_id]] if xf[:font_id]&.positive? && styles[:fonts][xf[:font_id]]
160
- entry[:fill] = styles[:fills][xf[:fill_id]] if xf[:fill_id]&.positive? && styles[:fills][xf[:fill_id]]
161
- entry[:border] = styles[:borders][xf[:border_id]] if xf[:border_id]&.positive? && styles[:borders][xf[:border_id]]
159
+ entry[:font] = fonts_arr[xf[:font_id]] if xf[:font_id]&.positive? && fonts_arr && fonts_arr[xf[:font_id]]
160
+ entry[:fill] = fills_arr[xf[:fill_id]] if xf[:fill_id]&.positive? && fills_arr && fills_arr[xf[:fill_id]]
161
+ entry[:border] = borders_arr[xf[:border_id]] if xf[:border_id]&.positive? && borders_arr && borders_arr[xf[:border_id]]
162
162
  if xf[:num_fmt_id]&.positive?
163
- code = resolve_num_fmt_code(xf[:num_fmt_id], styles[:num_fmts])
163
+ code = resolve_num_fmt_code(xf[:num_fmt_id], n_fmts)
164
164
  entry[:num_fmt] = code if code
165
165
  end
166
166
  entry[:alignment] = xf[:alignment] if xf[:alignment]
@@ -177,7 +177,7 @@ module Xlsxrb
177
177
  styles = load_styles
178
178
  return [] if styles.empty?
179
179
 
180
- styles[:dxfs] || []
180
+ styles.respond_to?(:dxfs) ? styles.dxfs : (styles[:dxfs] || [])
181
181
  end
182
182
 
183
183
  # Returns array of font entries from the styles.
@@ -185,7 +185,7 @@ module Xlsxrb
185
185
  styles = load_styles
186
186
  return [] if styles.empty?
187
187
 
188
- styles[:fonts] || []
188
+ styles.respond_to?(:fonts) ? styles.fonts : (styles[:fonts] || [])
189
189
  end
190
190
 
191
191
  # Returns array of fill entries from the styles.
@@ -193,7 +193,7 @@ module Xlsxrb
193
193
  styles = load_styles
194
194
  return [] if styles.empty?
195
195
 
196
- styles[:fills] || []
196
+ styles.respond_to?(:fills) ? styles.fills : (styles[:fills] || [])
197
197
  end
198
198
 
199
199
  # Returns array of border entries from the styles.
@@ -201,7 +201,7 @@ module Xlsxrb
201
201
  styles = load_styles
202
202
  return [] if styles.empty?
203
203
 
204
- styles[:borders] || []
204
+ styles.respond_to?(:borders) ? styles.borders : (styles[:borders] || [])
205
205
  end
206
206
 
207
207
  # Returns custom number formats as { numFmtId => formatCode }.
@@ -209,7 +209,7 @@ module Xlsxrb
209
209
  styles = load_styles
210
210
  return {} if styles.empty?
211
211
 
212
- styles[:num_fmts] || {}
212
+ styles.respond_to?(:num_fmts) ? styles.num_fmts : (styles[:num_fmts] || {})
213
213
  end
214
214
 
215
215
  # Returns indexed colors palette (array of ARGB hex strings).
@@ -217,7 +217,7 @@ module Xlsxrb
217
217
  styles = load_styles
218
218
  return [] if styles.empty?
219
219
 
220
- styles[:indexed_colors] || []
220
+ styles.respond_to?(:indexed_colors) ? styles.indexed_colors : (styles[:indexed_colors] || [])
221
221
  end
222
222
 
223
223
  # Returns MRU (most recently used) colors (array of color hashes).
@@ -225,7 +225,7 @@ module Xlsxrb
225
225
  styles = load_styles
226
226
  return [] if styles.empty?
227
227
 
228
- styles[:mru_colors] || []
228
+ styles.respond_to?(:mru_colors) ? styles.mru_colors : (styles[:mru_colors] || [])
229
229
  end
230
230
 
231
231
  # Returns table styles configuration hash.
@@ -233,7 +233,7 @@ module Xlsxrb
233
233
  styles = load_styles
234
234
  return {} if styles.empty?
235
235
 
236
- styles[:table_styles] || {}
236
+ styles.respond_to?(:table_styles) ? styles.table_styles : (styles[:table_styles] || {})
237
237
  end
238
238
 
239
239
  # Returns array of cellStyleXfs entries (base style format definitions).
@@ -241,7 +241,7 @@ module Xlsxrb
241
241
  styles = load_styles
242
242
  return [] if styles.empty?
243
243
 
244
- styles[:cell_style_xfs] || []
244
+ styles.respond_to?(:cell_style_xfs) ? styles.cell_style_xfs : (styles[:cell_style_xfs] || [])
245
245
  end
246
246
 
247
247
  # Returns array of cellXfs entries (cell format definitions).
@@ -249,7 +249,7 @@ module Xlsxrb
249
249
  styles = load_styles
250
250
  return [] if styles.empty?
251
251
 
252
- styles[:cell_xfs] || []
252
+ styles.respond_to?(:cell_xfs) ? styles.cell_xfs : (styles[:cell_xfs] || [])
253
253
  end
254
254
 
255
255
  # Returns array of named cell styles (cellStyle elements).
@@ -257,7 +257,7 @@ module Xlsxrb
257
257
  styles = load_styles
258
258
  return [] if styles.empty?
259
259
 
260
- styles[:cell_styles] || []
260
+ styles.respond_to?(:cell_styles) ? styles.cell_styles : (styles[:cell_styles] || [])
261
261
  end
262
262
 
263
263
  # Returns the autoFilter range string (e.g. "A1:B10") or nil.
@@ -644,34 +644,15 @@ module Xlsxrb
644
644
 
645
645
  # Returns all ZIP entry paths in the file.
646
646
  def entry_names
647
- names = []
648
- File.open(@filepath, "rb") do |file|
649
- loop do
650
- sig = file.read(4)
651
- break if sig.nil? || sig.bytesize < 4
652
-
653
- sig_val = sig.unpack1("V")
654
- break if [0x02014b50, 0x06054b50].include?(sig_val)
655
- break unless sig_val == 0x04034b50
656
-
657
- header = file.read(26)
658
- break if header.nil? || header.bytesize < 26
659
-
660
- _ver, flags, _cm, _mt, _md, _crc, comp_size, _unc, fname_len, extra_len = header.unpack("v v v v v V V V v v")
661
- break if flags.anybits?(0x0008)
662
-
663
- fname = file.read(fname_len)
664
- file.read(extra_len)
665
- file.read(comp_size)
666
- names << fname
667
- end
668
- end
669
- names
647
+ ZipReader.open(@filepath, &:entry_names)
670
648
  end
671
649
 
672
650
  # Returns raw bytes for a ZIP entry by path.
673
651
  def raw_entry(name)
674
- extract_zip_entry(name)
652
+ data = extract_zip_entry(name)
653
+ return nil unless data
654
+
655
+ name.end_with?(".xml", ".rels", ".txt") && data.valid_encoding? ? data : data.b
675
656
  end
676
657
 
677
658
  # Returns true if the file contains VBA macros (vbaProject.bin).
@@ -971,10 +952,14 @@ module Xlsxrb
971
952
 
972
953
  STRICT_SSML_NS = "http://purl.oclc.org/ooxml/spreadsheetml/main/2006/main"
973
954
  TRANSITIONAL_SSML_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
955
+ OFFICE_DOC_REL_TYPES = [
956
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
957
+ "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument"
958
+ ].freeze
974
959
 
975
960
  # Returns :strict or :transitional based on the namespace of the workbook XML.
976
961
  def format_variant
977
- workbook_xml = extract_zip_entry("xl/workbook.xml")
962
+ workbook_xml = extract_zip_entry(resolve_workbook_entry)
978
963
  return :transitional if workbook_xml.nil? || workbook_xml.empty?
979
964
 
980
965
  if workbook_xml.include?(STRICT_SSML_NS)
@@ -986,6 +971,22 @@ module Xlsxrb
986
971
 
987
972
  private
988
973
 
974
+ def resolve_workbook_entry
975
+ @resolve_workbook_entry ||= begin
976
+ rels_xml = extract_zip_entry("_rels/.rels")
977
+ target_path = nil
978
+ if rels_xml && !rels_xml.empty?
979
+ entries = parse_rels_with_types(rels_xml)
980
+ doc_rel = entries.find { |e| OFFICE_DOC_REL_TYPES.include?(e[:type]) }
981
+ if doc_rel
982
+ clean = doc_rel[:target].delete_prefix("/")
983
+ target_path = clean if extract_zip_entry(clean)
984
+ end
985
+ end
986
+ target_path || (extract_zip_entry("xl/workbook.xml") ? "xl/workbook.xml" : "workbook.xml")
987
+ end
988
+ end
989
+
989
990
  def resolve_sheet_for_defined_name(sheet)
990
991
  sheets = discover_sheets
991
992
  if sheet
@@ -1001,7 +1002,7 @@ module Xlsxrb
1001
1002
  end
1002
1003
 
1003
1004
  def parse_workbook_metadata
1004
- workbook_xml = extract_zip_entry("xl/workbook.xml")
1005
+ workbook_xml = extract_zip_entry(resolve_workbook_entry)
1005
1006
  if workbook_xml.nil? || workbook_xml.empty?
1006
1007
  return { workbook_properties: {}, workbook_views: {}, calc_properties: {}, file_recovery_properties: {},
1007
1008
  workbook_protection: nil }
@@ -1024,29 +1025,63 @@ module Xlsxrb
1024
1025
  }
1025
1026
  end
1026
1027
 
1027
- def load_worksheet_xml(sheet)
1028
+ def resolve_sheet_entry_path(sheet)
1028
1029
  sheets = discover_sheets
1029
1030
  raise ArgumentError, "workbook has no sheets" if sheets.empty?
1030
1031
 
1031
1032
  target = resolve_sheet_target(sheets, sheet)
1032
1033
  raise ArgumentError, "sheet not found: #{sheet.inspect}" if target.nil?
1033
1034
 
1034
- # Target may be absolute (/xl/worksheets/sheet1.xml) or relative (worksheets/sheet1.xml).
1035
- entry_path = if target.start_with?("/")
1036
- target.delete_prefix("/")
1035
+ wb_dir = File.dirname(resolve_workbook_entry)
1036
+ candidates = if target.start_with?("/")
1037
+ [target.delete_prefix("/")]
1037
1038
  else
1038
- "xl/#{target}"
1039
+ [
1040
+ wb_dir == "." ? target : "#{wb_dir}/#{target}",
1041
+ "xl/#{target}",
1042
+ target
1043
+ ]
1039
1044
  end
1045
+ candidates.find { |c| extract_zip_entry(c) } || candidates.first
1046
+ end
1040
1047
 
1048
+ def load_worksheet_xml(sheet)
1049
+ entry_path = resolve_sheet_entry_path(sheet)
1041
1050
  extract_zip_entry(entry_path)
1042
1051
  end
1043
1052
 
1053
+ NON_WORKSHEET_REL_SUFFIXES = %w[
1054
+ /chartsheet
1055
+ /dialogsheet
1056
+ /xlMacrosheet
1057
+ /xlIntlMacrosheet
1058
+ /macrosheet
1059
+ ].freeze
1060
+ NON_WORKSHEET_TARGET_PREFIXES = %w[
1061
+ chartsheets/
1062
+ dialogsheets/
1063
+ macrosheets/
1064
+ xl/chartsheets/
1065
+ xl/dialogsheets/
1066
+ xl/macrosheets/
1067
+ ].freeze
1068
+ private_constant :NON_WORKSHEET_REL_SUFFIXES, :NON_WORKSHEET_TARGET_PREFIXES
1069
+
1044
1070
  def discover_sheets
1045
- workbook_xml = extract_zip_entry("xl/workbook.xml")
1071
+ wb_path = resolve_workbook_entry
1072
+ workbook_xml = extract_zip_entry(wb_path)
1046
1073
  return [{ name: "Sheet1", rid: "rId1", target: "worksheets/sheet1.xml" }] if workbook_xml.nil? || workbook_xml.empty?
1047
1074
 
1048
- rels_xml = extract_zip_entry("xl/_rels/workbook.xml.rels")
1049
- rid_to_target = parse_rels(rels_xml)
1075
+ wb_dir = File.dirname(wb_path)
1076
+ rels_path = wb_dir == "." ? "_rels/#{File.basename(wb_path)}.rels" : "#{wb_dir}/_rels/#{File.basename(wb_path)}.rels"
1077
+ rels_xml = extract_zip_entry(rels_path)
1078
+ rel_entries = parse_rels_with_types(rels_xml)
1079
+ rid_to_target = {}
1080
+ rid_to_type = {}
1081
+ rel_entries.each do |r|
1082
+ rid_to_target[r[:id]] = r[:target]
1083
+ rid_to_type[r[:id]] = r[:type]
1084
+ end
1050
1085
 
1051
1086
  sheets = []
1052
1087
  parser = REXML::Parsers::SAX2Parser.new(workbook_xml)
@@ -1054,13 +1089,30 @@ module Xlsxrb
1054
1089
  parser.listen(listener)
1055
1090
  parser.parse
1056
1091
 
1092
+ ws_idx = 0
1057
1093
  listener.sheets.each do |s|
1058
- target = rid_to_target[s[:rid]]
1059
- sheets << { name: s[:name], rid: s[:rid], target: target, state: s[:state] } if target
1094
+ raw_target = rid_to_target[s[:rid]]
1095
+ rel_type = rid_to_type[s[:rid]]
1096
+ next if non_worksheet_relationship?(rel_type, raw_target)
1097
+
1098
+ target = raw_target || (wb_dir == "." ? "worksheets/sheet#{ws_idx + 1}.xml" : "#{wb_dir}/worksheets/sheet#{ws_idx + 1}.xml")
1099
+ ws_idx += 1
1100
+ sheets << { name: s[:name], rid: s[:rid], target: target, state: s[:state] }
1060
1101
  end
1061
1102
  sheets
1062
1103
  end
1063
1104
 
1105
+ def non_worksheet_relationship?(rel_type, target_path)
1106
+ return true if rel_type && NON_WORKSHEET_REL_SUFFIXES.any? { |suffix| rel_type.end_with?(suffix) }
1107
+
1108
+ if target_path
1109
+ clean_target = target_path.delete_prefix("/")
1110
+ return true if NON_WORKSHEET_TARGET_PREFIXES.any? { |prefix| clean_target.start_with?(prefix) }
1111
+ end
1112
+
1113
+ false
1114
+ end
1115
+
1064
1116
  def parse_rels(rels_xml)
1065
1117
  return {} if rels_xml.nil? || rels_xml.empty?
1066
1118
 
@@ -1097,14 +1149,23 @@ module Xlsxrb
1097
1149
  end
1098
1150
 
1099
1151
  def load_shared_strings
1100
- sst_xml = extract_zip_entry("xl/sharedStrings.xml")
1101
- return [] if sst_xml.nil? || sst_xml.empty?
1102
-
1103
- parser = REXML::Parsers::SAX2Parser.new(sst_xml)
1104
- listener = SharedStringsListener.new
1105
- parser.listen(listener)
1106
- parser.parse
1107
- listener.strings
1152
+ wb_dir = File.dirname(resolve_workbook_entry)
1153
+ candidates = [
1154
+ wb_dir == "." ? "sharedStrings.xml" : "#{wb_dir}/sharedStrings.xml",
1155
+ "xl/sharedStrings.xml",
1156
+ "sharedStrings.xml"
1157
+ ]
1158
+ candidates.each do |c|
1159
+ sst_xml = extract_zip_entry(c)
1160
+ next if sst_xml.nil? || sst_xml.empty?
1161
+
1162
+ parser = REXML::Parsers::SAX2Parser.new(sst_xml)
1163
+ listener = SharedStringsListener.new
1164
+ parser.listen(listener)
1165
+ parser.parse
1166
+ return listener.strings
1167
+ end
1168
+ []
1108
1169
  end
1109
1170
 
1110
1171
  def resolve_sheet_index(sheet)
@@ -1198,21 +1259,30 @@ module Xlsxrb
1198
1259
  end
1199
1260
 
1200
1261
  def load_styles
1201
- styles_xml = extract_zip_entry("xl/styles.xml")
1202
- return {} if styles_xml.nil? || styles_xml.empty?
1203
-
1204
- parser = REXML::Parsers::SAX2Parser.new(styles_xml)
1205
- listener = StylesListener.new
1206
- parser.listen(listener)
1207
- parser.parse
1208
- {
1209
- num_fmts: listener.num_fmts, cell_xfs: listener.cell_xfs,
1210
- cell_style_xfs: listener.cell_style_xfs, cell_styles: listener.cell_styles,
1211
- fonts: listener.fonts, fills: listener.fills,
1212
- borders: listener.borders, dxfs: listener.dxfs,
1213
- indexed_colors: listener.indexed_colors, mru_colors: listener.mru_colors,
1214
- table_styles: listener.table_styles
1215
- }
1262
+ wb_dir = File.dirname(resolve_workbook_entry)
1263
+ candidates = [
1264
+ wb_dir == "." ? "styles.xml" : "#{wb_dir}/styles.xml",
1265
+ "xl/styles.xml",
1266
+ "styles.xml"
1267
+ ]
1268
+ candidates.each do |c|
1269
+ styles_xml = extract_zip_entry(c)
1270
+ next if styles_xml.nil? || styles_xml.empty?
1271
+
1272
+ parser = REXML::Parsers::SAX2Parser.new(styles_xml)
1273
+ listener = StylesListener.new
1274
+ parser.listen(listener)
1275
+ parser.parse
1276
+ return {
1277
+ num_fmts: listener.num_fmts, cell_xfs: listener.cell_xfs,
1278
+ cell_style_xfs: listener.cell_style_xfs, cell_styles: listener.cell_styles,
1279
+ fonts: listener.fonts, fills: listener.fills,
1280
+ borders: listener.borders, dxfs: listener.dxfs,
1281
+ indexed_colors: listener.indexed_colors, mru_colors: listener.mru_colors,
1282
+ table_styles: listener.table_styles
1283
+ }
1284
+ end
1285
+ {}
1216
1286
  end
1217
1287
 
1218
1288
  def parse_cell_style_indices(xml)
@@ -1225,17 +1295,21 @@ module Xlsxrb
1225
1295
 
1226
1296
  def resolve_date_cells(raw_cells, cell_style_map, styles)
1227
1297
  is_date1904 = workbook_properties&.[](:date1904) || false
1298
+ cell_xfs = styles.respond_to?(:cell_xfs) ? styles.cell_xfs : styles[:cell_xfs]
1299
+ cell_style_xfs = styles.respond_to?(:cell_style_xfs) ? styles.cell_style_xfs : styles[:cell_style_xfs]
1300
+ n_fmts = styles.respond_to?(:num_fmts) ? styles.num_fmts : styles[:num_fmts]
1301
+
1228
1302
  raw_cells.each do |cell_ref, value|
1229
1303
  next unless value.is_a?(Numeric)
1230
1304
 
1231
1305
  xf_index = cell_style_map[cell_ref]
1232
1306
  next unless xf_index
1233
1307
 
1234
- xf = resolve_effective_xf(styles[:cell_xfs][xf_index], styles[:cell_style_xfs])
1308
+ xf = resolve_effective_xf(cell_xfs ? cell_xfs[xf_index] : nil, cell_style_xfs)
1235
1309
  next unless xf
1236
1310
 
1237
1311
  fmt_id = xf[:num_fmt_id]
1238
- next unless date_format?(fmt_id, styles[:num_fmts])
1312
+ next unless date_format?(fmt_id, n_fmts)
1239
1313
 
1240
1314
  raw_cells[cell_ref] = if value.is_a?(Float) && (value % 1).positive?
1241
1315
  Xlsxrb::Ooxml::Utils.serial_to_datetime(value, date1904: is_date1904)
@@ -1280,57 +1354,11 @@ module Xlsxrb
1280
1354
  end
1281
1355
 
1282
1356
  def date_pattern?(code)
1283
- # Strip quoted strings to avoid false matches.
1284
- stripped = code.gsub(/"[^"]*"/, "").gsub(/\\[.]/, "")
1285
- stripped.match?(/[ymdhsYMDHS]/)
1357
+ NumberFormatter.date_format?(code)
1286
1358
  end
1287
1359
 
1288
1360
  def extract_zip_entry(entry_name)
1289
- File.open(@filepath, "rb") do |file|
1290
- loop do
1291
- signature = file.read(4)
1292
- break if signature.nil? || signature.bytesize < 4
1293
-
1294
- signature_value = signature.unpack1("V")
1295
- break if [0x02014b50, 0x06054b50].include?(signature_value)
1296
-
1297
- raise ZipError, "invalid ZIP local header signature" unless signature_value == 0x04034b50
1298
-
1299
- header = file.read(26)
1300
- raise ZipError, "truncated ZIP local header" if header.nil? || header.bytesize < 26
1301
-
1302
- _version, flags, compression_method, _mtime, _mdate, _crc32, compressed_size,
1303
- _uncompressed_size, file_name_length, extra_field_length = header.unpack("v v v v v V V V v v")
1304
-
1305
- raise ZipError, "ZIP data descriptor is not supported" if flags.anybits?(0x0008)
1306
-
1307
- file_name = file.read(file_name_length)
1308
- raise ZipError, "truncated ZIP file name" if file_name.nil? || file_name.bytesize < file_name_length
1309
-
1310
- file.read(extra_field_length)
1311
-
1312
- compressed_data = file.read(compressed_size)
1313
- raise ZipError, "truncated ZIP entry data" if compressed_data.nil? || compressed_data.bytesize < compressed_size
1314
-
1315
- next unless file_name == entry_name
1316
-
1317
- case compression_method
1318
- when 0
1319
- return compressed_data
1320
- when 8
1321
- inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
1322
- begin
1323
- return inflater.inflate(compressed_data)
1324
- ensure
1325
- inflater.close
1326
- end
1327
- else
1328
- raise ZipError, "unsupported ZIP compression method: #{compression_method}"
1329
- end
1330
- end
1331
- end
1332
-
1333
- nil
1361
+ ZipReader.open(@filepath) { |zr| zr.read_entry(entry_name) }
1334
1362
  end
1335
1363
 
1336
1364
  def parse_worksheet_cells(xml, shared_strings)