rukbat 0.1.0
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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +100 -0
- data/exe/rukbat +37 -0
- data/lib/rukbat/application.rb +37 -0
- data/lib/rukbat/atomic_file.rb +11 -0
- data/lib/rukbat/cell_source.rb +25 -0
- data/lib/rukbat/csv_file.rb +125 -0
- data/lib/rukbat/grid_view.rb +847 -0
- data/lib/rukbat/pdf_file.rb +163 -0
- data/lib/rukbat/version.rb +5 -0
- data/lib/rukbat/workbook.rb +1045 -0
- data/lib/rukbat.rb +23 -0
- data/sig/rukbat.rbs +118 -0
- metadata +201 -0
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Rukbat
|
|
6
|
+
class Workbook
|
|
7
|
+
MAX_ROWS = 1_048_576
|
|
8
|
+
MAX_COLUMNS = 16_384
|
|
9
|
+
MAX_SORT_CELLS = 100_000
|
|
10
|
+
MAX_FORMAT_CELLS = 100_000
|
|
11
|
+
MAX_HIDDEN_CELLS = 100_000
|
|
12
|
+
MAX_FILTER_ROWS = 100_000
|
|
13
|
+
MAX_CONDITIONAL_FORMATS = 256
|
|
14
|
+
FORMAT_KEYS = %i[number_format font_family font_size bold italic color background border_color border_width horizontal_alignment vertical_alignment].freeze
|
|
15
|
+
CONDITIONAL_OPERATORS = %i[greater_than greater_than_or_equal less_than less_than_or_equal equal not_equal contains].freeze
|
|
16
|
+
COLORS = {black: "#000000", blue: "#0000FF", cyan: "#00FFFF", green: "#008000",
|
|
17
|
+
magenta: "#FF00FF", red: "#FF0000", white: "#FFFFFF", yellow: "#FFFF00"}.freeze
|
|
18
|
+
|
|
19
|
+
Summary = Data.define(:count, :numeric_count, :sum, :min, :max, :average, :types)
|
|
20
|
+
CellRange = Data.define(:top, :left, :bottom, :right) do
|
|
21
|
+
def include?(row, column)
|
|
22
|
+
row.between?(top, bottom) && column.between?(left, right)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :active_sheet
|
|
27
|
+
|
|
28
|
+
def self.from_rows(rows, sheet: "Sheet1")
|
|
29
|
+
workbook = new(sheet: sheet)
|
|
30
|
+
workbook.__send__(:load_rows, rows, sheet)
|
|
31
|
+
workbook
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(sheet: "Sheet1")
|
|
35
|
+
name = validate_name(sheet)
|
|
36
|
+
@sheets = {name => Denebola::Sheet.new}
|
|
37
|
+
@calculated = {name => Denebola::Sheet.new}
|
|
38
|
+
@active_sheet = name
|
|
39
|
+
@history = []
|
|
40
|
+
@redo = []
|
|
41
|
+
@names = {}
|
|
42
|
+
@formats = {}
|
|
43
|
+
@comments = {}
|
|
44
|
+
@conditional_formats = [].freeze
|
|
45
|
+
@hidden_rows = {}.freeze
|
|
46
|
+
@hidden_columns = {}.freeze
|
|
47
|
+
@frozen_panes = {name => {rows: 1, columns: 1}.freeze}.freeze
|
|
48
|
+
@print_areas = {}.freeze
|
|
49
|
+
@change_observers = []
|
|
50
|
+
@source = CellSource.new(self)
|
|
51
|
+
@engine = Furud::Engine.new(@source)
|
|
52
|
+
@source_path = nil
|
|
53
|
+
@source_digest = :absent
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def sheet_names = @sheets.keys.freeze
|
|
57
|
+
|
|
58
|
+
def on_cells_changed(&observer)
|
|
59
|
+
raise ArgumentError, "a change observer is required" unless observer
|
|
60
|
+
|
|
61
|
+
@change_observers << observer
|
|
62
|
+
observer
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def remove_cells_changed_observer(observer)
|
|
66
|
+
@change_observers.delete(observer)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sheet(name = @active_sheet)
|
|
70
|
+
@sheets.fetch(name.to_s) { raise Error, "unknown sheet: #{name}" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def activate(name)
|
|
74
|
+
name = name.to_s
|
|
75
|
+
sheet(name)
|
|
76
|
+
@active_sheet = name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def add_sheet(name)
|
|
80
|
+
name = validate_name(name)
|
|
81
|
+
raise Error, "sheet already exists: #{name}" if @sheets.key?(name)
|
|
82
|
+
|
|
83
|
+
record_history
|
|
84
|
+
@sheets = @sheets.merge(name => Denebola::Sheet.new)
|
|
85
|
+
@frozen_panes = @frozen_panes.merge(name => {rows: 1, columns: 1}.freeze).freeze
|
|
86
|
+
rebuild_engine
|
|
87
|
+
@active_sheet = name
|
|
88
|
+
notify_cells_changed(nil)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def remove_sheet(name)
|
|
92
|
+
name = name.to_s
|
|
93
|
+
raise Error, "cannot remove the last sheet" if @sheets.length == 1
|
|
94
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
95
|
+
raise Error, "cannot remove referenced sheet: #{name}" if @names.values.any? { |area| area.sheet == name }
|
|
96
|
+
raise Error, "cannot remove referenced sheet: #{name}" if sheet_referenced?(name)
|
|
97
|
+
|
|
98
|
+
record_history
|
|
99
|
+
@sheets = @sheets.reject { |key, _| key == name }
|
|
100
|
+
@formats = @formats.reject { |(sheet_name, _row, _column), _style| sheet_name == name }.freeze
|
|
101
|
+
@comments = @comments.reject { |(sheet_name, _row, _column), _text| sheet_name == name }.freeze
|
|
102
|
+
@conditional_formats = @conditional_formats.reject { |rule| rule[:area].sheet == name }.freeze
|
|
103
|
+
@hidden_rows = @hidden_rows.reject { |sheet_name, _| sheet_name == name }.freeze
|
|
104
|
+
@hidden_columns = @hidden_columns.reject { |sheet_name, _| sheet_name == name }.freeze
|
|
105
|
+
@frozen_panes = @frozen_panes.reject { |sheet_name, _| sheet_name == name }.freeze
|
|
106
|
+
@print_areas = @print_areas.reject { |sheet_name, _| sheet_name == name }.freeze
|
|
107
|
+
@active_sheet = @sheets.keys.first if @active_sheet == name
|
|
108
|
+
rebuild_engine
|
|
109
|
+
notify_cells_changed(nil)
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def [](row, column, sheet: @active_sheet)
|
|
114
|
+
@engine.value(reference(row, column, sheet))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def input_at(row, column, sheet: @active_sheet)
|
|
118
|
+
name = sheet || @active_sheet
|
|
119
|
+
return nil unless @sheets.key?(name.to_s)
|
|
120
|
+
return nil unless row.is_a?(Integer) && column.is_a?(Integer) && row.between?(1, MAX_ROWS) && column.between?(1, MAX_COLUMNS)
|
|
121
|
+
|
|
122
|
+
@sheets.fetch(name.to_s)[row - 1, column - 1]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def formula(row, column, sheet: @active_sheet)
|
|
126
|
+
@engine.formula(reference(row, column, sheet))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def set(row, column, value, sheet: @active_sheet)
|
|
130
|
+
ref = reference(row, column, sheet)
|
|
131
|
+
current = input_at(row, column, sheet: ref.sheet)
|
|
132
|
+
return self if current == value
|
|
133
|
+
return clear(row, column, sheet: ref.sheet) if value.nil?
|
|
134
|
+
|
|
135
|
+
validate_formula(value, ref)
|
|
136
|
+
updated = update_sheet(@sheets.fetch(ref.sheet), [[row - 1, column - 1, value]])
|
|
137
|
+
record_history
|
|
138
|
+
@engine.set(ref, value)
|
|
139
|
+
@sheets[ref.sheet] = updated
|
|
140
|
+
update_calculated(@engine.recalculate)
|
|
141
|
+
notify_cells_changed(ref.sheet, [[ref.row, ref.column].freeze])
|
|
142
|
+
self
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def set_many(changes, sheet: @active_sheet)
|
|
146
|
+
entries = changes.map do |change|
|
|
147
|
+
raise Error, "each cell change must contain row, column, and value" unless change.respond_to?(:length) && change.length == 3
|
|
148
|
+
|
|
149
|
+
row, column, value = change
|
|
150
|
+
[reference(row, column, sheet), value]
|
|
151
|
+
end
|
|
152
|
+
return self if entries.empty?
|
|
153
|
+
|
|
154
|
+
ref = entries.first.first
|
|
155
|
+
values = entries.to_h { |cell, value| [[cell.row, cell.column], [cell, value]] }
|
|
156
|
+
current = @sheets.fetch(ref.sheet)
|
|
157
|
+
changes_to_apply = values.filter_map do |(row, column), (cell, value)|
|
|
158
|
+
validate_formula(value, cell)
|
|
159
|
+
[[row, column, value], cell, value] if current[row - 1, column - 1] != value
|
|
160
|
+
end
|
|
161
|
+
return self if changes_to_apply.empty?
|
|
162
|
+
|
|
163
|
+
updated = update_sheet(current, changes_to_apply.map do |(row, column, value), _cell, _input|
|
|
164
|
+
[row - 1, column - 1, value]
|
|
165
|
+
end)
|
|
166
|
+
record_history
|
|
167
|
+
changes_to_apply.each do |_change, cell, value|
|
|
168
|
+
if value.nil?
|
|
169
|
+
@engine.clear(cell)
|
|
170
|
+
else
|
|
171
|
+
@engine.set(cell, value)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
@sheets[ref.sheet] = updated
|
|
175
|
+
update_calculated(@engine.recalculate)
|
|
176
|
+
notify_cells_changed(ref.sheet, changes_to_apply.map do |_change, cell, _value|
|
|
177
|
+
[cell.row, cell.column].freeze
|
|
178
|
+
end)
|
|
179
|
+
self
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def clear(row, column, sheet: @active_sheet)
|
|
183
|
+
ref = reference(row, column, sheet)
|
|
184
|
+
return self if input_at(row, column, sheet: ref.sheet).nil?
|
|
185
|
+
|
|
186
|
+
record_history
|
|
187
|
+
@sheets[ref.sheet] = @sheets.fetch(ref.sheet).delete(row - 1, column - 1)
|
|
188
|
+
@engine.clear(ref)
|
|
189
|
+
update_calculated(@engine.recalculate)
|
|
190
|
+
notify_cells_changed(ref.sheet, [[ref.row, ref.column].freeze])
|
|
191
|
+
self
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def each_in(top, left, bottom, right, sheet: @active_sheet, &block)
|
|
195
|
+
return enum_for(__method__, top, left, bottom, right, sheet: sheet) unless block
|
|
196
|
+
|
|
197
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
198
|
+
@calculated.fetch(name)
|
|
199
|
+
.each_in(top - 1, left - 1, bottom - 1, right - 1) do |point, _value|
|
|
200
|
+
ref = Furud::Reference.new(sheet: name, row: point.row + 1, column: point.column + 1)
|
|
201
|
+
block.call(ref, @engine.value(ref))
|
|
202
|
+
end
|
|
203
|
+
self
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def summary(top, left, bottom, right, sheet: @active_sheet)
|
|
207
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
208
|
+
values = @calculated.fetch(name)
|
|
209
|
+
.summary(top - 1, left - 1, bottom - 1, right - 1)
|
|
210
|
+
numeric_count = values.types.sum { |type, count| type <= Numeric ? count : 0 }
|
|
211
|
+
Summary.new(count: values.count, numeric_count: numeric_count, sum: values.sum,
|
|
212
|
+
min: values.min, max: values.max, average: numeric_count.zero? ? nil : values.sum.to_f / numeric_count,
|
|
213
|
+
types: values.types)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def define_name(name, top, left, bottom, right, sheet: @active_sheet)
|
|
217
|
+
name = name.to_s
|
|
218
|
+
ast = Furud::Formula.parse("=#{name}")
|
|
219
|
+
raise Error, "invalid named range: #{name}" unless ast.type == :name
|
|
220
|
+
|
|
221
|
+
top, left, bottom, right, sheet = range_coordinates(top, left, bottom, right, sheet)
|
|
222
|
+
key = name.downcase
|
|
223
|
+
area = Furud::Area.new(sheet: sheet, top: top, left: left, bottom: bottom, right: right)
|
|
224
|
+
return area if @names[key] == area
|
|
225
|
+
|
|
226
|
+
record_history
|
|
227
|
+
@names = @names.merge(key => area).freeze
|
|
228
|
+
@engine.define_name(name, area)
|
|
229
|
+
update_calculated(@engine.recalculate)
|
|
230
|
+
area
|
|
231
|
+
rescue Furud::ParseError => error
|
|
232
|
+
raise Error, "invalid named range: #{error.message}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def find(query, top: 1, left: 1, bottom: MAX_ROWS, right: MAX_COLUMNS, sheet: @active_sheet)
|
|
236
|
+
query = query.to_s.downcase
|
|
237
|
+
raise Error, "search text must not be empty" if query.empty?
|
|
238
|
+
|
|
239
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
240
|
+
matches = []
|
|
241
|
+
@sheets.fetch(name).each_in(top - 1, left - 1, bottom - 1, right - 1) do |point, input|
|
|
242
|
+
ref = Furud::Reference.new(sheet: name, row: point.row + 1, column: point.column + 1)
|
|
243
|
+
value = @engine.value(ref)
|
|
244
|
+
matches << ref if [input, value].compact.any? { |item| item.to_s.downcase.include?(query) }
|
|
245
|
+
end
|
|
246
|
+
matches.freeze
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def filter_rows(top, bottom, by:, query:, sheet: @active_sheet)
|
|
250
|
+
by = strict_integer(by)
|
|
251
|
+
top, by, bottom, by, name = range_coordinates(top, by, bottom, by, sheet)
|
|
252
|
+
raise Error, "filter exceeds #{MAX_FILTER_ROWS} rows" if bottom - top + 1 > MAX_FILTER_ROWS
|
|
253
|
+
query = query.to_s.downcase
|
|
254
|
+
return (top..bottom).to_a.freeze if query.empty?
|
|
255
|
+
|
|
256
|
+
(top..bottom).select do |row|
|
|
257
|
+
value = @engine.value(Furud::Reference.new(sheet: name, row: row, column: by))
|
|
258
|
+
value.to_s.downcase.include?(query)
|
|
259
|
+
end.freeze
|
|
260
|
+
rescue ArgumentError, TypeError
|
|
261
|
+
raise Error, "filter column must be an integer"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def replace_all(query, replacement, top: 1, left: 1, bottom: MAX_ROWS, right: MAX_COLUMNS, sheet: @active_sheet)
|
|
265
|
+
query = query.to_s
|
|
266
|
+
raise Error, "search text must not be empty" if query.empty?
|
|
267
|
+
|
|
268
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
269
|
+
changes = []
|
|
270
|
+
@sheets.fetch(name).each_in(top - 1, left - 1, bottom - 1, right - 1) do |point, input|
|
|
271
|
+
next unless input.is_a?(String) && input.include?(query)
|
|
272
|
+
|
|
273
|
+
updated = input.gsub(query, replacement.to_s)
|
|
274
|
+
changes << [point.row + 1, point.column + 1, updated] if updated != input
|
|
275
|
+
end
|
|
276
|
+
count = changes.length
|
|
277
|
+
set_many(changes, sheet: name)
|
|
278
|
+
count
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def sort(top, left, bottom, right, by:, ascending: true, sheet: @active_sheet)
|
|
282
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
283
|
+
by = strict_integer(by)
|
|
284
|
+
raise Error, "sort column must be within the selected range" unless by.between?(left, right)
|
|
285
|
+
cells = (bottom - top + 1) * (right - left + 1)
|
|
286
|
+
raise Error, "sort area exceeds #{MAX_SORT_CELLS} cells" if cells > MAX_SORT_CELLS
|
|
287
|
+
|
|
288
|
+
width = right - left + 1
|
|
289
|
+
rows = (top..bottom).map do |row|
|
|
290
|
+
values = Array.new(width) { |offset| input_at(row, left + offset, sheet: name) }
|
|
291
|
+
key = @engine.value(Furud::Reference.new(sheet: name, row: row, column: by))
|
|
292
|
+
[row, values, key]
|
|
293
|
+
end
|
|
294
|
+
populated, blank = rows.partition { |_row, _values, key| !key.nil? }
|
|
295
|
+
ordered = populated.sort do |a, b|
|
|
296
|
+
comparison = sort_key(a[2]) <=> sort_key(b[2])
|
|
297
|
+
comparison = -comparison if !ascending && comparison
|
|
298
|
+
comparison.zero? ? a[0] <=> b[0] : comparison
|
|
299
|
+
end + blank
|
|
300
|
+
changes = ordered.each_with_index.flat_map do |(source_row, values, _key), offset|
|
|
301
|
+
target_row = top + offset
|
|
302
|
+
values.each_with_index.map do |value, column_offset|
|
|
303
|
+
target_column = left + column_offset
|
|
304
|
+
if value.is_a?(String) && value.start_with?("=")
|
|
305
|
+
value = translate_formula(value,
|
|
306
|
+
from: Furud::Reference.new(sheet: name, row: source_row, column: target_column),
|
|
307
|
+
to: Furud::Reference.new(sheet: name, row: target_row, column: target_column))
|
|
308
|
+
end
|
|
309
|
+
[target_row, target_column, value]
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
formats = sort_cell_metadata(@formats, ordered, top, left, right, name)
|
|
313
|
+
comments = sort_cell_metadata(@comments, ordered, top, left, right, name)
|
|
314
|
+
data_changed = changes.any? { |row, column, value| @sheets.fetch(name)[row - 1, column - 1] != value }
|
|
315
|
+
metadata_changed = formats != @formats || comments != @comments
|
|
316
|
+
return self unless data_changed || metadata_changed
|
|
317
|
+
|
|
318
|
+
record_history unless data_changed
|
|
319
|
+
set_many(changes, sheet: name) if data_changed
|
|
320
|
+
@formats, @comments = formats, comments
|
|
321
|
+
notify_cells_changed(name)
|
|
322
|
+
self
|
|
323
|
+
rescue ArgumentError, TypeError
|
|
324
|
+
raise Error, "sort column must be an integer"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def remove_duplicates(top, left, bottom, right, sheet: @active_sheet)
|
|
328
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
329
|
+
cells = (bottom - top + 1) * (right - left + 1)
|
|
330
|
+
raise Error, "duplicate check exceeds #{MAX_SORT_CELLS} cells" if cells > MAX_SORT_CELLS
|
|
331
|
+
|
|
332
|
+
seen, duplicates = {}, []
|
|
333
|
+
(top..bottom).each do |row|
|
|
334
|
+
key = (left..right).map do |column|
|
|
335
|
+
@engine.value(Furud::Reference.new(sheet: name, row: row, column: column))
|
|
336
|
+
end.freeze
|
|
337
|
+
duplicates << row if seen.key?(key)
|
|
338
|
+
seen[key] = true
|
|
339
|
+
end
|
|
340
|
+
return 0 if duplicates.empty?
|
|
341
|
+
|
|
342
|
+
runs = duplicates.reverse.chunk_while { |previous, current| current == previous - 1 }
|
|
343
|
+
.map { |run| [run.last, run.length] }
|
|
344
|
+
named_ranges = @names.values
|
|
345
|
+
runs.each do |at, count|
|
|
346
|
+
preflight_structural_edit(:delete_rows, at, count, name, MAX_ROWS)
|
|
347
|
+
adjustment = Furud::Adjustment.new(type: :delete_rows, sheet: name, at: at, count: count)
|
|
348
|
+
named_ranges = named_ranges.map { |area| area.sheet == name ? adjusted_area(area, adjustment) : area }
|
|
349
|
+
raise Error, "cannot delete an entire named range" if named_ranges.any?(&:nil?)
|
|
350
|
+
end
|
|
351
|
+
record_history
|
|
352
|
+
runs.each do |at, count|
|
|
353
|
+
@engine.delete_rows(name, at, count)
|
|
354
|
+
@sheets[name] = @sheets.fetch(name).delete_rows(at - 1, count)
|
|
355
|
+
calculated = @calculated.fetch(name)
|
|
356
|
+
@calculated[name] = calculated.delete_rows(at - 1, count) if at - 1 <= calculated.row_count
|
|
357
|
+
adjust_metadata(:delete_rows, at, count, name)
|
|
358
|
+
end
|
|
359
|
+
sync_formula_inputs
|
|
360
|
+
update_calculated(@engine.recalculate)
|
|
361
|
+
notify_cells_changed(name)
|
|
362
|
+
duplicates.length
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def translate_formula(value, from:, to:)
|
|
366
|
+
return value unless value.is_a?(String) && value.start_with?("=")
|
|
367
|
+
|
|
368
|
+
ast = Furud::Formula.parse(value, origin: from)
|
|
369
|
+
invalid_reference = false
|
|
370
|
+
Furud::Formula.visit(ast) do |node|
|
|
371
|
+
next unless %i[reference qualified_reference].include?(node.type)
|
|
372
|
+
|
|
373
|
+
reference = node.value
|
|
374
|
+
row = reference.absolute_row ? reference.row : to.row + reference.row - from.row
|
|
375
|
+
column = reference.absolute_column ? reference.column : to.column + reference.column - from.column
|
|
376
|
+
invalid_reference ||= !row.between?(1, MAX_ROWS) || !column.between?(1, MAX_COLUMNS)
|
|
377
|
+
end
|
|
378
|
+
return "=#REF!" if invalid_reference
|
|
379
|
+
|
|
380
|
+
translated = Furud::Formula.translate(ast, from: from, to: to)
|
|
381
|
+
invalid_reference = Furud::Formula.references(translated).any? do |reference|
|
|
382
|
+
if reference.is_a?(Furud::Reference)
|
|
383
|
+
!reference.row.between?(1, MAX_ROWS) || !reference.column.between?(1, MAX_COLUMNS)
|
|
384
|
+
else
|
|
385
|
+
reference.top < 1 || reference.left < 1 || reference.bottom > MAX_ROWS || reference.right > MAX_COLUMNS
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
invalid_reference ? "=#REF!" : Furud::Formula.render(translated, origin: to)
|
|
389
|
+
rescue Furud::ParseError, ArgumentError
|
|
390
|
+
value
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def format_range(top, left, bottom, right, sheet: @active_sheet, **properties)
|
|
394
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
395
|
+
cells = (bottom - top + 1) * (right - left + 1)
|
|
396
|
+
raise Error, "format area exceeds #{MAX_FORMAT_CELLS} cells" if cells > MAX_FORMAT_CELLS
|
|
397
|
+
|
|
398
|
+
style = normalize_format(properties)
|
|
399
|
+
updated = @formats.dup
|
|
400
|
+
(top..bottom).each do |row|
|
|
401
|
+
(left..right).each do |column|
|
|
402
|
+
key = [name, row, column].freeze
|
|
403
|
+
value = updated.fetch(key, {}).merge(style).reject { |_key, entry| entry.nil? }
|
|
404
|
+
value.empty? ? updated.delete(key) : updated[key] = value.freeze
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
return self if updated == @formats
|
|
408
|
+
|
|
409
|
+
record_history
|
|
410
|
+
@formats = updated.freeze
|
|
411
|
+
notify_cells_changed(name, CellRange.new(top: top, left: left, bottom: bottom, right: right))
|
|
412
|
+
self
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def format_at(row, column, sheet: @active_sheet)
|
|
416
|
+
ref = reference(row, column, sheet)
|
|
417
|
+
@formats.fetch([ref.sheet, ref.row, ref.column], {})
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def presentation_at(row, column, sheet: @active_sheet)
|
|
421
|
+
value = self[row, column, sheet: sheet]
|
|
422
|
+
format = format_at(row, column, sheet: sheet)
|
|
423
|
+
text, number_style = Furud::Format.apply(value,
|
|
424
|
+
Furud::Format.parse(format.fetch(:number_format, "General")))
|
|
425
|
+
style = format.merge(conditional_style_at(row, column, value, sheet: sheet))
|
|
426
|
+
section_color = COLORS[number_style[:color]]
|
|
427
|
+
style = style.merge(color: section_color) if section_color && !style.key?(:color)
|
|
428
|
+
[text, style.freeze]
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def set_comment(row, column, text, sheet: @active_sheet)
|
|
432
|
+
ref = reference(row, column, sheet)
|
|
433
|
+
raise Error, "comment must be a String or nil" unless text.nil? || text.is_a?(String)
|
|
434
|
+
raise Error, "comment exceeds 5,000 characters" if text && text.length > 5_000
|
|
435
|
+
|
|
436
|
+
key = [ref.sheet, ref.row, ref.column].freeze
|
|
437
|
+
updated = @comments.dup
|
|
438
|
+
text.nil? || text.empty? ? updated.delete(key) : updated[key] = text.dup.freeze
|
|
439
|
+
return self if updated == @comments
|
|
440
|
+
|
|
441
|
+
record_history
|
|
442
|
+
@comments = updated.freeze
|
|
443
|
+
notify_cells_changed(ref.sheet, [[ref.row, ref.column].freeze])
|
|
444
|
+
self
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def comment_at(row, column, sheet: @active_sheet)
|
|
448
|
+
ref = reference(row, column, sheet)
|
|
449
|
+
@comments[[ref.sheet, ref.row, ref.column]]
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def add_conditional_format(top, left, bottom, right, operator:, value:, style:, sheet: @active_sheet)
|
|
453
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
454
|
+
operator = operator.to_sym if operator.is_a?(String) || operator.is_a?(Symbol)
|
|
455
|
+
raise Error, "unsupported conditional format operator" unless CONDITIONAL_OPERATORS.include?(operator)
|
|
456
|
+
raise Error, "conditional format style must be a Hash" unless style.is_a?(Hash)
|
|
457
|
+
|
|
458
|
+
rule = {area: Furud::Area.new(sheet: name, top: top, left: left, bottom: bottom, right: right),
|
|
459
|
+
operator: operator, value: value.is_a?(String) ? value.dup.freeze : value,
|
|
460
|
+
style: normalize_format(style)}.freeze
|
|
461
|
+
return self if @conditional_formats.include?(rule)
|
|
462
|
+
raise Error, "conditional format limit exceeded" if @conditional_formats.length >= MAX_CONDITIONAL_FORMATS
|
|
463
|
+
|
|
464
|
+
record_history
|
|
465
|
+
@conditional_formats = (@conditional_formats + [rule]).freeze
|
|
466
|
+
notify_cells_changed(name)
|
|
467
|
+
self
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def clear_conditional_formats(sheet: @active_sheet)
|
|
471
|
+
name = sheet.to_s
|
|
472
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
473
|
+
updated = @conditional_formats.reject { |rule| rule[:area].sheet == name }
|
|
474
|
+
return self if updated.length == @conditional_formats.length
|
|
475
|
+
|
|
476
|
+
record_history
|
|
477
|
+
@conditional_formats = updated.freeze
|
|
478
|
+
notify_cells_changed(name)
|
|
479
|
+
self
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def hide_rows(top, bottom = top, hidden: true, sheet: @active_sheet)
|
|
483
|
+
set_hidden_axis(:rows, top, bottom, hidden, sheet)
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def hide_columns(left, right = left, hidden: true, sheet: @active_sheet)
|
|
487
|
+
set_hidden_axis(:columns, left, right, hidden, sheet)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def row_hidden?(row, sheet: @active_sheet)
|
|
491
|
+
ref = reference(row, 1, sheet)
|
|
492
|
+
@hidden_rows.fetch(ref.sheet, Set.new).include?(ref.row)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def hidden_rows(sheet: @active_sheet)
|
|
496
|
+
name = sheet.to_s
|
|
497
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
498
|
+
|
|
499
|
+
@hidden_rows.fetch(name, Set.new).to_a.sort.freeze
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def column_hidden?(column, sheet: @active_sheet)
|
|
503
|
+
ref = reference(1, column, sheet)
|
|
504
|
+
@hidden_columns.fetch(ref.sheet, Set.new).include?(ref.column)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def hidden_columns(sheet: @active_sheet)
|
|
508
|
+
name = sheet.to_s
|
|
509
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
510
|
+
|
|
511
|
+
@hidden_columns.fetch(name, Set.new).to_a.sort.freeze
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def clear_hidden(axis, sheet: @active_sheet)
|
|
515
|
+
state = {rows: @hidden_rows, columns: @hidden_columns}.fetch(axis)
|
|
516
|
+
name = sheet.to_s
|
|
517
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
518
|
+
return self unless state.key?(name)
|
|
519
|
+
|
|
520
|
+
record_history
|
|
521
|
+
axis == :rows ? @hidden_rows = state.reject { |key, _| key == name }.freeze :
|
|
522
|
+
@hidden_columns = state.reject { |key, _| key == name }.freeze
|
|
523
|
+
self
|
|
524
|
+
rescue KeyError
|
|
525
|
+
raise Error, "hidden axis must be rows or columns"
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def set_frozen_panes(rows:, columns:, sheet: @active_sheet)
|
|
529
|
+
name = sheet.to_s
|
|
530
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
531
|
+
rows, columns = strict_integer(rows), strict_integer(columns)
|
|
532
|
+
unless rows.between?(0, MAX_ROWS + 1) && columns.between?(0, MAX_COLUMNS + 1)
|
|
533
|
+
raise Error, "frozen pane counts are outside sheet limits"
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
panes = {rows: rows, columns: columns}.freeze
|
|
537
|
+
return self if @frozen_panes[name] == panes
|
|
538
|
+
|
|
539
|
+
record_history
|
|
540
|
+
@frozen_panes = @frozen_panes.merge(name => panes).freeze
|
|
541
|
+
self
|
|
542
|
+
rescue ArgumentError, TypeError
|
|
543
|
+
raise Error, "frozen pane counts must be integers"
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def frozen_panes(sheet: @active_sheet)
|
|
547
|
+
name = sheet.to_s
|
|
548
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
549
|
+
|
|
550
|
+
panes = @frozen_panes.fetch(name, {rows: 1, columns: 1})
|
|
551
|
+
[panes[:rows], panes[:columns]].freeze
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def set_print_area(top, left, bottom, right, sheet: @active_sheet)
|
|
555
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
556
|
+
area = Furud::Area.new(sheet: name, top: top, left: left, bottom: bottom, right: right)
|
|
557
|
+
return area if @print_areas[name] == area
|
|
558
|
+
|
|
559
|
+
record_history
|
|
560
|
+
@print_areas = @print_areas.merge(name => area).freeze
|
|
561
|
+
area
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def clear_print_area(sheet: @active_sheet)
|
|
565
|
+
name = sheet.to_s
|
|
566
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
567
|
+
return self unless @print_areas.key?(name)
|
|
568
|
+
|
|
569
|
+
record_history
|
|
570
|
+
@print_areas = @print_areas.reject { |sheet_name, _| sheet_name == name }.freeze
|
|
571
|
+
self
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def print_area(sheet: @active_sheet)
|
|
575
|
+
name = sheet.to_s
|
|
576
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
577
|
+
|
|
578
|
+
@print_areas[name]
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def insert_rows(at, count = 1, sheet: @active_sheet)
|
|
582
|
+
structural_edit(:insert_rows, at, count, sheet)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def delete_rows(at, count = 1, sheet: @active_sheet)
|
|
586
|
+
structural_edit(:delete_rows, at, count, sheet)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def insert_columns(at, count = 1, sheet: @active_sheet)
|
|
590
|
+
structural_edit(:insert_columns, at, count, sheet)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def delete_columns(at, count = 1, sheet: @active_sheet)
|
|
594
|
+
structural_edit(:delete_columns, at, count, sheet)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def undo
|
|
598
|
+
return false if @history.empty?
|
|
599
|
+
|
|
600
|
+
@redo << snapshot
|
|
601
|
+
restore(@history.pop)
|
|
602
|
+
notify_cells_changed(nil)
|
|
603
|
+
true
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def redo
|
|
607
|
+
return false if @redo.empty?
|
|
608
|
+
|
|
609
|
+
@history << snapshot
|
|
610
|
+
restore(@redo.pop)
|
|
611
|
+
notify_cells_changed(nil)
|
|
612
|
+
true
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def source_digest_for(path)
|
|
616
|
+
File.expand_path(path) == @source_path ? @source_digest : :absent
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
private
|
|
620
|
+
|
|
621
|
+
def bind_source(path, digest)
|
|
622
|
+
@source_path, @source_digest = File.expand_path(path).freeze, digest
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def reference(row, column, name)
|
|
626
|
+
row = strict_integer(row)
|
|
627
|
+
column = strict_integer(column)
|
|
628
|
+
name = name.to_s
|
|
629
|
+
raise Error, "row must be between 1 and #{MAX_ROWS}" unless row.between?(1, MAX_ROWS)
|
|
630
|
+
raise Error, "column must be between 1 and #{MAX_COLUMNS}" unless column.between?(1, MAX_COLUMNS)
|
|
631
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
632
|
+
|
|
633
|
+
Furud::Reference.new(sheet: name, row: row, column: column)
|
|
634
|
+
rescue ArgumentError, TypeError
|
|
635
|
+
raise Error, "row and column must be integers"
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def range_coordinates(top, left, bottom, right, name)
|
|
639
|
+
top, left, bottom, right = [top, left, bottom, right].map { |value| strict_integer(value) }
|
|
640
|
+
name = (name || @active_sheet).to_s
|
|
641
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
642
|
+
raise Error, "range coordinates are outside sheet limits" unless top.between?(1, MAX_ROWS) && bottom.between?(top, MAX_ROWS) && left.between?(1, MAX_COLUMNS) && right.between?(left, MAX_COLUMNS)
|
|
643
|
+
|
|
644
|
+
[top, left, bottom, right, name]
|
|
645
|
+
rescue ArgumentError, TypeError
|
|
646
|
+
raise Error, "range coordinates must be integers"
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def validate_name(value)
|
|
650
|
+
name = value.to_s
|
|
651
|
+
raise Error, "sheet name must not be empty" if name.empty?
|
|
652
|
+
raise Error, "sheet name must be at most 31 characters" if name.length > 31
|
|
653
|
+
raise Error, "sheet name contains an invalid character" if name.match?(/[\\\/?*\[\]:]/)
|
|
654
|
+
|
|
655
|
+
name.freeze
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def structural_edit(type, at, count, name)
|
|
659
|
+
at, count, name = strict_integer(at), strict_integer(count), name.to_s
|
|
660
|
+
raise Error, "count must be positive" unless count.positive?
|
|
661
|
+
raise Error, "unknown sheet: #{name}" unless @sheets.key?(name)
|
|
662
|
+
extent = type.to_s.end_with?("rows") ? MAX_ROWS : MAX_COLUMNS
|
|
663
|
+
raise Error, "insertion exceeds sheet limit" if type.to_s.start_with?("insert") && at + count - 1 > extent
|
|
664
|
+
raise Error, "deletion exceeds sheet limit" if type.to_s.start_with?("delete") && at + count - 1 > extent
|
|
665
|
+
raise Error, "index is outside sheet limits" unless at.between?(1, extent)
|
|
666
|
+
preflight_structural_edit(type, at, count, name, extent)
|
|
667
|
+
|
|
668
|
+
record_history
|
|
669
|
+
@engine.public_send(type, name, at, count)
|
|
670
|
+
denebola_at = at - 1
|
|
671
|
+
current = @sheets.fetch(name)
|
|
672
|
+
extent = type.to_s.end_with?("rows") ? current.row_count : current.column_count
|
|
673
|
+
@sheets[name] = current.public_send(type, denebola_at, count) if denebola_at <= extent
|
|
674
|
+
calculated = @calculated.fetch(name)
|
|
675
|
+
calculated_extent = type.to_s.end_with?("rows") ? calculated.row_count : calculated.column_count
|
|
676
|
+
@calculated[name] = calculated.public_send(type, denebola_at, count) if denebola_at <= calculated_extent
|
|
677
|
+
adjust_metadata(type, at, count, name)
|
|
678
|
+
sync_formula_inputs
|
|
679
|
+
update_calculated(@engine.recalculate)
|
|
680
|
+
notify_cells_changed(name)
|
|
681
|
+
self
|
|
682
|
+
rescue ArgumentError, TypeError
|
|
683
|
+
raise Error, "index and count must be integers"
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def strict_integer(value)
|
|
687
|
+
return value if value.is_a?(Integer)
|
|
688
|
+
return Integer(value, 10) if value.is_a?(String) && value.match?(/\A[+-]?\d+\z/)
|
|
689
|
+
|
|
690
|
+
raise ArgumentError, "expected an integer"
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def sync_formula_inputs
|
|
694
|
+
@sheets.each do |name, current|
|
|
695
|
+
changes = []
|
|
696
|
+
current.each_in(0, 0, current.row_count - 1, current.column_count - 1) do |point, input|
|
|
697
|
+
next unless input.is_a?(String) && input.start_with?("=")
|
|
698
|
+
|
|
699
|
+
new_formula = @engine.formula(Furud::Reference.new(sheet: name, row: point.row + 1, column: point.column + 1))
|
|
700
|
+
changes << [point.row, point.column, new_formula] if new_formula && new_formula != input
|
|
701
|
+
end if current.row_count.positive? && current.column_count.positive?
|
|
702
|
+
@sheets[name] = update_sheet(current, changes) unless changes.empty?
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def record_history
|
|
707
|
+
@history << snapshot
|
|
708
|
+
@redo.clear
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def snapshot = [@sheets.dup, @active_sheet, @names.dup, @formats.dup, @comments.dup,
|
|
712
|
+
@conditional_formats.dup, @hidden_rows.dup, @hidden_columns.dup, @frozen_panes.dup, @print_areas.dup]
|
|
713
|
+
|
|
714
|
+
def restore(state)
|
|
715
|
+
@sheets, @active_sheet, @names, @formats, @comments, @conditional_formats, @hidden_rows, @hidden_columns, @frozen_panes, @print_areas = state
|
|
716
|
+
rebuild_engine
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def rebuild_engine
|
|
720
|
+
@engine = Furud::Engine.new(@source)
|
|
721
|
+
@names.each { |name, area| @engine.define_name(name, area) }
|
|
722
|
+
@calculated = @sheets.to_h { |name, _sheet| [name, Denebola::Sheet.new] }
|
|
723
|
+
@sheets.each do |name, current|
|
|
724
|
+
next if current.row_count.zero? || current.column_count.zero?
|
|
725
|
+
|
|
726
|
+
current.each_in(0, 0, current.row_count - 1, current.column_count - 1) do |point, value|
|
|
727
|
+
@engine.set(Furud::Reference.new(sheet: name, row: point.row + 1, column: point.column + 1), value)
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
update_calculated(@engine.recalculate)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def load_rows(rows, name)
|
|
734
|
+
changes = []
|
|
735
|
+
rows.each_with_index do |row, row_index|
|
|
736
|
+
raise Error, "CSV exceeds sheet row limit" if row_index >= MAX_ROWS
|
|
737
|
+
row.each_with_index do |value, column_index|
|
|
738
|
+
raise Error, "CSV exceeds sheet column limit" if column_index >= MAX_COLUMNS
|
|
739
|
+
next if value.nil?
|
|
740
|
+
|
|
741
|
+
ref = Furud::Reference.new(sheet: name, row: row_index + 1, column: column_index + 1)
|
|
742
|
+
validate_formula(value, ref)
|
|
743
|
+
changes << [row_index, column_index, value]
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
current = @sheets.fetch(name)
|
|
747
|
+
@sheets[name] = update_sheet(current, changes) unless changes.empty?
|
|
748
|
+
changes.each do |row, column, value|
|
|
749
|
+
@engine.set(Furud::Reference.new(sheet: name, row: row + 1, column: column + 1), value)
|
|
750
|
+
end
|
|
751
|
+
update_calculated(@engine.recalculate)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def update_calculated(references)
|
|
755
|
+
changes = references.group_by(&:sheet)
|
|
756
|
+
changes.each do |name, refs|
|
|
757
|
+
current = @calculated[name]
|
|
758
|
+
next unless current
|
|
759
|
+
|
|
760
|
+
edits = refs.map do |ref|
|
|
761
|
+
value = @engine.value(ref)
|
|
762
|
+
[ref.row - 1, ref.column - 1, value]
|
|
763
|
+
end
|
|
764
|
+
@calculated[name] = update_sheet(current, edits)
|
|
765
|
+
notify_cells_changed(name, refs.map { |ref| [ref.row, ref.column].freeze })
|
|
766
|
+
end
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def notify_cells_changed(sheet, cells = nil)
|
|
770
|
+
@change_observers.dup.each { |observer| observer.call(sheet, cells) }
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def update_sheet(current, changes)
|
|
774
|
+
return current if changes.empty?
|
|
775
|
+
return current.set_many(changes) if current.respond_to?(:set_many)
|
|
776
|
+
|
|
777
|
+
changes.reduce(current) do |sheet, (row, column, value)|
|
|
778
|
+
value.nil? ? sheet.delete(row, column) : sheet.set(row, column, value)
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def validate_formula(value, reference)
|
|
783
|
+
Furud::Formula.parse(value, origin: reference) if value.is_a?(String) && value.start_with?("=")
|
|
784
|
+
rescue Furud::ParseError => error
|
|
785
|
+
raise Error, "invalid formula: #{error.message}"
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def sheet_referenced?(name)
|
|
789
|
+
@sheets.any? do |sheet_name, current|
|
|
790
|
+
next false if current.row_count.zero? || current.column_count.zero?
|
|
791
|
+
|
|
792
|
+
referenced = false
|
|
793
|
+
current.each_in(0, 0, current.row_count - 1, current.column_count - 1) do |point, value|
|
|
794
|
+
next unless value.is_a?(String) && value.start_with?("=")
|
|
795
|
+
|
|
796
|
+
formula_ref = Furud::Reference.new(sheet: sheet_name, row: point.row + 1, column: point.column + 1)
|
|
797
|
+
referenced ||= Furud::Formula.references(Furud::Formula.parse(value, origin: formula_ref)).any? do |reference|
|
|
798
|
+
reference.sheet == name
|
|
799
|
+
end
|
|
800
|
+
break if referenced
|
|
801
|
+
end
|
|
802
|
+
referenced
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def preflight_structural_edit(type, at, count, name, limit)
|
|
807
|
+
current = @sheets.fetch(name)
|
|
808
|
+
calculated = @calculated.fetch(name)
|
|
809
|
+
occupied_extent = type.to_s.end_with?("rows") ? [current.row_count, calculated.row_count].max : [current.column_count, calculated.column_count].max
|
|
810
|
+
metadata_extent = [@formats, @comments].flat_map do |entries|
|
|
811
|
+
entries.keys.filter_map do |sheet_name, row, column|
|
|
812
|
+
next unless sheet_name == name
|
|
813
|
+
type.to_s.end_with?("rows") ? row : column
|
|
814
|
+
end
|
|
815
|
+
end.max || 0
|
|
816
|
+
area_extent = (@names.values + @conditional_formats.map { |rule| rule[:area] }).filter_map do |area|
|
|
817
|
+
next unless area.sheet == name
|
|
818
|
+
type.to_s.end_with?("rows") ? area.bottom : area.right
|
|
819
|
+
end.max || 0
|
|
820
|
+
print_extent = @print_areas.values.filter_map do |area|
|
|
821
|
+
next unless area.sheet == name
|
|
822
|
+
type.to_s.end_with?("rows") ? area.bottom : area.right
|
|
823
|
+
end.max || 0
|
|
824
|
+
area_extent = [area_extent, print_extent].max
|
|
825
|
+
hidden_extent = (type.to_s.end_with?("rows") ? @hidden_rows : @hidden_columns)
|
|
826
|
+
.fetch(name, Set.new).max || 0
|
|
827
|
+
panes = @frozen_panes.fetch(name, {rows: 1, columns: 1})
|
|
828
|
+
frozen_extent = panes.fetch(type.to_s.end_with?("rows") ? :rows : :columns) - 1
|
|
829
|
+
occupied_extent = [occupied_extent, metadata_extent, area_extent, hidden_extent, frozen_extent].max
|
|
830
|
+
|
|
831
|
+
if type.to_s.start_with?("delete")
|
|
832
|
+
operation = Furud::Adjustment.new(type: type, sheet: name, at: at, count: count)
|
|
833
|
+
if @names.values.any? { |area| area.sheet == name && adjusted_area(area, operation).nil? }
|
|
834
|
+
raise Error, "cannot delete an entire named range"
|
|
835
|
+
end
|
|
836
|
+
return
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
return unless at - 1 < occupied_extent
|
|
840
|
+
raise Error, "insertion exceeds sheet limit" if occupied_extent + count > limit
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def normalize_format(properties)
|
|
844
|
+
unknown = properties.keys.map(&:to_sym) - FORMAT_KEYS
|
|
845
|
+
raise Error, "unsupported cell format: #{unknown.first}" unless unknown.empty?
|
|
846
|
+
|
|
847
|
+
properties.to_h do |key, value|
|
|
848
|
+
key = key.to_sym
|
|
849
|
+
case key
|
|
850
|
+
when :number_format
|
|
851
|
+
raise Error, "number format must be a String or nil" unless value.nil? || (value.is_a?(String) && value.length <= 128)
|
|
852
|
+
when :font_family
|
|
853
|
+
raise Error, "font family must be a nonempty String or nil" unless value.nil? || (value.is_a?(String) && !value.empty? && value.length <= 100)
|
|
854
|
+
when :font_size
|
|
855
|
+
raise Error, "font size must be between 6 and 72" unless value.nil? || (value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value.between?(6, 72))
|
|
856
|
+
value = value&.to_f
|
|
857
|
+
when :bold, :italic
|
|
858
|
+
raise Error, "#{key} must be true, false, or nil" unless value.nil? || value == true || value == false
|
|
859
|
+
when :color, :background, :border_color
|
|
860
|
+
raise Error, "#{key} must be a six-digit hex color or nil" unless value.nil? || (value.is_a?(String) && value.match?(/\A#[0-9a-fA-F]{6}\z/))
|
|
861
|
+
when :border_width
|
|
862
|
+
raise Error, "border width must be between 0 and 4" unless value.nil? || (value.is_a?(Numeric) && !value.is_a?(Complex) && value.finite? && value.between?(0, 4))
|
|
863
|
+
value = value&.to_f
|
|
864
|
+
when :horizontal_alignment
|
|
865
|
+
raise Error, "horizontal alignment must be left, center, right, or nil" unless value.nil? || ((value.is_a?(String) || value.is_a?(Symbol)) && %i[left center right].include?(value.to_sym))
|
|
866
|
+
value = value&.to_sym
|
|
867
|
+
when :vertical_alignment
|
|
868
|
+
raise Error, "vertical alignment must be top, middle, bottom, or nil" unless value.nil? || ((value.is_a?(String) || value.is_a?(Symbol)) && %i[top middle bottom].include?(value.to_sym))
|
|
869
|
+
value = value&.to_sym
|
|
870
|
+
end
|
|
871
|
+
[key, value.is_a?(String) ? value.dup.freeze : value]
|
|
872
|
+
end.freeze
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def conditional_style_at(row, column, value, sheet:)
|
|
876
|
+
@conditional_formats.each_with_object({}) do |rule, style|
|
|
877
|
+
area = rule[:area]
|
|
878
|
+
next unless area.sheet == sheet && row.between?(area.top, area.bottom) && column.between?(area.left, area.right)
|
|
879
|
+
next unless conditional_match?(value, rule[:operator], rule[:value])
|
|
880
|
+
|
|
881
|
+
style.merge!(rule[:style])
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def conditional_match?(value, operator, expected)
|
|
886
|
+
return value.to_s.downcase.include?(expected.to_s.downcase) if operator == :contains
|
|
887
|
+
return false if value.nil?
|
|
888
|
+
return false if value.is_a?(Numeric) != expected.is_a?(Numeric) && !%i[equal not_equal].include?(operator)
|
|
889
|
+
|
|
890
|
+
comparison = value <=> expected
|
|
891
|
+
return comparison.nil? ? (operator == :not_equal && value != expected) :
|
|
892
|
+
{greater_than: comparison.positive?, greater_than_or_equal: comparison >= 0,
|
|
893
|
+
less_than: comparison.negative?, less_than_or_equal: comparison <= 0,
|
|
894
|
+
equal: comparison.zero?, not_equal: !comparison.zero?}.fetch(operator)
|
|
895
|
+
rescue ArgumentError, TypeError
|
|
896
|
+
false
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def adjust_metadata(type, at, count, sheet)
|
|
900
|
+
@formats = move_cell_metadata(@formats, type, at, count, sheet).freeze
|
|
901
|
+
@comments = move_cell_metadata(@comments, type, at, count, sheet).freeze
|
|
902
|
+
@hidden_rows = move_hidden_indices(@hidden_rows, type, at, count, sheet, :rows).freeze
|
|
903
|
+
@hidden_columns = move_hidden_indices(@hidden_columns, type, at, count, sheet, :columns).freeze
|
|
904
|
+
operation = Furud::Adjustment.new(type: type, sheet: sheet, at: at, count: count)
|
|
905
|
+
@names = @names.to_h do |name, area|
|
|
906
|
+
[name, area.sheet == sheet ? adjusted_area(area, operation) : area]
|
|
907
|
+
end.freeze
|
|
908
|
+
@conditional_formats = @conditional_formats.filter_map do |rule|
|
|
909
|
+
area = adjusted_area(rule[:area], operation)
|
|
910
|
+
area && rule.merge(area: area).freeze
|
|
911
|
+
end.freeze
|
|
912
|
+
@print_areas = @print_areas.filter_map do |sheet_name, area|
|
|
913
|
+
adjusted = sheet_name == sheet ? adjusted_area(area, operation) : area
|
|
914
|
+
[sheet_name, adjusted] if adjusted
|
|
915
|
+
end.to_h.freeze
|
|
916
|
+
panes = @frozen_panes.fetch(sheet, {rows: 1, columns: 1})
|
|
917
|
+
axis = type.to_s.end_with?("rows") ? :rows : :columns
|
|
918
|
+
@frozen_panes = @frozen_panes.merge(sheet => panes.merge(axis => adjust_frozen_count(panes.fetch(axis), type, at, count)).freeze).freeze
|
|
919
|
+
@names.each { |name, area| @engine.define_name(name, area) }
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def adjust_frozen_count(frozen_count, type, at, count)
|
|
923
|
+
frozen_cells = frozen_count - 1 # Grid row/column zero is the header.
|
|
924
|
+
if type.to_s.start_with?("insert")
|
|
925
|
+
at <= frozen_cells ? frozen_count + count : frozen_count
|
|
926
|
+
elsif at <= frozen_cells
|
|
927
|
+
frozen_count - [frozen_cells - at + 1, count].min
|
|
928
|
+
else
|
|
929
|
+
frozen_count
|
|
930
|
+
end
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
def move_hidden_indices(hidden, type, at, count, target_sheet, axis)
|
|
934
|
+
insertion = type.to_s.start_with?("insert")
|
|
935
|
+
hidden.to_h do |sheet_name, indices|
|
|
936
|
+
moved = indices.filter_map do |index|
|
|
937
|
+
next index unless sheet_name == target_sheet && ((axis == :rows) == type.to_s.end_with?("rows"))
|
|
938
|
+
next index + count if insertion && index >= at
|
|
939
|
+
next if !insertion && index.between?(at, at + count - 1)
|
|
940
|
+
next index - count if !insertion && index > at + count - 1
|
|
941
|
+
|
|
942
|
+
index
|
|
943
|
+
end
|
|
944
|
+
[sheet_name, moved.to_set.freeze]
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def set_hidden_axis(axis, first, last, hidden, sheet)
|
|
949
|
+
raise Error, "hidden state must be boolean" unless hidden == true || hidden == false
|
|
950
|
+
limit = axis == :rows ? MAX_ROWS : MAX_COLUMNS
|
|
951
|
+
first, last = strict_integer(first), strict_integer(last)
|
|
952
|
+
sheet = sheet.to_s
|
|
953
|
+
raise Error, "unknown sheet: #{sheet}" unless @sheets.key?(sheet)
|
|
954
|
+
raise Error, "hidden range is outside sheet limits" unless first.between?(1, limit) && last.between?(first, limit)
|
|
955
|
+
raise Error, "hidden range exceeds #{MAX_HIDDEN_CELLS} entries" if last - first + 1 > MAX_HIDDEN_CELLS
|
|
956
|
+
|
|
957
|
+
state = axis == :rows ? @hidden_rows : @hidden_columns
|
|
958
|
+
indices = state.fetch(sheet, Set.new)
|
|
959
|
+
updated_indices = indices.dup
|
|
960
|
+
hidden ? (first..last).each { |index| updated_indices.add(index) } : (first..last).each { |index| updated_indices.delete(index) }
|
|
961
|
+
return self if updated_indices == indices
|
|
962
|
+
|
|
963
|
+
record_history
|
|
964
|
+
updated = state.merge(sheet => updated_indices.freeze).freeze
|
|
965
|
+
axis == :rows ? @hidden_rows = updated : @hidden_columns = updated
|
|
966
|
+
self
|
|
967
|
+
rescue ArgumentError, TypeError
|
|
968
|
+
raise Error, "hidden range coordinates must be integers"
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
def move_cell_metadata(metadata, type, at, count, target_sheet)
|
|
972
|
+
axis = type.to_s.end_with?("rows") ? 1 : 2
|
|
973
|
+
insertion = type.to_s.start_with?("insert")
|
|
974
|
+
deleted_end = at + count - 1
|
|
975
|
+
metadata.each_with_object({}) do |((sheet_name, row, column), value), moved|
|
|
976
|
+
coordinate = axis == 1 ? row : column
|
|
977
|
+
if sheet_name == target_sheet
|
|
978
|
+
if insertion
|
|
979
|
+
coordinate += count if coordinate >= at
|
|
980
|
+
elsif coordinate.between?(at, deleted_end)
|
|
981
|
+
next
|
|
982
|
+
elsif coordinate > deleted_end
|
|
983
|
+
coordinate -= count
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
key = axis == 1 ? [sheet_name, coordinate, column] : [sheet_name, row, coordinate]
|
|
987
|
+
moved[key.freeze] = value
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def sort_cell_metadata(metadata, ordered, top, left, right, sheet)
|
|
992
|
+
updated = metadata.reject do |(sheet_name, row, column), _value|
|
|
993
|
+
sheet_name == sheet && row.between?(top, top + ordered.length - 1) && column.between?(left, right)
|
|
994
|
+
end
|
|
995
|
+
ordered.each_with_index do |(source_row, _values, _key), offset|
|
|
996
|
+
target_row = top + offset
|
|
997
|
+
(left..right).each do |column|
|
|
998
|
+
value = metadata[[sheet, source_row, column]]
|
|
999
|
+
updated[[sheet, target_row, column].freeze] = value if value
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
updated.freeze
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def adjusted_area(area, operation)
|
|
1006
|
+
return area unless area.sheet == operation.sheet
|
|
1007
|
+
|
|
1008
|
+
axis = operation.type.to_s.end_with?("rows") ? :row : :column
|
|
1009
|
+
first = area.public_send(axis == :row ? :top : :left)
|
|
1010
|
+
last = area.public_send(axis == :row ? :bottom : :right)
|
|
1011
|
+
if operation.type.to_s.start_with?("insert")
|
|
1012
|
+
if operation.at <= first
|
|
1013
|
+
first += operation.count
|
|
1014
|
+
last += operation.count
|
|
1015
|
+
elsif operation.at <= last
|
|
1016
|
+
last += operation.count
|
|
1017
|
+
end
|
|
1018
|
+
else
|
|
1019
|
+
deleted_end = operation.at + operation.count - 1
|
|
1020
|
+
overlap = [last, deleted_end].min - [first, operation.at].max + 1
|
|
1021
|
+
if overlap >= last - first + 1
|
|
1022
|
+
return nil
|
|
1023
|
+
elsif overlap.positive?
|
|
1024
|
+
last -= overlap
|
|
1025
|
+
first = operation.at if first >= operation.at
|
|
1026
|
+
elsif first > deleted_end
|
|
1027
|
+
first -= operation.count
|
|
1028
|
+
last -= operation.count
|
|
1029
|
+
end
|
|
1030
|
+
end
|
|
1031
|
+
axis == :row ? Furud::Area.new(sheet: area.sheet, top: first, left: area.left, bottom: last, right: area.right) :
|
|
1032
|
+
Furud::Area.new(sheet: area.sheet, top: area.top, left: first, bottom: area.bottom, right: last)
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def sort_key(value)
|
|
1036
|
+
case value
|
|
1037
|
+
when Numeric
|
|
1038
|
+
number = value.to_f unless value.is_a?(Complex)
|
|
1039
|
+
[number&.finite? ? 0 : 2, number&.finite? ? number : value.to_s.downcase]
|
|
1040
|
+
when Date, Time then [1, value.to_s]
|
|
1041
|
+
else [2, value.to_s.downcase]
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
end
|