xlsxrb 0.1.15 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +22 -3
- data/benchmark/micro_ips.rb +70 -0
- data/lib/xlsxrb/elements/styles.rb +427 -15
- data/lib/xlsxrb/elements/workbook.rb +31 -4
- data/lib/xlsxrb/elements/worksheet.rb +22 -7
- data/lib/xlsxrb/ooxml/cfb.rb +10 -0
- data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +56 -38
- data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +7 -1
- data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +12 -7
- data/lib/xlsxrb/ooxml/reader.rb +165 -136
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +83 -28
- data/lib/xlsxrb/ooxml/styles_parser.rb +57 -14
- data/lib/xlsxrb/ooxml/utils.rb +59 -2
- data/lib/xlsxrb/ooxml/workbook_parser.rb +42 -3
- data/lib/xlsxrb/ooxml/workbook_writer.rb +215 -50
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +192 -123
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +238 -108
- data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +16 -12
- data/lib/xlsxrb/ooxml/writer/styles_xml.rb +64 -15
- data/lib/xlsxrb/ooxml/writer.rb +31 -28
- data/lib/xlsxrb/ooxml/xml_builder.rb +12 -4
- data/lib/xlsxrb/ooxml/xml_parser.rb +45 -0
- data/lib/xlsxrb/ooxml/zip_reader.rb +48 -29
- data/lib/xlsxrb/stream_row.rb +4 -3
- data/lib/xlsxrb/stream_sheet.rb +26 -6
- data/lib/xlsxrb/stream_writer.rb +35 -14
- data/lib/xlsxrb/utils.rb +15 -0
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +110 -10
- data/lib/xlsxrb/worksheet_builder.rb +15 -7
- data/lib/xlsxrb.rb +416 -108
- data/sig/generated/xlsxrb/elements/styles.rbs +271 -5
- data/sig/generated/xlsxrb/elements/workbook.rbs +26 -3
- data/sig/generated/xlsxrb/elements/worksheet.rbs +12 -2
- data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +2 -0
- data/sig/generated/xlsxrb/ooxml/reader.rbs +12 -0
- data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +3 -1
- data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +2 -0
- data/sig/generated/xlsxrb/ooxml/utils.rbs +22 -0
- data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +7 -0
- data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +12 -0
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +18 -2
- data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +3 -1
- data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +5 -2
- data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +4 -1
- data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +6 -6
- data/sig/generated/xlsxrb/stream_row.rbs +3 -2
- data/sig/generated/xlsxrb/stream_sheet.rbs +21 -5
- data/sig/generated/xlsxrb/stream_writer.rbs +18 -11
- data/sig/generated/xlsxrb/utils.rbs +13 -0
- data/sig/generated/xlsxrb/worksheet_builder.rbs +3 -2
- data/sig/generated/xlsxrb.rbs +47 -10
- 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
|
-
|
|
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("
|
|
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(
|
|
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 +
|
|
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(
|
|
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(
|
|
100
|
+
first_t = xml.index(t_start_pattern, si_open_end + 1)
|
|
77
101
|
if first_t && first_t < si_end
|
|
78
|
-
|
|
79
|
-
if
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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 +
|
|
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(
|
|
149
|
+
t_start = xml.index(t_start_pattern, pos)
|
|
116
150
|
break unless t_start && t_start < to
|
|
117
151
|
|
|
118
|
-
|
|
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(
|
|
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 +
|
|
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
|
-
|
|
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] =
|
|
68
|
+
@current_font[:bold] = truthy_val?(attrs) if @current_font
|
|
61
69
|
when "i"
|
|
62
|
-
@current_font[:italic] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
+
target[:alignment] = alignment unless alignment.empty?
|
|
193
234
|
end
|
|
194
235
|
|
|
195
236
|
def handle_protection(attrs)
|
|
196
|
-
|
|
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
|
-
|
|
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"]
|
data/lib/xlsxrb/ooxml/utils.rb
CHANGED
|
@@ -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
|
-
{
|
|
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 = {
|
|
@@ -114,6 +128,31 @@ module Xlsxrb
|
|
|
114
128
|
listener.relationships
|
|
115
129
|
end
|
|
116
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
|
+
|
|
117
156
|
# SAX listener for .rels relationship files.
|
|
118
157
|
class Listener
|
|
119
158
|
include REXML::SAX2Listener
|