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
@@ -10,6 +10,9 @@ module Xlsxrb
10
10
  # SAX-based parser for xl/sharedStrings.xml.
11
11
  # Returns an Array of strings (index = SST index).
12
12
  class SharedStringsParser
13
+ TAG_DELIM_BYTES = [62, 32, 47, 9, 10, 13].freeze # '>', ' ', '/', '\t', '\n', '\r'
14
+ private_constant :TAG_DELIM_BYTES
15
+
13
16
  # Parses all shared strings and returns an Array of strings.
14
17
  def self.parse(xml_string, _part_name: "xl/sharedStrings.xml")
15
18
  return [] if xml_string.nil? || xml_string.empty?
@@ -44,22 +47,43 @@ module Xlsxrb
44
47
 
45
48
  def self.fast_scan(xml_src)
46
49
  xml = xml_src.b
47
- sst_start = xml.index("<sst")
50
+ sst_term = xml.index("sst")
51
+ return unless sst_term
52
+
53
+ sst_start = xml.rindex("<", sst_term)
48
54
  return unless sst_start
49
55
 
56
+ prefix = xml.byteslice(sst_start + 1, sst_term - (sst_start + 1))
57
+ return if !prefix.empty? && !prefix.end_with?(":")
58
+
50
59
  sst_open_end = xml.index(">", sst_start)
51
60
  return unless sst_open_end
52
61
  return if xml.getbyte(sst_open_end - 1) == 47 # self-closing <sst/>
53
62
 
54
- sst_end = xml.index("</sst>", sst_open_end)
63
+ sst_end = xml.index("</#{prefix}sst>", sst_open_end)
55
64
  sst_end ||= xml.bytesize
56
65
 
66
+ si_start_pattern = "<#{prefix}si"
67
+ si_start_len = si_start_pattern.bytesize
68
+ si_end_tag = "</#{prefix}si>"
69
+ si_end_len = si_end_tag.bytesize
70
+
71
+ t_start_pattern = "<#{prefix}t"
72
+ t_start_len = t_start_pattern.bytesize
73
+ t_end_tag = "</#{prefix}t>"
74
+ t_end_len = t_end_tag.bytesize
75
+
76
+ rph_start_pattern = "<#{prefix}rPh"
77
+ rph_end_tag = "</#{prefix}rPh>"
78
+ rph_end_len = rph_end_tag.bytesize
79
+ has_rph = xml.include?(rph_start_pattern)
80
+
57
81
  pos = sst_open_end + 1
58
82
  while pos < sst_end
59
- si_start = xml.index("<si", pos)
83
+ si_start = xml.index(si_start_pattern, pos)
60
84
  break unless si_start && si_start < sst_end
61
85
 
62
- si_open_end = xml.index(">", si_start + 3)
86
+ si_open_end = xml.index(">", si_start + si_start_len)
63
87
  break unless si_open_end
64
88
 
65
89
  # Self-closing <si/>
@@ -69,53 +93,84 @@ module Xlsxrb
69
93
  next
70
94
  end
71
95
 
72
- si_end = xml.index("</si>", si_open_end + 1)
96
+ si_end = xml.index(si_end_tag, si_open_end + 1)
73
97
  break unless si_end
74
98
 
75
99
  # Fast path: check for simple single <t>...</t> inside <si>
76
- first_t = xml.index("<t", si_open_end + 1)
100
+ first_t = xml.index(t_start_pattern, si_open_end + 1)
77
101
  if first_t && first_t < si_end
