xlsxrb 0.1.9 → 0.1.11

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +71 -253
  4. data/Rakefile +2 -1
  5. data/benchmark.rb +3 -1
  6. data/docs/PEER_LIBRARIES.md +121 -0
  7. data/docs/assets/benchmark_results.svg +144 -0
  8. data/docs/assets/lsp_autocompletion.png +0 -0
  9. data/docs/assets/playground_preview.png +0 -0
  10. data/docs/visual/VisualGallery.md +183 -0
  11. data/docs/wasm/ruby.wasm +0 -0
  12. data/lib/xlsxrb/chart_builder.rb +333 -0
  13. data/lib/xlsxrb/elements/cell.rb +66 -12
  14. data/lib/xlsxrb/elements/workbook.rb +2 -2
  15. data/lib/xlsxrb/ooxml/reader/listeners.rb +6450 -0
  16. data/lib/xlsxrb/ooxml/reader.rb +2 -6438
  17. data/lib/xlsxrb/ooxml/workbook_writer.rb +297 -236
  18. data/lib/xlsxrb/ooxml/worksheet_parser.rb +218 -266
  19. data/lib/xlsxrb/ooxml/worksheet_writer.rb +127 -10
  20. data/lib/xlsxrb/ooxml/zip_writer.rb +31 -38
  21. data/lib/xlsxrb/stream_row.rb +34 -8
  22. data/lib/xlsxrb/stream_sheet.rb +119 -0
  23. data/lib/xlsxrb/stream_writer.rb +1733 -0
  24. data/lib/xlsxrb/version.rb +1 -1
  25. data/lib/xlsxrb/workbook_builder.rb +309 -0
  26. data/lib/xlsxrb/worksheet_builder.rb +691 -0
  27. data/lib/xlsxrb.rb +238 -2968
  28. data/sig/generated/xlsxrb/chart_builder.rbs +269 -0
  29. data/sig/generated/xlsxrb/elements/cell.rbs +137 -12
  30. data/sig/generated/xlsxrb/ooxml/reader/listeners.rbs +1074 -0
  31. data/sig/generated/xlsxrb/ooxml/reader.rbs +0 -1067
  32. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +17 -1
  33. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +18 -4
  34. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +4 -0
  35. data/sig/generated/xlsxrb/stream_row.rbs +6 -2
  36. data/sig/generated/xlsxrb/stream_sheet.rbs +100 -0
  37. data/sig/generated/xlsxrb/stream_writer.rbs +960 -0
  38. data/sig/generated/xlsxrb/workbook_builder.rbs +146 -0
  39. data/sig/generated/xlsxrb/worksheet_builder.rbs +349 -0
  40. data/sig/generated/xlsxrb.rbs +103 -1563
  41. metadata +17 -1
@@ -129,30 +129,41 @@ module Xlsxrb
129
129
  def self.fast_scan_events(xml_src, shared_strings, part_name, &block)
130
130
  xml = xml_src.b # force ASCII-8BIT for O(1) byte indexing
131
131
 
132
- sd_start = xml.index("<sheetData")
132
+ sd_term = xml.index("sheetData")
133
+ return unless sd_term
134
+
135
+ sd_start = xml.rindex("<", sd_term)
133
136
  return unless sd_start
134
137
 
138
+ prefix = xml.byteslice(sd_start + 1, sd_term - (sd_start + 1))
139
+
135
140
  sd_open_end = xml.index(">", sd_start)
136
141
  return unless sd_open_end
137
142
 
138
143
  return if xml.getbyte(sd_open_end - 1) == 47 # self-closing <sheetData/>
139
144
 
140
- sd_end = xml.index("</sheetData>", sd_open_end)
145
+ sd_end_tag = "</#{prefix}sheetData>"
146
+ sd_end = xml.index(sd_end_tag, sd_open_end)
141
147
  return unless sd_end
142
148
 
149
+ row_start_pattern = "<#{prefix}row"
150
+ row_start_len = row_start_pattern.bytesize
151
+ row_end_tag = "</#{prefix}row>"
152
+ row_end_len = row_end_tag.bytesize
153
+
143
154
  pos = sd_open_end + 1
