xlsxrb 0.1.5 → 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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/.gem_rbs_collection/nokogiri/1.11/.rbs_meta.yaml +9 -0
  3. data/.gem_rbs_collection/nokogiri/1.11/nokogiri.rbs +2332 -0
  4. data/.gem_rbs_collection/nokogiri/1.11/patch.rbs +4 -0
  5. data/.gem_rbs_collection/rubyzip/3.2/.rbs_meta.yaml +9 -0
  6. data/.gem_rbs_collection/rubyzip/3.2/manifest.yaml +8 -0
  7. data/.gem_rbs_collection/rubyzip/3.2/zip/central_directory.rbs +42 -0
  8. data/.gem_rbs_collection/rubyzip/3.2/zip/compressor.rbs +5 -0
  9. data/.gem_rbs_collection/rubyzip/3.2/zip/constants.rbs +47 -0
  10. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/aes_encryption.rbs +30 -0
  11. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/decrypted_io.rbs +9 -0
  12. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/encryption.rbs +7 -0
  13. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/null_encryption.rbs +19 -0
  14. data/.gem_rbs_collection/rubyzip/3.2/zip/crypto/traditional_encryption.rbs +31 -0
  15. data/.gem_rbs_collection/rubyzip/3.2/zip/decompressor.rbs +18 -0
  16. data/.gem_rbs_collection/rubyzip/3.2/zip/deflater.rbs +12 -0
  17. data/.gem_rbs_collection/rubyzip/3.2/zip/dirtyable.rbs +11 -0
  18. data/.gem_rbs_collection/rubyzip/3.2/zip/dos_time.rbs +13 -0
  19. data/.gem_rbs_collection/rubyzip/3.2/zip/entry.rbs +95 -0
  20. data/.gem_rbs_collection/rubyzip/3.2/zip/entry_set.rbs +31 -0
  21. data/.gem_rbs_collection/rubyzip/3.2/zip/errors.rbs +58 -0
  22. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/aes.rbs +24 -0
  23. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/generic.rbs +17 -0
  24. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/ntfs.rbs +23 -0
  25. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/old_unix.rbs +22 -0
  26. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/universal_time.rbs +30 -0
  27. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unix.rbs +20 -0
  28. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/unknown.rbs +15 -0
  29. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field/zip64.rbs +26 -0
  30. data/.gem_rbs_collection/rubyzip/3.2/zip/extra_field.rbs +21 -0
  31. data/.gem_rbs_collection/rubyzip/3.2/zip/file.rbs +131 -0
  32. data/.gem_rbs_collection/rubyzip/3.2/zip/file_split.rbs +14 -0
  33. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/dir.rbs +33 -0
  34. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/directory_iterator.rbs +21 -0
  35. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file.rbs +63 -0
  36. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/file_stat.rbs +55 -0
  37. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem/zip_file_name_mapper.rbs +35 -0
  38. data/.gem_rbs_collection/rubyzip/3.2/zip/filesystem.rbs +7 -0
  39. data/.gem_rbs_collection/rubyzip/3.2/zip/inflater.rbs +10 -0
  40. data/.gem_rbs_collection/rubyzip/3.2/zip/input_stream.rbs +22 -0
  41. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_input_stream.rbs +29 -0
  42. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras/abstract_output_stream.rbs +17 -0
  43. data/.gem_rbs_collection/rubyzip/3.2/zip/ioextras.rbs +13 -0
  44. data/.gem_rbs_collection/rubyzip/3.2/zip/null_compressor.rbs +10 -0
  45. data/.gem_rbs_collection/rubyzip/3.2/zip/null_decompressor.rbs +8 -0
  46. data/.gem_rbs_collection/rubyzip/3.2/zip/null_input_stream.rbs +6 -0
  47. data/.gem_rbs_collection/rubyzip/3.2/zip/output_stream.rbs +30 -0
  48. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_compressor.rbs +10 -0
  49. data/.gem_rbs_collection/rubyzip/3.2/zip/pass_thru_decompressor.rbs +10 -0
  50. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_directory.rbs +5 -0
  51. data/.gem_rbs_collection/rubyzip/3.2/zip/streamable_stream.rbs +15 -0
  52. data/.gem_rbs_collection/rubyzip/3.2/zip/version.rbs +3 -0
  53. data/.gem_rbs_collection/rubyzip/3.2/zip.rbs +40 -0
  54. data/CHANGELOG.md +18 -0
  55. data/README.md +39 -32
  56. data/Rakefile +2 -1
  57. data/Steepfile +0 -2
  58. data/benchmark.rb +376 -0
  59. data/docs/ARCHITECTURE.md +12 -5
  60. data/docs/wasm/ruby.wasm +0 -0
  61. data/lib/xlsxrb/elements/cell.rb +39 -7
  62. data/lib/xlsxrb/elements/row.rb +17 -4
  63. data/lib/xlsxrb/elements/types.rb +4 -0
  64. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +104 -40
  65. data/lib/xlsxrb/ooxml/worksheet_parser.rb +144 -23
  66. data/lib/xlsxrb/ooxml/worksheet_writer.rb +160 -145
  67. data/lib/xlsxrb/ooxml/xml_builder.rb +16 -10
  68. data/lib/xlsxrb/ooxml/zip_reader.rb +104 -18
  69. data/lib/xlsxrb/ooxml/zip_writer.rb +63 -25
  70. data/lib/xlsxrb/style_builder.rb +158 -30
  71. data/lib/xlsxrb/version.rb +1 -1
  72. data/lib/xlsxrb.rb +443 -236
  73. data/rbs_collection.lock.yaml +28 -0
  74. data/sig/generated/xlsxrb/elements/cell.rbs +30 -0
  75. data/sig/generated/xlsxrb/elements/column.rbs +30 -0
  76. data/sig/generated/xlsxrb/elements/row.rbs +32 -0
  77. data/sig/generated/xlsxrb/elements/types.rbs +74 -0
  78. data/sig/generated/xlsxrb/elements/workbook.rbs +25 -0
  79. data/sig/generated/xlsxrb/elements/worksheet.rbs +27 -0
  80. data/sig/generated/xlsxrb/ooxml/reader.rbs +1412 -0
  81. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +21 -0
  82. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +44 -0
  83. data/sig/generated/xlsxrb/ooxml/utils.rbs +40 -0
  84. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +46 -0
  85. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +66 -0
  86. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +67 -0
  87. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +92 -0
  88. data/sig/generated/xlsxrb/ooxml/writer.rbs +880 -0
  89. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +45 -0
  90. data/sig/generated/xlsxrb/ooxml/xml_parser.rbs +30 -0
  91. data/sig/generated/xlsxrb/ooxml/zip_generator.rbs +38 -0
  92. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +45 -0
  93. data/sig/generated/xlsxrb/ooxml/zip_writer.rbs +45 -0
  94. data/sig/generated/xlsxrb/style_builder.rbs +212 -50
  95. data/sig/generated/xlsxrb.rbs +1276 -0
  96. data/sig/rexml.rbs +4 -0
  97. metadata +76 -1