78
- t_open_end = xml.index(">", first_t + 2)
79
- if t_open_end && t_open_end < si_end
80
- if xml.getbyte(t_open_end - 1) == 47 # <t/>
81
- str = ""
82
- else
83
- t_end = xml.index("</t>", t_open_end + 1)
84
- if t_end && t_end <= si_end
85
- next_t = xml.index("<t", t_end + 4)
86
- if next_t && next_t < si_end
87
- # Multiple <t> tags (rich text runs)
88
- str = extract_multi_t(xml, si_open_end + 1, si_end)
102
+ tb = xml.getbyte(first_t + t_start_len)
103
+ if TAG_DELIM_BYTES.include?(tb)
104
+ t_open_end = xml.index(">", first_t + t_start_len)
105
+ if t_open_end && t_open_end < si_end
106
+ if xml.getbyte(t_open_end - 1) == 47 # <t/>
107
+ next_t = xml.index(t_start_pattern, t_open_end + 1)
108
+ str = if next_t && next_t < si_end
109
+ extract_multi_t(xml, si_open_end + 1, si_end, t_start_pattern, t_start_len, t_end_tag, t_end_len, rph_start_pattern, rph_end_tag, rph_end_len, has_rph: has_rph)
110
+ else
111
+ ""
112
+ end
113
+ else
114
+ t_end = xml.index(t_end_tag, t_open_end + 1)
115
+ if t_end && t_end <= si_end
116
+ next_t = xml.index(t_start_pattern, t_end + t_end_len)
117
+ if next_t && next_t < si_end
118
+ # Multiple <t> tags (rich text runs)
119
+ str = extract_multi_t(xml, si_open_end + 1, si_end, t_start_pattern, t_start_len, t_end_tag, t_end_len, rph_start_pattern, rph_end_tag, rph_end_len, has_rph: has_rph)
120
+ else
121
+ raw_str = xml.byteslice(t_open_end + 1, t_end - t_open_end - 1).force_encoding("UTF-8")
122
+ str = XmlBuilder.unescape(raw_str)
123
+ end
89
124
  else
90
- raw_str = xml.byteslice(t_open_end + 1, t_end - t_open_end - 1).force_encoding("UTF-8")
91
- str = XmlBuilder.unescape(raw_str)
125
+ str = ""
92
126
  end
93
- else
94
- str = ""
95
127
  end
128
+ else
129
+ str = ""
96
130
  end
97
131
  else
98
- str = ""
132
+ str = extract_multi_t(xml, si_open_end + 1, si_end, t_start_pattern, t_start_len, t_end_tag, t_end_len, rph_start_pattern, rph_end_tag, rph_end_len, has_rph: has_rph)
99
133
  end
100
134
  else
101
135
  str = ""
102
136
  end
103
137
 
104
138
  yield normalize_newlines(str).freeze
105
- pos = si_end + 5
139
+ pos = si_end + si_end_len
106
140
  end
107
141
  end
108
142
 
109
143
  private_class_method :fast_scan
110
144
 
111
- def self.extract_multi_t(xml, from, to)
145
+ def self.extract_multi_t(xml, from, to, t_start_pattern, t_start_len, t_end_tag, t_end_len, rph_start_pattern, rph_end_tag, rph_end_len, has_rph: false)
112
146
  buf = +""
113
147
  pos = from
114
148
  while pos < to
115
- t_start = xml.index("<t", pos)
149
+ t_start = xml.index(t_start_pattern, pos)
116
150
  break unless t_start && t_start < to
117
151
 
118
- t_open_end = xml.index(">", t_start + 2)
152
+ # Ensure the tag is actually <t>, <t ...>, or <t/> and not <theme>, <table>, etc.
153
+ b = xml.getbyte(t_start + t_start_len)
154
+ # rubocop:disable Style/MultipleComparison -- Zero-allocation hot path check
155
+ unless b == 62 || b == 32 || b == 47 || b == 9 || b == 10 || b == 13
156
+ pos = t_start + t_start_len
157
+ next
158
+ end
159
+ # rubocop:enable Style/MultipleComparison
160
+
161
+ if has_rph
162
+ # Phonetic runs (<rPh>) hold furigana readings and are not part of the string
163
+ rph_start = xml.index(rph_start_pattern, pos)
164
+ if rph_start && rph_start < t_start
165
+ rph_end = xml.index(rph_end_tag, rph_start)
166
+ break unless rph_end && rph_end < to
167
+
168
+ pos = rph_end + rph_end_len
169
+ next
170
+ end
171
+ end
172
+
173
+ t_open_end = xml.index(">", t_start + t_start_len)
119
174
  break unless t_open_end
120
175
 
121
176
  if xml.getbyte(t_open_end - 1) == 47
@@ -123,12 +178,12 @@ module Xlsxrb
123
178
  next
124
179
  end
125
180
 
126
- t_end = xml.index("</t>", t_open_end + 1)
181
+ t_end = xml.index(t_end_tag, t_open_end + 1)
127
182
  break unless t_end && t_end <= to
128
183
 
129
184
  raw_chunk = xml.byteslice(t_open_end + 1, t_end - t_open_end - 1).force_encoding("UTF-8")
130
185
  buf << XmlBuilder.unescape(raw_chunk)
131
- pos = t_end + 4
186
+ pos = t_end + t_end_len
132
187
  end
133
188
  buf
134
189
  end
@@ -34,7 +34,8 @@ module Xlsxrb
34
34
  fills: [],