144
155
 
145
156
  while pos < sd_end
146
- row_start = xml.index("<row", pos)
157
+ row_start = xml.index(row_start_pattern, pos)
147
158
  break unless row_start && row_start < sd_end
148
159
 
149
- nb = xml.getbyte(row_start + 4)
160
+ nb = xml.getbyte(row_start + row_start_len)
150
161
  unless [32, 62, 9, 10, 13, 47].include?(nb)
151
- pos = row_start + 4
162
+ pos = row_start + row_start_len
152
163
  next
153
164
  end
154
165
 
155
- tag_end = xml.index(">", row_start + 4)
166
+ tag_end = xml.index(">", row_start + row_start_len)
156
167
  break unless tag_end
157
168
 
158
169
  if xml.getbyte(tag_end - 1) == 47
@@ -168,7 +179,7 @@ module Xlsxrb
168
179
 
169
180
  attrs = extract_row_attrs(row_tag)
170
181
 
171
- row_end = xml.index("</row>", tag_end + 1)
182
+ row_end = xml.index(row_end_tag, tag_end + 1)
172
183
  break unless row_end
173
184
 
174
185
  row_source = { part: part_name, row: row_index }
@@ -178,7 +189,7 @@ module Xlsxrb
178
189
  source: row_source
179
190
  ))
180
191
 
181
- fast_scan_cells_events(xml, tag_end + 1, row_end, shared_strings, row_source, &block)
192
+ fast_scan_cells_events(xml, tag_end + 1, row_end, shared_strings, row_source, prefix, &block)
182
193
 
183
194
  block.call(Event.new(
184
195
  type: :row_end,
@@ -186,7 +197,7 @@ module Xlsxrb
186
197
  source: row_source
187
198
  ))
188
199
 
189
- pos = row_end + 6
200
+ pos = row_end + row_end_len
190
201
  end
191
202
  end
192
203
 
@@ -236,20 +247,38 @@ module Xlsxrb
236
247
 
237
248
  private_class_method :tag_attr
238
249
 
239
- def self.fast_scan_cells_events(xml, from, to, shared_strings, row_source, &block)
250
+ def self.fast_scan_cells_events(xml, from, to, shared_strings, row_source, prefix = "", &block)
240
251
  pos = from
252
+ c_start_pattern = "<#{prefix}c"
253
+ c_start_len = c_start_pattern.bytesize
254
+ c_end_tag = "</#{prefix}c>"
255
+ c_end_len = c_end_tag.bytesize
256
+
257
+ v_tag_start_str = "<#{prefix}v>"
258
+ v_tag_start_len = v_tag_start_str.bytesize
259
+ v_tag_end_str = "</#{prefix}v>"
260
+ v_tag_end_len = v_tag_end_str.bytesize
261
+
262
+ is_tag_start_str = "<#{prefix}is>"
263
+ is_tag_start_len = is_tag_start_str.bytesize
264
+ is_tag_end_str = "</#{prefix}is>"
265
+ is_tag_end_len = is_tag_end_str.bytesize
266
+
267
+ f_tag_start_str = "<#{prefix}f"
268
+ f_tag_end_str = "</#{prefix}f>"
269
+ f_tag_end_len = f_tag_end_str.bytesize
241
270
 
242
271
  while pos < to
243
- c_start = xml.index("<c", pos)
272
+ c_start = xml.index(c_start_pattern, pos)
244
273
  break unless c_start && c_start < to
245
274
 
246
- nb = xml.getbyte(c_start + 2)
275
+ nb = xml.getbyte(c_start + c_start_len)
247
276
  unless [32, 62, 9, 10, 13, 47].include?(nb)
248
- pos = c_start + 2
277
+ pos = c_start + c_start_len
249
278
  next
250
279
  end
251
280
 
252
- c_tag_end = xml.index(">", c_start + 2)
281
+ c_tag_end = xml.index(">", c_start + c_start_len)
253
282
  break unless c_tag_end
