xlsxrb 0.1.3 → 0.1.5

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +2 -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/parallel/1.20/.rbs_meta.yaml +9 -0
  31. data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
  32. data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +9 -0
  33. data/.gem_rbs_collection/parser/3.2/manifest.yaml +7 -0
  34. data/.gem_rbs_collection/parser/3.2/parser.rbs +194 -0
  35. data/.gem_rbs_collection/parser/3.2/polyfill.rbs +4 -0
  36. data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +9 -0
  37. data/.gem_rbs_collection/rainbow/3.0/global.rbs +7 -0
  38. data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +209 -0
  39. data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +5 -0
  40. data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
  41. data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
  42. data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
  43. data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +9 -0
  44. data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +17 -0
  45. data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +9 -0
  46. data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +208 -0
  47. data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
  48. data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +903 -0
  49. data/CHANGELOG.md +24 -3
  50. data/README.md +91 -17
  51. data/Rakefile +124 -89
  52. data/Steepfile +19 -0
  53. data/docs/ARCHITECTURE.md +17 -0
  54. data/docs/DEVELOPMENT.md +58 -0
  55. data/docs/QUALITY_ASSURANCE.md +26 -0
  56. data/docs/visual/VisualGallery.md +893 -938
  57. data/docs/wasm/ruby.wasm +0 -0
  58. data/lib/xlsxrb/elements/cell.rb +74 -1
  59. data/lib/xlsxrb/elements/column.rb +2 -0
  60. data/lib/xlsxrb/elements/row.rb +37 -2
  61. data/lib/xlsxrb/elements/types.rb +2 -0
  62. data/lib/xlsxrb/elements/workbook.rb +29 -0
  63. data/lib/xlsxrb/elements/worksheet.rb +98 -1
  64. data/lib/xlsxrb/elements.rb +2 -0
  65. data/lib/xlsxrb/ooxml/reader.rb +8 -6
  66. data/lib/xlsxrb/ooxml/shared_strings_parser.rb +2 -0
  67. data/lib/xlsxrb/ooxml/styles_parser.rb +2 -0
  68. data/lib/xlsxrb/ooxml/utils.rb +2 -0
  69. data/lib/xlsxrb/ooxml/workbook_parser.rb +6 -1
  70. data/lib/xlsxrb/ooxml/workbook_writer.rb +15 -4
  71. data/lib/xlsxrb/ooxml/worksheet_parser.rb +12 -0
  72. data/lib/xlsxrb/ooxml/worksheet_writer.rb +49 -9
  73. data/lib/xlsxrb/ooxml/writer.rb +217 -5
  74. data/lib/xlsxrb/ooxml/xml_builder.rb +2 -0
  75. data/lib/xlsxrb/ooxml/xml_parser.rb +2 -0
  76. data/lib/xlsxrb/ooxml/zip_generator.rb +5 -8
  77. data/lib/xlsxrb/ooxml/zip_reader.rb +88 -52
  78. data/lib/xlsxrb/ooxml/zip_writer.rb +2 -0
  79. data/lib/xlsxrb/ooxml.rb +3 -1
  80. data/lib/xlsxrb/style_builder.rb +156 -36
  81. data/lib/xlsxrb/version.rb +3 -1
  82. data/lib/xlsxrb.rb +1510 -231
  83. data/rbs_collection.lock.yaml +224 -0
  84. data/rbs_collection.yaml +19 -0
  85. data/sig/generated/xlsxrb/elements.rbs +8 -0
  86. data/sig/generated/xlsxrb/ooxml.rbs +23 -0
  87. data/sig/generated/xlsxrb/style_builder.rbs +88 -0
  88. data/sig/generated/xlsxrb/version.rbs +5 -0
  89. metadata +67 -29
  90. data/benchmark.rb +0 -399
  91. data/measure_memory.rb +0 -42
  92. data/sig/xlsxrb.rbs +0 -23
Binary file
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents a single cell in a worksheet.
@@ -21,6 +23,53 @@ module Xlsxrb
21
23
  "#{self.class.column_letter(column_index)}#{row_index + 1}"
22
24
  end
23
25
 
