xlsxrb 0.1.4 → 0.1.6

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +1 -1
  3. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +9 -0
  4. data/.gem_rbs_collection/ast/2.4/ast.rbs +73 -0
  5. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
  6. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
  7. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +16 -0
  8. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +96 -0
  9. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
  10. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +68 -0
  11. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
  12. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +4 -0
  13. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +47 -0
  14. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
  15. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +9 -0
  16. data/.gem_rbs_collection/csv/3.3/csv.rbs +3871 -0
  17. data/.gem_rbs_collection/csv/3.3/manifest.yaml +3 -0
  18. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +9 -0
  19. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +48 -0
  20. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
  21. data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
  22. data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
  23. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +9 -0
  24. data/.gem_rbs_collection/logger/1.7/formatter.rbs +45 -0
  25. data/.gem_rbs_collection/logger/1.7/log_device.rbs +100 -0
  26. data/.gem_rbs_collection/logger/1.7/logger.rbs +796 -0
  27. data/.gem_rbs_collection/logger/1.7/manifest.yaml +2 -0
  28. data/.gem_rbs_collection/logger/1.7/period.rbs +17 -0
  29. data/.gem_rbs_collection/logger/1.7/severity.rbs +34 -0
  30. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +2332 -0
  32. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +4 -0
  33. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
  34. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  35. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +9 -0
  36. data/.gem_rbs_collection/parser/3.2/manifest.yaml +7 -0
  37. data/.gem_rbs_collection/parser/3.2/parser.rbs +194 -0
  38. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +4 -0
  39. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +9 -0
  40. data/.gem_rbs_collection/rainbow/3.0/global.rbs +7 -0
  41. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +209 -0
  42. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +5 -0
  43. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  44. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  45. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  46. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +9 -0
  47. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +17 -0
  48. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +9 -0
  49. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +208 -0
  50. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  51. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +903 -0
  52. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +9 -0
  53. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +8 -0
  54. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +42 -0
  55. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +5 -0
  56. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +47 -0
  57. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +30 -0
  58. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +9 -0
  59. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +7 -0
  60. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +19 -0
  61. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +31 -0
  62. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +18 -0
  63. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +12 -0
  64. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +11 -0
  65. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +13 -0
  66. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +95 -0
  67. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +31 -0
  68. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +58 -0
  69. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +24 -0
  70. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +17 -0
  71. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +23 -0
  72. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +22 -0
  73. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +30 -0
  74. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +20 -0
  75. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +15 -0
  76. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +26 -0
  77. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +21 -0
  78. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +131 -0
  79. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +14 -0
  80. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +33 -0
  81. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +21 -0
  82. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +63 -0
  83. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +55 -0
  84. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +35 -0
  85. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +7 -0
  86. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +10 -0
  87. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +22 -0
  88. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +29 -0
  89. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +17 -0
  90. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +13 -0
  91. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +10 -0
  92. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +8 -0
  93. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +6 -0
  94. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +30 -0
  95. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +10 -0
  96. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +10 -0
  97. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +5 -0
  98. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +15 -0
  99. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +3 -0
  100. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +40 -0
  101. data/CHANGELOG.md +42 -3
  102. data/README.md +130 -49
  103. data/Rakefile +23 -2
  104. data/Steepfile +17 -0
  105. data/benchmark.rb +324 -348
  106. data/docs/ARCHITECTURE.md +24 -0
  107. data/docs/DEVELOPMENT.md +58 -0
  108. data/docs/QUALITY_ASSURANCE.md +26 -0
  109. data/docs/visual/VisualGallery.md +893 -938
  110. data/docs/wasm/ruby.wasm +0 -0
  111. data/lib/xlsxrb/elements/cell.rb +113 -8
  112. data/lib/xlsxrb/elements/column.rb +2 -0
  113. data/lib/xlsxrb/elements/row.rb +52 -4
  114. data/lib/xlsxrb/elements/types.rb +6 -0
  115. data/lib/xlsxrb/elements/workbook.rb +29 -0
  116. data/lib/xlsxrb/elements/worksheet.rb +98 -1
  117. data/lib/xlsxrb/elements.rb +2 -0
  118. data/lib/xlsxrb/ooxml/reader.rb +8 -6
  119. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +106 -40
  120. data/lib/xlsxrb/ooxml/styles_parser.rb +2 -0
  121. data/lib/xlsxrb/ooxml/utils.rb +2 -0
  122. data/lib/xlsxrb/ooxml/workbook_parser.rb +6 -1
  123. data/lib/xlsxrb/ooxml/workbook_writer.rb +15 -4
  124. data/lib/xlsxrb/ooxml/worksheet_parser.rb +156 -23
  125. data/lib/xlsxrb/ooxml/worksheet_writer.rb +207 -152
  126. data/lib/xlsxrb/ooxml/writer.rb +217 -5
  127. data/lib/xlsxrb/ooxml/xml_builder.rb +18 -10
  128. data/lib/xlsxrb/ooxml/xml_parser.rb +2 -0
  129. data/lib/xlsxrb/ooxml/zip_generator.rb +5 -8
  130. data/lib/xlsxrb/ooxml/zip_reader.rb +179 -57
  131. data/lib/xlsxrb/ooxml/zip_writer.rb +65 -25
  132. data/lib/xlsxrb/ooxml.rb +3 -1
  133. data/lib/xlsxrb/style_builder.rb +284 -36
  134. data/lib/xlsxrb/version.rb +3 -1
  135. data/lib/xlsxrb.rb +1761 -275
  136. data/rbs_collection.lock.yaml +252 -0
  137. data/rbs_collection.yaml +19 -0
  138. data/sig/generated/xlsxrb/elements/cell.rbs +30 -0
  139. data/sig/generated/xlsxrb/elements/column.rbs +30 -0
  140. data/sig/generated/xlsxrb/elements/row.rbs +32 -0
  141. data/sig/generated/xlsxrb/elements/types.rbs +74 -0
  142. data/sig/generated/xlsxrb/elements/workbook.rbs +25 -0
  143. data/sig/generated/xlsxrb/elements/worksheet.rbs +27 -0
  144. data/sig/generated/xlsxrb/elements.rbs +8 -0
  145. data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
  146. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
  147. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
  148. data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
  149. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
  150. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
  151. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
  152. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
  153. data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
  154. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
  155. data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
  156. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
  157. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
  158. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
  159. data/sig/generated/xlsxrb/ooxml.rbs +23 -0
  160. data/sig/generated/xlsxrb/style_builder.rbs +250 -0
  161. data/sig/generated/xlsxrb/version.rbs +5 -0
  162. data/sig/generated/xlsxrb.rbs +1276 -0
  163. data/sig/rexml.rbs +4 -0
  164. metadata +145 -4
  165. data/measure_memory.rb +0 -42
  166. data/sig/xlsxrb.rbs +0 -23
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require_relative "xml_parser"
4
6
 