@@ -9,13 +9,20 @@ module Xlsxrb
9
9
  # SAX-based parser for xl/sharedStrings.xml.
10
10
  # Returns an Array of strings (index = SST index).
11
11
  class SharedStringsParser
12
+ XML_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&apos;" => "'" }.freeze
13
+
12
14
  # Parses all shared strings and returns an Array of strings.
13
- def self.parse(xml_string, part_name: "xl/sharedStrings.xml")
15
+ def self.parse(xml_string, _part_name: "xl/sharedStrings.xml")
14
16
  return [] if xml_string.nil? || xml_string.empty?
15
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
+
16
23
  strings = []
17
- each_event(xml_string, part_name: part_name) do |event|
18
- strings << event.args[0] if event.type == :sst_item
24
+ fast_scan(xml_string) do |str|
25
+ strings << str
19
26
  end
20
27
  strings
21
28
  end
@@ -25,53 +32,110 @@ module Xlsxrb
25
32
  return enum_for(:each_event, xml_string, part_name: part_name) unless block
26
33
  return if xml_string.nil? || xml_string.empty?
27
34
 
28
- listener = EventListener.new(part_name, &block)
29
- 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
30
44
  end
31
45
 
32
- # SAX listener for generating events from shared string table.
33
- class EventListener
34
- include REXML::SAX2Listener
35
-
36
- def initialize(part_name, &block)
37
- @part_name = part_name
38
- @block = block
39
- @in_si = false
40
- @in_t = false
41
- @current_text = +""
42
- @index = 0
43
- end
46
+ def self.fast_scan(xml_src)
47
+ xml = xml_src.b
48
+ sst_start = xml.index("<sst")
49
+ return unless sst_start
44
50
 
