xlsxrb 0.1.13 → 0.1.15

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/README.md +17 -14
  4. data/lib/xlsxrb/colors.rb +118 -0
  5. data/lib/xlsxrb/elements/cell.rb +205 -16
  6. data/lib/xlsxrb/elements/column.rb +6 -4
  7. data/lib/xlsxrb/elements/coordinate_access.rb +32 -0
  8. data/lib/xlsxrb/elements/image.rb +128 -0
  9. data/lib/xlsxrb/elements/row.rb +206 -9
  10. data/lib/xlsxrb/elements/styles.rb +157 -0
  11. data/lib/xlsxrb/elements/types.rb +95 -1
  12. data/lib/xlsxrb/elements/workbook.rb +121 -4
  13. data/lib/xlsxrb/elements/worksheet.rb +519 -29
  14. data/lib/xlsxrb/elements.rb +2 -0
  15. data/lib/xlsxrb/number_formatter.rb +429 -0
  16. data/lib/xlsxrb/ooxml/cfb.rb +1 -1
  17. data/lib/xlsxrb/ooxml/reader/listeners/core_listeners.rb +7 -5
  18. data/lib/xlsxrb/ooxml/reader/listeners/drawing_listeners.rb +11 -4
  19. data/lib/xlsxrb/ooxml/reader/listeners/feature_listeners.rb +7 -1
  20. data/lib/xlsxrb/ooxml/reader.rb +14 -17
  21. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +7 -1
  22. data/lib/xlsxrb/ooxml/styles_parser.rb +39 -9
  23. data/lib/xlsxrb/ooxml/utils.rb +34 -18
  24. data/lib/xlsxrb/ooxml/workbook_parser.rb +74 -16
  25. data/lib/xlsxrb/ooxml/workbook_writer.rb +82 -23
  26. data/lib/xlsxrb/ooxml/worksheet_parser.rb +319 -79
  27. data/lib/xlsxrb/ooxml/worksheet_writer.rb +263 -66
  28. data/lib/xlsxrb/ooxml/writer/features_xml.rb +5 -3
  29. data/lib/xlsxrb/ooxml/writer.rb +14 -0
  30. data/lib/xlsxrb/ooxml/xml_builder.rb +14 -2
  31. data/lib/xlsxrb/ooxml/zip_reader.rb +144 -28
  32. data/lib/xlsxrb/stream_row.rb +243 -16
  33. data/lib/xlsxrb/stream_sheet.rb +647 -16
  34. data/lib/xlsxrb/stream_writer.rb +375 -34
  35. data/lib/xlsxrb/style_builder.rb +308 -22
  36. data/lib/xlsxrb/utils.rb +211 -0
  37. data/lib/xlsxrb/version.rb +1 -1
  38. data/lib/xlsxrb/workbook_builder.rb +72 -11
  39. data/lib/xlsxrb/worksheet_builder.rb +738 -24
  40. data/lib/xlsxrb.rb +1073 -83
  41. data/sig/generated/xlsxrb/colors.rbs +40 -0
  42. data/sig/generated/xlsxrb/elements/cell.rbs +113 -8
  43. data/sig/generated/xlsxrb/elements/column.rbs +3 -2
  44. data/sig/generated/xlsxrb/elements/coordinate_access.rbs +18 -0
  45. data/sig/generated/xlsxrb/elements/image.rbs +82 -0
  46. data/sig/generated/xlsxrb/elements/row.rbs +83 -4
  47. data/sig/generated/xlsxrb/elements/styles.rbs +79 -0
  48. data/sig/generated/xlsxrb/elements/types.rbs +43 -0
  49. data/sig/generated/xlsxrb/elements/workbook.rbs +79 -2
  50. data/sig/generated/xlsxrb/elements/worksheet.rbs +282 -18
  51. data/sig/generated/xlsxrb/number_formatter.rbs +132 -0
  52. data/sig/generated/xlsxrb/ooxml/reader.rbs +6 -0
  53. data/sig/generated/xlsxrb/ooxml/shared_strings_parser.rbs +2 -0
  54. data/sig/generated/xlsxrb/ooxml/styles_parser.rbs +5 -2
  55. data/sig/generated/xlsxrb/ooxml/utils.rbs +17 -8
  56. data/sig/generated/xlsxrb/ooxml/workbook_parser.rbs +9 -3
  57. data/sig/generated/xlsxrb/ooxml/workbook_writer.rbs +1 -1
  58. data/sig/generated/xlsxrb/ooxml/worksheet_parser.rbs +28 -6
  59. data/sig/generated/xlsxrb/ooxml/worksheet_writer.rbs +13 -3
  60. data/sig/generated/xlsxrb/ooxml/writer.rbs +2 -0
  61. data/sig/generated/xlsxrb/ooxml/xml_builder.rbs +3 -1
  62. data/sig/generated/xlsxrb/ooxml/zip_reader.rbs +13 -0
  63. data/sig/generated/xlsxrb/stream_row.rbs +100 -6
  64. data/sig/generated/xlsxrb/stream_sheet.rbs +358 -14
  65. data/sig/generated/xlsxrb/stream_writer.rbs +220 -30
  66. data/sig/generated/xlsxrb/style_builder.rbs +55 -18
  67. data/sig/generated/xlsxrb/utils.rbs +151 -0
  68. data/sig/generated/xlsxrb/workbook_builder.rbs +26 -0
  69. data/sig/generated/xlsxrb/worksheet_builder.rbs +356 -18
  70. data/sig/generated/xlsxrb.rbs +223 -27
  71. metadata +11 -1