26
+ def content
27
+ value
28
+ end
29
+
30
+ def to_s
31
+ value.to_s
32
+ end
33
+
34
+ def to_i
35
+ value.to_i
36
+ end
37
+
38
+ def to_f
39
+ value.to_f
40
+ end
41
+
42
+ def to_date
43
+ return value if value.is_a?(Date)
44
+
45
+ # Excel epoch is 1899-12-30
46
+ if value.is_a?(Numeric)
47
+ Date.new(1899, 12, 30) + value.to_i
48
+ else
49
+ begin
50
+ Date.parse(value.to_s)
51
+ rescue StandardError
52
+ nil
53
+ end
54
+ end
55
+ end
56
+
57
+ def to_time
58
+ return value if value.is_a?(Time)
59
+
60
+ if value.is_a?(Numeric)
61
+ days = value.to_f
62
+ base_time = Time.utc(1899, 12, 30)
63
+ base_time + (days * 86_400)
64
+ else
65
+ begin
66
+ Time.parse(value.to_s)
67
+ rescue StandardError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+
24
73
  # Cache column letters up to Excel's limit (16,384)
25
74
  @column_letters = (0...16_384).map do |index|
26
75
  result = +""
@@ -35,6 +84,8 @@ module Xlsxrb
35
84
 
36
85
  # Converts a 0-based column index to a letter (0 -> "A", 25 -> "Z", 26 -> "AA").
37
86
  def self.column_letter(index)
87
+ raise ArgumentError, "Column index must be a non-negative Integer, got #{index.inspect}" unless index.is_a?(Integer) && index >= 0
88
+
38
89
  @column_letters[index] || begin
39
90
  result = +""
40
91
  i = index
@@ -47,6 +98,28 @@ module Xlsxrb
47
98
  end
48
99
  end
49
100
 
101
+ # Converts a column letter (e.g. "A", :AA) to a 0-based column index.
102
+ # If passed an integer or string/symbol representing an integer, it validates and returns the integer.
103
+ def self.column_index(letter)
104
+ if letter.is_a?(Integer)
105
+ raise ArgumentError, "Column index must be >= 0, got #{letter}" if letter.negative?
106
+
107
+ return letter
108
+ end
109
+
110
+ str = letter.to_s
111
+ if str.match?(/\A-?\d+\z/)
112
+ val = str.to_i
113
+ raise ArgumentError, "Column index must be >= 0, got #{val}" if val.negative?
114
+
115
+ return val
116
+ end
117
+
118
+ raise ArgumentError, "Invalid column letter: #{letter.inspect}" unless str.match?(/\A[a-zA-Z]+\z/)
119
+
120
+ str.upcase.chars.reduce(0) { |acc, c| (acc * 26) + (c.ord - "A".ord + 1) } - 1
121
+ end
122
+
50
123
  # Parses an Excel-style reference to [row_index, col_index] (both 0-based).
51
124
  def self.parse_ref(ref)
52
125
  match = ref.match(/\A([A-Z]+)(\d+)\z/)
@@ -60,7 +133,7 @@ module Xlsxrb
60
133
  def self.validate(row_index, column_index, value)
61
134
  if row_index.is_a?(Integer) && row_index >= 0 && row_index < 1_048_576 &&
62
135
  column_index.is_a?(Integer) && column_index >= 0 && column_index < 16_384 &&
63
- (value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Date) || value.is_a?(Time))
136
+ (value.nil? || value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.is_a?(Date) || value.is_a?(Time) || value.is_a?(Formula) || (value.is_a?(Hash) && value.key?(:formula)) || value.is_a?(RichText) || value.is_a?(CellError))
64
137
  return [].freeze
65
138
  end
66
139
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents column formatting in a worksheet.
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents a single row in a worksheet.
6
8
  # index is 0-based.
7
9
  Row = Data.define(:index, :cells, :height, :hidden, :custom_height, :outline_level, :unmapped_data, :errors) do
10
+ include Enumerable
11
+
8
12
  def initialize(index:, cells: [], height: nil, hidden: false, custom_height: false, outline_level: nil,
9
13
  unmapped_data: {}, errors: nil)
10
14
  computed_errors = errors || self.class.validate(index, cells)
