xlsxrb 0.1.15 → 0.1.17

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -0
  3. data/README.md +24 -5
  4. data/benchmark/micro_ips.rb +70 -0
  5. data/lib/xlsxrb/chart_builder.rb +41 -2
  6. data/lib/xlsxrb/dsl_helpers.rb +522 -6
  7. data/lib/xlsxrb/elements/cell.rb +34 -9
  8. data/lib/xlsxrb/elements/column.rb +2 -2
  9. data/lib/xlsxrb/elements/styles.rb +427 -15
  10. data/lib/xlsxrb/elements/types.rb +1 -0
  11. data/lib/xlsxrb/elements/workbook.rb +79 -4
  12. data/lib/xlsxrb/elements/worksheet.rb +173 -11
  13. data/lib/xlsxrb/ooxml/cfb.rb +10 -0
  14. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +77 -38
  15. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +187 -24
  16. data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +115 -2
  17. data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +28 -18
  18. data/lib/xlsxrb/ooxml/reader.rb +253 -136
  19. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +83 -28
  20. data/lib/xlsxrb/ooxml/styles_parser.rb +76 -25
  21. data/lib/xlsxrb/ooxml/utils.rb +59 -2
  22. data/lib/xlsxrb/ooxml/workbook_parser.rb +57 -3
  23. data/lib/xlsxrb/ooxml/workbook_writer.rb +471 -86
  24. data/lib/xlsxrb/ooxml/worksheet_parser.rb +210 -127
  25. data/lib/xlsxrb/ooxml/worksheet_writer.rb +515 -184
  26. data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +595 -453
  27. data/lib/xlsxrb/ooxml/writer/features_xml.rb +26 -9
  28. data/lib/xlsxrb/ooxml/writer/styles_xml.rb +87 -24
  29. data/lib/xlsxrb/ooxml/writer.rb +299 -59
  30. data/lib/xlsxrb/ooxml/xml_builder.rb +12 -4
  31. data/lib/xlsxrb/ooxml/xml_parser.rb +45 -0
  32. data/lib/xlsxrb/ooxml/zip_reader.rb +48 -29
  33. data/lib/xlsxrb/ooxml/zip_writer.rb +15 -5
  34. data/lib/xlsxrb/stream_row.rb +4 -3
  35. data/lib/xlsxrb/stream_sheet.rb +182 -8
  36. data/lib/xlsxrb/stream_writer.rb +977 -84
  37. data/lib/xlsxrb/style_builder.rb +59 -6
  38. data/lib/xlsxrb/utils.rb +80 -0
  39. data/lib/xlsxrb/version.rb +1 -1
  40. data/lib/xlsxrb/workbook_builder.rb +307 -24
  41. data/lib/xlsxrb/worksheet_builder.rb +1150 -49
  42. data/lib/xlsxrb.rb +705 -142
  43. data/sig/generated/xlsxrb/chart_builder.rbs +31 -3
  44. data/sig/generated/xlsxrb/dsl_helpers.rbs +119 -4
  45. data/sig/generated/xlsxrb/elements/cell.rbs +20 -3
  46. data/sig/generated/xlsxrb/elements/column.rbs +3 -3
  47. data/sig/generated/xlsxrb/elements/styles.rbs +271 -5
  48. data/sig/generated/xlsxrb/elements/types.rbs +2 -0
  49. data/sig/generated/xlsxrb/elements/workbook.rbs +63 -3
  50. data/sig/generated/xlsxrb/elements/worksheet.rbs +92 -2
  51. data/sig/generated/xlsxrb/ooxml/reader/listeners/core_listeners.rbs +2 -0
  52. data/sig/generated/xlsxrb/ooxml/reader/listeners/drawing_listeners.rbs +74 -2
  53. data/sig/generated/xlsxrb/ooxml/reader/listeners/feature_listeners.rbs +36 -0
  54. data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +5 -0
  55. data/sig/generated/xlsxrb/ooxml/reader.rbs +29 -0
  56. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +3 -1
  57. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -0
  58. data/sig/generated/xlsxrb/ooxml/utils.rbs +22 -0
  59. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +15 -0
  60. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +20 -4
  61. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +19 -3
  62. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +15 -2
  63. data/sig/generated/xlsxrb/ooxml/writer/drawing_xml.rbs +16 -0
  64. data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +5 -2
  65. data/sig/generated/xlsxrb/ooxml/writer.rbs +42 -8
  66. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +4 -1
  67. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +6 -6
  68. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +8 -2
  69. data/sig/generated/xlsxrb/stream_row.rbs +3 -2
  70. data/sig/generated/xlsxrb/stream_sheet.rbs +122 -6
  71. data/sig/generated/xlsxrb/stream_writer.rbs +606 -30
  72. data/sig/generated/xlsxrb/style_builder.rbs +24 -6
  73. data/sig/generated/xlsxrb/utils.rbs +33 -0
  74. data/sig/generated/xlsxrb/workbook_builder.rbs +87 -5
  75. data/sig/generated/xlsxrb/worksheet_builder.rbs +557 -12
  76. data/sig/generated/xlsxrb.rbs +118 -20
  77. 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,15 +55,33 @@ 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