@@ -330,6 +330,19 @@ module Xlsxrb
330
330
  validate_cell_address!(cell_address)
331
331
  sheet_name = sheet || @sheet_order.first
332
332
  raise ArgumentError, "unknown sheet: #{sheet_name}" unless @hyperlinks.key?(sheet_name)
333
+
334
+ if url.is_a?(Elements::Hyperlink)
335
+ link = {}
336
+ link[:url] = url.url if url.url
337
+ link[:display] = url.display || display if url.display || display
338
+ link[:tooltip] = url.tooltip || tooltip if url.tooltip || tooltip
339
+ link[:location] = url.location || location if url.location || location
340
+ raise ArgumentError, "url or location required" if link[:url].nil? && link[:location].nil?
341
+
342
+ @hyperlinks[sheet_name][cell_address] = link
343
+ return
344
+ end
345
+
333
346
  raise ArgumentError, "url or location required" if url.nil? && location.nil?
334
347
 
335
348
  link = {}
@@ -997,6 +1010,7 @@ module Xlsxrb
997
1010
  def set_workbook_property(name, value)
998
1011
  @workbook_properties[name] = value
999
1012
  end
1013
+ alias workbook_property set_workbook_property
1000
1014
 
1001
1015
  # Returns workbook properties hash.
1002
1016
  # : () -> untyped
@@ -118,14 +118,26 @@ 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
+ private_constant :NUM_ENTITY_RE
123
+
124
+ # Unescapes predefined and numeric/hex XML entities in a string.
122
125
  #
123
126
  # @param value [Object]
124
127
  # @return [String]
125
128
  #: (untyped value) -> String
126
129
  def self.unescape(value)
127
130
  str = value.to_s
128
- str.include?("&") ? str.gsub(UNESCAPE_RE, UNESCAPE_MAP) : str
131
+ return str unless str.include?("&")
132
+
133
+ res = str.gsub(UNESCAPE_RE, UNESCAPE_MAP)
134
+ res.gsub(NUM_ENTITY_RE) do |match|
135
+ dec = Regexp.last_match(1)
136
+ codepoint = dec ? Integer(dec) : Integer(Regexp.last_match(2), 16)
137
+ codepoint.chr(Encoding::UTF_8)
138
+ rescue RangeError
139
+ match
140
+ end
129
141
  end
130
142
 
131
143
  private
@@ -49,6 +49,7 @@ module Xlsxrb
49
49
  @total_uncompressed = 0
50
50
  @eof = false
51
51
  @closed = false
52
+ @bom_checked = false
52
53
  @inflater = @compression_method == 8 ? Zlib::Inflate.new(-Zlib::MAX_WBITS) : nil
53
54
  end
54
55
 
@@ -56,6 +57,8 @@ module Xlsxrb
56
57
  def read(length = nil)
57
58
  raise IOError, "closed stream" if @closed
58
59
 
60
+ check_bom_encoding!
61
+
59
62
  if length.nil?
60
63
  result = @buffer.dup
61
64
  @buffer.clear
@@ -96,6 +99,11 @@ module Xlsxrb
96
99
  return if @closed
97
100
 
98
101
  @closed = true
102
+ begin
103
+ @inflater&.reset
104
+ rescue StandardError
105
+ nil
106
+ end
99
107
  @inflater&.close
