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.
@@ -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