254
283
 
255
284
  # Extract tag substring for bounded attribute search
@@ -272,7 +301,7 @@ module Xlsxrb
272
301
  next
273
302
  end
274
303
 
275
- c_end = xml.index("</c>", c_tag_end + 1)
304
+ c_end = xml.index(c_end_tag, c_tag_end + 1)
276
305
  break unless c_end
277
306
 
278
307
  # Parse cell content sequentially (bounded to c_end - avoid unbounded scans)
@@ -284,35 +313,27 @@ module Xlsxrb
284
313
  tag_pos = xml.index("<", cpos)
285
314
  break unless tag_pos && tag_pos < c_end
286
315
 
287
- tag_char = xml.getbyte(tag_pos + 1)
288
- case tag_char
289
- when 118 # 'v'
290
- if xml.getbyte(tag_pos + 2) == 62 # <v>
291
- v_val_start = tag_pos + 3
292
- v_end = xml.index("</v>", v_val_start)
293
- if v_end
294
- raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
295
- value = resolve_fast_value(raw_value, type, shared_strings)
296
- cpos = v_end + 4
297
- else
298
- cpos = tag_pos + 3
299
- end
300
- elsif xml.getbyte(tag_pos + 2) == 47 && xml.getbyte(tag_pos + 3) == 62 # <v/>
301
- cpos = tag_pos + 4
316
+ if xml.byteslice(tag_pos, v_tag_start_len) == v_tag_start_str
317
+ v_val_start = tag_pos + v_tag_start_len
318
+ v_end = xml.index(v_tag_end_str, v_val_start)
319
+ if v_end
320
+ raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
321
+ value = resolve_fast_value(raw_value, type, shared_strings)
322
+ cpos = v_end + v_tag_end_len
302
323
  else
303
- cpos = tag_pos + 2
324
+ cpos = tag_pos + v_tag_start_len
304
325
  end
305
- when 102 # 'f'
306
- f_tag_end = xml.index(">", tag_pos + 2)
326
+ elsif xml.byteslice(tag_pos, f_tag_start_str.bytesize) == f_tag_start_str
327
+ f_tag_end = xml.index(">", tag_pos + f_tag_start_str.bytesize)
307
328
  if f_tag_end && f_tag_end < c_end
308
329
  if xml.getbyte(f_tag_end - 1) == 47 # self-closing <f ... />
309
330
  cpos = f_tag_end + 1
310
331
  else
311
- f_end = xml.index("</f>", f_tag_end + 1)
332
+ f_end = xml.index(f_tag_end_str, f_tag_end + 1)
312
333
  if f_end && f_end <= c_end
313
334
  formula = xml.byteslice(f_tag_end + 1, f_end - f_tag_end - 1).force_encoding("UTF-8")
314
335
  formula = decode_xml_entities(formula) if formula.include?("&")
315
- cpos = f_end + 4
336
+ cpos = f_end + f_tag_end_len
316
337
  else
317
338
  cpos = f_tag_end + 1
318
339
  end
@@ -320,17 +341,13 @@ module Xlsxrb
320
341
  else
321
342
  cpos = tag_pos + 2
322
343
  end
323
- when 105 # 'i' - <is>
324
- if xml.byteslice(tag_pos, 4) == "<is>"
325
- is_end = xml.index("</is>", tag_pos + 4)
326
- if is_end && is_end <= c_end
327
- inline_str = extract_inline_text(xml, tag_pos + 4, is_end)
328
- cpos = is_end + 5
329
- else
330
- cpos = tag_pos + 4
331
- end
344
+ elsif xml.byteslice(tag_pos, is_tag_start_len) == is_tag_start_str
345
+ is_end = xml.index(is_tag_end_str, tag_pos + is_tag_start_len)
346
+ if is_end && is_end <= c_end
347
+ inline_str = extract_inline_text(xml, tag_pos + is_tag_start_len, is_end)
348
+ cpos = is_end + is_tag_end_len
332
349
  else