5
7
  module Xlsxrb
@@ -7,13 +9,20 @@ module Xlsxrb
7
9
  # SAX-based parser for xl/sharedStrings.xml.
8
10
  # Returns an Array of strings (index = SST index).
9
11
  class SharedStringsParser
12
+ XML_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&apos;" => "'" }.freeze
13
+
10
14
  # Parses all shared strings and returns an Array of strings.
11
- def self.parse(xml_string, part_name: "xl/sharedStrings.xml")
15
+ def self.parse(xml_string, _part_name: "xl/sharedStrings.xml")
12
16
  return [] if xml_string.nil? || xml_string.empty?
13
17
 
18
+ if xml_string.include?("<!DOCTYPE") || xml_string.include?("<!ENTITY")
19
+ listener = Class.new { include REXML::SAX2Listener }.new
20
+ XmlParser.parse(xml_string, listener)
21
+ end
22
+
14
23
  strings = []
15
- each_event(xml_string, part_name: part_name) do |event|
16
- strings << event.args[0] if event.type == :sst_item
24
+ fast_scan(xml_string) do |str|
25
+ strings << str
17
26
  end
18
27
  strings
19
28
  end
@@ -23,53 +32,110 @@ module Xlsxrb
23
32
  return enum_for(:each_event, xml_string, part_name: part_name) unless block
24
33
  return if xml_string.nil? || xml_string.empty?
25
34
 
26
- listener = EventListener.new(part_name, &block)
27
- XmlParser.parse(xml_string, listener)
35
+ index = 0
36
+ fast_scan(xml_string) do |str|
37
+ block.call(Event.new(
38
+ type: :sst_item,
39
+ args: [str],
40
+ source: { part: part_name, index: index }
41
+ ))
42
+ index += 1
43
+ end
28
44
  end
29
45
 