83
+ when "vertAlign"
84
+ @current_font[:vert_align] = attrs["val"] if @current_font && attrs["val"]
65
85
  when "sz"
66
86
  @current_font[:sz] = attrs["val"]&.to_f if @current_font
67
87
  when "color"
@@ -69,16 +89,16 @@ module Xlsxrb
69
89
  when "name"
70
90
  @current_font[:name] = attrs["val"] if @current_font
71
91
  when "fill"
72
- @current_fill = {} if parent_context?("fills")
92
+ @current_fill = {} if parent_context?("fills") || (@current_dxf && parent_context?("dxf"))
73
93
  when "patternFill"
74
94
  @current_fill_pattern = attrs["patternType"] if @current_fill
75
95
  when "fgColor", "bgColor"
76
96
  handle_fill_color(localname, attrs) if @current_fill
77
97
  when "border"
78
- if parent_context?("borders")
98
+ if parent_context?("borders") || (@current_dxf && parent_context?("dxf"))
79
99
  @current_border = {}
80
- @current_border[:diagonal_up] = true if attrs["diagonalUp"] == "1"
81
- @current_border[:diagonal_down] = true if attrs["diagonalDown"] == "1"
100
+ @current_border[:diagonal_up] = true if boolean_val(attrs["diagonalUp"])
101
+ @current_border[:diagonal_down] = true if boolean_val(attrs["diagonalDown"])
82
102
  end
83
103
  when "left", "right", "top", "bottom", "diagonal"
84
104
  handle_border_side(localname, attrs)
@@ -99,19 +119,36 @@ module Xlsxrb
99
119
  case localname
100
120
  when "font"
101
121
  if @current_font
102
- @result[:fonts] << @current_font
122
+ if @current_dxf
123
+ @current_dxf[:font] = @current_font
124
+ else
125
+ @result[:fonts] << @current_font
126
+ end
103
127
  @current_font = nil
104
128
  end
105
129
  when "fill"
106
130
  if @current_fill
107
131
  @current_fill[:pattern] = @current_fill_pattern if @current_fill_pattern
108
- @result[:fills] << @current_fill
132
+ if @current_dxf
133
+ @current_dxf[:fill] = @current_fill
134
+ else
135
+ @result[:fills] << @current_fill
136
+ end
109
137
  @current_fill = nil
110
138
  @current_fill_pattern = nil
111
139
  end
140
+ when "dxf"
141
+ if @current_dxf
142
+ (@result[:dxfs] ||= []) << @current_dxf
143
+ @current_dxf = nil
144
+ end
112
145
  when "border"
113
146
  if @current_border