100
108
  @inflater = nil
101
109
  @buffer.clear
@@ -176,18 +184,48 @@ module Xlsxrb
176
184
  nil
177
185
  end
178
186
  end
187
+
188
+ def check_bom_encoding!
189
+ return if @bom_checked
190
+
191
+ @bom_checked = true
192
+ peek = fetch_chunk(DEFAULT_CHUNK_SIZE)
193
+ return unless peek
194
+
195
+ raw_b = peek.b
196
+ if raw_b.start_with?("\xFF\xFE".b)
197
+ rest = +""
198
+ while (chunk = fetch_chunk(DEFAULT_CHUNK_SIZE))
199
+ rest << chunk
200
+ end
201
+ full = (peek + rest).force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
202
+ @buffer = full
203
+ @eof = true
204
+ elsif raw_b.start_with?("\xFE\xFF".b)
205
+ rest = +""
206
+ while (chunk = fetch_chunk(DEFAULT_CHUNK_SIZE))
207
+ rest << chunk
208
+ end
209
+ full = (peek + rest).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
210
+ @buffer = full
211
+ @eof = true
212
+ elsif raw_b.start_with?("\xEF\xBB\xBF".b)
213
+ @buffer << peek.b.delete_prefix("\xEF\xBB\xBF".b).force_encoding(Encoding::UTF_8)
214
+ else
215
+ @buffer << peek
216
+ end
217
+ end
179
218
  end
180
219
 
181
220
  # Opens a ZIP from a file path or IO and yields the reader.
182
221
  #: (untyped source, ?max_uncompressed_size: Integer) ?{ (ZipReader) -> untyped } -> (ZipReader | untyped)
183
222
  def self.open(source, max_uncompressed_size: MAX_UNCOMPRESSED_SIZE)
184
- should_close = source.is_a?(String) && File.file?(source)
185
- io = if source.is_a?(String)
186
- if source.start_with?(LOCAL_HEADER_SIG) || source.include?("\x00")
187
- StringIO.new(source.b)
188
- else
189
- File.open(source, "rb")
190
- end
223
+ is_binary = source.is_a?(String) && (source.start_with?(LOCAL_HEADER_SIG) || source.include?("\x00"))
224
+ should_close = source.is_a?(String) && !is_binary && File.file?(source)
225
+ io = if should_close
226
+ File.open(source, "rb")
227
+ elsif source.is_a?(String)
228
+ StringIO.new(source.b)
191
229
  else
192
230
  source
193
231
  end
@@ -211,6 +249,7 @@ module Xlsxrb
211
249
  @tempfile = nil
212
250
  @io = nil
213
251
  @catalog = nil
252
+ @ci_catalog = nil
214
253
  @cached_entries = nil
215
254
  end
216
255
 
@@ -227,11 +266,15 @@ module Xlsxrb
227
266
  # Returns raw bytes for a single entry, or nil if not found.
228
267
  #: (String name) -> String?
229
268
  def read_entry(name)
230
- return @cached_entries[name] if @cached_entries&.key?(name)
269
+ norm_name = normalize_entry_name(name)
270
+ cached = @cached_entries[norm_name] if @cached_entries&.key?(norm_name)
231
271
 
232
- entry = catalog[name]
272
+ entry = lookup_entry(norm_name)
233
273
  return nil unless entry
234
274
 
275
+ cached ||= @cached_entries[entry.name] if @cached_entries&.key?(entry.name)
276
+ return normalize_text_encoding(cached) if cached
277
+
235
278
  io.seek(entry.data_offset, IO::SEEK_SET)
236
279
  raw = io.read(entry.compressed_size)
237
280
  decompress(raw, entry.method)
@@ -250,7 +293,7 @@ module Xlsxrb
250
293
  # Opens an EntryStreamIO for an entry by name.
251
294
  #: (String entry_name) -> EntryStreamIO
252
295
  def open_entry_io(entry_name)
253
- entry = catalog[entry_name]
296
+ entry = lookup_entry(entry_name)
254
297
  raise ArgumentError, "Entry not found in archive: #{entry_name}" unless entry
255
298
 
256
299
  EntryStreamIO.new(io, entry.data_offset, entry.compressed_size, entry.method, max_uncompressed_size: @max_uncompressed_size)
@@ -272,7 +315,7 @@ module Xlsxrb
272
315
  # Returns true if the archive contains the specified entry.
273
316
  #: (String name) -> bool
274
317
  def entry?(name)