333
- cpos = tag_pos + 2
350
+ cpos = tag_pos + is_tag_start_len
334
351
  end
335
352
  else
336
353
  # Skip unknown tag
@@ -348,7 +365,7 @@ module Xlsxrb
348
365
  source: cell_source
349
366
  ))
350
367
 
351
- pos = c_end + 4
368
+ pos = c_end + c_end_len
352
369
  end
353
370
  end
354
371
 
@@ -358,21 +375,32 @@ module Xlsxrb
358
375
  result = +""
359
376
  pos = from
360
377
  while pos < to
361
- t_start = xml.index("<t", pos)
378
+ t_start = xml.index("<", pos)
362
379
  break unless t_start && t_start < to
363
380
 
364
- t_tag_end = xml.index(">", t_start)
365
- break unless t_tag_end
366
- next (pos = t_start + 2) if xml.getbyte(t_tag_end - 1) == 47
381
+ t_term = xml.index("t", t_start)
382
+ break unless t_term && t_term < to
383
+
384
+ t_tag_end = xml.index(">", t_term)
385
+ break unless t_tag_end && t_tag_end < to
367
386
 
368
- t_end = xml.index("</t>", t_tag_end + 1)
387
+ if xml.getbyte(t_tag_end - 1) == 47 # <t ... />
388
+ pos = t_tag_end + 1
389
+ next
390
+ end
391
+
392
+ t_close = xml.index("</", t_tag_end + 1)
393
+ break unless t_close && t_close <= to
394
+
395
+ t_end = xml.index(">", t_close + 2)
369
396
  break unless t_end && t_end <= to
370
397
 
371
- result << xml.byteslice(t_tag_end + 1, t_end - t_tag_end - 1)
372
- pos = t_end + 4
398
+ text_segment = xml.byteslice(t_tag_end + 1, t_close - t_tag_end - 1).force_encoding("UTF-8")
399
+ text_segment = decode_xml_entities(text_segment) if text_segment.include?("&")
400
+ result << text_segment
401
+
402
+ pos = t_end + 1
373
403
  end
374
- result.force_encoding("UTF-8")
375
- result = decode_xml_entities(result) if result.include?("&")
376
404
  result
377
405
  end
378
406
 
@@ -448,32 +476,43 @@ module Xlsxrb
448
476
  def characters(_text); end
449
477
  end
450
478
 
451
- def self.fast_scan_rows_direct(xml_src, shared_strings, part_name, &block)
479
+ def self.fast_scan_rows_direct(xml_src, shared_strings, _part_name, &block)
452
480
  xml = xml_src.b # force ASCII-8BIT for O(1) byte indexing
453
481
 
454
- sd_start = xml.index("<sheetData")
482
+ sd_term = xml.index("sheetData")
483
+ return unless sd_term
484
+
485
+ sd_start = xml.rindex("<", sd_term)
455
486
  return unless sd_start
456
487
 
488
+ prefix = xml.byteslice(sd_start + 1, sd_term - (sd_start + 1))
489
+
457
490
  sd_open_end = xml.index(">", sd_start)
458
491
  return unless sd_open_end
459
492
  return if xml.getbyte(sd_open_end - 1) == 47 # self-closing <sheetData/>
460
493
 
461
- sd_end = xml.index("</sheetData>", sd_open_end)
494
+ sd_end_tag = "</#{prefix}sheetData>"
495
+ sd_end = xml.index(sd_end_tag, sd_open_end)
462
496
  return unless sd_end
463
497
 
498
+ row_start_pattern = "<#{prefix}row"
499
+ row_start_len = row_start_pattern.bytesize
500
+ row_end_tag = "</#{prefix}row>"
501
+ row_end_len = row_end_tag.bytesize
502
+
464
503
  pos = sd_open_end + 1
465
504
 
466
505
  while pos < sd_end
467
- row_start = xml.index("<row", pos)
506
+ row_start = xml.index(row_start_pattern, pos)
468
507
  break unless row_start && row_start < sd_end
469
508
 