114
- @result[:borders] << @current_border
147
+ if @current_dxf
148
+ @current_dxf[:border] = @current_border
149
+ else
150
+ @result[:borders] << @current_border
151
+ end
115
152
  @current_border = nil
116
153
  end
117
154
  when "left", "right", "top", "bottom", "diagonal"
@@ -171,34 +208,47 @@ module Xlsxrb
171
208
  fill_id: attrs["fillId"]&.to_i,
172
209
  border_id: attrs["borderId"]&.to_i,
173
210
  xf_id: attrs["xfId"]&.to_i,
174
- apply_number_format: attrs["applyNumberFormat"] == "1",
175
- apply_font: attrs["applyFont"] == "1",
176
- apply_fill: attrs["applyFill"] == "1",
177
- apply_border: attrs["applyBorder"] == "1",
178
- apply_alignment: attrs["applyAlignment"] == "1"
211
+ apply_number_format: boolean_val(attrs["applyNumberFormat"]),
212
+ apply_font: boolean_val(attrs["applyFont"]),
213
+ apply_fill: boolean_val(attrs["applyFill"]),
214
+ apply_border: boolean_val(attrs["applyBorder"]),
215
+ apply_alignment: boolean_val(attrs["applyAlignment"]),
216
+ apply_protection: boolean_val(attrs["applyProtection"])
179
217
  }
180
218
  end
181
219
 
220
+ #: (untyped val) -> bool
221
+ def boolean_val(val)
222
+ val == "1" || (val&.casecmp?("true") || false)
223
+ end
224
+
225
+ def truthy_val?(attrs)
226
+ val = attrs["val"]
227
+ val.nil? || (val != "0" && val.downcase != "false")
228
+ end
229
+
182
230
  def handle_alignment(attrs)
183
- return unless @current_xf
231
+ target = @current_xf || (@current_dxf if parent_context?("dxf"))
232
+ return unless target
184
233
 
185
234
  alignment = {}
186
235
  alignment[:horizontal] = attrs["horizontal"] if attrs["horizontal"]
187
236
  alignment[:vertical] = attrs["vertical"] if attrs["vertical"]
188
- alignment[:wrap_text] = true if attrs["wrapText"] == "1"
237
+ alignment[:wrap_text] = true if boolean_val(attrs["wrapText"])
189
238
  alignment[:text_rotation] = attrs["textRotation"]&.to_i if attrs["textRotation"]
190
239
  alignment[:indent] = attrs["indent"]&.to_i if attrs["indent"]
191
- alignment[:shrink_to_fit] = true if attrs["shrinkToFit"] == "1"
192
- @current_xf[:alignment] = alignment unless alignment.empty?
240
+ alignment[:shrink_to_fit] = true if boolean_val(attrs["shrinkToFit"])
241
+ target[:alignment] = alignment unless alignment.empty?
193
242
  end
194
243
 
195
244
  def handle_protection(attrs)
196
- return unless @current_xf
245
+ target = @current_xf || (@current_dxf if parent_context?("dxf"))
246
+ return unless target
197
247
 
198
248
  protection = {}
199
- protection[:locked] = attrs["locked"] == "1" unless attrs["locked"].nil?
200
- protection[:hidden] = attrs["hidden"] == "1" unless attrs["hidden"].nil?
201
- @current_xf[:protection] = protection unless protection.empty?
249
+ protection[:locked] = !%w[0 false].include?(attrs["locked"]&.downcase) unless attrs["locked"].nil?
250
+ protection[:hidden] = boolean_val(attrs["hidden"]) unless attrs["hidden"].nil?
251
+ target[:protection] = protection unless protection.empty?
202
252
  end
203
253
 
204
254
  def finalize_xf
@@ -214,6 +264,7 @@ module Xlsxrb
214
264
 
215
265
  def extract_color(attrs)
216
266
  color = {}
267
+ color[:auto] = true if %w[1 true].include?(attrs["auto"])
217
268
  color[:rgb] = attrs["rgb"] if attrs["rgb"]