275
- catalog.key?(name)
318
+ !lookup_entry(name).nil?
276
319
  end
277
320
 
278
321
  # Returns entry metadata catalog.
@@ -290,7 +333,7 @@ module Xlsxrb
290
333
  # Copies the specified entry to a ZipWriter directly without decompression.
291
334
  #: (String entry_name, ZipWriter writer, ?target_path: String?) -> void
292
335
  def copy_to_writer(entry_name, writer, target_path: nil)
293
- entry = catalog[entry_name]
336
+ entry = lookup_entry(entry_name)
294
337
  raise ArgumentError, "Entry not found in archive: #{entry_name}" unless entry
295
338
 
296
339
  if entry.compressed_size.zero? && entry.uncompressed_size.positive?
@@ -354,7 +397,7 @@ module Xlsxrb
354
397
  cd_catalog = parse_catalog_from_central_directory(io)
355
398
  return cd_catalog if cd_catalog && !cd_catalog.empty?
356
399
 
357
- io.seek(-4, IO::SEEK_CUR) if io.respond_to?(:seek)
400
+ io.seek(0, IO::SEEK_SET) if io.respond_to?(:seek)
358
401
  parse_catalog_from_local_headers(io)
359
402
  end
360
403
 
@@ -394,9 +437,13 @@ module Xlsxrb
394
437
 
395
438
  _v_made, _v_need, _gp, method, _time, _date, crc, csize, usize, nlen, elen, clen, _dnum, _iattr, _eattr, offset = cd_header.unpack("vvvvvvVVVvvvvvVV")
396
439
  entry_name = io.read(nlen).force_encoding("UTF-8")
397
- io.read(elen + clen)
440
+ extra_data = elen.positive? ? io.read(elen) : nil
441
+ io.read(clen) if clen.positive?
398
442
 
399
- next if entry_name.end_with?("/")
443
+ usize, csize, offset = parse_zip64_extra(extra_data, usize, csize, offset) if extra_data && (usize == 0xFFFF_FFFF || csize == 0xFFFF_FFFF || offset == 0xFFFF_FFFF)
444
+
445
+ norm_name = normalize_entry_name(entry_name)
446
+ next if norm_name.end_with?("/")
400
447
 
401
448
  current_cd_pos = io.pos
402
449
 
@@ -411,8 +458,8 @@ module Xlsxrb
411
458
  local_elen = local_header[24, 2].unpack1("v")
412
459
  data_offset = offset + 30 + local_nlen + local_elen
413
460
 
414
- result[entry_name] = Entry.new(
415
- name: entry_name,
461
+ entry = Entry.new(
462
+ name: norm_name,
416
463
  method: method,
417
464
  compressed_size: csize,
418
465
  uncompressed_size: usize,
@@ -420,6 +467,8 @@ module Xlsxrb
420
467
  local_header_offset: offset,
421
468
  data_offset: data_offset
422
469
  )
470
+ result[norm_name] = entry
471
+ result[entry_name] = entry if entry_name != norm_name
423
472
 
424
473
  io.seek(current_cd_pos, IO::SEEK_SET)
425
474
  end
@@ -450,19 +499,22 @@ module Xlsxrb
450
499
  extra_len = header[24, 2].unpack1("v")
451
500
 
452
501
  entry_name = io.read(name_len).force_encoding("UTF-8")
453
- io.read(extra_len)
502
+ extra_data = extra_len.positive? ? io.read(extra_len) : nil
503
+ 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)
454
504
  data_offset = io.pos
455
505
 
456
- next if entry_name.end_with?("/")
506
+ norm_name = normalize_entry_name(entry_name)
507
+ next if norm_name.end_with?("/")
457
508
 
458
509
  has_data_descriptor = gp_flag.anybits?(0x08)
459
510
 
460
511
  if has_data_descriptor && compressed_size.zero?
461
512
  entry_data, = find_data_descriptor_stream(io, method)
462
513
  @cached_entries ||= {}
