xlsxrb 0.1.12 → 0.1.13

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 (153) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -42
  3. data/README.md +26 -22
  4. data/lib/xlsxrb/dsl_helpers.rb +59 -0
  5. data/lib/xlsxrb/elements/workbook.rb +11 -0
  6. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +3 -5
  7. data/lib/xlsxrb/ooxml/workbook_writer.rb +5 -7
  8. data/lib/xlsxrb/ooxml/worksheet_parser.rb +172 -9
  9. data/lib/xlsxrb/ooxml/writer.rb +2 -1
  10. data/lib/xlsxrb/ooxml/xml_builder.rb +20 -0
  11. data/lib/xlsxrb/ooxml/zip_reader.rb +364 -78
  12. data/lib/xlsxrb/ooxml/zip_writer.rb +32 -5
  13. data/lib/xlsxrb/stream_sheet.rb +94 -6
  14. data/lib/xlsxrb/stream_writer.rb +33 -66
  15. data/lib/xlsxrb/version.rb +1 -1
  16. data/lib/xlsxrb/workbook_builder.rb +1 -1
  17. data/lib/xlsxrb/worksheet_builder.rb +3 -31
  18. data/lib/xlsxrb.rb +448 -171
  19. data/sig/generated/xlsxrb/dsl_helpers.rbs +32 -0
  20. data/sig/generated/xlsxrb/elements/workbook.rbs +7 -0
  21. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +0 -2
  22. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +5 -4
  23. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +11 -0
  24. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +96 -8
  25. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +5 -1
  26. data/sig/generated/xlsxrb/stream_sheet.rbs +43 -3
  27. data/sig/generated/xlsxrb.rbs +18 -11
  28. metadata +6 -146
  29. data/.devcontainer/Dockerfile +0 -65
  30. data/.devcontainer/devcontainer.json +0 -17
  31. data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +0 -9
  32. data/.gem_rbs_collection/ast/2.4/ast.rbs +0 -73
  33. data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +0 -9
  34. data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +0 -4
  35. data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +0 -16
  36. data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +0 -96
  37. data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +0 -4
  38. data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +0 -68
  39. data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +0 -249
  40. data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +0 -4
  41. data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +0 -47
  42. data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +0 -5
  43. data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +0 -9
  44. data/.gem_rbs_collection/csv/3.3/csv.rbs +0 -3871
  45. data/.gem_rbs_collection/csv/3.3/manifest.yaml +0 -3
  46. data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +0 -9
  47. data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +0 -48
  48. data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +0 -9
  49. data/.gem_rbs_collection/listen/3.9/listen.rbs +0 -25
  50. data/.gem_rbs_collection/listen/3.9/listener.rbs +0 -24
  51. data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +0 -9
  52. data/.gem_rbs_collection/logger/1.7/formatter.rbs +0 -45
  53. data/.gem_rbs_collection/logger/1.7/log_device.rbs +0 -100
  54. data/.gem_rbs_collection/logger/1.7/logger.rbs +0 -796
  55. data/.gem_rbs_collection/logger/1.7/manifest.yaml +0 -2
  56. data/.gem_rbs_collection/logger/1.7/period.rbs +0 -17
  57. data/.gem_rbs_collection/logger/1.7/severity.rbs +0 -34
  58. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +0 -9
  59. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +0 -2332
  60. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +0 -4
  61. data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +0 -9
  62. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +0 -86
  63. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +0 -9
  64. data/.gem_rbs_collection/parser/3.2/manifest.yaml +0 -7
  65. data/.gem_rbs_collection/parser/3.2/parser.rbs +0 -194
  66. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +0 -4
  67. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +0 -9
  68. data/.gem_rbs_collection/rainbow/3.0/global.rbs +0 -7
  69. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +0 -209
  70. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +0 -5
  71. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +0 -9
  72. data/.gem_rbs_collection/rake/13.0/manifest.yaml +0 -2
  73. data/.gem_rbs_collection/rake/13.0/rake.rbs +0 -39
  74. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +0 -9
  75. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +0 -17
  76. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +0 -9
  77. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +0 -208
  78. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +0 -9
  79. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +0 -903
  80. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +0 -9
  81. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +0 -8
  82. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +0 -42
  83. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +0 -5
  84. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +0 -47
  85. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +0 -30
  86. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +0 -9
  87. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +0 -7
  88. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +0 -19
  89. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +0 -31
  90. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +0 -18
  91. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +0 -12
  92. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +0 -11
  93. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +0 -13
  94. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +0 -95
  95. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +0 -31
  96. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +0 -58
  97. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +0 -24
  98. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +0 -17
  99. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +0 -23
  100. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +0 -22
  101. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +0 -30
  102. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +0 -20
  103. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +0 -15
  104. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +0 -26
  105. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +0 -21
  106. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +0 -131
  107. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +0 -14
  108. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +0 -33
  109. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +0 -21
  110. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +0 -63
  111. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +0 -55
  112. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +0 -35
  113. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +0 -7
  114. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +0 -10
  115. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +0 -22
  116. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +0 -29
  117. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +0 -17
  118. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +0 -13
  119. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +0 -10
  120. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +0 -8
  121. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +0 -6
  122. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +0 -30
  123. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +0 -10
  124. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +0 -10
  125. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +0 -5
  126. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +0 -15
  127. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +0 -3
  128. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +0 -40
  129. data/.mutant.yml +0 -8
  130. data/Rakefile +0 -633
  131. data/Steepfile +0 -18
  132. data/benchmark.rb +0 -406
  133. data/docs/ARCHITECTURE.md +0 -514
  134. data/docs/DEVELOPMENT.md +0 -137
  135. data/docs/MUTATION_TESTING.md +0 -52
  136. data/docs/PEER_LIBRARIES.md +0 -121
  137. data/docs/QUALITY_ASSURANCE.md +0 -26
  138. data/docs/SPEC_SOURCES.md +0 -48
  139. data/docs/assets/benchmark_results.svg +0 -144
  140. data/docs/assets/lsp_autocompletion.png +0 -0
  141. data/docs/assets/playground_preview.png +0 -0
  142. data/docs/coi-serviceworker.js +0 -82
  143. data/docs/office_thread.js +0 -77
  144. data/docs/preview.html +0 -719
  145. data/docs/visual/VisualGallery.md +0 -4683
  146. data/docs/wasm/ruby.wasm +0 -0
  147. data/docs/wasm/wasm_doc_helper.css +0 -351
  148. data/docs/wasm/wasm_doc_helper.js +0 -439
  149. data/docs/zeta.js +0 -1107
  150. data/rbs_collection.lock.yaml +0 -252
  151. data/rbs_collection.yaml +0 -19
  152. data/vendor/sdk_runner/Program.cs +0 -93
  153. data/vendor/sdk_runner/sdk_runner.csproj +0 -13