35
35
  borders: [],
36
36
  cell_xfs: [],
37
- cell_style_xfs: []
37
+ cell_style_xfs: [],
38
+ dxfs: []
38
39
  }
39
40
  @context = []
40
41
  @current_font = nil
@@ -45,6 +46,7 @@ module Xlsxrb
45
46
  @current_xf = nil
46
47
  @in_cell_xfs = false
47
48
  @in_cell_style_xfs = false
49
+ @current_dxf = nil
48
50
  end
49
51
 
50
52
  def start_element(_uri, localname, _qname, attrs)
@@ -53,13 +55,29 @@ module Xlsxrb
53
55
  when "numFmt"
54
56
  id = attrs["numFmtId"]&.to_i
55
57
  code = attrs["formatCode"]
56
- @result[:num_fmts][id] = code if id && code
58
+ if @current_dxf
59
+ @current_dxf[:num_fmt] = { num_fmt_id: id, format_code: code } if id && code
60
+ elsif parent_context?("numFmts") && id && code
61
+ @result[:num_fmts][id] = code
62
+ end
63
+ when "dxf"
64
+ @current_dxf = {} if parent_context?("dxfs")
57
65
  when "font"
58
- @current_font = {} if parent_context?("fonts")
66
+ @current_font = {} if parent_context?("fonts") || (@current_dxf && parent_context?("dxf"))
59
67
  when "b"
60
- @current_font[:bold] = true if @current_font
68
+ @current_font[:bold] = truthy_val?(attrs) if @current_font
61
69
  when "i"
62
- @current_font[:italic] = true if @current_font
70
+ @current_font[:italic] = truthy_val?(attrs) if @current_font
71
+ when "strike"
72
+ @current_font[:strike] = truthy_val?(attrs) if @current_font
73
+ when "shadow"
74
+ @current_font[:shadow] = truthy_val?(attrs) if @current_font
75
+ when "outline"
76
+ @current_font[:outline] = truthy_val?(attrs) if @current_font
77
+ when "condense"
78
+ @current_font[:condense] = truthy_val?(attrs) if @current_font
79
+ when "extend"
80
+ @current_font[:extend] = truthy_val?(attrs) if @current_font
63
81
  when "u"
64
82
  @current_font[:underline] = attrs["val"] || "single" if @current_font
65
83
  when "sz"
@@ -69,13 +87,13 @@ module Xlsxrb
69
87
  when "name"
70
88
  @current_font[:name] = attrs["val"] if @current_font
71
89
  when "fill"
72
- @current_fill = {} if parent_context?("fills")
90
+ @current_fill = {} if parent_context?("fills") || (@current_dxf && parent_context?("dxf"))
73
91
  when "patternFill"
74
92
  @current_fill_pattern = attrs["patternType"] if @current_fill
75
93
  when "fgColor", "bgColor"
76
94
  handle_fill_color(localname, attrs) if @current_fill
77
95
  when "border"
78
- if parent_context?("borders")
96
+ if parent_context?("borders") || (@current_dxf && parent_context?("dxf"))
79
97
  @current_border = {}
80
98
  @current_border[:diagonal_up] = true if attrs["diagonalUp"] == "1"
81
99
  @current_border[:diagonal_down] = true if attrs["diagonalDown"] == "1"
@@ -99,19 +117,36 @@ module Xlsxrb
99
117
  case localname
100
118
  when "font"
101
119
  if @current_font
102
- @result[:fonts] << @current_font
120
+ if @current_dxf
121
+ @current_dxf[:font] = @current_font
122
+ else
123
+ @result[:fonts] << @current_font
124
+ end
103
125
  @current_font = nil
104
126
  end
105
127
  when "fill"
106
128
  if @current_fill
107
129
  @current_fill[:pattern] = @current_fill_pattern if @current_fill_pattern
108
- @result[:fills] << @current_fill
130
+ if @current_dxf
131
+ @current_dxf[:fill] = @current_fill
132
+ else
133
+ @result[:fills] << @current_fill
134
+ end
109
135
  @current_fill = nil
110
136
  @current_fill_pattern = nil
111
137
  end
138
+ when "dxf"
139
+ if @current_dxf
140
+ (@result[:dxfs] ||= []) << @current_dxf
141
+ @current_dxf = nil
142
+ end
112
143
  when "border"
113
144
  if @current_border