30
- # SAX listener for generating events from shared string table.
31
- class EventListener
32
- include REXML::SAX2Listener
33
-
34
- def initialize(part_name, &block)
35
- @part_name = part_name
36
- @block = block
37
- @in_si = false
38
- @in_t = false
39
- @current_text = +""
40
- @index = 0
41
- end
46
+ def self.fast_scan(xml_src)
47
+ xml = xml_src.b
48
+ sst_start = xml.index("<sst")
49
+ return unless sst_start
42
50
 
43
- def start_element(_uri, localname, _qname, _attrs)
44
- case localname
45
- when "si"
46
- @in_si = true
47
- @current_text = +""
48
- when "t"
49
- @in_t = true
51
+ sst_open_end = xml.index(">", sst_start)
52
+ return unless sst_open_end
53
+ return if xml.getbyte(sst_open_end - 1) == 47 # self-closing <sst/>
54
+
55
+ sst_end = xml.index("</sst>", sst_open_end)
56
+ sst_end ||= xml.bytesize
57
+
58
+ pos = sst_open_end + 1
59
+ while pos < sst_end
60
+ si_start = xml.index("<si", pos)
61
+ break unless si_start && si_start < sst_end
62
+
63
+ si_open_end = xml.index(">", si_start + 3)
64
+ break unless si_open_end
65
+
66
+ # Self-closing <si/>
67
+ if xml.getbyte(si_open_end - 1) == 47
68
+ yield ""
69
+ pos = si_open_end + 1
70
+ next
50
71
  end
51
- end
52
72
 
53
- def end_element(_uri, localname, _qname)
54
- case localname
55
- when "si"
56
- @in_si = false
57
- frozen_str = @current_text.freeze
58
- @block.call(Event.new(
59
- type: :sst_item,
60
- args: [frozen_str],
61
- source: { part: @part_name, index: @index }
62
- ))
63
- @index += 1
64
- when "t"
65
- @in_t = false
73
+ si_end = xml.index("</si>", si_open_end + 1)
74
+ break unless si_end
75
+
76
+ # Fast path: check for simple single <t>...</t> inside <si>
77
+ first_t = xml.index("<t", si_open_end + 1)
78
+ if first_t && first_t < si_end
79
+ t_open_end = xml.index(">", first_t + 2)
80
+ if t_open_end && t_open_end < si_end
81
+ if xml.getbyte(t_open_end - 1) == 47 # <t/>
82
+ str = ""
83
+ else
84
+ t_end = xml.index("</t>", t_open_end + 1)
85
+ if t_end && t_end <= si_end
86
+ next_t = xml.index("<t", t_end + 4)
87
+ if next_t && next_t < si_end
88
+ # Multiple <t> tags (rich text runs)
89
+ str = extract_multi_t(xml, si_open_end + 1, si_end)
90
+ else
91
+ raw_str = xml.byteslice(t_open_end + 1, t_end - t_open_end - 1).force_encoding("UTF-8")
92
+ str = raw_str.include?("&") ? raw_str.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES) : raw_str
93
+ end
94
+ else
95
+ str = ""
96
+ end
97
+ end
98
+ else
99
+ str = ""
100
+ end
101
+ else
102
+ str = ""
66
103
  end
104
+
105
+ yield str.freeze
106
+ pos = si_end + 5
67
107
  end
108
+ end
109
+
110
+ private_class_method :fast_scan
111
+
112
+ def self.extract_multi_t(xml, from, to)
113
+ buf = +""
114
+ pos = from
115
+ while pos < to
116
+ t_start = xml.index("<t", pos)
117
+ break unless t_start && t_start < to
68
118
 
69
- def characters(text)
70
- @current_text << text if @in_si && @in_t
119
+ t_open_end = xml.index(">", t_start + 2)
120
+ break unless t_open_end
121
+
122
+ if xml.getbyte(t_open_end - 1) == 47
123
+ pos = t_open_end + 1
124
+ next
125
+ end
126
+
127
+ t_end = xml.index("</t>", t_open_end + 1)
128
+ break unless t_end && t_end <= to
129
+
130
+ raw_chunk = xml.byteslice(t_open_end + 1, t_end - t_open_end - 1).force_encoding("UTF-8")
131
+ raw_chunk = raw_chunk.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES) if raw_chunk.include?("&")
132
+ buf << raw_chunk
133
+ pos = t_end + 4
71
134
  end
135
+ buf
72
136
  end
137
+
138
+ private_class_method :extract_multi_t
73
139
  end
74
140
  end
