rukbat 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +22 -3
- data/lib/rukbat/grid_view.rb +80 -2
- data/lib/rukbat/version.rb +1 -1
- data/lib/rukbat/workbook.rb +277 -15
- data/sig/rukbat.rbs +25 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd6ea385045b568f6f5bae52724b003fcff14728cbcd3906eff5f7fd93e16ec9
|
|
4
|
+
data.tar.gz: 3e89c333f415e01537198f72e4c54e668da7664d4a3d561e867c2ae95cb6445f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 546c1d4e7ee470e5cd0b58bd3f28a4c8d74fb50eacefcb4c3875569409770a1195c2da8bcbe83ea61e985f6f093813100baf441db040a91b3baf97871c1d9a0d
|
|
7
|
+
data.tar.gz: 23b96cef205cd02e05b743423c9c7e2b3220487fabef9810b4389d807cf93aa4b7f959dc5ce3eb0d2c7e97d75bc0b2b2d3117eb4e4b184aabd4be420cd04ceaa
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-09-24
|
|
4
|
+
|
|
5
|
+
- Add range-based whole-number input validation with undo support.
|
|
6
|
+
- Add static pivot tables with sum and count aggregation.
|
|
7
|
+
|
|
3
8
|
## 0.2.0 — 2026-09-24
|
|
4
9
|
|
|
5
10
|
- Add donut, scatter, area, stacked-area, and stacked-bar chart options.
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Furud formula engine.
|
|
|
7
7
|
The current implementation includes a virtualized million-row grid, formula
|
|
8
8
|
editing and completion, multi-sheet formulas, range summaries, undo/redo,
|
|
9
9
|
structural row/column edits, formatting, sorting, filtering, duplicate removal,
|
|
10
|
-
find/replace, comments, named ranges, conditional formatting, line/bar/pie,
|
|
10
|
+
find/replace, comments, named ranges, conditional formatting, basic pivot tables, line/bar/pie,
|
|
11
11
|
donut/scatter/area/stacked charts, CSV/TSV import/export, and searchable PDF
|
|
12
12
|
export. On arm64 macOS
|
|
13
13
|
with Ruby 4.0.6, the million-row integrated scroll benchmark measured 11.383 ms
|
|
@@ -71,6 +71,26 @@ The toolbar provides common number, font, alignment, fill, and border formats;
|
|
|
71
71
|
sorting, filtering, duplicate removal, search/replace, comments, named ranges,
|
|
72
72
|
positive-value highlighting, freeze/unfreeze panes, print areas, and line, bar,
|
|
73
73
|
pie, donut, scatter, area, stacked-area, and stacked-bar charts.
|
|
74
|
+
Select a range, enter inclusive minimum/maximum values, and choose **Apply whole-number rule**
|
|
75
|
+
to require each nonblank edited value (including a formula's calculated result) to be
|
|
76
|
+
a whole number in that range. Invalid edits are rejected atomically; clearing cells is
|
|
77
|
+
allowed. **Clear rule** removes validation from the selected range, including only the
|
|
78
|
+
selected portion of a larger rule. Rules follow row/column insertions and deletions and
|
|
79
|
+
are included in undo/redo. Existing values are not retroactively changed when a rule is
|
|
80
|
+
applied. Input validations are session metadata: CSV/TSV has no place to store them, so
|
|
81
|
+
they reset when the workbook is reopened. A workbook supports up to 256 validation
|
|
82
|
+
ranges.
|
|
83
|
+
For a pivot, select a source rectangle including its header row, enter the one-based
|
|
84
|
+
key and value column positions relative to that selection, choose **Sum** or **Count**,
|
|
85
|
+
and choose **Create pivot**. Formula results are used; groups match exact key values
|
|
86
|
+
(text is case-sensitive), retain their first-seen order, and blank keys form their own
|
|
87
|
+
group. **Sum** adds finite real numbers only, and **Count**
|
|
88
|
+
counts nonblank values in the selected value column. The output is static data in a new
|
|
89
|
+
`Pivot` sheet (or the next unused `Pivot2`, etc.), not a live link to the source. At most
|
|
90
|
+
100,000 data rows and 10,000 groups can be summarized.
|
|
91
|
+
Creating a pivot leaves its source sheet active. CSV/TSV saves the active sheet only;
|
|
92
|
+
select the pivot sheet before saving if you want to export the summary instead. One
|
|
93
|
+
Undo removes the generated pivot sheet.
|
|
74
94
|
Freeze panes are kept per sheet and restored by undo/redo; the selected cell and
|
|
75
95
|
the row/column headers before it stay visible while scrolling. **Unfreeze** removes
|
|
76
96
|
all frozen rows and columns. Clear highlights removes conditional formatting
|
|
@@ -79,8 +99,7 @@ Select a range and choose **Set print area** to constrain PDF export; **Clear
|
|
|
79
99
|
print area** restores full-sheet output. **Export PDF** prompts for an embeddable
|
|
80
100
|
font and output path, applying the current print area. The CLI can also export
|
|
81
101
|
with `bundle exec rukbat --export-pdf report.pdf --font /path/to/font.ttf`.
|
|
82
|
-
Print areas are session metadata
|
|
83
|
-
when the workbook is reopened.
|
|
102
|
+
Print areas are also session metadata and reset when the workbook is reopened.
|
|
84
103
|
PDF export embeds the supplied font, resolves formatted font families from the
|
|
85
104
|
system font database, and applies bold/italic along with cell formatting; bold
|
|
86
105
|
and italic are synthesized in the PDF when the selected face has no matching
|
data/lib/rukbat/grid_view.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Rukbat
|
|
|
6
6
|
MAX_FILL_CELLS = 100_000
|
|
7
7
|
|
|
8
8
|
attr_reader :grid, :formula_field, :active_cell, :status, :completion_session,
|
|
9
|
-
:completion_candidate
|
|
9
|
+
:completion_candidate, :validation_min_field, :validation_max_field
|
|
10
10
|
|
|
11
11
|
def initialize(workbook, on_save: nil)
|
|
12
12
|
super()
|
|
@@ -61,6 +61,19 @@ module Rukbat
|
|
|
61
61
|
@set_print_area_button = UI::Button.new("Set print area", size: :sm, variant: :ghost).on_click { set_print_area }
|
|
62
62
|
@clear_print_area_button = UI::Button.new("Clear print area", size: :sm, variant: :ghost).on_click { clear_print_area }
|
|
63
63
|
@export_pdf_button = UI::Button.new("Export PDF", size: :sm, variant: :ghost).on_click { export_pdf }
|
|
64
|
+
@validation_min_field = UI::TextField.new("")
|
|
65
|
+
@validation_max_field = UI::TextField.new("")
|
|
66
|
+
@set_validation_button = UI::Button.new("Apply whole-number rule", size: :sm, variant: :ghost)
|
|
67
|
+
.on_click { apply_whole_number_validation }
|
|
68
|
+
@clear_validation_button = UI::Button.new("Clear rule", size: :sm, variant: :ghost)
|
|
69
|
+
.on_click { clear_input_validation }
|
|
70
|
+
@pivot_key_field = UI::TextField.new("1")
|
|
71
|
+
@pivot_value_field = UI::TextField.new("2")
|
|
72
|
+
@pivot_aggregate = :sum
|
|
73
|
+
@pivot_aggregate_dropdown = UI::Dropdown.new("Sum", items: %w[Sum Count])
|
|
74
|
+
.on_change { |value, *_| @pivot_aggregate = value.to_s.downcase.to_sym }
|
|
75
|
+
@create_pivot_button = UI::Button.new("Create pivot", size: :sm, variant: :secondary)
|
|
76
|
+
.on_click { create_pivot_table }
|
|
64
77
|
@find_field = UI::TextField.new("")
|
|
65
78
|
@replace_field = UI::TextField.new("")
|
|
66
79
|
@find_button = UI::Button.new("Find", size: :sm, variant: :ghost).on_click { find_selection }
|
|
@@ -129,6 +142,16 @@ module Rukbat
|
|
|
129
142
|
.child(@insert_row_button).child(@delete_rows_button)
|
|
130
143
|
.child(@insert_column_button).child(@delete_columns_button)
|
|
131
144
|
.child(@set_print_area_button).child(@clear_print_area_button).child(@export_pdf_button)
|
|
145
|
+
validation_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
|
|
146
|
+
.child(UI::Label.new("Whole numbers", size: :sm))
|
|
147
|
+
.child(UI::Label.new("Min", size: :sm)).child(@validation_min_field.style(width: 72))
|
|
148
|
+
.child(UI::Label.new("Max", size: :sm)).child(@validation_max_field.style(width: 72))
|
|
149
|
+
.child(@set_validation_button).child(@clear_validation_button)
|
|
150
|
+
pivot_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
|
|
151
|
+
.child(UI::Label.new("Pivot (selected range; first row is headers)", size: :sm))
|
|
152
|
+
.child(UI::Label.new("Key col", size: :sm)).child(@pivot_key_field.style(width: 56))
|
|
153
|
+
.child(UI::Label.new("Value col", size: :sm)).child(@pivot_value_field.style(width: 56))
|
|
154
|
+
.child(@pivot_aggregate_dropdown).child(@create_pivot_button)
|
|
132
155
|
annotation_toolbar = Zaniah::Div.new.flex_row.items_center.gap(cx.theme.spacing[1])
|
|
133
156
|
.child(@highlight_button).child(@comment_field.style(width: 150)).child(@comment_button)
|
|
134
157
|
.child(@name_field.style(width: 120)).child(@name_button)
|
|
@@ -158,7 +181,7 @@ module Rukbat
|
|
|
158
181
|
content = Zaniah::Div.new.flex_col.gap(cx.theme.spacing[1]).p(cx.theme.spacing[2])
|
|
159
182
|
.style(width: percent(100), height: percent(100))
|
|
160
183
|
.child(toolbar).child(format_toolbar).child(data_toolbar).child(structure_toolbar).child(annotation_toolbar)
|
|
161
|
-
.child(find_toolbar).child(formula_row).child(@grid.flex_1)
|
|
184
|
+
.child(validation_toolbar).child(pivot_toolbar).child(find_toolbar).child(formula_row).child(@grid.flex_1)
|
|
162
185
|
content.child(@chart_component) if @chart_component
|
|
163
186
|
content.child(status_row)
|
|
164
187
|
content
|
|
@@ -223,6 +246,53 @@ module Rukbat
|
|
|
223
246
|
|
|
224
247
|
private
|
|
225
248
|
|
|
249
|
+
def apply_whole_number_validation
|
|
250
|
+
top, left, bottom, right = selected_coordinates
|
|
251
|
+
minimum = Integer(@validation_min_field.value, 10)
|
|
252
|
+
maximum = Integer(@validation_max_field.value, 10)
|
|
253
|
+
@workbook.set_whole_number_validation(top, left, bottom, right,
|
|
254
|
+
minimum: minimum, maximum: maximum)
|
|
255
|
+
@status = "Whole-number rule applied to #{cell_address(top, left)}:#{cell_address(bottom, right)}"
|
|
256
|
+
request_frame
|
|
257
|
+
true
|
|
258
|
+
rescue Rukbat::Error, ArgumentError, TypeError => error
|
|
259
|
+
@status = error.message
|
|
260
|
+
request_frame
|
|
261
|
+
false
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def clear_input_validation
|
|
265
|
+
top, left, bottom, right = selected_coordinates
|
|
266
|
+
@workbook.clear_input_validation(top, left, bottom, right)
|
|
267
|
+
@status = "Input rule cleared from #{cell_address(top, left)}:#{cell_address(bottom, right)}"
|
|
268
|
+
request_frame
|
|
269
|
+
true
|
|
270
|
+
rescue Rukbat::Error, ArgumentError, TypeError => error
|
|
271
|
+
@status = error.message
|
|
272
|
+
request_frame
|
|
273
|
+
false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def create_pivot_table
|
|
277
|
+
top, left, bottom, right = selected_coordinates
|
|
278
|
+
source_cell = @active_cell
|
|
279
|
+
pivot = @workbook.pivot_table(top, left, bottom, right,
|
|
280
|
+
row_key_column: Integer(@pivot_key_field.value, 10),
|
|
281
|
+
value_column: Integer(@pivot_value_field.value, 10), aggregate: @pivot_aggregate)
|
|
282
|
+
name = @workbook.add_pivot_sheet(next_pivot_sheet_name, pivot)
|
|
283
|
+
sync_grid_visibility
|
|
284
|
+
sync_frozen_panes
|
|
285
|
+
@active_cell = source_cell
|
|
286
|
+
sync_formula_field
|
|
287
|
+
@status = "Created #{name}: #{pivot.rows.length} groups (#{pivot.aggregate}); select its sheet tab to view"
|
|
288
|
+
request_frame
|
|
289
|
+
true
|
|
290
|
+
rescue Rukbat::Error, ArgumentError, TypeError => error
|
|
291
|
+
@status = error.message
|
|
292
|
+
request_frame
|
|
293
|
+
false
|
|
294
|
+
end
|
|
295
|
+
|
|
226
296
|
def sort_selection
|
|
227
297
|
clear_filter
|
|
228
298
|
top, left, bottom, right = selected_coordinates
|
|
@@ -842,6 +912,14 @@ module Rukbat
|
|
|
842
912
|
request_frame
|
|
843
913
|
end
|
|
844
914
|
|
|
915
|
+
def next_pivot_sheet_name
|
|
916
|
+
return "Pivot" unless @workbook.sheet_names.include?("Pivot")
|
|
917
|
+
|
|
918
|
+
index = 2
|
|
919
|
+
index += 1 while @workbook.sheet_names.include?("Pivot#{index}")
|
|
920
|
+
"Pivot#{index}"
|
|
921
|
+
end
|
|
922
|
+
|
|
845
923
|
def save
|
|
846
924
|
@status = @on_save ? @on_save.call.to_s : "Save is unavailable"
|
|
847
925
|
request_frame
|
data/lib/rukbat/version.rb
CHANGED
data/lib/rukbat/workbook.rb
CHANGED
|
@@ -10,7 +10,10 @@ module Rukbat
|
|
|
10
10
|
MAX_FORMAT_CELLS = 100_000
|
|
11
11
|
MAX_HIDDEN_CELLS = 100_000
|
|
12
12
|
MAX_FILTER_ROWS = 100_000
|
|
13
|
+
MAX_PIVOT_ROWS = 100_000
|
|
14
|
+
MAX_PIVOT_GROUPS = 10_000
|
|
13
15
|
MAX_CONDITIONAL_FORMATS = 256
|
|
16
|
+
MAX_INPUT_VALIDATIONS = 256
|
|
14
17
|
FORMAT_KEYS = %i[number_format font_family font_size bold italic color background border_color border_width horizontal_alignment vertical_alignment].freeze
|
|
15
18
|
CONDITIONAL_OPERATORS = %i[greater_than greater_than_or_equal less_than less_than_or_equal equal not_equal contains].freeze
|
|
16
19
|
COLORS = {black: "#000000", blue: "#0000FF", cyan: "#00FFFF", green: "#008000",
|
|
@@ -22,6 +25,8 @@ module Rukbat
|
|
|
22
25
|
row.between?(top, bottom) && column.between?(left, right)
|
|
23
26
|
end
|
|
24
27
|
end
|
|
28
|
+
InputValidation = Data.define(:area, :minimum, :maximum)
|
|
29
|
+
PivotTable = Data.define(:source_area, :row_key_column, :value_column, :aggregate, :headers, :rows)
|
|
25
30
|
|
|
26
31
|
attr_reader :active_sheet
|
|
27
32
|
|
|
@@ -46,6 +51,7 @@ module Rukbat
|
|
|
46
51
|
@hidden_columns = {}.freeze
|
|
47
52
|
@frozen_panes = {name => {rows: 1, columns: 1}.freeze}.freeze
|
|
48
53
|
@print_areas = {}.freeze
|
|
54
|
+
@input_validations = [].freeze
|
|
49
55
|
@change_observers = []
|
|
50
56
|
@source = CellSource.new(self)
|
|
51
57
|
@engine = Furud::Engine.new(@source)
|
|
@@ -100,6 +106,7 @@ module Rukbat
|
|
|
100
106
|
@formats = @formats.reject { |(sheet_name, _row, _column), _style| sheet_name == name }.freeze
|
|
101
107
|
@comments = @comments.reject { |(sheet_name, _row, _column), _text| sheet_name == name }.freeze
|
|
102
108
|
@conditional_formats = @conditional_formats.reject { |rule| rule[:area].sheet == name }.freeze
|
|
109
|
+
@input_validations = @input_validations.reject { |rule| rule.area.sheet == name }.freeze
|
|
103
110
|
@hidden_rows = @hidden_rows.reject { |sheet_name, _| sheet_name == name }.freeze
|
|
104
111
|
@hidden_columns = @hidden_columns.reject { |sheet_name, _| sheet_name == name }.freeze
|
|
105
112
|
@frozen_panes = @frozen_panes.reject { |sheet_name, _| sheet_name == name }.freeze
|
|
@@ -129,15 +136,26 @@ module Rukbat
|
|
|
129
136
|
def set(row, column, value, sheet: @active_sheet)
|
|
130
137
|
ref = reference(row, column, sheet)
|
|
131
138
|
current = input_at(row, column, sheet: ref.sheet)
|
|
132
|
-
return self if current == value
|
|
133
139
|
return clear(row, column, sheet: ref.sheet) if value.nil?
|
|
134
140
|
|
|
135
141
|
validate_formula(value, ref)
|
|
142
|
+
if current == value
|
|
143
|
+
validate_input_values!([ref])
|
|
144
|
+
return self
|
|
145
|
+
end
|
|
146
|
+
|
|
136
147
|
updated = update_sheet(@sheets.fetch(ref.sheet), [[row - 1, column - 1, value]])
|
|
137
|
-
record_history
|
|
138
148
|
@engine.set(ref, value)
|
|
149
|
+
changed = @engine.recalculate
|
|
150
|
+
begin
|
|
151
|
+
validate_input_values!([ref, *changed])
|
|
152
|
+
rescue Error
|
|
153
|
+
rebuild_engine
|
|
154
|
+
raise
|
|
155
|
+
end
|
|
156
|
+
record_history
|
|
139
157
|
@sheets[ref.sheet] = updated
|
|
140
|
-
update_calculated(
|
|
158
|
+
update_calculated(changed)
|
|
141
159
|
notify_cells_changed(ref.sheet, [[ref.row, ref.column].freeze])
|
|
142
160
|
self
|
|
143
161
|
end
|
|
@@ -158,12 +176,14 @@ module Rukbat
|
|
|
158
176
|
validate_formula(value, cell)
|
|
159
177
|
[[row, column, value], cell, value] if current[row - 1, column - 1] != value
|
|
160
178
|
end
|
|
161
|
-
|
|
179
|
+
if changes_to_apply.empty?
|
|
180
|
+
validate_input_values!(values.values.map(&:first))
|
|
181
|
+
return self
|
|
182
|
+
end
|
|
162
183
|
|
|
163
184
|
updated = update_sheet(current, changes_to_apply.map do |(row, column, value), _cell, _input|
|
|
164
185
|
[row - 1, column - 1, value]
|
|
165
186
|
end)
|
|
166
|
-
record_history
|
|
167
187
|
changes_to_apply.each do |_change, cell, value|
|
|
168
188
|
if value.nil?
|
|
169
189
|
@engine.clear(cell)
|
|
@@ -171,8 +191,16 @@ module Rukbat
|
|
|
171
191
|
@engine.set(cell, value)
|
|
172
192
|
end
|
|
173
193
|
end
|
|
194
|
+
changed = @engine.recalculate
|
|
195
|
+
begin
|
|
196
|
+
validate_input_values!([*changes_to_apply.map { |_change, cell, _value| cell }, *changed])
|
|
197
|
+
rescue Error
|
|
198
|
+
rebuild_engine
|
|
199
|
+
raise
|
|
200
|
+
end
|
|
201
|
+
record_history
|
|
174
202
|
@sheets[ref.sheet] = updated
|
|
175
|
-
update_calculated(
|
|
203
|
+
update_calculated(changed)
|
|
176
204
|
notify_cells_changed(ref.sheet, changes_to_apply.map do |_change, cell, _value|
|
|
177
205
|
[cell.row, cell.column].freeze
|
|
178
206
|
end)
|
|
@@ -183,14 +211,64 @@ module Rukbat
|
|
|
183
211
|
ref = reference(row, column, sheet)
|
|
184
212
|
return self if input_at(row, column, sheet: ref.sheet).nil?
|
|
185
213
|
|
|
214
|
+
@engine.clear(ref)
|
|
215
|
+
changed = @engine.recalculate
|
|
216
|
+
begin
|
|
217
|
+
validate_input_values!([ref, *changed])
|
|
218
|
+
rescue Error
|
|
219
|
+
rebuild_engine
|
|
220
|
+
raise
|
|
221
|
+
end
|
|
186
222
|
record_history
|
|
187
223
|
@sheets[ref.sheet] = @sheets.fetch(ref.sheet).delete(row - 1, column - 1)
|
|
188
|
-
|
|
189
|
-
update_calculated(@engine.recalculate)
|
|
224
|
+
update_calculated(changed)
|
|
190
225
|
notify_cells_changed(ref.sheet, [[ref.row, ref.column].freeze])
|
|
191
226
|
self
|
|
192
227
|
end
|
|
193
228
|
|
|
229
|
+
def set_whole_number_validation(top, left, bottom, right, minimum:, maximum:, sheet: @active_sheet)
|
|
230
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
231
|
+
minimum, maximum = strict_integer(minimum), strict_integer(maximum)
|
|
232
|
+
raise Error, "minimum must not exceed maximum" if minimum > maximum
|
|
233
|
+
|
|
234
|
+
area = Furud::Area.new(sheet: name, top: top, left: left, bottom: bottom, right: right)
|
|
235
|
+
validation = InputValidation.new(area: area, minimum: minimum, maximum: maximum)
|
|
236
|
+
updated = @input_validations.flat_map do |existing|
|
|
237
|
+
existing.area.sheet == name ? subtract_input_validation(existing, top, left, bottom, right) : [existing]
|
|
238
|
+
end
|
|
239
|
+
updated << validation
|
|
240
|
+
raise Error, "input validation limit exceeded" if updated.length > MAX_INPUT_VALIDATIONS
|
|
241
|
+
return self if updated == @input_validations
|
|
242
|
+
|
|
243
|
+
record_history
|
|
244
|
+
@input_validations = updated.freeze
|
|
245
|
+
self
|
|
246
|
+
rescue ArgumentError, TypeError
|
|
247
|
+
raise Error, "validation limits must be integers"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def clear_input_validation(top, left, bottom, right, sheet: @active_sheet)
|
|
251
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
252
|
+
updated = @input_validations.flat_map do |existing|
|
|
253
|
+
existing.area.sheet == name ? subtract_input_validation(existing, top, left, bottom, right) : [existing]
|
|
254
|
+
end
|
|
255
|
+
raise Error, "input validation limit exceeded" if updated.length > MAX_INPUT_VALIDATIONS
|
|
256
|
+
return self if updated == @input_validations
|
|
257
|
+
|
|
258
|
+
record_history
|
|
259
|
+
@input_validations = updated.freeze
|
|
260
|
+
self
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def input_validation_at(row, column, sheet: @active_sheet)
|
|
264
|
+
ref = reference(row, column, sheet)
|
|
265
|
+
@input_validations.find do |validation|
|
|
266
|
+
area = validation.area
|
|
267
|
+
area.sheet == ref.sheet && ref.row.between?(area.top, area.bottom) &&
|
|
268
|
+
ref.column.between?(area.left, area.right)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
194
272
|
def each_in(top, left, bottom, right, sheet: @active_sheet, &block)
|
|
195
273
|
return enum_for(__method__, top, left, bottom, right, sheet: sheet) unless block
|
|
196
274
|
|
|
@@ -213,6 +291,104 @@ module Rukbat
|
|
|
213
291
|
types: values.types)
|
|
214
292
|
end
|
|
215
293
|
|
|
294
|
+
def pivot_table(top, left, bottom, right, row_key_column:, value_column:, aggregate: :sum, sheet: @active_sheet)
|
|
295
|
+
top, left, bottom, right, name = range_coordinates(top, left, bottom, right, sheet)
|
|
296
|
+
row_key_column, value_column = strict_integer(row_key_column), strict_integer(value_column)
|
|
297
|
+
width = right - left + 1
|
|
298
|
+
raise Error, "pivot row-key column must be between 1 and #{width}" unless row_key_column.between?(1, width)
|
|
299
|
+
raise Error, "pivot value column must be between 1 and #{width}" unless value_column.between?(1, width)
|
|
300
|
+
raise Error, "pivot aggregate must be :sum or :count" unless %i[sum count].include?(aggregate)
|
|
301
|
+
raise Error, "pivot source exceeds #{MAX_PIVOT_ROWS} data rows" if bottom - top > MAX_PIVOT_ROWS
|
|
302
|
+
|
|
303
|
+
key_column, measure_column = left + row_key_column - 1, left + value_column - 1
|
|
304
|
+
groups, keys = {}, []
|
|
305
|
+
((top + 1)..bottom).each do |row|
|
|
306
|
+
key = @engine.value(Furud::Reference.new(sheet: name, row: row, column: key_column))
|
|
307
|
+
value = @engine.value(Furud::Reference.new(sheet: name, row: row, column: measure_column))
|
|
308
|
+
unless groups.key?(key)
|
|
309
|
+
raise Error, "pivot exceeds #{MAX_PIVOT_GROUPS} groups" if groups.length >= MAX_PIVOT_GROUPS
|
|
310
|
+
|
|
311
|
+
keys << (key.is_a?(String) ? key.dup.freeze : key)
|
|
312
|
+
groups[key] = 0
|
|
313
|
+
end
|
|
314
|
+
if aggregate == :count
|
|
315
|
+
groups[key] += 1 unless value.nil?
|
|
316
|
+
elsif finite_pivot_number?(value)
|
|
317
|
+
sum = groups[key] + value
|
|
318
|
+
raise Error, "pivot sum is not finite" unless finite_pivot_number?(sum)
|
|
319
|
+
|
|
320
|
+
groups[key] = sum
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
key_header = @engine.value(Furud::Reference.new(sheet: name, row: top, column: key_column))
|
|
325
|
+
value_header = @engine.value(Furud::Reference.new(sheet: name, row: top, column: measure_column))
|
|
326
|
+
key_header = "Column #{row_key_column}" if key_header.nil? || key_header.to_s.empty?
|
|
327
|
+
value_header = "Column #{value_column}" if value_header.nil? || value_header.to_s.empty?
|
|
328
|
+
headers = [key_header.to_s, "#{aggregate.to_s.capitalize} of #{value_header}"].freeze
|
|
329
|
+
rows = keys.map { |key| [key, groups.fetch(key)].freeze }.freeze
|
|
330
|
+
area = Furud::Area.new(sheet: name, top: top, left: left, bottom: bottom, right: right)
|
|
331
|
+
PivotTable.new(source_area: area, row_key_column: row_key_column,
|
|
332
|
+
value_column: value_column, aggregate: aggregate, headers: headers, rows: rows)
|
|
333
|
+
rescue ArgumentError, TypeError
|
|
334
|
+
raise Error, "pivot columns must be integers"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def add_pivot_sheet(name, pivot)
|
|
338
|
+
name = validate_name(name)
|
|
339
|
+
raise Error, "sheet already exists: #{name}" if @sheets.key?(name)
|
|
340
|
+
raise Error, "pivot must be a PivotTable" unless pivot.is_a?(PivotTable)
|
|
341
|
+
raise Error, "pivot source area is invalid" unless pivot.source_area.is_a?(Furud::Area)
|
|
342
|
+
raise Error, "unknown pivot source sheet: #{pivot.source_area.sheet}" unless @sheets.key?(pivot.source_area.sheet)
|
|
343
|
+
range_coordinates(pivot.source_area.top, pivot.source_area.left,
|
|
344
|
+
pivot.source_area.bottom, pivot.source_area.right, pivot.source_area.sheet)
|
|
345
|
+
source_width = pivot.source_area.right - pivot.source_area.left + 1
|
|
346
|
+
raise Error, "pivot columns are invalid" unless
|
|
347
|
+
pivot.row_key_column.is_a?(Integer) && pivot.row_key_column.between?(1, source_width) &&
|
|
348
|
+
pivot.value_column.is_a?(Integer) && pivot.value_column.between?(1, source_width) &&
|
|
349
|
+
%i[sum count].include?(pivot.aggregate)
|
|
350
|
+
raise Error, "pivot must have two headers and at most #{MAX_PIVOT_GROUPS} groups" unless
|
|
351
|
+
pivot.headers.is_a?(Array) && pivot.headers.length == 2 && pivot.rows.is_a?(Array) &&
|
|
352
|
+
pivot.rows.length <= MAX_PIVOT_GROUPS
|
|
353
|
+
|
|
354
|
+
rows = pivot.rows.each_with_index.map do |row, index|
|
|
355
|
+
raise Error, "pivot row #{index + 1} must contain a key and value" unless row.is_a?(Array) && row.length == 2
|
|
356
|
+
|
|
357
|
+
[row[0], row[1]]
|
|
358
|
+
end
|
|
359
|
+
changes = pivot.headers.each_with_index.map { |value, column| [1, column + 1, value] }
|
|
360
|
+
rows.each_with_index do |(key, value), index|
|
|
361
|
+
changes << [index + 2, 1, key]
|
|
362
|
+
changes << [index + 2, 2, value]
|
|
363
|
+
end
|
|
364
|
+
prepared = changes.map do |row, column, value|
|
|
365
|
+
row, column = strict_integer(row), strict_integer(column)
|
|
366
|
+
raise Error, "pivot output exceeds sheet limits" unless row.between?(1, MAX_ROWS) && column.between?(1, MAX_COLUMNS)
|
|
367
|
+
|
|
368
|
+
ref = Furud::Reference.new(sheet: name, row: row, column: column)
|
|
369
|
+
input = pivot_cell_input(value)
|
|
370
|
+
validate_formula(input, ref)
|
|
371
|
+
[row - 1, column - 1, input, ref]
|
|
372
|
+
end
|
|
373
|
+
updated = update_sheet(Denebola::Sheet.new, prepared.map { |row, column, value, _ref| [row, column, value] })
|
|
374
|
+
previous_state = snapshot
|
|
375
|
+
begin
|
|
376
|
+
@sheets = @sheets.merge(name => updated)
|
|
377
|
+
@calculated = @calculated.merge(name => Denebola::Sheet.new)
|
|
378
|
+
@frozen_panes = @frozen_panes.merge(name => {rows: 1, columns: 1}.freeze).freeze
|
|
379
|
+
prepared.each { |_row, _column, value, ref| @engine.set(ref, value) unless value.nil? }
|
|
380
|
+
changed = @engine.recalculate
|
|
381
|
+
rescue StandardError => error
|
|
382
|
+
restore(previous_state)
|
|
383
|
+
raise Error, "could not create pivot sheet: #{error.message}"
|
|
384
|
+
end
|
|
385
|
+
@history << previous_state
|
|
386
|
+
@redo.clear
|
|
387
|
+
update_calculated(changed)
|
|
388
|
+
notify_cells_changed(nil)
|
|
389
|
+
name
|
|
390
|
+
end
|
|
391
|
+
|
|
216
392
|
def define_name(name, top, left, bottom, right, sheet: @active_sheet)
|
|
217
393
|
name = name.to_s
|
|
218
394
|
ast = Furud::Formula.parse("=#{name}")
|
|
@@ -348,7 +524,7 @@ module Rukbat
|
|
|
348
524
|
named_ranges = named_ranges.map { |area| area.sheet == name ? adjusted_area(area, adjustment) : area }
|
|
349
525
|
raise Error, "cannot delete an entire named range" if named_ranges.any?(&:nil?)
|
|
350
526
|
end
|
|
351
|
-
|
|
527
|
+
previous_state = snapshot
|
|
352
528
|
runs.each do |at, count|
|
|
353
529
|
@engine.delete_rows(name, at, count)
|
|
354
530
|
@sheets[name] = @sheets.fetch(name).delete_rows(at - 1, count)
|
|
@@ -357,7 +533,16 @@ module Rukbat
|
|
|
357
533
|
adjust_metadata(:delete_rows, at, count, name)
|
|
358
534
|
end
|
|
359
535
|
sync_formula_inputs
|
|
360
|
-
|
|
536
|
+
changed = @engine.recalculate
|
|
537
|
+
begin
|
|
538
|
+
validate_input_values!(changed)
|
|
539
|
+
rescue Error
|
|
540
|
+
restore(previous_state)
|
|
541
|
+
raise
|
|
542
|
+
end
|
|
543
|
+
@history << previous_state
|
|
544
|
+
@redo.clear
|
|
545
|
+
update_calculated(changed)
|
|
361
546
|
notify_cells_changed(name)
|
|
362
547
|
duplicates.length
|
|
363
548
|
end
|
|
@@ -635,6 +820,63 @@ module Rukbat
|
|
|
635
820
|
raise Error, "row and column must be integers"
|
|
636
821
|
end
|
|
637
822
|
|
|
823
|
+
def validate_input_values!(references)
|
|
824
|
+
references.uniq.each do |ref|
|
|
825
|
+
validation = input_validation_at(ref.row, ref.column, sheet: ref.sheet)
|
|
826
|
+
next unless validation
|
|
827
|
+
|
|
828
|
+
value = @engine.value(ref)
|
|
829
|
+
next if value.nil?
|
|
830
|
+
|
|
831
|
+
whole_number = value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value == value.to_i)
|
|
832
|
+
next if whole_number && value >= validation.minimum && value <= validation.maximum
|
|
833
|
+
|
|
834
|
+
address = "#{Furud::Formula.column_name(ref.column)}#{ref.row}"
|
|
835
|
+
raise Error, "#{address} must be a whole number between #{validation.minimum} and #{validation.maximum}"
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def finite_pivot_number?(value)
|
|
840
|
+
value.is_a?(Numeric) && !value.is_a?(Complex) && (!value.respond_to?(:finite?) || value.finite?)
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def pivot_cell_input(value)
|
|
844
|
+
case value
|
|
845
|
+
when String
|
|
846
|
+
value.start_with?("=") ? "=#{Furud::Formula.render_literal(value)}" : value
|
|
847
|
+
when Float
|
|
848
|
+
value.finite? ? value : value.to_s
|
|
849
|
+
when Complex
|
|
850
|
+
value.to_s
|
|
851
|
+
when NilClass, TrueClass, FalseClass, Numeric, Furud::ErrorValue
|
|
852
|
+
value
|
|
853
|
+
else
|
|
854
|
+
value.to_s
|
|
855
|
+
end
|
|
856
|
+
rescue StandardError => error
|
|
857
|
+
raise Error, "pivot value cannot be written: #{error.message}"
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
def subtract_input_validation(validation, top, left, bottom, right)
|
|
861
|
+
area = validation.area
|
|
862
|
+
intersect_top, intersect_left = [area.top, top].max, [area.left, left].max
|
|
863
|
+
intersect_bottom, intersect_right = [area.bottom, bottom].min, [area.right, right].min
|
|
864
|
+
return [validation] if intersect_top > intersect_bottom || intersect_left > intersect_right
|
|
865
|
+
|
|
866
|
+
ranges = []
|
|
867
|
+
ranges << [area.top, area.left, intersect_top - 1, area.right] if area.top < intersect_top
|
|
868
|
+
ranges << [intersect_bottom + 1, area.left, area.bottom, area.right] if intersect_bottom < area.bottom
|
|
869
|
+
ranges << [intersect_top, area.left, intersect_bottom, intersect_left - 1] if area.left < intersect_left
|
|
870
|
+
ranges << [intersect_top, intersect_right + 1, intersect_bottom, area.right] if intersect_right < area.right
|
|
871
|
+
ranges.map do |range_top, range_left, range_bottom, range_right|
|
|
872
|
+
InputValidation.new(
|
|
873
|
+
area: Furud::Area.new(sheet: area.sheet, top: range_top, left: range_left,
|
|
874
|
+
bottom: range_bottom, right: range_right),
|
|
875
|
+
minimum: validation.minimum, maximum: validation.maximum
|
|
876
|
+
)
|
|
877
|
+
end
|
|
878
|
+
end
|
|
879
|
+
|
|
638
880
|
def range_coordinates(top, left, bottom, right, name)
|
|
639
881
|
top, left, bottom, right = [top, left, bottom, right].map { |value| strict_integer(value) }
|
|
640
882
|
name = (name || @active_sheet).to_s
|
|
@@ -665,7 +907,7 @@ module Rukbat
|
|
|
665
907
|
raise Error, "index is outside sheet limits" unless at.between?(1, extent)
|
|
666
908
|
preflight_structural_edit(type, at, count, name, extent)
|
|
667
909
|
|
|
668
|
-
|
|
910
|
+
previous_state = snapshot
|
|
669
911
|
@engine.public_send(type, name, at, count)
|
|
670
912
|
denebola_at = at - 1
|
|
671
913
|
current = @sheets.fetch(name)
|
|
@@ -676,7 +918,16 @@ module Rukbat
|
|
|
676
918
|
@calculated[name] = calculated.public_send(type, denebola_at, count) if denebola_at <= calculated_extent
|
|
677
919
|
adjust_metadata(type, at, count, name)
|
|
678
920
|
sync_formula_inputs
|
|
679
|
-
|
|
921
|
+
changed = @engine.recalculate
|
|
922
|
+
begin
|
|
923
|
+
validate_input_values!(changed)
|
|
924
|
+
rescue Error
|
|
925
|
+
restore(previous_state)
|
|
926
|
+
raise
|
|
927
|
+
end
|
|
928
|
+
@history << previous_state
|
|
929
|
+
@redo.clear
|
|
930
|
+
update_calculated(changed)
|
|
680
931
|
notify_cells_changed(name)
|
|
681
932
|
self
|
|
682
933
|
rescue ArgumentError, TypeError
|
|
@@ -709,10 +960,12 @@ module Rukbat
|
|
|
709
960
|
end
|
|
710
961
|
|
|
711
962
|
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
|
|
963
|
+
@conditional_formats.dup, @hidden_rows.dup, @hidden_columns.dup, @frozen_panes.dup, @print_areas.dup,
|
|
964
|
+
@input_validations.dup]
|
|
713
965
|
|
|
714
966
|
def restore(state)
|
|
715
|
-
@sheets, @active_sheet, @names, @formats, @comments, @conditional_formats, @hidden_rows,
|
|
967
|
+
@sheets, @active_sheet, @names, @formats, @comments, @conditional_formats, @hidden_rows,
|
|
968
|
+
@hidden_columns, @frozen_panes, @print_areas, @input_validations = state
|
|
716
969
|
rebuild_engine
|
|
717
970
|
end
|
|
718
971
|
|
|
@@ -817,11 +1070,16 @@ module Rukbat
|
|
|
817
1070
|
next unless area.sheet == name
|
|
818
1071
|
type.to_s.end_with?("rows") ? area.bottom : area.right
|
|
819
1072
|
end.max || 0
|
|
1073
|
+
validation_extent = @input_validations.filter_map do |rule|
|
|
1074
|
+
area = rule.area
|
|
1075
|
+
next unless area.sheet == name
|
|
1076
|
+
type.to_s.end_with?("rows") ? area.bottom : area.right
|
|
1077
|
+
end.max || 0
|
|
820
1078
|
print_extent = @print_areas.values.filter_map do |area|
|
|
821
1079
|
next unless area.sheet == name
|
|
822
1080
|
type.to_s.end_with?("rows") ? area.bottom : area.right
|
|
823
1081
|
end.max || 0
|
|
824
|
-
area_extent = [area_extent, print_extent].max
|
|
1082
|
+
area_extent = [area_extent, print_extent, validation_extent].max
|
|
825
1083
|
hidden_extent = (type.to_s.end_with?("rows") ? @hidden_rows : @hidden_columns)
|
|
826
1084
|
.fetch(name, Set.new).max || 0
|
|
827
1085
|
panes = @frozen_panes.fetch(name, {rows: 1, columns: 1})
|
|
@@ -909,6 +1167,10 @@ module Rukbat
|
|
|
909
1167
|
area = adjusted_area(rule[:area], operation)
|
|
910
1168
|
area && rule.merge(area: area).freeze
|
|
911
1169
|
end.freeze
|
|
1170
|
+
@input_validations = @input_validations.filter_map do |validation|
|
|
1171
|
+
area = adjusted_area(validation.area, operation)
|
|
1172
|
+
InputValidation.new(area: area, minimum: validation.minimum, maximum: validation.maximum) if area
|
|
1173
|
+
end.freeze
|
|
912
1174
|
@print_areas = @print_areas.filter_map do |sheet_name, area|
|
|
913
1175
|
adjusted = sheet_name == sheet ? adjusted_area(area, operation) : area
|
|
914
1176
|
[sheet_name, adjusted] if adjusted
|
data/sig/rukbat.rbs
CHANGED
|
@@ -14,6 +14,15 @@ module Rukbat
|
|
|
14
14
|
class Workbook
|
|
15
15
|
MAX_ROWS: Integer
|
|
16
16
|
MAX_COLUMNS: Integer
|
|
17
|
+
MAX_INPUT_VALIDATIONS: Integer
|
|
18
|
+
MAX_PIVOT_ROWS: Integer
|
|
19
|
+
MAX_PIVOT_GROUPS: Integer
|
|
20
|
+
|
|
21
|
+
class InputValidation
|
|
22
|
+
attr_reader area: Furud::Area
|
|
23
|
+
attr_reader minimum: Integer
|
|
24
|
+
attr_reader maximum: Integer
|
|
25
|
+
end
|
|
17
26
|
|
|
18
27
|
class CellRange
|
|
19
28
|
attr_reader top: Integer
|
|
@@ -33,6 +42,15 @@ module Rukbat
|
|
|
33
42
|
attr_reader types: Hash[Class, Integer]
|
|
34
43
|
end
|
|
35
44
|
|
|
45
|
+
class PivotTable
|
|
46
|
+
attr_reader source_area: Furud::Area
|
|
47
|
+
attr_reader row_key_column: Integer
|
|
48
|
+
attr_reader value_column: Integer
|
|
49
|
+
attr_reader aggregate: :sum | :count
|
|
50
|
+
attr_reader headers: [String, String]
|
|
51
|
+
attr_reader rows: Array[[untyped, untyped]]
|
|
52
|
+
end
|
|
53
|
+
|
|
36
54
|
attr_reader active_sheet: String
|
|
37
55
|
|
|
38
56
|
def self.from_rows: (Enumerable[Array[untyped]] rows, ?sheet: String) -> Workbook
|
|
@@ -50,9 +68,14 @@ module Rukbat
|
|
|
50
68
|
def set: (Integer row, Integer column, untyped value, ?sheet: String) -> self
|
|
51
69
|
def set_many: (Enumerable[Array[untyped]] changes, ?sheet: String) -> self
|
|
52
70
|
def clear: (Integer row, Integer column, ?sheet: String) -> self
|
|
71
|
+
def set_whole_number_validation: (Integer top, Integer left, Integer bottom, Integer right, minimum: Integer, maximum: Integer, ?sheet: String) -> self
|
|
72
|
+
def clear_input_validation: (Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) -> self
|
|
73
|
+
def input_validation_at: (Integer row, Integer column, ?sheet: String) -> InputValidation?
|
|
53
74
|
def each_in: (Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) -> Enumerator[[Furud::Reference, untyped], self]
|
|
54
75
|
| (Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) { (Furud::Reference reference, untyped value) -> void } -> self
|
|
55
76
|
def summary: (Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) -> Summary
|
|
77
|
+
def pivot_table: (Integer top, Integer left, Integer bottom, Integer right, row_key_column: Integer, value_column: Integer, ?aggregate: :sum | :count, ?sheet: String) -> PivotTable
|
|
78
|
+
def add_pivot_sheet: (String name, PivotTable pivot) -> String
|
|
56
79
|
def define_name: (String name, Integer top, Integer left, Integer bottom, Integer right, ?sheet: String) -> Furud::Area
|
|
57
80
|
def find: (String query, ?top: Integer, ?left: Integer, ?bottom: Integer, ?right: Integer, ?sheet: String) -> Array[Furud::Reference]
|
|
58
81
|
def filter_rows: (Integer top, Integer bottom, by: Integer, query: String, ?sheet: String) -> Array[Integer]
|
|
@@ -100,6 +123,8 @@ module Rukbat
|
|
|
100
123
|
end
|
|
101
124
|
|
|
102
125
|
class GridView
|
|
126
|
+
attr_reader validation_min_field: untyped
|
|
127
|
+
attr_reader validation_max_field: untyped
|
|
103
128
|
def initialize: (Workbook workbook, ?on_save: untyped) -> void
|
|
104
129
|
def undo: () -> bool
|
|
105
130
|
def redo: () -> bool
|