114
- @result[:borders] << @current_border
145
+ if @current_dxf
146
+ @current_dxf[:border] = @current_border
147
+ else
148
+ @result[:borders] << @current_border
149
+ end
115
150
  @current_border = nil
116
151
  end
117
152
  when "left", "right", "top", "bottom", "diagonal"
@@ -179,8 +214,14 @@ module Xlsxrb
179
214
  }
180
215
  end
181
216
 
217
+ def truthy_val?(attrs)
218
+ val = attrs["val"]
219
+ val.nil? || (val != "0" && val.downcase != "false")
220
+ end
221
+
182
222
  def handle_alignment(attrs)
183
- return unless @current_xf
223
+ target = @current_xf || (@current_dxf if parent_context?("dxf"))
224
+ return unless target
184
225
 
185
226
  alignment = {}
186
227
  alignment[:horizontal] = attrs["horizontal"] if attrs["horizontal"]
@@ -189,16 +230,17 @@ module Xlsxrb
189
230
  alignment[:text_rotation] = attrs["textRotation"]&.to_i if attrs["textRotation"]
190
231
  alignment[:indent] = attrs["indent"]&.to_i if attrs["indent"]
191
232
  alignment[:shrink_to_fit] = true if attrs["shrinkToFit"] == "1"
192
- @current_xf[:alignment] = alignment unless alignment.empty?
233
+ target[:alignment] = alignment unless alignment.empty?
193
234
  end
194
235
 
195
236
  def handle_protection(attrs)
196
- return unless @current_xf
237
+ target = @current_xf || (@current_dxf if parent_context?("dxf"))
238
+ return unless target
197
239
 
198
240
  protection = {}
199
241
  protection[:locked] = attrs["locked"] == "1" unless attrs["locked"].nil?
200
242
  protection[:hidden] = attrs["hidden"] == "1" unless attrs["hidden"].nil?
201
- @current_xf[:protection] = protection unless protection.empty?
243
+ target[:protection] = protection unless protection.empty?
202
244
  end
203
245
 
204
246
  def finalize_xf
@@ -214,6 +256,7 @@ module Xlsxrb
214
256
 
215
257
  def extract_color(attrs)
216
258
  color = {}
259
+ color[:auto] = true if %w[1 true].include?(attrs["auto"])
217
260
  color[:rgb] = attrs["rgb"] if attrs["rgb"]
218
261
  color[:theme] = attrs["theme"]&.to_i if attrs["theme"]
219
262
  color[:indexed] = attrs["indexed"]&.to_i if attrs["indexed"]
@@ -11,13 +11,20 @@ module Xlsxrb
11
11
  # OOXML-specific utility methods and constants.
12
12
  module Utils
13
13
  # Excel 1900 date system epoch.
14
- EPOCH_1900 = Date.new(1899, 12, 31) # serial 1 = Jan 1, 1900
14
+ EPOCH_1900 = Date.new(1899, 12, 31).freeze # serial 1 = Jan 1, 1900
15
15
  EPOCH_1900_JD = EPOCH_1900.jd
16
16
 
17
17
  # Excel 1904 date system epoch.
18
- EPOCH_1904 = Date.new(1904, 1, 1) # serial 0 = Jan 1, 1904
18
+ EPOCH_1904 = Date.new(1904, 1, 1).freeze # serial 0 = Jan 1, 1904
19
19
  EPOCH_1904_JD = EPOCH_1904.jd
20
20
 
21
+ # Maximum date supported by Excel date systems.
22
+ MAX_DATE = Date.new(9999, 12, 31).freeze
23
+
24
+ # Maximum serial values supported by Excel date systems (corresponding to 9999-12-31).
25
+ MAX_SERIAL_1900 = 2_958_465
26
+ MAX_SERIAL_1904 = 2_957_003
27
+
21
28
  # Built-in number format codes defined by SpreadsheetML.