@@ -3,6 +3,7 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  require_relative "xml_parser"
6
+ require_relative "xml_builder"
6
7
 
7
8
  module Xlsxrb
8
9
  module Ooxml
@@ -25,11 +26,15 @@ module Xlsxrb
25
26
  end
26
27
 
27
28
  # Streaming parse: yields one raw row hash at a time.
28
- def self.each_row(xml_string, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", &block)
29
- return enum_for(:each_row, xml_string, shared_strings: shared_strings, part_name: part_name) unless block
30
- return if xml_string.nil? || xml_string.empty?
29
+ def self.each_row(xml_source, shared_strings: [], part_name: "xl/worksheets/sheet1.xml", &block)
30
+ return enum_for(:each_row, xml_source, shared_strings: shared_strings, part_name: part_name) unless block
31
+ return if xml_source.nil? || (xml_source.respond_to?(:empty?) && xml_source.empty?)
31
32
 
32
- fast_scan_rows_direct(xml_string, shared_strings, part_name, &block)
33
+ if xml_source.is_a?(String)
34
+ fast_scan_rows_direct(xml_source, shared_strings, part_name, &block)
35
+ else
36
+ scan_rows_stream(xml_source, shared_strings, part_name, &block)
37
+ end
33
38
  end
34
39
 
35
40
  # Parses column definitions (<cols>) from a worksheet.
@@ -428,17 +433,14 @@ module Xlsxrb
428
433
 
429
434
  private_class_method :resolve_fast_value
430
435
 
431
- XML_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&apos;" => "'" }.freeze
432
-
433
436
  # SECURITY NOTE: Decodes only standard predefined XML entities in a single pass.
434
437
  # This completely prevents "Billion Laughs" attacks (exponential entity expansion)
435
438
  # because it avoids recursive expansion and ignores custom entities entirely.
439
+ #: (String str) -> String
436
440
  def self.decode_xml_entities(str)
437
- str.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES)
441
+ XmlBuilder.unescape(str)
438
442
  end