470
- nb = xml.getbyte(row_start + 4)
509
+ nb = xml.getbyte(row_start + row_start_len)
471
510
  unless [32, 62, 9, 10, 13, 47].include?(nb)
472
- pos = row_start + 4
511
+ pos = row_start + row_start_len
473
512
  next
474
513
  end
475
514
 
476
- tag_end = xml.index(">", row_start + 4)
515
+ tag_end = xml.index(">", row_start + row_start_len)
477
516
  break unless tag_end
478
517
 
479
518
  if xml.getbyte(tag_end - 1) == 47
@@ -483,7 +522,7 @@ module Xlsxrb
483
522
 
484
523
  row_index = 0
485
524
  has_custom_attrs = false
486
- ri = row_start + 4
525
+ ri = row_start + row_start_len
487
526
  while ri < tag_end
488
527
  rb = xml.getbyte(ri)
489
528
  if rb == 114 && xml.getbyte(ri + 1) == 61 && xml.getbyte(ri + 2) == 34 # r="
@@ -512,233 +551,146 @@ module Xlsxrb
512
551
  EMPTY_HASH
513
552
  end
514
553
 
515
- row_end = xml.index("</row>", tag_end + 1)
554
+ row_end = xml.index(row_end_tag, tag_end + 1)
516
555
  break unless row_end
517
556
 