@@ -15,6 +19,33 @@ module Xlsxrb
15
19
  unmapped_data: unmapped_data, errors: computed_errors)
16
20
  end
17
21
 
22
+ def [](col_index)
23
+ cells[col_index]
24
+ end
25
+
26
+ def each(&)
27
+ return to_enum(:each) unless block_given?
28
+
29
+ cells.each(&)
30
+ end
31
+
32
+ def each_cell(&)
33
+ return to_enum(:each_cell) unless block_given?
34
+
35
+ cells.each(&)
36
+ end
37
+
38
+ def to_a
39
+ return [] if cells.empty?
40
+
41
+ max_col = cells.map(&:column_index).max
42
+ arr = Array.new(max_col + 1)
43
+ cells.each do |cell|
44
+ arr[cell.column_index] = cell.value
45
+ end
46
+ arr
47
+ end
48
+
18
49
  def valid?
19
50
  errors.empty?
20
51
  end
@@ -35,10 +66,14 @@ module Xlsxrb
35
66
  end
36
67
 
37
68
  def self.validate(index, cells)
38
- return [].freeze if index.is_a?(Integer) && index >= 0 && cells.is_a?(Array)
69
+ return [].freeze if index.is_a?(Integer) && index >= 0 && index < 1_048_576 && cells.is_a?(Array)
39
70
 
40
71
  errs = []
41
- errs << "index must be a non-negative Integer (got #{index.inspect})" if !index.is_a?(Integer) || index.negative?
72
+ if !index.is_a?(Integer) || index.negative?
73
+ errs << "index must be a non-negative Integer (got #{index.inspect})"
74
+ elsif index >= 1_048_576
75
+ errs << "index must be < 1048576 (got #{index}, max row is 1048576)"
76
+ end
42
77
  errs << "cells must be an Array (got #{cells.class})" unless cells.is_a?(Array)
43
78
  errs
44
79
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents a formula with an optional cached value.
@@ -1,15 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents an entire XLSX workbook.
6
8
  Workbook = Data.define(:sheets, :shared_strings, :styles, :unmapped_data, :errors) do
9
+ include Enumerable
10
+
7
11
  def initialize(sheets: [], shared_strings: [], styles: {}, unmapped_data: {}, errors: nil)
8
12
  computed_errors = errors || self.class.validate(sheets)
9
13
  super(sheets: sheets.freeze, shared_strings: shared_strings.freeze, styles: styles,
10
14
  unmapped_data: unmapped_data, errors: computed_errors.freeze)
11
15
  end
12
16
 
17
+ def each(&)
18
+ sheets.each(&)
19
+ end
20
+
13
21
  def valid?
14
22
  errors.empty?
15
23
  end
@@ -23,12 +31,33 @@ module Xlsxrb
23
31
  sheets.find { |s| s.name == identifier }
24
32
  end
25
33
  end
34
+ alias_method :[], :sheet
35
+
36
+ # Returns a new Workbook with the specified sheet updated.
37
+ # If a block is given, it yields the matched sheet and expects a new Worksheet back.
38
+ def update_sheet(identifier)
39
+ raise ArgumentError, "block is required" unless block_given?
40
+
41
+ sheet_to_update = sheet(identifier)
42
+ raise ArgumentError, "sheet not found: #{identifier}" unless sheet_to_update
43
+
44
+ new_sheet = yield sheet_to_update
45
+ raise TypeError, "block must return a Worksheet" unless new_sheet.is_a?(Worksheet)
46
+
47
+ new_sheets = sheets.map { |s| s == sheet_to_update ? new_sheet : s }
48
+ with(sheets: new_sheets)
49
+ end
26
50
 
27
51
  # Returns sheet names.
28
52
  def sheet_names
29
53
  sheets.map(&:name)
30
54
  end
31
55
 
56
+ # Save the workbook to a file.
57
+ def save(filepath)
58
+ Xlsxrb.write(filepath, self)
59
+ end
60
+
32
61
  def self.validate(sheets)
33
62
  errs = []
34
63
  errs << "sheets must be an Array (got #{sheets.class})" unless sheets.is_a?(Array)
@@ -1,15 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module Xlsxrb
4
6
  module Elements
5
7
  # Represents a single worksheet in a workbook.