439
443
 
440
- private_class_method :decode_xml_entities
441
-
442
444
  # Parses <cols> section for column definitions.
443
445
  class ColumnsListener
444
446
  include REXML::SAX2Listener
@@ -574,6 +576,167 @@ module Xlsxrb
574
576
 
575
577
  private_class_method :fast_scan_rows_direct
576
578
 
579
+ def self.scan_rows_stream(source, shared_strings, _part_name, &block)
580
+ buffer = +""
581
+ buffer.force_encoding(Encoding::BINARY)
582
+ in_sheet_data = false
583
+ prefix = ""
584
+ row_start_pattern = "<row"
585
+ row_start_len = 4
586
+ row_end_tag = "</row>"
587
+ row_end_len = 6
588
+ sd_end_tag = "</sheetData>"
589
+
590
+ push_chunk = lambda do |chunk|
591
+ return if in_sheet_data == :closed
592
+ return if chunk.nil? || chunk.empty?
593
+
594
+ buffer << chunk.b
595
+
596
+ unless in_sheet_data
597
+ sd_term = buffer.index("sheetData")
598
+ if sd_term.nil?
599
+ buffer.slice!(0...(buffer.bytesize - 32)) if buffer.bytesize > 64
600
+ return
601
+ end
602
+
603
+ sd_start = buffer.rindex("<", sd_term)
604
+ if sd_start.nil?
605
+ buffer.slice!(0...sd_term)
606
+ return
607
+ end
608
+
609
+ sd_open_end = buffer.index(">", sd_term)
610
+ return if sd_open_end.nil?
611
+
612
+ if buffer.getbyte(sd_open_end - 1) == 47
613
+ in_sheet_data = :closed
614
+ buffer.clear
615
+ return
616
+ end
617
+
618
+ prefix = buffer.byteslice(sd_start + 1, sd_term - (sd_start + 1))
619
+ row_start_pattern = "<#{prefix}row"
620
+ row_start_len = row_start_pattern.bytesize
621
+ row_end_tag = "</#{prefix}row>"
622
+ row_end_len = row_end_tag.bytesize
623
+ sd_end_tag = "</#{prefix}sheetData>"
624
+
625
+ buffer.slice!(0..sd_open_end)
626
+ in_sheet_data = true
627
+ end
628
+
629
+ pos = 0
630
+ last_consumed_pos = 0
631
+
632
+ loop do
633
+ sd_end_pos = buffer.index(sd_end_tag, pos)
634
+ row_start = buffer.index(row_start_pattern, pos)
635
+
636
+ if sd_end_pos && (row_start.nil? || sd_end_pos < row_start)
637
+ in_sheet_data = :closed
638
+ buffer.clear
639
+ return
640
+ end
641
+
642
+ break if row_start.nil?
643
+
644
+ next_pos = row_start + row_start_len
645
+ break if next_pos >= buffer.bytesize
646
+
647
+ nb = buffer.getbyte(next_pos)
648
+ unless [32, 62, 9, 10, 13, 47].include?(nb)
649
+ pos = next_pos
650
+ next
651
+ end
652
+
653
+ tag_end = buffer.index(">", next_pos)
654
+ break if tag_end.nil?
655
+
656
+ if buffer.getbyte(tag_end - 1) == 47
657
+ pos = tag_end + 1
658
+ last_consumed_pos = pos
659
+ next
660
+ end
661
+
662
+ row_end = buffer.index(row_end_tag, tag_end + 1)
663
+ if row_end.nil?
664
+ raise ArgumentError, "Malformed XML: row size exceeds maximum buffer limit" if buffer.bytesize > 50 * 1024 * 1024
665
+
666
+ break
667
+ end
668
+
669
+ row_index = 0
670
+ has_custom_attrs = false
671
+ ri = row_start + row_start_len
672
+ while ri < tag_end
673
+ rb = buffer.getbyte(ri)
674
+ if rb == 114 && buffer.getbyte(ri + 1) == 61 && buffer.getbyte(ri + 2) == 34 # r="
675
+ ri += 3
676
+ while ri < tag_end
677
+ cb = buffer.getbyte(ri)
678
+ break unless cb.between?(48, 57)
679
+
680
+ row_index = (row_index * 10) + (cb - 48)
681
+ ri += 1
682
+ end
683
+ row_index -= 1
684
+ elsif [104, 99, 111].include?(rb)
685
+ has_custom_attrs = true
686
+ ri += 1
687
+ else
688
+ ri += 1
689
+ end
690
+ end
691
+
692
+ attrs = if has_custom_attrs
693
+ row_tag = buffer.byteslice(row_start, tag_end - row_start)
694
+ extract_row_attrs(row_tag)
695
+ else
696
+ EMPTY_HASH
697
+ end
698
+
699
+ cells_xml = buffer.byteslice(tag_end + 1, row_end - (tag_end + 1))
700
+ row_obj = StreamRow.fast_create(
701
+ row_index,
702
+ cells_xml,
703
+ 0,
704
+ cells_xml.bytesize,
705
+ shared_strings,
706
+ prefix,
707
+ attrs[:height],
708
+ attrs[:hidden] || false,
709
+ attrs[:custom_height] || false,
710
+ attrs[:outline_level]
711
+ )
712
+ block.call(row_obj)
713
+
714
+ pos = row_end + row_end_len
715
+ last_consumed_pos = pos
716
+ end
717
+
718
+ buffer.slice!(0...last_consumed_pos) if last_consumed_pos.positive?
719
+ end
720
+
721
+ if source.respond_to?(:each_chunk)
722
+ source.each_chunk(65_536, &push_chunk)
723
+ elsif source.respond_to?(:read)
724
+ while (chunk = source.read(65_536))
725
+ break if chunk.empty?
726
+
727
+ push_chunk.call(chunk)
728
+ end
729
+ elsif source.respond_to?(:call)
730
+ source.call(&push_chunk)
731
+ elsif source.respond_to?(:each)
732
+ source.each(&push_chunk)
733
+ else
734
+ raise ArgumentError, "Unsupported stream source: #{source.class}"
735
+ end
736
+ end
737
+
738
+ private_class_method :scan_rows_stream
739
+
577
740
  COL_LETTERS = (0...16_384).map do |index|
