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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +37 -17
- data/benchmark/micro_ips.rb +70 -0
- data/lib/xlsxrb/elements/styles.rb +427 -15
- data/lib/xlsxrb/elements/workbook.rb +67 -4
- data/lib/xlsxrb/elements/worksheet.rb +117 -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 +14 -2
- data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +12 -7
- data/lib/xlsxrb/ooxml/reader.rb +167 -139
- 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 +63 -4
- data/lib/xlsxrb/ooxml/workbook_writer.rb +232 -59
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +192 -123
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +307 -125
- data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +16 -12
- data/lib/xlsxrb/ooxml/writer/features_xml.rb +1 -1
- 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 +22 -2
- data/lib/xlsxrb/ooxml/xml_parser.rb +45 -0
- data/lib/xlsxrb/ooxml/zip_reader.rb +83 -32
- data/lib/xlsxrb/stream_row.rb +4 -3
- data/lib/xlsxrb/stream_sheet.rb +259 -7
- data/lib/xlsxrb/stream_writer.rb +197 -31
- data/lib/xlsxrb/utils.rb +68 -0
- data/lib/xlsxrb/version.rb +1 -1
- data/lib/xlsxrb/workbook_builder.rb +110 -19
- data/lib/xlsxrb/worksheet_builder.rb +526 -15
- data/lib/xlsxrb.rb +889 -90
- data/sig/generated/xlsxrb/elements/styles.rbs +271 -5
- data/sig/generated/xlsxrb/elements/workbook.rbs +54 -3
- data/sig/generated/xlsxrb/elements/worksheet.rbs +63 -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 +13 -1
- data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +18 -2
- data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +13 -3
- data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +5 -2
- data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +6 -1
- data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +9 -6
- data/sig/generated/xlsxrb/stream_row.rbs +3 -2
- data/sig/generated/xlsxrb/stream_sheet.rbs +132 -6
- data/sig/generated/xlsxrb/stream_writer.rbs +118 -27
- data/sig/generated/xlsxrb/utils.rbs +45 -0
- data/sig/generated/xlsxrb/worksheet_builder.rbs +252 -9
- data/sig/generated/xlsxrb.rbs +84 -10
- metadata +2 -1
|
@@ -159,8 +159,6 @@ module Xlsxrb
|
|
|
159
159
|
else
|
|
160
160
|
""
|
|
161
161
|
end
|
|
162
|
-
head_part = build_line_end_xml("headEnd", shape[:head_end])
|
|
163
|
-
tail_part = build_line_end_xml("tailEnd", shape[:tail_end])
|
|
164
162
|
join_part = case shape[:line_join]
|
|
165
163
|
when "round" then "<a:round/>"
|
|
166
164
|
when "bevel" then "<a:bevel/>"
|
|
@@ -168,7 +166,9 @@ module Xlsxrb
|
|
|
168
166
|
shape[:line_miter_limit] ? "<a:miter lim=\"#{shape[:line_miter_limit].to_i}\"/>" : "<a:miter/>"
|
|
169
167
|
else ""
|
|
170
168
|
end
|
|
171
|
-
|
|
169
|
+
head_part = build_line_end_xml("headEnd", shape[:head_end])
|
|
170
|
+
tail_part = build_line_end_xml("tailEnd", shape[:tail_end])
|
|
171
|
+
%(<a:ln#{ln_attrs}>#{fill_part}#{dash_part}#{join_part}#{head_part}#{tail_part}</a:ln>)
|
|
172
172
|
else
|
|
173
173
|
""
|
|
174
174
|
end
|
|
@@ -425,14 +425,6 @@ module Xlsxrb
|
|
|
425
425
|
attrs << %( err="1") if font[:err]
|
|
426
426
|
attrs << %( bmk="#{xml_escape(font[:bmk])}") if font[:bmk]
|
|
427
427
|
children = +""
|
|
428
|
-
children << %(<a:solidFill>#{color_xml(font[:color])}</a:solidFill>) if font[:color]
|
|
429
|
-
children << %(<a:highlight>#{color_xml(font[:highlight])}</a:highlight>) if font[:highlight]
|
|
430
|
-
children << %(<a:latin typeface="#{xml_escape(font[:name])}"/>) if font[:name]
|
|
431
|
-
children << %(<a:ea typeface="#{xml_escape(font[:ea_font])}"/>) if font[:ea_font]
|
|
432
|
-
children << %(<a:cs typeface="#{xml_escape(font[:cs_font])}"/>) if font[:cs_font]
|
|
433
|
-
children << %(<a:sym typeface="#{xml_escape(font[:sym_font])}"/>) if font[:sym_font]
|
|
434
|
-
children << "<a:uFillTx/>" if font[:u_fill_tx]
|
|
435
|
-
children << "<a:uLnTx/>" if font[:u_ln_tx]
|
|
436
428
|
if font[:line_color] || font[:line_width] || font[:line_dash] || font[:line_cap] || font[:line_join]
|
|
437
429
|
ln_attrs = +(font[:line_width] ? %( w="#{font[:line_width].to_i}") : "")
|
|
438
430
|
ln_attrs << %( cap="#{xml_escape(font[:line_cap])}") if font[:line_cap]
|
|
@@ -447,6 +439,7 @@ module Xlsxrb
|
|
|
447
439
|
end
|
|
448
440
|
children << "<a:ln#{ln_attrs}>#{ln_fill}#{ln_dash}#{ln_join}</a:ln>"
|
|
449
441
|
end
|
|
442
|
+
children << %(<a:solidFill>#{color_xml(font[:color])}</a:solidFill>) if font[:color]
|
|
450
443
|
if font[:outer_shadow] || font[:inner_shadow] || font[:glow] || font[:soft_edge] || font[:reflection] || font[:blur]
|
|
451
444
|
eff = +""
|
|
452
445
|
if font[:outer_shadow]
|
|
@@ -504,6 +497,13 @@ module Xlsxrb
|
|
|
504
497
|
end
|
|
505
498
|
children << "<a:effectLst>#{eff}</a:effectLst>"
|
|
506
499
|
end
|
|
500
|
+
children << %(<a:highlight>#{color_xml(font[:highlight])}</a:highlight>) if font[:highlight]
|
|
501
|
+
children << "<a:uLnTx/>" if font[:u_ln_tx]
|
|
502
|
+
children << "<a:uFillTx/>" if font[:u_fill_tx]
|
|
503
|
+
children << %(<a:latin typeface="#{xml_escape(font[:name])}"/>) if font[:name]
|
|
504
|
+
children << %(<a:ea typeface="#{xml_escape(font[:ea_font])}"/>) if font[:ea_font]
|
|
505
|
+
children << %(<a:cs typeface="#{xml_escape(font[:cs_font])}"/>) if font[:cs_font]
|
|
506
|
+
children << %(<a:sym typeface="#{xml_escape(font[:sym_font])}"/>) if font[:sym_font]
|
|
507
507
|
if children.empty?
|
|
508
508
|
"<#{tag}#{attrs}/>"
|
|
509
509
|
else
|
|
@@ -513,7 +513,11 @@ module Xlsxrb
|
|
|
513
513
|
|
|
514
514
|
# : (untyped tag, untyped col, untyped row, ?col_off: ::Integer, ?row_off: ::Integer) -> ::String
|
|
515
515
|
def anchor_xml(tag, col, row, col_off: 0, row_off: 0)
|
|
516
|
-
|
|
516
|
+
c = col.to_s.empty? ? 0 : col.to_i
|
|
517
|
+
r = row.to_s.empty? ? 0 : row.to_i
|
|
518
|
+
co = col_off.to_s.empty? ? 0 : col_off.to_i
|
|
519
|
+
ro = row_off.to_s.empty? ? 0 : row_off.to_i
|
|
520
|
+
"<xdr:#{tag}><xdr:col>#{c}</xdr:col><xdr:colOff>#{co}</xdr:colOff><xdr:row>#{r}</xdr:row><xdr:rowOff>#{ro}</xdr:rowOff></xdr:#{tag}>"
|
|
517
521
|
end
|
|
518
522
|
|
|
519
523
|
# : (untyped obj) -> ::String
|
|
@@ -715,7 +715,7 @@ module Xlsxrb
|
|
|
715
715
|
"<vt:i4>#{prop[:value]}</vt:i4>"
|
|
716
716
|
when :float, :r8
|
|
717
717
|
"<vt:r8>#{prop[:value]}</vt:r8>"
|
|
718
|
-
when :bool
|
|
718
|
+
when :bool, :boolean
|
|
719
719
|
"<vt:bool>#{prop[:value] ? "true" : "false"}</vt:bool>"
|
|
720
720
|
when :date, :filetime
|
|
721
721
|
"<vt:filetime>#{prop[:value]}</vt:filetime>"
|
|
@@ -125,7 +125,10 @@ module Xlsxrb
|
|
|
125
125
|
parts << "<colors>"
|
|
126
126
|
unless @indexed_colors.empty?
|
|
127
127
|
parts << "<indexedColors>"
|
|
128
|
-
@indexed_colors.each
|
|
128
|
+
@indexed_colors.each do |c|
|
|
129
|
+
rgb_str = format_rgb_hex(c) || c
|
|
130
|
+
parts << %(<rgbColor rgb="#{rgb_str}"/>)
|
|
131
|
+
end
|
|
129
132
|
parts << "</indexedColors>"
|
|
130
133
|
end
|
|
131
134
|
unless @mru_colors.empty?
|
|
@@ -163,18 +166,63 @@ module Xlsxrb
|
|
|
163
166
|
"<protection #{attrs.join(" ")}/>"
|
|
164
167
|
end
|
|
165
168
|
|
|
166
|
-
# : (untyped
|
|
169
|
+
# : (untyped rgb) -> ::String?
|
|
170
|
+
def format_rgb_hex(rgb)
|
|
171
|
+
return nil unless rgb
|
|
172
|
+
|
|
173
|
+
str = rgb.to_s.sub(/^#/, "").strip
|
|
174
|
+
return nil if str.empty?
|
|
175
|
+
|
|
176
|
+
case str
|
|
177
|
+
when /\A[0-9a-fA-F]{8}\z/
|
|
178
|
+
str.upcase
|
|
179
|
+
when /\A[0-9a-fA-F]{6}\z/
|
|
180
|
+
"FF#{str.upcase}"
|
|
181
|
+
when /\A[0-9a-fA-F]{3}\z/
|
|
182
|
+
r, g, b = str.chars
|
|
183
|
+
"FF#{r}#{r}#{g}#{g}#{b}#{b}".upcase
|
|
184
|
+
else
|
|
185
|
+
resolved = StyleBuilder.resolve_color(rgb)
|
|
186
|
+
case resolved
|
|
187
|
+
when /\A[0-9a-fA-F]{8}\z/
|
|
188
|
+
resolved.upcase
|
|
189
|
+
when /\A[0-9a-fA-F]{6}\z/
|
|
190
|
+
"FF#{resolved.upcase}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# : (untyped source, ?tag: ::String) -> ::String
|
|
167
196
|
def emit_color_xml(source, tag: "color")
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
197
|
+
return "" unless source
|
|
198
|
+
|
|
199
|
+
spec = if source.is_a?(Hash) && source[:color]
|
|
200
|
+
source[:color]
|
|
201
|
+
else
|
|
202
|
+
source
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
return "" unless spec
|
|
206
|
+
|
|
207
|
+
if spec.is_a?(Hash)
|
|
208
|
+
if spec[:auto]
|
|
209
|
+
%(<#{tag} auto="1"/>)
|
|
210
|
+
elsif spec[:rgb] || (spec[:color] && !spec[:color].is_a?(Hash))
|
|
211
|
+
raw_rgb = spec[:rgb] || spec[:color]
|
|
212
|
+
formatted_rgb = format_rgb_hex(raw_rgb)
|
|
213
|
+
formatted_rgb ? %(<#{tag} rgb="#{formatted_rgb}"/>) : ""
|
|
214
|
+
elsif spec[:theme]
|
|
215
|
+
attrs = [%(theme="#{spec[:theme]}")]
|
|
216
|
+
attrs << %(tint="#{spec[:tint]}") if spec[:tint]
|
|
217
|
+
%(<#{tag} #{attrs.join(" ")}/>)
|
|
218
|
+
elsif spec[:indexed]
|
|
219
|
+
%(<#{tag} indexed="#{spec[:indexed]}"/>)
|
|
220
|
+
else
|
|
221
|
+
""
|
|
222
|
+
end
|
|
223
|
+
elsif spec.is_a?(String) || spec.is_a?(Symbol)
|
|
224
|
+
formatted_rgb = format_rgb_hex(spec)
|
|
225
|
+
formatted_rgb ? %(<#{tag} rgb="#{formatted_rgb}"/>) : ""
|
|
178
226
|
else
|
|
179
227
|
""
|
|
180
228
|
end
|
|
@@ -226,10 +274,11 @@ module Xlsxrb
|
|
|
226
274
|
parts.join
|
|
227
275
|
end
|
|
228
276
|
|
|
229
|
-
# : (untyped tag, untyped fill, untyped prefix) ->
|
|
277
|
+
# : (untyped tag, untyped fill, untyped prefix) -> ::String
|
|
230
278
|
def emit_fill_color_xml(tag, fill, prefix)
|
|
231
|
-
|
|
232
|
-
|
|
279
|
+
val = fill[:"#{prefix}_color"]
|
|
280
|
+
if val
|
|
281
|
+
emit_color_xml(val, tag: tag)
|
|
233
282
|
elsif fill[:"#{prefix}_color_theme"]
|
|
234
283
|
attrs = [%(theme="#{fill[:"#{prefix}_color_theme"]}")]
|
|
235
284
|
attrs << %(tint="#{fill[:"#{prefix}_color_tint"]}") if fill[:"#{prefix}_color_tint"]
|
data/lib/xlsxrb/ooxml/writer.rb
CHANGED
|
@@ -2691,35 +2691,38 @@ module Xlsxrb
|
|
|
2691
2691
|
|
|
2692
2692
|
# Emit <dataValidations> if defined.
|
|
2693
2693
|
unless sheet_dv.empty?
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2694
|
+
valid_dv = sheet_dv.reject { |dv| dv[:sqref].nil? || dv[:sqref].to_s.empty? }
|
|
2695
|
+
unless valid_dv.empty?
|
|
2696
|
+
dv_container_attrs = %( count="#{valid_dv.size}")
|
|
2697
|
+
dv_container_attrs << ' disablePrompts="1"' if dv_options[:disable_prompts]
|
|
2698
|
+
dv_container_attrs << %( xWindow="#{dv_options[:x_window]}") if dv_options[:x_window]
|
|
2699
|
+
dv_container_attrs << %( yWindow="#{dv_options[:y_window]}") if dv_options[:y_window]
|
|
2700
|
+
parts << "<dataValidations#{dv_container_attrs}>"
|
|
2701
|
+
valid_dv.each do |dv|
|
|
2702
|
+
dv_attrs = %(sqref="#{dv[:sqref]}")
|
|
2703
|
+
dv_attrs << %( type="#{dv[:type]}") if dv[:type]
|
|
2704
|
+
dv_attrs << %( operator="#{dv[:operator]}") if dv[:operator]
|
|
2705
|
+
dv_attrs << %( errorStyle="#{dv[:error_style]}") if dv[:error_style]
|
|
2706
|
+
dv_attrs << ' allowBlank="1"' if dv[:allow_blank]
|
|
2707
|
+
dv_attrs << ' showInputMessage="1"' if dv[:show_input_message]
|
|
2708
|
+
dv_attrs << ' showErrorMessage="1"' if dv[:show_error_message]
|
|
2709
|
+
dv_attrs << %( errorTitle="#{xml_escape(dv[:error_title])}") if dv[:error_title]
|
|
2710
|
+
dv_attrs << %( error="#{xml_escape(dv[:error])}") if dv[:error]
|
|
2711
|
+
dv_attrs << %( promptTitle="#{xml_escape(dv[:prompt_title])}") if dv[:prompt_title]
|
|
2712
|
+
dv_attrs << %( prompt="#{xml_escape(dv[:prompt])}") if dv[:prompt]
|
|
2713
|
+
dv_attrs << ' showDropDown="1"' if dv[:show_drop_down]
|
|
2714
|
+
dv_attrs << %( imeMode="#{dv[:ime_mode]}") if dv[:ime_mode]
|
|
2715
|
+
if dv[:formula1] || dv[:formula2]
|
|
2716
|
+
parts << "<dataValidation #{dv_attrs}>"
|
|
2717
|
+
parts << "<formula1>#{xml_escape(dv[:formula1])}</formula1>" if dv[:formula1]
|
|
2718
|
+
parts << "<formula2>#{xml_escape(dv[:formula2])}</formula2>" if dv[:formula2]
|
|
2719
|
+
parts << "</dataValidation>"
|
|
2720
|
+
else
|
|
2721
|
+
parts << "<dataValidation #{dv_attrs}/>"
|
|
2722
|
+
end
|
|
2720
2723
|
end
|
|
2724
|
+
parts << "</dataValidations>"
|
|
2721
2725
|
end
|
|
2722
|
-
parts << "</dataValidations>"
|
|
2723
2726
|
end
|
|
2724
2727
|
|
|
2725
2728
|
# Emit <printOptions> if defined.
|
|
@@ -2754,7 +2757,7 @@ module Xlsxrb
|
|
|
2754
2757
|
ps_attrs << 'blackAndWhite="1"' if sheet_ps[:black_and_white]
|
|
2755
2758
|
ps_attrs << 'draft="1"' if sheet_ps[:draft]
|
|
2756
2759
|
ps_attrs << %(cellComments="#{sheet_ps[:cell_comments]}") if sheet_ps[:cell_comments]
|
|
2757
|
-
ps_attrs << %(firstPageNumber="#{sheet_ps[:first_page_number]}") if sheet_ps[:first_page_number]
|
|
2760
|
+
ps_attrs << %(firstPageNumber="#{sheet_ps[:first_page_number]}") if sheet_ps[:first_page_number] && sheet_ps[:first_page_number].to_i >= 0
|
|
2758
2761
|
ps_attrs << 'useFirstPageNumber="1"' if sheet_ps[:use_first_page_number]
|
|
2759
2762
|
ps_attrs << %(horizontalDpi="#{sheet_ps[:horizontal_dpi]}") if sheet_ps[:horizontal_dpi]
|
|
2760
2763
|
ps_attrs << %(verticalDpi="#{sheet_ps[:vertical_dpi]}") if sheet_ps[:vertical_dpi]
|
|
@@ -118,14 +118,34 @@ module Xlsxrb
|
|
|
118
118
|
str.match?(ESCAPE_RE) ? str.gsub(ESCAPE_RE, ESCAPE_MAP) : str
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
NUM_ENTITY_RE = /&#(?:(\d+)|x([0-9a-fA-F]+));/
|
|
122
|
+
XSTRING_RE = /_x([0-9A-Fa-f]{4})_/
|
|
123
|
+
private_constant :NUM_ENTITY_RE, :XSTRING_RE
|
|
124
|
+
|
|
125
|
+
# Unescapes predefined and numeric/hex XML entities, as well as
|
|
126
|
+
# ISO/IEC 29500-1 ST_Xstring (_xHHHH_) control character sequences.
|
|
122
127
|
#
|
|
123
128
|
# @param value [Object]
|
|
124
129
|
# @return [String]
|
|
125
130
|
#: (untyped value) -> String
|
|
126
131
|
def self.unescape(value)
|
|
127
132
|
str = value.to_s
|
|
128
|
-
str.include?("&")
|
|
133
|
+
return str unless str.include?("&") || str.include?("_x")
|
|
134
|
+
|
|
135
|
+
res = str.gsub(UNESCAPE_RE, UNESCAPE_MAP)
|
|
136
|
+
res = res.gsub(NUM_ENTITY_RE) do |match|
|
|
137
|
+
dec = Regexp.last_match(1)
|
|
138
|
+
codepoint = dec ? Integer(dec) : Integer(Regexp.last_match(2), 16)
|
|
139
|
+
codepoint.chr(Encoding::UTF_8)
|
|
140
|
+
rescue RangeError
|
|
141
|
+
match
|
|
142
|
+
end
|
|
143
|
+
res.gsub(XSTRING_RE) do |match|
|
|
144
|
+
codepoint = Integer(Regexp.last_match(1), 16)
|
|
145
|
+
codepoint.chr(Encoding::UTF_8)
|
|
146
|
+
rescue RangeError
|
|
147
|
+
match
|
|
148
|
+
end
|
|
129
149
|
end
|
|
130
150
|
|
|
131
151
|
private
|
|
@@ -5,6 +5,51 @@
|
|
|
5
5
|
require "rexml/parsers/sax2parser"
|
|
6
6
|
require "rexml/sax2listener"
|
|
7
7
|
|
|
8
|
+
# Ensure REXML constants and security limits are accessible across Ractors
|
|
9
|
+
begin
|
|
10
|
+
if defined?(Ractor)
|
|
11
|
+
Ractor.make_shareable(REXML::Parsers::BaseParser::DEFAULT_ENTITIES) if defined?(REXML::Parsers::BaseParser::DEFAULT_ENTITIES) && !Ractor.shareable?(REXML::Parsers::BaseParser::DEFAULT_ENTITIES)
|
|
12
|
+
if defined?(REXML::Source) && REXML::Source.const_defined?(:Private)
|
|
13
|
+
priv = REXML::Source.const_get(:Private)
|
|
14
|
+
if priv.const_defined?(:PRE_DEFINED_TERM_PATTERNS)
|
|
15
|
+
terms = priv.const_get(:PRE_DEFINED_TERM_PATTERNS)
|
|
16
|
+
Ractor.make_shareable(terms) unless Ractor.shareable?(terms)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
if defined?(REXML::Security)
|
|
20
|
+
prev_verbose = $VERBOSE
|
|
21
|
+
$VERBOSE = nil
|
|
22
|
+
begin
|
|
23
|
+
REXML::Security.instance_eval do
|
|
24
|
+
@entity_expansion_limit = 10_000
|
|
25
|
+
@entity_expansion_text_limit = 10_240
|
|
26
|
+
|
|
27
|
+
def self.entity_expansion_limit
|
|
28
|
+
@entity_expansion_limit || 10_000
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.entity_expansion_limit=(val)
|
|
32
|
+
@entity_expansion_limit = val
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.entity_expansion_text_limit
|
|
36
|
+
@entity_expansion_text_limit || 10_240
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.entity_expansion_text_limit=(val)
|
|
40
|
+
@entity_expansion_text_limit = val
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
Ractor.make_shareable(REXML::Security)
|
|
44
|
+
ensure
|
|
45
|
+
$VERBOSE = prev_verbose
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError
|
|
50
|
+
# Best-effort Ractor isolation preparation for standard library REXML
|
|
51
|
+
end
|
|
52
|
+
|
|
8
53
|
module Xlsxrb
|
|
9
54
|
module Ooxml
|
|
10
55
|
# Thin wrapper around REXML SAX2 parser for streaming XML processing.
|
|
@@ -11,7 +11,7 @@ module Xlsxrb
|
|
|
11
11
|
# Reads ZIP archives using only stdlib (zlib).
|
|
12
12
|
# Supports streaming entry decompression with bounded memory consumption.
|
|
13
13
|
class ZipReader
|
|
14
|
-
LOCAL_HEADER_SIG = "PK\x03\x04".b
|
|
14
|
+
LOCAL_HEADER_SIG = "PK\x03\x04".b.freeze
|
|
15
15
|
MAX_UNCOMPRESSED_SIZE = 500 * 1024 * 1024 # 500MB per file limit
|
|
16
16
|
attr_accessor :max_uncompressed_size
|
|
17
17
|
|
|
@@ -37,14 +37,15 @@ module Xlsxrb
|
|
|
37
37
|
|
|
38
38
|
attr_reader :data_offset, :compressed_size, :compression_method
|
|
39
39
|
|
|
40
|
-
#: (untyped io, Integer data_offset, Integer compressed_size, Integer compression_method, ?max_uncompressed_size: Integer?) -> void
|
|
41
|
-
def initialize(io, data_offset, compressed_size, compression_method, max_uncompressed_size: nil)
|
|
40
|
+
#: (untyped io, Integer data_offset, Integer compressed_size, Integer compression_method, ?max_uncompressed_size: Integer?, ?encoding: Encoding?) -> void
|
|
41
|
+
def initialize(io, data_offset, compressed_size, compression_method, max_uncompressed_size: nil, encoding: nil)
|
|
42
42
|
@io = io
|
|
43
43
|
@data_offset = data_offset
|
|
44
44
|
@compressed_size = compressed_size
|
|
45
45
|
@compression_method = compression_method
|
|
46
46
|
@max_uncompressed_size = max_uncompressed_size || ZipReader::MAX_UNCOMPRESSED_SIZE
|
|
47
|
-
@
|
|
47
|
+
@encoding = encoding
|
|
48
|
+
@buffer = +"".b
|
|
48
49
|
@bytes_read_compressed = 0
|
|
49
50
|
@total_uncompressed = 0
|
|
50
51
|
@eof = false
|
|
@@ -60,15 +61,16 @@ module Xlsxrb
|
|
|
60
61
|
check_bom_encoding!
|
|
61
62
|
|
|
62
63
|
if length.nil?
|
|
63
|
-
result = @buffer.
|
|
64
|
-
@buffer.
|
|
64
|
+
result = @buffer.b
|
|
65
|
+
@buffer = +"".b
|
|
65
66
|
while (chunk = fetch_chunk(DEFAULT_CHUNK_SIZE))
|
|
66
67
|
result << chunk
|
|
67
68
|
end
|
|
69
|
+
result.force_encoding(@encoding) if @encoding
|
|
68
70
|
return result
|
|
69
71
|
end
|
|
70
72
|
|
|
71
|
-
return "" if length.zero?
|
|
73
|
+
return "".force_encoding(@encoding || Encoding::BINARY) if length.zero?
|
|
72
74
|
|
|
73
75
|
while @buffer.bytesize < length && !@eof
|
|
74
76
|
chunk = fetch_chunk([length - @buffer.bytesize, DEFAULT_CHUNK_SIZE].max)
|
|
@@ -79,7 +81,9 @@ module Xlsxrb
|
|
|
79
81
|
|
|
80
82
|
return nil if @buffer.empty? && @eof
|
|
81
83
|
|
|
82
|
-
@buffer.slice!(0, length)
|
|
84
|
+
sliced = @buffer.slice!(0, length)
|
|
85
|
+
sliced.force_encoding(@encoding) if @encoding && sliced
|
|
86
|
+
sliced
|
|
83
87
|
end
|
|
84
88
|
|
|
85
89
|
#: (?Integer chunk_size) ?{ (String) -> void } -> (void | Enumerator[String, void])
|
|
@@ -142,7 +146,7 @@ module Xlsxrb
|
|
|
142
146
|
|
|
143
147
|
@bytes_read_compressed += raw.bytesize
|
|
144
148
|
@total_uncompressed += raw.bytesize
|
|
145
|
-
raise
|
|
149
|
+
raise ZipBombError, "ZIP bomb detected: Uncompressed size exceeds #{@max_uncompressed_size} bytes" if @total_uncompressed > @max_uncompressed_size
|
|
146
150
|
|
|
147
151
|
raw
|
|
148
152
|
elsif @compression_method == 8
|
|
@@ -176,7 +180,7 @@ module Xlsxrb
|
|
|
176
180
|
@eof = true if @inflater&.finished?
|
|
177
181
|
|
|
178
182
|
@total_uncompressed += inflated.bytesize
|
|
179
|
-
raise
|
|
183
|
+
raise ZipBombError, "ZIP bomb detected: Uncompressed size exceeds #{@max_uncompressed_size} bytes" if @total_uncompressed > @max_uncompressed_size
|
|
180
184
|
|
|
181
185
|
inflated.empty? && @eof ? nil : inflated
|
|
182
186
|
else
|
|
@@ -194,25 +198,25 @@ module Xlsxrb
|
|
|
194
198
|
|
|
195
199
|
raw_b = peek.b
|
|
196
200
|
if raw_b.start_with?("\xFF\xFE".b)
|
|
197
|
-
rest = +""
|
|
201
|
+
rest = +"".b
|
|
198
202
|
while (chunk = fetch_chunk(DEFAULT_CHUNK_SIZE))
|
|
199
203
|
rest << chunk
|
|
200
204
|
end
|
|
201
205
|
full = (peek + rest).force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
|
|
202
|
-
@buffer = full
|
|
206
|
+
@buffer = full.b
|
|
203
207
|
@eof = true
|
|
204
208
|
elsif raw_b.start_with?("\xFE\xFF".b)
|
|
205
|
-
rest = +""
|
|
209
|
+
rest = +"".b
|
|
206
210
|
while (chunk = fetch_chunk(DEFAULT_CHUNK_SIZE))
|
|
207
211
|
rest << chunk
|
|
208
212
|
end
|
|
209
213
|
full = (peek + rest).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
|
|
210
|
-
@buffer = full
|
|
214
|
+
@buffer = full.b
|
|
211
215
|
@eof = true
|
|
212
216
|
elsif raw_b.start_with?("\xEF\xBB\xBF".b)
|
|
213
|
-
@buffer << peek.b.delete_prefix("\xEF\xBB\xBF".b)
|
|
217
|
+
@buffer << peek.b.delete_prefix("\xEF\xBB\xBF".b)
|
|
214
218
|
else
|
|
215
|
-
@buffer << peek
|
|
219
|
+
@buffer << peek.b
|
|
216
220
|
end
|
|
217
221
|
end
|
|
218
222
|
end
|
|
@@ -291,20 +295,35 @@ module Xlsxrb
|
|
|
291
295
|
end
|
|
292
296
|
|
|
293
297
|
# Opens an EntryStreamIO for an entry by name.
|
|
294
|
-
#: (String entry_name) -> EntryStreamIO
|
|
295
|
-
def open_entry_io(entry_name)
|
|
298
|
+
#: (String entry_name, ?encoding: (Encoding | String | false | nil)) -> EntryStreamIO
|
|
299
|
+
def open_entry_io(entry_name, encoding: nil)
|
|
296
300
|
entry = lookup_entry(entry_name)
|
|
297
|
-
raise
|
|
301
|
+
raise ZipError, "Entry not found in archive: #{entry_name}" unless entry
|
|
298
302
|
|
|
299
|
-
|
|
303
|
+
resolved_encoding = if encoding == false
|
|
304
|
+
nil
|
|
305
|
+
elsif encoding
|
|
306
|
+
encoding.is_a?(String) ? Encoding.find(encoding) : encoding
|
|
307
|
+
elsif entry_name.end_with?(".xml", ".rels") || entry.name.end_with?(".xml", ".rels")
|
|
308
|
+
Encoding::UTF_8
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
EntryStreamIO.new(
|
|
312
|
+
io,
|
|
313
|
+
entry.data_offset,
|
|
314
|
+
entry.compressed_size,
|
|
315
|
+
entry.method,
|
|
316
|
+
max_uncompressed_size: @max_uncompressed_size,
|
|
317
|
+
encoding: resolved_encoding
|
|
318
|
+
)
|
|
300
319
|
end
|
|
301
320
|
|
|
302
321
|
# Yields inflated binary chunks directly from the compressed stream.
|
|
303
|
-
#: (String entry_name, ?chunk_size: Integer) ?{ (String) -> void } -> (void | Enumerator[String, void])
|
|
304
|
-
def each_entry_chunk(entry_name, chunk_size: 65_536, &block)
|
|
305
|
-
return enum_for(:each_entry_chunk, entry_name, chunk_size: chunk_size) unless block
|
|
322
|
+
#: (String entry_name, ?chunk_size: Integer, ?encoding: (Encoding | String | false | nil)) ?{ (String) -> void } -> (void | Enumerator[String, void])
|
|
323
|
+
def each_entry_chunk(entry_name, chunk_size: 65_536, encoding: nil, &block)
|
|
324
|
+
return enum_for(:each_entry_chunk, entry_name, chunk_size: chunk_size, encoding: encoding) unless block
|
|
306
325
|
|
|
307
|
-
stream = open_entry_io(entry_name)
|
|
326
|
+
stream = open_entry_io(entry_name, encoding: encoding)
|
|
308
327
|
begin
|
|
309
328
|
stream.each_chunk(chunk_size, &block)
|
|
310
329
|
ensure
|
|
@@ -334,7 +353,7 @@ module Xlsxrb
|
|
|
334
353
|
#: (String entry_name, ZipWriter writer, ?target_path: String?) -> void
|
|
335
354
|
def copy_to_writer(entry_name, writer, target_path: nil)
|
|
336
355
|
entry = lookup_entry(entry_name)
|
|
337
|
-
raise
|
|
356
|
+
raise ZipError, "Entry not found in archive: #{entry_name}" unless entry
|
|
338
357
|
|
|
339
358
|
if entry.compressed_size.zero? && entry.uncompressed_size.positive?
|
|
340
359
|
data = read_entry(entry_name)
|
|
@@ -391,13 +410,13 @@ module Xlsxrb
|
|
|
391
410
|
|
|
392
411
|
def parse_catalog
|
|
393
412
|
first_sig = io.read(4)
|
|
394
|
-
raise
|
|
413
|
+
raise ZipError, "Invalid magic number: Expected a valid ZIP/XLSX file format (PK\\x03\\x04)" unless first_sig == LOCAL_HEADER_SIG
|
|
395
414
|
|
|
396
415
|
# Try Central Directory first
|
|
397
416
|
cd_catalog = parse_catalog_from_central_directory(io)
|
|
398
417
|
return cd_catalog if cd_catalog && !cd_catalog.empty?
|
|
399
418
|
|
|
400
|
-
io.seek(
|
|
419
|
+
io.seek(0, IO::SEEK_SET) if io.respond_to?(:seek)
|
|
401
420
|
parse_catalog_from_local_headers(io)
|
|
402
421
|
end
|
|
403
422
|
|
|
@@ -437,7 +456,10 @@ module Xlsxrb
|
|
|
437
456
|
|
|
438
457
|
_v_made, _v_need, _gp, method, _time, _date, crc, csize, usize, nlen, elen, clen, _dnum, _iattr, _eattr, offset = cd_header.unpack("vvvvvvVVVvvvvvVV")
|
|
439
458
|
entry_name = io.read(nlen).force_encoding("UTF-8")
|
|
440
|
-
io.read(elen
|
|
459
|
+
extra_data = elen.positive? ? io.read(elen) : nil
|
|
460
|
+
io.read(clen) if clen.positive?
|
|
461
|
+
|
|
462
|
+
usize, csize, offset = parse_zip64_extra(extra_data, usize, csize, offset) if extra_data && (usize == 0xFFFF_FFFF || csize == 0xFFFF_FFFF || offset == 0xFFFF_FFFF)
|
|
441
463
|
|
|
442
464
|
norm_name = normalize_entry_name(entry_name)
|
|
443
465
|
next if norm_name.end_with?("/")
|
|
@@ -496,7 +518,8 @@ module Xlsxrb
|
|
|
496
518
|
extra_len = header[24, 2].unpack1("v")
|
|
497
519
|
|
|
498
520
|
entry_name = io.read(name_len).force_encoding("UTF-8")
|
|
499
|
-
io.read(extra_len)
|
|
521
|
+
extra_data = extra_len.positive? ? io.read(extra_len) : nil
|
|
522
|
+
uncompressed_size, compressed_size, = parse_zip64_extra(extra_data, uncompressed_size, compressed_size, 0) if extra_data && (uncompressed_size == 0xFFFF_FFFF || compressed_size == 0xFFFF_FFFF)
|
|
500
523
|
data_offset = io.pos
|
|
501
524
|
|
|
502
525
|
norm_name = normalize_entry_name(entry_name)
|
|
@@ -550,7 +573,7 @@ module Xlsxrb
|
|
|
550
573
|
|
|
551
574
|
inflated = inflater.inflate(chunk)
|
|
552
575
|
limit = @max_uncompressed_size || MAX_UNCOMPRESSED_SIZE
|
|
553
|
-
raise
|
|
576
|
+
raise ZipBombError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
|
|
554
577
|
|
|
555
578
|
result << inflated
|
|
556
579
|
end
|
|
@@ -602,13 +625,13 @@ module Xlsxrb
|
|
|
602
625
|
while offset < raw_len
|
|
603
626
|
chunk = raw.byteslice(offset, chunk_size)
|
|
604
627
|
inflated = inflater.inflate(chunk)
|
|
605
|
-
raise
|
|
628
|
+
raise ZipBombError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
|
|
606
629
|
|
|
607
630
|
result << inflated
|
|
608
631
|
offset += chunk_size
|
|
609
632
|
end
|
|
610
633
|
inflated = inflater.finish
|
|
611
|
-
raise
|
|
634
|
+
raise ZipBombError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
|
|
612
635
|
|
|
613
636
|
result << inflated
|
|
614
637
|
ensure
|
|
@@ -645,6 +668,34 @@ module Xlsxrb
|
|
|
645
668
|
data.force_encoding(Encoding::UTF_8)
|
|
646
669
|
end
|
|
647
670
|
end
|
|
671
|
+
|
|
672
|
+
#: (String extra_data, Integer usize, Integer csize, Integer offset) -> [Integer, Integer, Integer]
|
|
673
|
+
def parse_zip64_extra(extra_data, usize, csize, offset)
|
|
674
|
+
pos = 0
|
|
675
|
+
len = extra_data.bytesize
|
|
676
|
+
while pos + 4 <= len
|
|
677
|
+
header_id, data_size = extra_data.byteslice(pos, 4).unpack("vv")
|
|
678
|
+
pos += 4
|
|
679
|
+
if header_id == 0x0001
|
|
680
|
+
field_data = extra_data.byteslice(pos, data_size)
|
|
681
|
+
if field_data
|
|
682
|
+
fpos = 0
|
|
683
|
+
if usize == 0xFFFF_FFFF && fpos + 8 <= field_data.bytesize
|
|
684
|
+
usize = field_data.byteslice(fpos, 8).unpack1("Q<")
|
|
685
|
+
fpos += 8
|
|
686
|
+
end
|
|
687
|
+
if csize == 0xFFFF_FFFF && fpos + 8 <= field_data.bytesize
|
|
688
|
+
csize = field_data.byteslice(fpos, 8).unpack1("Q<")
|
|
689
|
+
fpos += 8
|
|
690
|
+
end
|
|
691
|
+
offset = field_data.byteslice(fpos, 8).unpack1("Q<") if offset == 0xFFFF_FFFF && fpos + 8 <= field_data.bytesize
|
|
692
|
+
end
|
|
693
|
+
break
|
|
694
|
+
end
|
|
695
|
+
pos += data_size
|
|
696
|
+
end
|
|
697
|
+
[usize, csize, offset]
|
|
698
|
+
end
|
|
648
699
|
end
|
|
649
700
|
end
|
|
650
701
|
end
|
data/lib/xlsxrb/stream_row.rb
CHANGED
|
@@ -33,13 +33,14 @@ module Xlsxrb
|
|
|
33
33
|
# @param collapsed [Boolean] Whether the row is collapsed.
|
|
34
34
|
# @param outline_level [Integer, nil] Grouping/outline level.
|
|
35
35
|
# @param style_index [Integer, nil] Style index.
|
|
36
|
+
# @param styles [Elements::Styles, Hash, nil] Optional parsed styles.
|
|
36
37
|
# @param date1904 [Boolean] Whether the 1904 date system is active.
|
|
37
38
|
# @param pad_empty_cells [Boolean] Whether missing cells in this row are padded.
|
|
38
|
-
#: (index: Integer, xml_bytes: String, from: Integer, to: Integer, shared_strings: Array[String], ?prefix: String, ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?collapsed: bool, ?outline_level: Integer | nil, ?style_index: Integer | nil, ?styles: Hash[untyped, untyped]?, ?date1904: bool, ?pad_empty_cells: bool) -> void
|
|
39
|
+
#: (index: Integer, xml_bytes: String, from: Integer, to: Integer, shared_strings: Array[String], ?prefix: String, ?height: Float | Integer | nil, ?hidden: bool, ?custom_height: bool, ?collapsed: bool, ?outline_level: Integer | nil, ?style_index: Integer | nil, ?styles: (Elements::Styles | Hash[untyped, untyped])?, ?date1904: bool, ?pad_empty_cells: bool) -> void
|
|
39
40
|
def initialize(index:, xml_bytes:, from:, to:, shared_strings:, prefix: "", height: nil, hidden: false,
|
|
40
41
|
custom_height: false, collapsed: false, outline_level: nil, style_index: nil, styles: nil, date1904: false, pad_empty_cells: false)
|
|
41
42
|
@index = index
|
|
42
|
-
@xml = xml_bytes
|
|
43
|
+
@xml = xml_bytes.encoding == Encoding::BINARY ? xml_bytes : xml_bytes.b
|
|
43
44
|
@from = from
|
|
44
45
|
@to = to
|
|
45
46
|
@shared_strings = shared_strings
|
|
@@ -60,7 +61,7 @@ module Xlsxrb
|
|
|
60
61
|
def self.fast_create(index, xml_bytes, from, to, shared_strings, prefix = "", height = nil, hidden = false, custom_height = false, outline_level = nil, style_index = nil, styles = nil, date1904 = false, collapsed = false, pad_empty_cells = false)
|
|
61
62
|
inst = allocate
|
|
62
63
|
inst.instance_variable_set(:@index, index)
|
|
63
|
-
inst.instance_variable_set(:@xml, xml_bytes)
|
|
64
|
+
inst.instance_variable_set(:@xml, xml_bytes.encoding == Encoding::BINARY ? xml_bytes : xml_bytes.b)
|
|
64
65
|
inst.instance_variable_set(:@from, from)
|
|
65
66
|
inst.instance_variable_set(:@to, to)
|
|
66
67
|
inst.instance_variable_set(:@shared_strings, shared_strings)
|