75
141
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require_relative "xml_parser"
4
6
 
5
7
  module Xlsxrb
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "date"
4
6
  require "securerandom"
5
7
  require "openssl"
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require_relative "xml_parser"
4
6
 
5
7
  module Xlsxrb
@@ -28,8 +30,11 @@ module Xlsxrb
28
30
  def start_element(_uri, localname, _qname, attrs)
29
31
  return unless localname == "sheet"
30
32
 
33
+ name_attr = attrs["name"]
34
+ name_attr = REXML::Text.unnormalize(name_attr) if name_attr
35
+
31
36
  @sheets << {
32
- name: attrs["name"],
37
+ name: name_attr,
33
38
  sheet_id: attrs["sheetId"]&.to_i,
34
39
  r_id: attrs["r:id"] || attrs["id"] || attrs.find { |k, _| k.end_with?(":id") }&.last
35
40
  }
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "stringio"
4
6
  require_relative "xml_builder"
5
7
  require_relative "zip_writer"
@@ -30,19 +32,19 @@ module Xlsxrb
30
32
 
31
33
  def self.write(target, sheets:, shared_strings: [], shared_strings_index: nil, styles: nil,
32
34
  defined_names: nil, core_properties: nil, app_properties: nil,
33
- custom_properties: nil, workbook_protection: nil)
34
- Xlsxrb::TRACER.in_span("Ooxml::WorkbookWriter.write") do
35
+ custom_properties: nil, workbook_protection: nil, workbook_properties: nil)
36
+ Xlsxrb.in_span("Ooxml::WorkbookWriter.write") do
35
37
  writer = new(sheets: sheets, shared_strings: shared_strings, shared_strings_index: shared_strings_index, styles: styles,
36
38
  defined_names: defined_names, core_properties: core_properties,
37
39
  app_properties: app_properties, custom_properties: custom_properties,
38
- workbook_protection: workbook_protection)
40
+ workbook_protection: workbook_protection, workbook_properties: workbook_properties)
39
41
  writer.write_to(target)
40
42
  end
41
43
  end
42
44
 
43
45
  def initialize(sheets:, shared_strings: [], shared_strings_index: nil, styles: nil,
44
46
  defined_names: nil, core_properties: nil, app_properties: nil,
45
- custom_properties: nil, workbook_protection: nil)
47
+ custom_properties: nil, workbook_protection: nil, workbook_properties: nil)
46
48
  @sheets = sheets
47
49
  @shared_strings = shared_strings
48
50
  @shared_strings_index = shared_strings_index
@@ -52,6 +54,7 @@ module Xlsxrb
52
54
  @app_properties = app_properties || {}
53
55
  @custom_properties = custom_properties || []
54
56
  @workbook_protection = workbook_protection
57
+ @workbook_properties = workbook_properties || { update_links: "never" }
55
58
  @drawing_count = 0
56
59
  @chart_count = 0
57
60
  @comment_count = 0
@@ -436,6 +439,14 @@ module Xlsxrb
436
439
  "xmlns:r": DOC_REL
437
440
  })
438
441
 
442
+ # Workbook properties
443
+ if @workbook_properties && !@workbook_properties.empty?
444
+ attrs = {}
445
+ attrs[:date1904] = "1" if @workbook_properties[:date1904]
446
+ attrs[:updateLinks] = @workbook_properties[:update_links] if @workbook_properties[:update_links]
447
+ b.empty_tag("workbookPr", attrs) unless attrs.empty?
448
+ end
449
+
439
450
  # Workbook protection
440
451
  if @workbook_protection
441
452
  wp_attrs = {}
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require_relative "xml_parser"
4
6
 
5
7
  module Xlsxrb
@@ -116,6 +118,13 @@ module Xlsxrb
116
118
  end
117
119
 
118
120
  # ---- Fast string-scanning event parser (byte-level) ----
121
+ #
122
+ # SECURITY NOTE: This custom XML parsing approach is secure by design against
123
+ # typical XML vulnerabilities:
124
+ # - XXE (XML External Entity Expansion): It does not interpret DTDs or expand
125
+ # arbitrary entities. It purely scans for literal `<row>` and `<c>` tags.
126
+ # - ReDoS: It uses O(1) byte indexing and bounded `String#index` searches
127
+ # rather than unbounded or backtracking regular expressions.
119
128
 
120
129
  def self.fast_scan_events(xml_src, shared_strings, part_name, &block)
