xlsxrb 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +37 -17
  4. data/benchmark/micro_ips.rb +70 -0
  5. data/lib/xlsxrb/elements/styles.rb +427 -15
  6. data/lib/xlsxrb/elements/workbook.rb +67 -4
  7. data/lib/xlsxrb/elements/worksheet.rb +117 -7
  8. data/lib/xlsxrb/ooxml/cfb.rb +10 -0
  9. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +56 -38
  10. data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +14 -2
  11. data/lib/xlsxrb/ooxml/reader/listeners/style_listeners.rb +12 -7
  12. data/lib/xlsxrb/ooxml/reader.rb +167 -139
  13. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +83 -28
  14. data/lib/xlsxrb/ooxml/styles_parser.rb +57 -14
  15. data/lib/xlsxrb/ooxml/utils.rb +59 -2
  16. data/lib/xlsxrb/ooxml/workbook_parser.rb +63 -4
  17. data/lib/xlsxrb/ooxml/workbook_writer.rb +232 -59
  18. data/lib/xlsxrb/ooxml/worksheet_parser.rb +192 -123
  19. data/lib/xlsxrb/ooxml/worksheet_writer.rb +307 -125
  20. data/lib/xlsxrb/ooxml/writer/drawing_xml.rb +16 -12
  21. data/lib/xlsxrb/ooxml/writer/features_xml.rb +1 -1
  22. data/lib/xlsxrb/ooxml/writer/styles_xml.rb +64 -15
  23. data/lib/xlsxrb/ooxml/writer.rb +31 -28
  24. data/lib/xlsxrb/ooxml/xml_builder.rb +22 -2
  25. data/lib/xlsxrb/ooxml/xml_parser.rb +45 -0
  26. data/lib/xlsxrb/ooxml/zip_reader.rb +83 -32
  27. data/lib/xlsxrb/stream_row.rb +4 -3
  28. data/lib/xlsxrb/stream_sheet.rb +259 -7
  29. data/lib/xlsxrb/stream_writer.rb +197 -31
  30. data/lib/xlsxrb/utils.rb +68 -0
  31. data/lib/xlsxrb/version.rb +1 -1
  32. data/lib/xlsxrb/workbook_builder.rb +110 -19
  33. data/lib/xlsxrb/worksheet_builder.rb +526 -15
  34. data/lib/xlsxrb.rb +889 -90
  35. data/sig/generated/xlsxrb/elements/styles.rbs +271 -5
  36. data/sig/generated/xlsxrb/elements/workbook.rbs +54 -3
  37. data/sig/generated/xlsxrb/elements/worksheet.rbs +63 -2
  38. data/sig/generated/xlsxrb/ooxml/reader/listeners/style_listeners.rbs +2 -0
  39. data/sig/generated/xlsxrb/ooxml/reader.rbs +12 -0
  40. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +3 -1
  41. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +2 -0
  42. data/sig/generated/xlsxrb/ooxml/utils.rbs +22 -0
  43. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +7 -0
  44. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +13 -1
  45. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +18 -2
  46. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +13 -3
  47. data/sig/generated/xlsxrb/ooxml/writer/styles_xml.rbs +5 -2
  48. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +6 -1
  49. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +9 -6
  50. data/sig/generated/xlsxrb/stream_row.rbs +3 -2
  51. data/sig/generated/xlsxrb/stream_sheet.rbs +132 -6
  52. data/sig/generated/xlsxrb/stream_writer.rbs +118 -27
  53. data/sig/generated/xlsxrb/utils.rbs +45 -0
  54. data/sig/generated/xlsxrb/worksheet_builder.rbs +252 -9
  55. data/sig/generated/xlsxrb.rbs +84 -10
  56. metadata +2 -1