218
269
  color[:theme] = attrs["theme"]&.to_i if attrs["theme"]
219
270
  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,39 @@ 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, first_sheet: nil, show_sheet_tabs: nil, tab_ratio: nil, code_name: 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
+ first_sheet: listener.first_sheet,
28
+ show_sheet_tabs: listener.show_sheet_tabs,
29
+ tab_ratio: listener.tab_ratio,
30
+ code_name: listener.code_name
31
+ }
22
32
  end
23
33
 
24
34
  # SAX listener for workbook.xml sheets, properties, and defined names.
25
35
  class Listener
26
36
  include REXML::SAX2Listener
27
37
 
28
- attr_reader :sheets, :date1904, :defined_names
38
+ attr_reader :sheets, :date1904, :defined_names, :active_tab, :full_calc_on_load, :first_sheet, :show_sheet_tabs, :tab_ratio, :code_name
29
39
 
30
40
  def initialize
31
41
  @sheets = []
32
42
  @date1904 = false
33
43
  @defined_names = []
44
+ @active_tab = nil
45
+ @full_calc_on_load = nil
46
+ @first_sheet = nil
47
+ @show_sheet_tabs = nil
48
+ @tab_ratio = nil
49
+ @code_name = nil
34
50
  @in_defined_name = false
35
51
  @current_dn = nil
36
52
  @dn_text = +""
@@ -57,6 +73,19 @@ module Xlsxrb
57
73
  when "workbookPr"
58
74
  d1904 = attrs["date1904"]
59
75
  @date1904 = %w[1 true].include?(d1904) unless d1904.nil?
76
+ @code_name = attrs["codeName"] if attrs["codeName"]
77
+ when "workbookView"
78
+ at = attrs["activeTab"]
79
+ @active_tab = at.to_i if at
80
+ fs = attrs["firstSheet"]
81
+ @first_sheet = fs.to_i if fs
82
+ sst = attrs["showSheetTabs"]
83
+ @show_sheet_tabs = %w[1 true].include?(sst) unless sst.nil?
84
+ tr = attrs["tabRatio"]
85
+ @tab_ratio = tr.to_i if tr
86
+ when "calcPr"
87
+ fcol = attrs["fullCalcOnLoad"]
88
+ @full_calc_on_load = %w[1 true].include?(fcol) unless fcol.nil?
60
89
  when "definedName"
61
90
  @in_defined_name = true
62
91
  @current_dn = {
@@ -114,6 +143,31 @@ module Xlsxrb
114
143
  listener.relationships
115
144
  end
116
145
 
146
+ # Parses .rels files to return array of relationship hashes with id, type, and target.
147
+ def self.parse_entries(xml_string)
148
+ return [] if xml_string.nil? || xml_string.empty?
149
+
150
+ sanitized = xml_string.lstrip
151
+ if sanitized.start_with?("<?xml")
152
+ first_decl_end = sanitized.index("?>")
153
+ if first_decl_end
154
+ decl = sanitized[0..(first_decl_end + 1)]
155
+ rest = sanitized[(first_decl_end + 2)..].gsub(/<\?xml\b[^>]*\?>/i, "")
156
+ sanitized = "#{decl}#{rest}".lstrip
157
+ end
158
+ end
159
+
160
+ entries = []
161
+ sanitized.scan(%r{<(?:[a-zA-Z0-9_]+:)?Relationship\b([^>]*)/?>}i) do |match|
162
+ attrs_str = match[0]
163
+ id = attrs_str[/\bId=["']([^"']+)["']/i, 1]
164
+ type = attrs_str[/\bType=["']([^"']+)["']/i, 1]
165
+ target = attrs_str[/\bTarget=["']([^"']+)["']/i, 1]
166
+ entries << { id: id, type: type, target: target } if id && target
167
+ end
168
+ entries
169
+ end
170
+
117
171
  # SAX listener for .rels relationship files.
118
172
  class Listener
119
173
  include REXML::SAX2Listener