121
130
  xml = xml_src.b # force ASCII-8BIT for O(1) byte indexing
@@ -384,8 +393,7 @@ module Xlsxrb
384
393
  if raw.include?(".")
385
394
  raw.to_f
386
395
  else
387
- int_val = raw.to_i
388
- int_val.to_s == raw ? int_val : raw.to_f
396
+ raw.to_i
389
397
  end
390
398
  end
391
399
  end
@@ -394,6 +402,9 @@ module Xlsxrb
394
402
 
395
403
  XML_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&apos;" => "'" }.freeze
396
404
 
405
+ # SECURITY NOTE: Decodes only standard predefined XML entities in a single pass.
406
+ # This completely prevents "Billion Laughs" attacks (exponential entity expansion)
407
+ # because it avoids recursive expansion and ignores custom entities entirely.
397
408
  def self.decode_xml_entities(str)
398
409
  str.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES)
399
410
  end
@@ -470,19 +481,53 @@ module Xlsxrb
470
481
  next
471
482
  end
472
483
 
473
- row_tag = xml.byteslice(row_start, tag_end - row_start)
474
484
  row_index = 0
475
- r_val = tag_attr(row_tag, ' r="')
476
- row_index = r_val.to_i - 1 if r_val
485
+ has_custom_attrs = false
486
+ ri = row_start + 4
487
+ while ri < tag_end
488
+ rb = xml.getbyte(ri)
489
+ if rb == 114 && xml.getbyte(ri + 1) == 61 && xml.getbyte(ri + 2) == 34 # r="
490
+ ri += 3
491
+ while ri < tag_end
492
+ cb = xml.getbyte(ri)
493
+ break unless cb.between?(48, 57)
494
+
495
+ row_index = (row_index * 10) + (cb - 48)
496
+ ri += 1
497
+
498
+ end
499
+ row_index -= 1
500
+ elsif [104, 99, 111].include?(rb) # 'h', 'c', 'o' for ht, customHeight, hidden, outlineLevel
501
+ has_custom_attrs = true
502
+ ri += 1
503
+ else
504
+ ri += 1
505
+ end
506
+ end
507
+
508
+ attrs = if has_custom_attrs
509
+ row_tag = xml.byteslice(row_start, tag_end - row_start)
510
+ extract_row_attrs(row_tag)
511
+ else
512
+ EMPTY_HASH
513
+ end
477
514
 
478
- attrs = extract_row_attrs(row_tag)
479
515
  row_end = xml.index("</row>", tag_end + 1)
480
516
  break unless row_end
481
517
 
482
518
  row_source = { part: part_name, row: row_index }
483
519
  cells = fast_parse_cells_direct(xml, tag_end + 1, row_end, shared_strings, row_source)
484
520
 
485
- block.call({ index: row_index, cells: cells, attrs: attrs, unmapped: EMPTY_ARRAY, source: row_source })
521
+ row_obj = Elements::Row.new(
522
+ index: row_index,
523
+ cells: cells,
524
+ height: attrs[:height],
525
+ hidden: attrs[:hidden] || false,
526
+ custom_height: attrs[:custom_height] || false,
527
+ outline_level: attrs[:outline_level],
528
+ errors: Elements::EMPTY_ERRORS
529
+ )
530
+ block.call(row_obj)
486
531
 
487
532
  pos = row_end + 6
488
533
  end
@@ -507,18 +552,90 @@ module Xlsxrb
507
552
  c_tag_end = xml.index(">", c_start + 2)
508
553
  break unless c_tag_end
509
554
 
