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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +125 -0
- data/README.md +24 -5
- data/benchmark/micro_ips.rb +70 -0
- data/lib/xlsxrb/chart_builder.rb +41 -2
- data/lib/xlsxrb/dsl_helpers.rb +522 -6
- data/lib/xlsxrb/elements/cell.rb +34 -9
- data/lib/xlsxrb/elements/column.rb +2 -2
- data/lib/xlsxrb/elements/styles.rb +427 -15
- data/lib/xlsxrb/elements/types.rb +1 -0
- data/lib/xlsxrb/elements/workbook.rb +79 -4
- data/lib/xlsxrb/elements/worksheet.rb +173 -11
- data/lib/xlsxrb/ooxml/cfb.rb +10 -0
- data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +77 -38
- data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +187 -24
- data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +115 -2
- data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +28 -18
- data/lib/xlsxrb/ooxml/reader.rb +253 -136
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +83 -28
- data/lib/xlsxrb/ooxml/styles_parser.rb +76 -25
- data/lib/xlsxrb/ooxml/utils.rb +59 -2
- data/lib/xlsxrb/ooxml/workbook_parser.rb +57 -3
- data/lib/xlsxrb/ooxml/workbook_writer.rb +471 -86
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +210 -127
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +515 -184
- data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +595 -453
- data/lib/xlsxrb/ooxml/writer/features_xml.rb +26 -9
- data/lib/xlsxrb/ooxml/writer/styles_xml.rb +87 -24
- data/lib/xlsxrb/ooxml/writer.rb +299 -59
- 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/ooxml/zip_writer.rb +15 -5
- data/lib/xlsxrb/stream_row.rb +4 -3
- data/lib/xlsxrb/stream_sheet.rb +182 -8
- data/lib/xlsxrb/stream_writer.rb +977 -84
- data/lib/xlsxrb/style_builder.rb +59 -6
- data/lib/xlsxrb/utils.rb +80 -0
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +307 -24
- data/lib/xlsxrb/worksheet_builder.rb +1150 -49
- data/lib/xlsxrb.rb +705 -142
- data/sig/generated/xlsxrb/chart_builder.rbs +31 -3
- data/sig/generated/xlsxrb/dsl_helpers.rbs +119 -4
- data/sig/generated/xlsxrb/elements/cell.rbs +20 -3
- data/sig/generated/xlsxrb/elements/column.rbs +3 -3
- data/sig/generated/xlsxrb/elements/styles.rbs +271 -5
- data/sig/generated/xlsxrb/elements/types.rbs +2 -0
- data/sig/generated/xlsxrb/elements/workbook.rbs +63 -3
- data/sig/generated/xlsxrb/elements/worksheet.rbs +92 -2
- data/sig/generated/xlsxrb/ooxml/reader/listeners/core_listeners.rbs +2 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners/drawing_listeners.rbs +74 -2
- data/sig/generated/xlsxrb/ooxml/reader/listeners/feature_listeners.rbs +36 -0
- data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +5 -0
- data/sig/generated/xlsxrb/ooxml/reader.rbs +29 -0
- data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +3 -1
- data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -0
- data/sig/generated/xlsxrb/ooxml/utils.rbs +22 -0
- data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +15 -0
- data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +20 -4
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +19 -3
- data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +15 -2
- data/sig/generated/xlsxrb/ooxml/writer/drawing_xml.rbs +16 -0
- data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +5 -2
- data/sig/generated/xlsxrb/ooxml/writer.rbs +42 -8
- data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +4 -1
- data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +6 -6
- data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +8 -2
- data/sig/generated/xlsxrb/stream_row.rbs +3 -2
- data/sig/generated/xlsxrb/stream_sheet.rbs +122 -6
- data/sig/generated/xlsxrb/stream_writer.rbs +606 -30
- data/sig/generated/xlsxrb/style_builder.rbs +24 -6
- data/sig/generated/xlsxrb/utils.rbs +33 -0
- data/sig/generated/xlsxrb/workbook_builder.rbs +87 -5
- data/sig/generated/xlsxrb/worksheet_builder.rbs +557 -12
- data/sig/generated/xlsxrb.rbs +118 -20
- 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,15 +55,33 @@ 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
|
|
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"]
|
|
81
|
-
@current_border[:diagonal_down] = true if attrs["diagonalDown"]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|
|
175
|
-
apply_font: attrs["applyFont"]
|
|
176
|
-
apply_fill: attrs["applyFill"]
|
|
177
|
-
apply_border: attrs["applyBorder"]
|
|
178
|
-
apply_alignment: attrs["applyAlignment"]
|
|
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
|
-
|
|
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"]
|
|
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"]
|
|
192
|
-
|
|
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
|
-
|
|
245
|
+
target = @current_xf || (@current_dxf if parent_context?("dxf"))
|
|
246
|
+
return unless target
|
|
197
247
|
|
|
198
248
|
protection = {}
|
|
199
|
-
protection[:locked] = attrs["locked"]
|
|
200
|
-
protection[:hidden] = attrs["hidden"]
|
|
201
|
-
|
|
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"]
|
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,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
|
-
{
|
|
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
|