6
8
  Worksheet = Data.define(:name, :rows, :columns, :charts, :unmapped_data, :errors) do
9
+ include Enumerable
10
+
7
11
  def initialize(name:, rows: [], columns: [], charts: [], unmapped_data: {}, errors: nil)
8
12
  computed_errors = errors || self.class.validate(name, rows)
9
13
  super(name: name, rows: rows.freeze, columns: columns.freeze, charts: charts.freeze,
10
14
  unmapped_data: unmapped_data, errors: computed_errors.freeze)
11
15
  end
12
16
 
17
+ def cells_hash
18
+ h = {}
19
+ rows.each do |r|
20
+ r.cells.each do |c|
21
+ ref = "#{Cell.column_letter(c.column_index)}#{c.row_index + 1}"
22
+ h[ref] = c
23
+ end
24
+ end
25
+ h
26
+ end
27
+
28
+ def cells
29
+ # Ensure ordered traversal
30
+ cells_hash.values.sort_by { |c| [c.row_index, c.column_index] }
31
+ end
32
+
33
+ def [](ref)
34
+ cells_hash[ref.to_s.upcase]
35
+ end
36
+
37
+ def each(&)
38
+ return to_enum(:each) unless block_given?
39
+
40
+ cells.each(&)
41
+ end
42
+
43
+ def each_cell(&)
44
+ return to_enum(:each_cell) unless block_given?
45
+
46
+ cells.each(&)
47
+ end
48
+
49
+ def each_row(&)
50
+ return to_enum(:each_row) unless block_given?
51
+
52
+ rows.each(&)
53
+ end
54
+
13
55
  def valid?
14
56
  errors.empty?
15
57
  end
@@ -19,6 +61,14 @@ module Xlsxrb
19
61
  rows.find { |r| r.index == index }
20
62
  end
21
63
 
64
+ def first_row
65
+ rows.min_by(&:index)
66
+ end
67
+
68
+ def last_row
69
+ rows.max_by(&:index)
70
+ end
71
+
22
72
  # Returns cell value at Excel-style reference (e.g. "A1").
23
73
  def cell_value(ref)
24
74
  parsed = Cell.parse_ref(ref)
@@ -32,9 +82,56 @@ module Xlsxrb
32
82
  cell&.value
33
83
  end
34
84
 
85
+ # Returns a new Worksheet with the specified cell updated.
86
+ #
87
+ # @param ref [String] The cell reference (e.g. "B1").
88
+ # @param value [Object] The new cell value.
89
+ # @param style_index [Integer, String, nil] Optional new style index.
90
+ # @param formula [Elements::Formula, nil] Optional new formula.
91
+ # @return [Worksheet] A new Worksheet instance.
92
+ def update_cell(ref, value: nil, style_index: nil, formula: nil)
93
+ parsed = Cell.parse_ref(ref)
94
+ raise ArgumentError, "invalid cell reference: #{ref}" unless parsed
95
+
96
+ row_idx, col_idx = parsed
97
+ existing_row = row_at(row_idx)
98
+
99
+ if existing_row
100
+ existing_cell = existing_row.cell_at(col_idx)
101
+ new_cell = if existing_cell
102
+ existing_cell.with(
103
+ value: value || existing_cell.value,
104
+ style_index: style_index || existing_cell.style_index,
105
+ formula: formula || existing_cell.formula
106
+ )
107
+ else
108
+ Cell.new(row_index: row_idx, column_index: col_idx, value: value, style_index: style_index, formula: formula)
109
+ end
110
+
111
+ # Replace cell in the existing row
112
+ new_cells = existing_row.cells.reject { |c| c.column_index == col_idx }
113
+ new_cells << new_cell
114
+ new_cells.sort_by!(&:column_index)
115
+
116
+ new_row = existing_row.with(cells: new_cells)
117
+ new_rows = rows.map { |r| r.index == row_idx ? new_row : r }
118
+ else
119
+ # Row doesn't exist, create it
120
+ new_cell = Cell.new(row_index: row_idx, column_index: col_idx, value: value, style_index: style_index, formula: formula)
121
+ new_row = Row.new(index: row_idx, cells: [new_cell])
122
+ new_rows = (rows + [new_row]).sort_by!(&:index)
123
+ end
124
+ with(rows: new_rows)
125
+ end
126
+
35
127
  def self.validate(name, rows)