463
- @cached_entries[entry_name] = entry_data
464
- result[entry_name] = Entry.new(
465
- name: entry_name,
514
+ @cached_entries[norm_name] = entry_data
515
+ @cached_entries[entry_name] = entry_data if entry_name != norm_name
516
+ entry = Entry.new(
517
+ name: norm_name,
466
518
  method: method,
467
519
  compressed_size: entry_data.bytesize,
468
520
  uncompressed_size: entry_data.bytesize,
@@ -470,9 +522,11 @@ module Xlsxrb
470
522
  local_header_offset: header_pos,
471
523
  data_offset: data_offset
472
524
  )
525
+ result[norm_name] = entry
526
+ result[entry_name] = entry if entry_name != norm_name
473
527
  else
474
- result[entry_name] = Entry.new(
475
- name: entry_name,
528
+ entry = Entry.new(
529
+ name: norm_name,
476
530
  method: method,
477
531
  compressed_size: compressed_size,
478
532
  uncompressed_size: uncompressed_size,
@@ -480,6 +534,8 @@ module Xlsxrb
480
534
  local_header_offset: header_pos,
481
535
  data_offset: data_offset
482
536
  )
537
+ result[norm_name] = entry
538
+ result[entry_name] = entry if entry_name != norm_name
483
539
 
484
540
  io.seek(compressed_size, IO::SEEK_CUR) if io.respond_to?(:seek)
485
541
  end
@@ -527,11 +583,15 @@ module Xlsxrb
527
583
  end
528
584
 
529
585
  def decompress(raw, method)
530
- return raw&.dup&.force_encoding("UTF-8") || "" if method.zero?
531
-
532
- safe_inflate(raw || "", -Zlib::MAX_WBITS)
586
+ data = if method.zero?
587
+ raw&.dup || ""
588
+ else
589
+ safe_inflate(raw || "", -Zlib::MAX_WBITS)
590
+ end
591
+ normalize_text_encoding(data)
533
592
  rescue Zlib::DataError
534
- safe_inflate(raw || "", -Zlib::MAX_WBITS)
593
+ data = safe_inflate(raw || "", -Zlib::MAX_WBITS)
594
+ normalize_text_encoding(data)
535
595
  end
536
596
 
537
597
  def safe_inflate(raw, wbits)
@@ -561,6 +621,62 @@ module Xlsxrb
561
621
 
562
622
  result.force_encoding("UTF-8")
563
623
  end
624
+
625
+ def normalize_entry_name(name)
626
+ name.include?("\\") ? name.tr("\\", "/") : name
627
+ end
628
+
629
+ def lookup_entry(name)
630
+ norm_name = normalize_entry_name(name)
631
+ catalog[norm_name] || ci_catalog[norm_name.downcase]
632
+ end
633
+
634
+ def ci_catalog
635
+ @ci_catalog ||= catalog.transform_keys(&:downcase)
636
+ end
637
+
638
+ def normalize_text_encoding(data)
639
+ return data if data.nil? || data.empty?
640
+
641
+ raw_b = data.b
642
+ if raw_b.start_with?("\xFF\xFE".b)
643
+ data.force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
644
+ elsif raw_b.start_with?("\xFE\xFF".b)
645
+ data.force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8).delete_prefix("\uFEFF")
646
+ elsif raw_b.start_with?("\xEF\xBB\xBF".b)
647
+ data.b.delete_prefix("\xEF\xBB\xBF".b).force_encoding(Encoding::UTF_8)
648
+ else
649
+ data.force_encoding(Encoding::UTF_8)
650
+ end
651
+ end
652
+
653
+ #: (String extra_data, Integer usize, Integer csize, Integer offset) -> [Integer, Integer, Integer]
654
+ def parse_zip64_extra(extra_data, usize, csize, offset)
655
+ pos = 0
656
+ len = extra_data.bytesize
657
+ while pos + 4 <= len
658
+ header_id, data_size = extra_data.byteslice(pos, 4).unpack("vv")
659
+ pos += 4
660
+ if header_id == 0x0001
661
+ field_data = extra_data.byteslice(pos, data_size)
662
+ if field_data
663
+ fpos = 0
664
+ if usize == 0xFFFF_FFFF && fpos + 8 <= field_data.bytesize
665
+ usize = field_data.byteslice(fpos, 8).unpack1("Q<")
666
+ fpos += 8
667
+ end
668
+ if csize == 0xFFFF_FFFF && fpos + 8 <= field_data.bytesize
669
+ csize = field_data.byteslice(fpos, 8).unpack1("Q<")
670
+ fpos += 8
671
+ end
672
+ offset = field_data.byteslice(fpos, 8).unpack1("Q<") if offset == 0xFFFF_FFFF && fpos + 8 <= field_data.bytesize
673
+ end
674
+ break
675
+ end
676
+ pos += data_size
677
+ end
678
+ [usize, csize, offset]
679
+ end
564
680
  end
565
681
  end
566
682
  end