@@ -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
- %(<a:ln#{ln_attrs}>#{fill_part}#{dash_part}#{head_part}#{tail_part}#{join_part}</a:ln>)
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
- "<xdr:#{tag}><xdr:col>#{col}</xdr:col><xdr:colOff>#{col_off}</xdr:colOff><xdr:row>#{row}</xdr:row><xdr:rowOff>#{row_off}</xdr:rowOff></xdr:#{tag}>"
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 { |c| parts << %(<rgbColor rgb="#{c}"/>) }
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 source, ?tag: ::String) -> (::String | ::String | ::String | ::String | "")
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
- if source[:auto]
169
- %(<#{tag} auto="1"/>)
170
- elsif source[:color] || source[:rgb]
171
- %(<#{tag} rgb="#{source[:color] || source[:rgb]}"/>)
172
- elsif source[:theme]
173
- attrs = [%(theme="#{source[:theme]}")]
174
- attrs << %(tint="#{source[:tint]}") if source[:tint]
175
- %(<#{tag} #{attrs.join(" ")}/>)
176
- elsif source[:indexed]
177
- %(<#{tag} indexed="#{source[:indexed]}"/>)
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) -> (::String | ::String | ::String | ::String | "")
277
+ # : (untyped tag, untyped fill, untyped prefix) -> ::String
230
278
  def emit_fill_color_xml(tag, fill, prefix)
231
- if fill[:"#{prefix}_color"]
232
- %(<#{tag} rgb="#{fill[:"#{prefix}_color"]}"/>)
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"]
@@ -2691,35 +2691,38 @@ module Xlsxrb
2691
2691
 
2692
2692
  # Emit <dataValidations> if defined.
2693
2693
  unless sheet_dv.empty?
2694
- dv_container_attrs = %( count="#{sheet_dv.size}")
2695
- dv_container_attrs << ' disablePrompts="1"' if dv_options[:disable_prompts]
2696
- dv_container_attrs << %( xWindow="#{dv_options[:x_window]}") if dv_options[:x_window]
2697
- dv_container_attrs << %( yWindow="#{dv_options[:y_window]}") if dv_options[:y_window]
2698
- parts << "<dataValidations#{dv_container_attrs}>"
2699
- sheet_dv.each do |dv|
2700
- dv_attrs = %(sqref="#{dv[:sqref]}")
2701
- dv_attrs << %( type="#{dv[:type]}") if dv[:type]
2702
- dv_attrs << %( operator="#{dv[:operator]}") if dv[:operator]
2703
- dv_attrs << %( errorStyle="#{dv[:error_style]}") if dv[:error_style]
2704
- dv_attrs << ' allowBlank="1"' if dv[:allow_blank]
2705
- dv_attrs << ' showInputMessage="1"' if dv[:show_input_message]
2706
- dv_attrs << ' showErrorMessage="1"' if dv[:show_error_message]
2707
- dv_attrs << %( errorTitle="#{xml_escape(dv[:error_title])}") if dv[:error_title]
2708
- dv_attrs << %( error="#{xml_escape(dv[:error])}") if dv[:error]
2709
- dv_attrs << %( promptTitle="#{xml_escape(dv[:prompt_title])}") if dv[:prompt_title]
2710
- dv_attrs << %( prompt="#{xml_escape(dv[:prompt])}") if dv[:prompt]
2711
- dv_attrs << ' showDropDown="1"' if dv[:show_drop_down]
2712
- dv_attrs << %( imeMode="#{dv[:ime_mode]}") if dv[:ime_mode]
2713
- if dv[:formula1] || dv[:formula2]
2714
- parts << "<dataValidation #{dv_attrs}>"
2715
- parts << "<formula1>#{xml_escape(dv[:formula1])}</formula1>" if dv[:formula1]
2716
- parts << "<formula2>#{xml_escape(dv[:formula2])}</formula2>" if dv[:formula2]
2717
- parts << "</dataValidation>"
2718
- else
2719
- parts << "<dataValidation #{dv_attrs}/>"
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
- # Unescapes predefined XML entities in a string in a single pass.
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?("&") ? str.gsub(UNESCAPE_RE, UNESCAPE_MAP) : str
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
- @buffer = +""
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.dup
64
- @buffer.clear
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 ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{@max_uncompressed_size} bytes" if @total_uncompressed > @max_uncompressed_size
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 ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{@max_uncompressed_size} bytes" if @total_uncompressed > @max_uncompressed_size
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).force_encoding(Encoding::UTF_8)
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 ArgumentError, "Entry not found in archive: #{entry_name}" unless entry
301
+ raise ZipError, "Entry not found in archive: #{entry_name}" unless entry
298
302
 
299
- EntryStreamIO.new(io, entry.data_offset, entry.compressed_size, entry.method, max_uncompressed_size: @max_uncompressed_size)
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 ArgumentError, "Entry not found in archive: #{entry_name}" unless entry
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 ArgumentError, "Invalid magic number: Expected a valid ZIP/XLSX file format (PK\\x03\\x04)" unless first_sig == LOCAL_HEADER_SIG
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(-4, IO::SEEK_CUR) if io.respond_to?(: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 + clen)
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 ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
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 ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
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 ArgumentError, "ZIP bomb detected: Uncompressed size exceeds #{limit} bytes" if result.bytesize + inflated.bytesize > limit
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
@@ -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)