22
29
  BUILTIN_NUM_FMT_CODES = {
23
30
  0 => "General",
@@ -60,12 +67,27 @@ module Xlsxrb
60
67
  DEFAULT_DATETIME_FORMAT = "yyyy\\-mm\\-dd\\ hh:mm:ss"
61
68
 
62
69
  class << self
70
+ # Checks whether a serial number is a valid Excel date within the supported range.
71
+ #: (untyped serial, ?date1904: bool) -> bool
72
+ def valid_excel_date?(serial, date1904: false)
73
+ return false unless serial.is_a?(Numeric)
74
+
75
+ max_serial = date1904 ? MAX_SERIAL_1904 : MAX_SERIAL_1900
76
+ serial >= 0 && serial < max_serial + 1
77
+ end
78
+
63
79
  # Converts a Date to an Excel serial number (1900 or 1904 system).
64
80
  #: (Date date, ?date1904: bool) -> untyped
65
81
  def date_to_serial(date, date1904: false)
66
82
  if date1904
83
+ raise ArgumentError, "Date is before the 1904 date system epoch (1904-01-01)" if date < EPOCH_1904
84
+ raise ArgumentError, "Date exceeds the maximum supported date (9999-12-31)" if date > MAX_DATE
85
+
67
86
  date.jd - EPOCH_1904_JD
68
87
  else
88
+ raise ArgumentError, "Date is before the 1900 date system epoch (1899-12-31)" if date < EPOCH_1900
89
+ raise ArgumentError, "Date exceeds the maximum supported date (9999-12-31)" if date > MAX_DATE
90
+
69
91
  serial = date.jd - EPOCH_1900_JD
70
92
  # Lotus 1-2-3 bug: serial 60 = Feb 29, 1900 (doesn't exist).
71
93
  # Dates on or after Mar 1, 1900 (raw serial >= 60) need +1.
@@ -77,6 +99,11 @@ module Xlsxrb
77
99
  # Converts an Excel serial number (1900 or 1904 system) to a Date.
78
100
  #: (Numeric serial, ?date1904: bool) -> Date
79
101
  def serial_to_date(serial, date1904: false)
102
+ max_serial = date1904 ? MAX_SERIAL_1904 : MAX_SERIAL_1900
103
+ raise ArgumentError, "Excel serial date cannot be NaN" if serial.respond_to?(:nan?) && serial.nan?
104
+ raise ArgumentError, "Excel serial date must be non-negative" if serial.negative?
105
+ raise ArgumentError, "Excel serial date exceeds maximum supported date (9999-12-31)" if serial >= max_serial + 1
106
+
80
107
  if date1904
81
108
  EPOCH_1904 + serial
82
109
  else
@@ -97,6 +124,7 @@ module Xlsxrb
97
124
  end
98
125
 
99
126
  # Converts a fractional Excel serial number to a Time (1900 or 1904 system, UTC).
127
+ # Excel serial numbers carry no timezone metadata; returned Time objects are in UTC.
100
128
  #: (Numeric serial, ?date1904: bool) -> Time
101
129
  def serial_to_datetime(serial, date1904: false)
102
130
  int_part, frac = serial.divmod(1)
@@ -133,6 +161,35 @@ module Xlsxrb
133
161
  spin_count: spin_count
134
162
  }
135
163
  end
164
+
165
+ # Converts a boolean to "1" or "0" for OOXML schema compliance.
166
+ # Returns non-boolean values as-is.
167
+ #: (untyped value) -> untyped
168
+ def booleanize(value)
169
+ case value
170
+ when true then "1"
171
+ when false then "0"
172
+ else value
173
+ end
174
+ end
175
+
176
+ # Converts an attribute name to camelCase (or PascalCase) for OOXML attributes.
177
+ #: (String | Symbol attr_name, ?capitalize_first: bool) -> String
178
+ def camelize(attr_name, capitalize_first: false)
179
+ str = attr_name.to_s
180
+ return "" if str.empty?
181
+
182
+ parts = str.split("_")
183
+ if parts.empty?
184
+ ""
185
+ elsif parts.size == 1
186
+ capitalize_first ? parts[0].sub(/\A\w/, &:upcase) : parts[0].sub(/\A\w/, &:downcase)
187
+ else
188
+ first = capitalize_first ? parts[0].capitalize : parts[0].sub(/\A\w/, &:downcase)
189
+ rest = parts[1..].map(&:capitalize).join
190
+ "#{first}#{rest}"
191
+ end
192
+ end
136
193
  end
137
194
  end
138
195
  end
@@ -14,23 +14,31 @@ module Xlsxrb
14
14
  end
15
15
 
16
16
  def self.parse_with_properties(xml_string)
17
- return { sheets: [], date1904: false, defined_names: [] } if xml_string.nil? || xml_string.empty?
17
+ return { sheets: [], date1904: false, defined_names: [], active_tab: nil, full_calc_on_load: nil } if xml_string.nil? || xml_string.empty?
18
18
 
19
19
  listener = Listener.new
20
20
  XmlParser.parse(xml_string, listener)