45
- def start_element(_uri, localname, _qname, _attrs)
46
- case localname
47
- when "si"
48
- @in_si = true
49
- @current_text = +""
50
- when "t"
51
- @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
52
71
  end
53
- end
54
72
 
55
- def end_element(_uri, localname, _qname)
56
- case localname
57
- when "si"
58
- @in_si = false
59
- frozen_str = @current_text.freeze
60
- @block.call(Event.new(
61
- type: :sst_item,
62
- args: [frozen_str],
63
- source: { part: @part_name, index: @index }
64
- ))
65
- @index += 1
66
- when "t"
67
- @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 = ""
68
103
  end
104
+
105
+ yield str.freeze
106
+ pos = si_end + 5
69
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
70
118
 
71
- def characters(text)
72
- @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
73
134
  end
135
+ buf
74
136
  end
137
+
138
+ private_class_method :extract_multi_t
75
139
  end
76
140
  end
77
141
  end
@@ -393,8 +393,7 @@ module Xlsxrb
393
393
  if raw.include?(".")
394
394
  raw.to_f
395
395
  else
396
- int_val = raw.to_i
397
- int_val.to_s == raw ? int_val : raw.to_f
396
+ raw.to_i
398
397
  end
399
398
  end
400
399
  end
@@ -482,19 +481,53 @@ module Xlsxrb
482
481
  next
483
482
  end
484
483
 
485
- row_tag = xml.byteslice(row_start, tag_end - row_start)
486
484
  row_index = 0
487
- r_val = tag_attr(row_tag, ' r="')
488
- 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
489
514
 
490
- attrs = extract_row_attrs(row_tag)
491
515
  row_end = xml.index("</row>", tag_end + 1)
492
516
  break unless row_end
493
517
 
494
518
  row_source = { part: part_name, row: row_index }
495
519
  cells = fast_parse_cells_direct(xml, tag_end + 1, row_end, shared_strings, row_source)
496
520
 
497
- 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)
498
531
 
499
532
  pos = row_end + 6
500
533
  end
@@ -519,18 +552,90 @@ module Xlsxrb
519
552
  c_tag_end = xml.index(">", c_start + 2)
520
553
  break unless c_tag_end
521
554
 
522
- # Extract tag substring for bounded attribute search
523
- c_tag = xml.byteslice(c_start, c_tag_end - c_start)
524
- ref = tag_attr(c_tag, ' r="')
525
- type = tag_attr(c_tag, ' t="')
526
- style_str = tag_attr(c_tag, ' s="')
527
- 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
528
629
 
529
630
  # Self-closing <c ... />
530
631
  if xml.getbyte(c_tag_end - 1) == 47
531
- cell_source = row_source.dup
532
- cell_source[:cell] = ref
533
- 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
+ )
534
639
  pos = c_tag_end + 1
535
640
  next
536
641
  end
@@ -554,8 +659,21 @@ module Xlsxrb
554
659
  v_val_start = tag_pos + 3
555
660
  v_end = xml.index("</v>", v_val_start)
556
661
  if v_end
557
- raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
558
- 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
559
677
  cpos = v_end + 4
560
678
  else
561
679
  cpos = tag_pos + 3
@@ -603,11 +721,14 @@ module Xlsxrb
603
721
  end
604
722
 
605
723
  val_to_use = inline_str || value
606
- cell_source = row_source.dup
607
- cell_source[:cell] = ref
608
- cell = { ref: ref, type: type, style_index: style_index, value: val_to_use, source: cell_source }
609
- cell[:formula] = formula if formula
610
- 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
+ )
611
732
 
612
733
  pos = c_end + 4
613
734
  end