510
- # Extract tag substring for bounded attribute search
511
- c_tag = xml.byteslice(c_start, c_tag_end - c_start)
512
- ref = tag_attr(c_tag, ' r="')
513
- type = tag_attr(c_tag, ' t="')
514
- style_str = tag_attr(c_tag, ' s="')
515
- style_index = style_str&.to_i
555
+ # Zero-allocation attribute scan directly within the tag
556
+ type = nil
557
+ style_index = nil
558
+ col_idx = nil
559
+ row_idx = nil
560
+
561
+ ai = c_start + 2
562
+ while ai < c_tag_end
563
+ b = xml.getbyte(ai)
564
+ if [32, 9, 10, 13].include?(b)
565
+ ai += 1
566
+ next
567
+ end
568
+
569
+ if b == 114 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # r="
570
+ ai += 3
571
+ col_idx = 0
572
+ while ai < c_tag_end
573
+ cb = xml.getbyte(ai)
574
+ if cb.between?(65, 90)
575
+ col_idx = (col_idx * 26) + (cb - 64)
576
+ ai += 1
577
+ elsif cb.between?(97, 122)
578
+ col_idx = (col_idx * 26) + (cb - 96)
579
+ ai += 1
580
+ else
581
+ break
582
+ end
583
+ end
584
+ col_idx -= 1
585
+ row_idx = 0
586
+ while ai < c_tag_end
587
+ cb = xml.getbyte(ai)
588
+ break unless cb.between?(48, 57)
589
+
590
+ row_idx = (row_idx * 10) + (cb - 48)
591
+ ai += 1
592
+
593
+ end
594
+ row_idx -= 1
595
+ ai += 1 if xml.getbyte(ai) == 34
596
+ elsif b == 116 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # t="
597
+ ai += 3
598
+ type_b = xml.getbyte(ai)
599
+ if type_b == 115 && xml.getbyte(ai + 1) == 34 # "s"
600
+ type = "s"
601
+ ai += 2
602
+ elsif type_b == 98 && xml.getbyte(ai + 1) == 34 # "b"
603
+ type = "b"
604
+ ai += 2
605
+ elsif type_b == 101 && xml.getbyte(ai + 1) == 34 # "e"
606
+ type = "e"
607
+ ai += 2
608
+ else
609
+ t_end = xml.index('"', ai)
610
+ type = xml.byteslice(ai, t_end - ai) if t_end
611
+ ai = t_end ? t_end + 1 : c_tag_end
612
+ end
613
+ elsif b == 115 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # s="
614
+ ai += 3
615
+ style_index = 0
616
+ while ai < c_tag_end
617
+ cb = xml.getbyte(ai)
618
+ break unless cb.between?(48, 57)
619
+
620
+ style_index = (style_index * 10) + (cb - 48)
621
+ ai += 1
622
+
623
+ end
624
+ ai += 1 if xml.getbyte(ai) == 34
625
+ else
626
+ ai += 1
627
+ end
628
+ end
516
629
 
517
630
  # Self-closing <c ... />
518
631
  if xml.getbyte(c_tag_end - 1) == 47
519
- cell_source = row_source.dup
520
- cell_source[:cell] = ref
521
- cells << { ref: ref, type: type, style_index: style_index, value: nil, source: cell_source }
632
+ cells << Elements::Cell.new(
633
+ row_index: row_idx || row_source[:row],
634
+ column_index: col_idx || cells.size,
635
+ value: nil,
636
+ style_index: style_index,
637
+ errors: Elements::EMPTY_ERRORS
638
+ )
522
639
  pos = c_tag_end + 1
523
640
  next
524
641
  end
@@ -542,8 +659,21 @@ module Xlsxrb
542
659
  v_val_start = tag_pos + 3
543
660
  v_end = xml.index("</v>", v_val_start)
544
661
  if v_end
545
- raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
546
- value = resolve_fast_value(raw_value, type, shared_strings)
662
+ if type == "s"
663
+ # Zero-allocation integer parse for SST index
664
+ s_idx = 0
665
+ v_i = v_val_start
666
+ while v_i < v_end
667
+ s_idx = (s_idx * 10) + (xml.getbyte(v_i) - 48)
668
+ v_i += 1
669
+ end
670
+ value = shared_strings[s_idx] || ""
671
+ elsif type == "b"
672
+ value = xml.getbyte(v_val_start) == 49 # '1'
673
+ else
674
+ raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
675
+ value = resolve_fast_value(raw_value, type, shared_strings)
676
+ end
547
677
  cpos = v_end + 4
548
678
  else
549
679
  cpos = tag_pos + 3
@@ -591,11 +721,14 @@ module Xlsxrb
591
721
  end
592
722
 
593
723
  val_to_use = inline_str || value
594
- cell_source = row_source.dup
595
- cell_source[:cell] = ref
596
- cell = { ref: ref, type: type, style_index: style_index, value: val_to_use, source: cell_source }
597
- cell[:formula] = formula if formula
598
- cells << cell
724
+ cells << Elements::Cell.new(
725
+ row_index: row_idx || row_source[:row],
726
+ column_index: col_idx || cells.size,
727
+ value: val_to_use,
728
+ formula: formula,
729
+ style_index: style_index,
730
+ errors: Elements::EMPTY_ERRORS
731
+ )
599
732
 
600
733
  pos = c_end + 4
601
734
  end