21
- { sheets: listener.sheets, date1904: listener.date1904, defined_names: listener.defined_names }
21
+ {
22
+ sheets: listener.sheets,
23
+ date1904: listener.date1904,
24
+ defined_names: listener.defined_names,
25
+ active_tab: listener.active_tab,
26
+ full_calc_on_load: listener.full_calc_on_load
27
+ }
22
28
  end
23
29
 
24
30
  # SAX listener for workbook.xml sheets, properties, and defined names.
25
31
  class Listener
26
32
  include REXML::SAX2Listener
27
33
 
28
- attr_reader :sheets, :date1904, :defined_names
34
+ attr_reader :sheets, :date1904, :defined_names, :active_tab, :full_calc_on_load
29
35
 
30
36
  def initialize
31
37
  @sheets = []
32
38
  @date1904 = false
33
39
  @defined_names = []
40
+ @active_tab = nil
41
+ @full_calc_on_load = nil
34
42
  @in_defined_name = false
35
43
  @current_dn = nil
36
44
  @dn_text = +""
@@ -57,6 +65,12 @@ module Xlsxrb
57
65
  when "workbookPr"
58
66
  d1904 = attrs["date1904"]
59
67
  @date1904 = %w[1 true].include?(d1904) unless d1904.nil?
68
+ when "workbookView"
69
+ at = attrs["activeTab"]
70
+ @active_tab = at.to_i if at
71
+ when "calcPr"
72
+ fcol = attrs["fullCalcOnLoad"]
73
+ @full_calc_on_load = %w[1 true].include?(fcol) unless fcol.nil?
60
74
  when "definedName"
61
75
  @in_defined_name = true
62
76
  @current_dn = {
@@ -89,11 +103,56 @@ module Xlsxrb
89
103
  def self.parse(xml_string)
90
104
  return {} if xml_string.nil? || xml_string.empty?
91
105
 
106
+ sanitized = xml_string.lstrip
107
+ if sanitized.start_with?("<?xml")
108
+ first_decl_end = sanitized.index("?>")
109
+ if first_decl_end
110
+ decl = sanitized[0..(first_decl_end + 1)]
111
+ rest = sanitized[(first_decl_end + 2)..].gsub(/<\?xml\b[^>]*\?>/i, "")
112
+ sanitized = "#{decl}#{rest}".lstrip
113
+ end
114
+ end
115
+
92
116
  listener = Listener.new
93
- XmlParser.parse(xml_string, listener)
117
+ begin
118
+ XmlParser.parse(sanitized, listener)
119
+ rescue StandardError
120
+ # Fallback to regex scanning if SAX parser fails on malformed XML
121
+ sanitized.scan(%r{<(?:[a-zA-Z0-9_]+:)?Relationship\b([^>]*)/?>}i) do |match|
122
+ attrs_str = match[0]
123
+ id = attrs_str[/\bId=["']([^"']+)["']/i, 1]
124
+ target = attrs_str[/\bTarget=["']([^"']+)["']/i, 1]
125
+ listener.relationships[id] = target if id && target
126
+ end
127
+ end
94
128
  listener.relationships
95
129
  end
96
130
 
131
+ # Parses .rels files to return array of relationship hashes with id, type, and target.
132
+ def self.parse_entries(xml_string)
133
+ return [] if xml_string.nil? || xml_string.empty?
134
+
135
+ sanitized = xml_string.lstrip
136
+ if sanitized.start_with?("<?xml")
137
+ first_decl_end = sanitized.index("?>")
138
+ if first_decl_end
139
+ decl = sanitized[0..(first_decl_end + 1)]
140
+ rest = sanitized[(first_decl_end + 2)..].gsub(/<\?xml\b[^>]*\?>/i, "")
141
+ sanitized = "#{decl}#{rest}".lstrip
142
+ end
143
+ end
144
+
145
+ entries = []
146
+ sanitized.scan(%r{<(?:[a-zA-Z0-9_]+:)?Relationship\b([^>]*)/?>}i) do |match|
147
+ attrs_str = match[0]
148
+ id = attrs_str[/\bId=["']([^"']+)["']/i, 1]
149
+ type = attrs_str[/\bType=["']([^"']+)["']/i, 1]
150
+ target = attrs_str[/\bTarget=["']([^"']+)["']/i, 1]
151
+ entries << { id: id, type: type, target: target } if id && target
152
+ end
153
+ entries
154
+ end
155
+
97
156
  # SAX listener for .rels relationship files.
98
157
  class Listener
99
158
  include REXML::SAX2Listener