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.
- checksums.yaml +4 -4
- data/.devcontainer/Dockerfile +2 -1
- data/.gem_rbs_collection/ast/2.4/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/ast/2.4/ast.rbs +73 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/array.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/atomic_reference.rbs +16 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/executor.rbs +96 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/hash.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/map.rbs +68 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/promises.rbs +249 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/set.rbs +4 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/timer_task.rbs +47 -0
- data/.gem_rbs_collection/concurrent-ruby/1.1/utility/processor_counter.rbs +5 -0
- data/.gem_rbs_collection/csv/3.3/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/csv/3.3/csv.rbs +3871 -0
- data/.gem_rbs_collection/csv/3.3/manifest.yaml +3 -0
- data/.gem_rbs_collection/lint_roller/1.1/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/lint_roller/1.1/lint_roller.rbs +48 -0
- data/.gem_rbs_collection/listen/3.9/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/listen/3.9/listen.rbs +25 -0
- data/.gem_rbs_collection/listen/3.9/listener.rbs +24 -0
- data/.gem_rbs_collection/logger/1.7/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/logger/1.7/formatter.rbs +45 -0
- data/.gem_rbs_collection/logger/1.7/log_device.rbs +100 -0
- data/.gem_rbs_collection/logger/1.7/logger.rbs +796 -0
- data/.gem_rbs_collection/logger/1.7/manifest.yaml +2 -0
- data/.gem_rbs_collection/logger/1.7/period.rbs +17 -0
- data/.gem_rbs_collection/logger/1.7/severity.rbs +34 -0
- data/.gem_rbs_collection/parallel/1.20/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/parallel/1.20/parallel.rbs +86 -0
- data/.gem_rbs_collection/parser/3.2/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/parser/3.2/manifest.yaml +7 -0
- data/.gem_rbs_collection/parser/3.2/parser.rbs +194 -0
- data/.gem_rbs_collection/parser/3.2/polyfill.rbs +4 -0
- data/.gem_rbs_collection/rainbow/3.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rainbow/3.0/global.rbs +7 -0
- data/.gem_rbs_collection/rainbow/3.0/presenter.rbs +209 -0
- data/.gem_rbs_collection/rainbow/3.0/rainbow.rbs +5 -0
- data/.gem_rbs_collection/rake/13.0/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rake/13.0/manifest.yaml +2 -0
- data/.gem_rbs_collection/rake/13.0/rake.rbs +39 -0
- data/.gem_rbs_collection/regexp_parser/2.8/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/regexp_parser/2.8/regexp_parser.rbs +17 -0
- data/.gem_rbs_collection/rubocop/1.57/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubocop/1.57/rubocop.rbs +208 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/.rbs_meta.yaml +9 -0
- data/.gem_rbs_collection/rubocop-ast/1.46/rubocop-ast.rbs +903 -0
- data/CHANGELOG.md +24 -3
- data/README.md +91 -17
- data/Rakefile +124 -89
- data/Steepfile +19 -0
- data/docs/ARCHITECTURE.md +17 -0
- data/docs/DEVELOPMENT.md +58 -0
- data/docs/QUALITY_ASSURANCE.md +26 -0
- data/docs/visual/VisualGallery.md +893 -938
- data/docs/wasm/ruby.wasm +0 -0
- data/lib/xlsxrb/elements/cell.rb +74 -1
- data/lib/xlsxrb/elements/column.rb +2 -0
- data/lib/xlsxrb/elements/row.rb +37 -2
- data/lib/xlsxrb/elements/types.rb +2 -0
- data/lib/xlsxrb/elements/workbook.rb +29 -0
- data/lib/xlsxrb/elements/worksheet.rb +98 -1
- data/lib/xlsxrb/elements.rb +2 -0
- data/lib/xlsxrb/ooxml/reader.rb +8 -6
- data/lib/xlsxrb/ooxml/shared_strings_parser.rb +2 -0
- data/lib/xlsxrb/ooxml/styles_parser.rb +2 -0
- data/lib/xlsxrb/ooxml/utils.rb +2 -0
- data/lib/xlsxrb/ooxml/workbook_parser.rb +6 -1
- data/lib/xlsxrb/ooxml/workbook_writer.rb +15 -4
- data/lib/xlsxrb/ooxml/worksheet_parser.rb +12 -0
- data/lib/xlsxrb/ooxml/worksheet_writer.rb +49 -9
- data/lib/xlsxrb/ooxml/writer.rb +217 -5
- data/lib/xlsxrb/ooxml/xml_builder.rb +2 -0
- data/lib/xlsxrb/ooxml/xml_parser.rb +2 -0
- data/lib/xlsxrb/ooxml/zip_generator.rb +5 -8
- data/lib/xlsxrb/ooxml/zip_reader.rb +88 -52
- data/lib/xlsxrb/ooxml/zip_writer.rb +2 -0
- data/lib/xlsxrb/ooxml.rb +3 -1
- data/lib/xlsxrb/style_builder.rb +156 -36
- data/lib/xlsxrb/version.rb +3 -1
- data/lib/xlsxrb.rb +1510 -231
- data/rbs_collection.lock.yaml +224 -0
- data/rbs_collection.yaml +19 -0
- data/sig/generated/xlsxrb/elements.rbs +8 -0
- data/sig/generated/xlsxrb/ooxml.rbs +23 -0
- data/sig/generated/xlsxrb/style_builder.rbs +88 -0
- data/sig/generated/xlsxrb/version.rbs +5 -0
- metadata +67 -29
- data/benchmark.rb +0 -399
- data/measure_memory.rb +0 -42
- data/sig/xlsxrb.rbs +0 -23
data/docs/wasm/ruby.wasm
ADDED
|
Binary file
|
data/lib/xlsxrb/elements/cell.rb
CHANGED
|
@@ -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
|
|
data/lib/xlsxrb/elements/row.rb
CHANGED
|
@@ -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
|
-
|
|
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,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
|
-
|
|
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)
|
data/lib/xlsxrb/elements.rb
CHANGED
data/lib/xlsxrb/ooxml/reader.rb
CHANGED
|
@@ -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
|
|
1299
|
+
raise ZipError, "invalid ZIP local header signature" unless signature_value == 0x04034b50
|
|
1298
1300
|
|
|
1299
1301
|
header = file.read(26)
|
|
1300
|
-
raise
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1330
|
+
raise ZipError, "unsupported ZIP compression method: #{compression_method}"
|
|
1329
1331
|
end
|
|
1330
1332
|
end
|
|
1331
1333
|
end
|
data/lib/xlsxrb/ooxml/utils.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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 = { "&" => "&", "<" => "<", ">" => ">", """ => '"', "'" => "'" }.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
|