518
- { part: part_name, row: row_index }
519
- row_obj = StreamRow.new(
520
- index: row_index,
521
- xml_bytes: xml,
522
- from: tag_end + 1,
523
- to: row_end,
524
- shared_strings: shared_strings,
525
- height: attrs[:height],
526
- hidden: attrs[:hidden] || false,
527
- custom_height: attrs[:custom_height] || false,
528
- outline_level: attrs[:outline_level]
557
+ row_obj = StreamRow.fast_create(
558
+ row_index,
559
+ xml,
560
+ tag_end + 1,
561
+ row_end,
562
+ shared_strings,
563
+ prefix,
564
+ attrs[:height],
565
+ attrs[:hidden] || false,
566
+ attrs[:custom_height] || false,
567
+ attrs[:outline_level]
529
568
  )
530
569
  block.call(row_obj)
531
570
 
532
- pos = row_end + 6
571
+ pos = row_end + row_end_len
533
572
  end
534
573
  end
535
574
 
536
575
  private_class_method :fast_scan_rows_direct
537
576
 
538
- def self.fast_parse_cells_direct(xml, from, to, shared_strings, row_source)
539
- cells = []
540
- fast_scan_cells_direct(xml, from, to, shared_strings, row_source) { |c| cells << c }
541
- cells
542
- end
543
-
544
- def self.fast_scan_cells_direct(xml, from, to, shared_strings, row_source, &block)
545
- pos = from
546
- cell_count = 0
547
-
548
- while pos < to
549
- c_start = xml.index("<c", pos)
550
- break unless c_start && c_start < to
577
+ COL_LETTERS = (0...16_384).map do |index|
578
+ result = +""
579
+ i = index
580
+ loop do
581
+ result.prepend(("A".ord + (i % 26)).chr)
582
+ i = (i / 26) - 1
583
+ break if i.negative?
584
+ end
585
+ result.freeze
586
+ end.freeze
551
587
 
552
- nb = xml.getbyte(c_start + 2)
553
- unless [32, 62, 9, 10, 13, 47].include?(nb)
554
- pos = c_start + 2
555
- next
556
- end
588
+ COL_MAP = (0...16_384).to_h do |idx|
589
+ [COL_LETTERS[idx], idx]
590
+ end.freeze
557
591
 
558
- c_tag_end = xml.index(">", c_start + 2)
559
- break unless c_tag_end
592
+ CELL_FAST_RE = %r{<(?:[a-zA-Z0-9_]+:)?c\s+r="([A-Za-z0-9]+)"(?:[^>]*?s="(\d+)")?(?:[^>]*?t="([a-zA-Z]+)")?[^>]*>(?:<(?:[a-zA-Z0-9_]+:)?f[^>]*>([^<]*)</(?:[a-zA-Z0-9_]+:)?f>)?(?:<(?:[a-zA-Z0-9_]+:)?v>([^<]*)</(?:[a-zA-Z0-9_]+:)?v>)?(?:<(?:[a-zA-Z0-9_]+:)?is>(.*?)</(?:[a-zA-Z0-9_]+:)?is>)?</(?:[a-zA-Z0-9_]+:)?c>|<(?:[a-zA-Z0-9_]+:)?c\s+r="([A-Za-z0-9]+)"(?:[^>]*?s="(\d+)")?[^>]*/>}
593
+ CELL_GENERIC_RE = %r{<(?:[a-zA-Z0-9_]+:)?c\b([^>]*?)(?:>(?:<(?:[a-zA-Z0-9_]+:)?f[^>]*>([^<]*)</(?:[a-zA-Z0-9_]+:)?f>)?(?:<(?:[a-zA-Z0-9_]+:)?v>([^<]*)</(?:[a-zA-Z0-9_]+:)?v>)?(?:<(?:[a-zA-Z0-9_]+:)?is>(.*?)</(?:[a-zA-Z0-9_]+:)?is>)?</(?:[a-zA-Z0-9_]+:)?c>|/>)}m
594
+ ATTR_R = /r="([A-Za-z0-9]+)"/
595
+ ATTR_T = /t="([a-zA-Z]+)"/
596
+ ATTR_S = /s="(\d+)"/
560
597
 
561
- # Zero-allocation attribute scan directly within the tag
562
- type = nil
563
- style_index = nil
564
- col_idx = nil
565
- row_idx = nil
566
-
567
- ai = c_start + 2
568
- while ai < c_tag_end
569
- b = xml.getbyte(ai)
570
- if [32, 9, 10, 13].include?(b)
571
- ai += 1
572
- next
573
- end
598
+ def self.fast_parse_cells_direct(xml, from, to, shared_strings, row_source, prefix = "")
599
+ cells = []
600
+ fast_scan_cells_direct(xml, from, to, shared_strings, row_source, prefix) { |c| cells << c }
601
+ cells
602
+ end
574
603
 
575
- if b == 114 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # r="
576
- ai += 3
577
- col_idx = 0
578
- while ai < c_tag_end
579
- cb = xml.getbyte(ai)
580
- if cb.between?(65, 90)
581
- col_idx = (col_idx * 26) + (cb - 64)
582
- ai += 1
583
- elsif cb.between?(97, 122)
584
- col_idx = (col_idx * 26) + (cb - 96)
585
- ai += 1
586
- else
587
- break
604
+ def self.fast_scan_cells_direct(xml, from, to, shared_strings, row_source, _prefix = "", &block)
605
+ chunk = xml.byteslice(from, to - from)
606
+ row_idx = row_source.is_a?(Hash) ? row_source[:row] : row_source
607
+ col_idx = 0
608
+ matched_any = false
609
+
610
+ chunk.scan(CELL_FAST_RE) do |r, s, t, f, v, is, self_r, self_s|
611
+ matched_any = true
612
+ ref = r || self_r
613
+ s_val = s || self_s
614
+ style_idx = s_val&.to_i
615
+ c_idx = if ref
616
+ expected_letter = COL_LETTERS[col_idx]
617
+ if expected_letter && ref.start_with?(expected_letter)
618
+ col_idx
619
+ else
620
+ c_len = 0
621
+ c_len += 1 while (b = ref.getbyte(c_len)) && (b.between?(65, 90) || b.between?(97, 122))
622
+ col_letters = ref.byteslice(0, c_len).upcase
623
+ COL_MAP[col_letters] || col_idx
624
+ end
625
+ else
626
+ col_idx
627
+ end
628
+ col_idx = c_idx + 1
629
+
630
+ val = if t == "s"
631
+ shared_strings[v.to_i] || ""
632
+ elsif t == "b"
633
+ v == "1"
634
+ elsif %w[inlineStr str e].include?(t)
635
+ if is
636
+ extract_inline_text(is, 0, is.bytesize)
637
+ elsif v
638
+ v.include?("&") ? decode_xml_entities(v) : v
639
+ else
640
+ ""
641
+ end
642
+ elsif v
643
+ v.include?(".") ? v.to_f : v.to_i
588
644
  end
589
- end
590
- col_idx -= 1
591
- row_idx = 0
592
- while ai < c_tag_end
593
- cb = xml.getbyte(ai)
594
- break unless cb.between?(48, 57)
595
-
596
- row_idx = (row_idx * 10) + (cb - 48)
597
- ai += 1
598
- end
599
- row_idx -= 1
600
- ai += 1 if xml.getbyte(ai) == 34
601
- elsif b == 116 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # t="
602
- ai += 3
603
- type_b = xml.getbyte(ai)
604
- if type_b == 115 && xml.getbyte(ai + 1) == 34 # "s"
605
- type = "s"
606
- ai += 2
607
- elsif type_b == 98 && xml.getbyte(ai + 1) == 34 # "b"
608
- type = "b"
609
- ai += 2
610
- elsif type_b == 101 && xml.getbyte(ai + 1) == 34 # "e"
611
- type = "e"
612
- ai += 2
613
- else
614
- t_end = xml.index('"', ai)
615
- type = xml.byteslice(ai, t_end - ai) if t_end
616
- ai = t_end ? t_end + 1 : c_tag_end
617
- end
618
- elsif b == 115 && xml.getbyte(ai + 1) == 61 && xml.getbyte(ai + 2) == 34 # s="
619
- ai += 3
620
- style_index = 0
621
- while ai < c_tag_end
622
- cb = xml.getbyte(ai)
623
- break unless cb.between?(48, 57)
624
645
 
625
- style_index = (style_index * 10) + (cb - 48)
626
- ai += 1
627
- end
628
- ai += 1 if xml.getbyte(ai) == 34
629
- else
630
- ai += 1
631
- end
632
- end
633
-
634
- # Self-closing <c ... />
635
- if xml.getbyte(c_tag_end - 1) == 47
636
- cell_obj = Elements::Cell.new(
637
- row_index: row_idx || row_source[:row],
638
- column_index: col_idx || cell_count,
639
- value: nil,
640
- style_index: style_index,
641
- errors: Elements::EMPTY_ERRORS
642
- )
643
- cell_count += 1
644
- block.call(cell_obj)
645
- pos = c_tag_end + 1
646
- next
647
- end
648
-
649
- c_end = xml.index("</c>", c_tag_end + 1)
650
- break unless c_end
651
-
652
- # Parse cell content sequentially (bounded to c_end - avoid unbounded scans)
653
- value = nil
654
- formula = nil
655
- inline_str = nil
656
- cpos = c_tag_end + 1
657
- while cpos < c_end
658
- tag_pos = xml.index("<", cpos)
659
- break unless tag_pos && tag_pos < c_end
646
+ formula_expr = f&.include?("&") ? decode_xml_entities(f) : f
647
+ cell = Elements::Cell.fast_create(row_idx, c_idx, val, style_idx, formula_expr)
648
+ block.call(cell)
649
+ end
660
650
 
661
- tag_char = xml.getbyte(tag_pos + 1)
662
- case tag_char
663
- when 118 # 'v'
664
- if xml.getbyte(tag_pos + 2) == 62 # <v>
665
- v_val_start = tag_pos + 3
666
- v_end = xml.index("</v>", v_val_start)
667
- if v_end
668
- if type == "s"
669
- # Zero-allocation integer parse for SST index
670
- s_idx = 0
671
- v_i = v_val_start
672
- while v_i < v_end
673
- s_idx = (s_idx * 10) + (xml.getbyte(v_i) - 48)
674
- v_i += 1
651
+ return if matched_any || (!chunk.include?("<c") && !chunk.include?(":c"))
652
+
653
+ # Fallback for non-standard attribute ordering
654
+ chunk.scan(CELL_GENERIC_RE) do |attrs, f, v, is|
655
+ r = attrs[ATTR_R, 1]
656
+ t = attrs[ATTR_T, 1]
657
+ s = attrs[ATTR_S, 1]
658
+
659
+ c_idx = if r
660
+ expected_letter = COL_LETTERS[col_idx]
661
+ if expected_letter && r.start_with?(expected_letter)
662
+ col_idx
663
+ else
664
+ c_len = 0
665
+ c_len += 1 while (b = r.getbyte(c_len)) && (b.between?(65, 90) || b.between?(97, 122))
666
+ col_letters = r.byteslice(0, c_len).upcase
667
+ COL_MAP[col_letters] || col_idx
675
668
  end
676
- value = shared_strings[s_idx] || ""
677
- elsif type == "b"
678
- value = xml.getbyte(v_val_start) == 49 # '1'
679
669
  else
680
- raw_value = xml.byteslice(v_val_start, v_end - v_val_start)
681
- value = resolve_fast_value(raw_value, type, shared_strings)
670
+ col_idx
682
671
  end
683
- cpos = v_end + 4
684
- else
685
- cpos = tag_pos + 3
686
- end
687
- elsif xml.getbyte(tag_pos + 2) == 47 && xml.getbyte(tag_pos + 3) == 62 # <v/>
688
- cpos = tag_pos + 4
689
- else
690
- cpos = tag_pos + 2
691
- end
692
- when 102 # 'f'
693
- f_tag_end = xml.index(">", tag_pos + 2)
694
- if f_tag_end && f_tag_end < c_end
695
- if xml.getbyte(f_tag_end - 1) == 47 # self-closing <f ... />
696
- cpos = f_tag_end + 1
697
- else
698
- f_end = xml.index("</f>", f_tag_end + 1)
699
- if f_end && f_end <= c_end
700
- formula = xml.byteslice(f_tag_end + 1, f_end - f_tag_end - 1).force_encoding("UTF-8")
701
- formula = decode_xml_entities(formula) if formula.include?("&")
702
- cpos = f_end + 4
672
+ col_idx = c_idx + 1
673
+
674
+ val = if t == "s"
675
+ shared_strings[v.to_i] || ""
676
+ elsif t == "b"
677
+ v == "1"
678
+ elsif %w[inlineStr str e].include?(t)
679
+ if is
680
+ extract_inline_text(is, 0, is.bytesize)
681
+ elsif v
682
+ v.include?("&") ? decode_xml_entities(v) : v
703
683
  else
704
- cpos = f_tag_end + 1
684
+ ""
705
685
  end
686
+ elsif v
687
+ v.include?(".") ? v.to_f : v.to_i
706
688
  end
707
- else
708
- cpos = tag_pos + 2
709
- end
710
- when 105 # 'i' - <is>
711
- if xml.byteslice(tag_pos, 4) == "<is>"
712
- is_end = xml.index("</is>", tag_pos + 4)
713
- if is_end && is_end <= c_end
714
- inline_str = extract_inline_text(xml, tag_pos + 4, is_end)
715
- cpos = is_end + 5
716
- else
717
- cpos = tag_pos + 4
718
- end
719
- else
720
- cpos = tag_pos + 2
721
- end
722
- else
723
- # Skip unknown tag
724
- close = xml.index(">", tag_pos + 1)
725
- cpos = close ? close + 1 : c_end
726
- end
727
- end
728
-
729
- val_to_use = inline_str || value
730
- cell_obj = Elements::Cell.new(
731
- row_index: row_idx || row_source[:row],
732
- column_index: col_idx || cell_count,
733
- value: val_to_use,
734
- formula: formula,
735
- style_index: style_index,
736
- errors: Elements::EMPTY_ERRORS
737
- )
738
- cell_count += 1
739
- block.call(cell_obj)
740
689
 
741
- pos = c_end + 4
690
+ style_idx = s&.to_i
691
+ formula_expr = f&.include?("&") ? decode_xml_entities(f) : f
692
+ cell = Elements::Cell.fast_create(row_idx, c_idx, val, style_idx, formula_expr)
693
+ block.call(cell)
742
694
  end
743
695
  end
744
696