36
128
  errs = []
37
- errs << "worksheet name must be a non-empty String (got #{name.inspect})" if name.nil? || (name.is_a?(String) && name.empty?)
129
+ if name.nil? || !name.is_a?(String) || name.empty?
130
+ errs << "worksheet name must be a non-empty String (got #{name.inspect})"
131
+ else
132
+ errs << "worksheet name cannot exceed 31 characters (got #{name.size})" if name.size > 31
133
+ errs << "worksheet name cannot contain \\, /, ?, *, [, or ]" if name.match?(%r{[\\/?*\[\]]})
134
+ end
38
135
  errs << "rows must be an Array (got #{rows.class})" unless rows.is_a?(Array)
39
136
  if rows.is_a?(Array)
40
137
  indices = rows.map(&:index)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require_relative "elements/types"
4
6
  require_relative "elements/cell"
5
7
  require_relative "elements/row"
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "zlib"
4
6
  require "rexml/parsers/sax2parser"
5
7
  require "rexml/sax2listener"
@@ -1294,23 +1296,23 @@ module Xlsxrb
1294
1296
  signature_value = signature.unpack1("V")
1295
1297
  break if [0x02014b50, 0x06054b50].include?(signature_value)
1296
1298
 
1297
- raise Error, "invalid ZIP local header signature" unless signature_value == 0x04034b50
1299
+ raise ZipError, "invalid ZIP local header signature" unless signature_value == 0x04034b50
1298
1300
 
1299
1301
  header = file.read(26)
1300
- raise Error, "truncated ZIP local header" if header.nil? || header.bytesize < 26
1302
+ raise ZipError, "truncated ZIP local header" if header.nil? || header.bytesize < 26
1301
1303
 
1302
1304
  _version, flags, compression_method, _mtime, _mdate, _crc32, compressed_size,
1303
1305
  _uncompressed_size, file_name_length, extra_field_length = header.unpack("v v v v v V V V v v")
1304
1306
 
1305
- raise Error, "ZIP data descriptor is not supported" if flags.anybits?(0x0008)
1307
+ raise ZipError, "ZIP data descriptor is not supported" if flags.anybits?(0x0008)
1306
1308
 
1307
1309
  file_name = file.read(file_name_length)
1308
- raise Error, "truncated ZIP file name" if file_name.nil? || file_name.bytesize < file_name_length
1310
+ raise ZipError, "truncated ZIP file name" if file_name.nil? || file_name.bytesize < file_name_length
1309
1311
 
1310
1312
  file.read(extra_field_length)
1311
1313
 
1312
1314
  compressed_data = file.read(compressed_size)
1313
- raise Error, "truncated ZIP entry data" if compressed_data.nil? || compressed_data.bytesize < compressed_size
1315
+ raise ZipError, "truncated ZIP entry data" if compressed_data.nil? || compressed_data.bytesize < compressed_size
1314
1316
 
1315
1317
  next unless file_name == entry_name
1316
1318
 
@@ -1325,7 +1327,7 @@ module Xlsxrb
1325
1327
  inflater.close
1326
1328
  end
1327
1329
  else
1328
- raise Error, "unsupported ZIP compression method: #{compression_method}"
1330
+ raise ZipError, "unsupported ZIP compression method: #{compression_method}"
1329
1331
  end
1330
1332
  end
1331
1333
  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_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
@@ -394,6 +403,9 @@ module Xlsxrb
394
403
 
395
404
  XML_ENTITIES = { "&amp;" => "&", "&lt;" => "<", "&gt;" => ">", "&quot;" => '"', "&apos;" => "'" }.freeze
396
405
 
406
+ # SECURITY NOTE: Decodes only standard predefined XML entities in a single pass.
407
+ # This completely prevents "Billion Laughs" attacks (exponential entity expansion)
408
+ # because it avoids recursive expansion and ignores custom entities entirely.
397
409
  def self.decode_xml_entities(str)
398
410
  str.gsub(/&(?:amp|lt|gt|quot|apos);/, XML_ENTITIES)
399
411
  end