578
741
  result = +""
579
742
  i = index
@@ -6,6 +6,7 @@ require_relative "zip_generator"
6
6
  require_relative "writer/drawing_xml"
7
7
  require_relative "writer/features_xml"
8
8
  require_relative "writer/styles_xml"
9
+ require_relative "../dsl_helpers"
9
10
 
10
11
  module Xlsxrb
11
12
  module Ooxml
@@ -3008,7 +3009,7 @@ module Xlsxrb
3008
3009
  # Converts a range like "A1:D20" to absolute "$A$1:$D$20".
3009
3010
  # : (untyped range) -> untyped
3010
3011
  def absolute_range(range)
3011
- range.split(":").map { |part| absolute_cell(part) }.join(":")
3012
+ DslHelpers.absolute_range(range)
3012
3013
  end
3013
3014
 
3014
3015
  # Converts "A1" to "$A$1".
@@ -98,6 +98,16 @@ module Xlsxrb
98
98
  @io.is_a?(StringIO) ? @io.string : @io.to_s
99
99
  end
100
100
 
101
+ UNESCAPE_MAP = {
102
+ "&amp;" => "&",
103
+ "&lt;" => "<",
104
+ "&gt;" => ">",
105
+ "&quot;" => '"',
106
+ "&apos;" => "'"
107
+ }.freeze
108
+
109
+ UNESCAPE_RE = /&(?:amp|lt|gt|quot|apos);/
110
+
101
111
  # Escapes XML special characters in a string.
102
112
  #
103
113
  # @param value [Object]
@@ -108,6 +118,16 @@ module Xlsxrb
108
118
  str.match?(ESCAPE_RE) ? str.gsub(ESCAPE_RE, ESCAPE_MAP) : str
109
119
  end
110
120
 
121
+ # Unescapes predefined XML entities in a string in a single pass.
122
+ #
123
+ # @param value [Object]
124
+ # @return [String]
125
+ #: (untyped value) -> String
126
+ def self.unescape(value)
127
+ str = value.to_s
128
+ str.include?("&") ? str.gsub(UNESCAPE_RE, UNESCAPE_MAP) : str
129
+ end
130
+
111
131
  private
112
132
 
113
133
  def write_attrs(attrs)