rukbat 0.2.0 → 0.4.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 +9 -0
- data/README.md +118 -59
- data/lib/rukbat/grid_view.rb +80 -2
- data/lib/rukbat/pdf_file.rb +47 -24
- data/lib/rukbat/version.rb +1 -1
- data/lib/rukbat/workbook.rb +277 -15
- data/sig/rukbat.rbs +25 -0
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a2a475aecf838cbaf8d29c8f72a546dfb56bf3db1fe006693ce4f7dd6a9edc3
|
|
4
|
+
data.tar.gz: 85e3dd7726823898c5f92072752c97ada40deb073d7f4c557a1f0792c6bf4327
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c3b8efedcdcc9792c74fbcd3df9d038534b2dddaff73df32feb1e322261a4d2550d642e0ef6989d2c2de714ba8e2fb9dc219ae3f5ad55cf803382575c5b8305
|
|
7
|
+
data.tar.gz: 89ebb5d63ac35ae5bae9425103bf78a2f3f09039fd152c734d5d5e12b0cae0bd2f40469d88d8deebdf460104ca2261c0634ca8974ff5d9e4042ea19b64a07a7d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-09-24
|
|
4
|
+
|
|
5
|
+
- Export spreadsheet pages through Zaniah vector recording and Okab PDF drawing while preserving pagination, formatting, and searchable text.
|
|
6
|
+
|
|
7
|
+
## 0.3.0 — 2026-09-24
|
|
8
|
+
|
|
9
|
+
- Add range-based whole-number input validation with undo support.
|
|
10
|
+
- Add static pivot tables with sum and count aggregation.
|
|
11
|
+
|
|
3
12
|
## 0.2.0 — 2026-09-24
|
|
4
13
|
|
|
5
14
|
- Add donut, scatter, area, stacked-area, and stacked-bar chart options.
|
data/README.md
CHANGED
|
@@ -1,29 +1,76 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">Rukbat</h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Ruby spreadsheet editor with sparse sheets, live formulas, and CSV/TSV workflows</strong>
|
|
5
|
+
</p>
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
against a 16.67 ms budget, and the 100,000-cell edit/recalculation benchmark
|
|
15
|
-
measured 1.773 s against a 3 s budget. Formula compatibility, public CI, and
|
|
16
|
-
dependency releases remain before the first release.
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/rukbat"><img src="https://img.shields.io/gem/v/rukbat.svg" alt="Gem version"></a>
|
|
9
|
+
<a href="https://rubygems.org/gems/rukbat"><img src="https://img.shields.io/gem/dt/rukbat.svg" alt="Gem downloads"></a>
|
|
10
|
+
<a href="https://github.com/noxdea/rukbat/actions/workflows/main.yml"><img src="https://github.com/noxdea/rukbat/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/Ruby-%3E%3D%203.2-cc342d.svg" alt="Ruby 3.2 or newer">
|
|
12
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
|
|
13
|
+
</p>
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="#features">Features</a> ·
|
|
17
|
+
<a href="#installation">Installation</a> ·
|
|
18
|
+
<a href="#quick-start">Quick start</a> ·
|
|
19
|
+
<a href="#workbook-api">Workbook API</a> ·
|
|
20
|
+
<a href="#development">Development</a>
|
|
21
|
+
</p>
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Rukbat combines [Denebola](https://github.com/noxdea/denebola)'s persistent,
|
|
26
|
+
sparse sheets with [Furud](https://github.com/noxdea/furud)'s formula engine.
|
|
27
|
+
It opens CSV and TSV files in a graphical editor or an interactive terminal,
|
|
28
|
+
and also exposes workbooks as a Ruby API.
|
|
29
|
+
Its name comes from Rukbat (α Sagittarii), Arabic *rukbat al-rāmī*
|
|
30
|
+
(“the archer’s knee”).
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- A virtualized million-row grid with formula editing, completion, multi-sheet references, and range summaries
|
|
35
|
+
- Undo/redo, row and column edits, formatting, sorting, filtering, duplicate removal, find/replace, comments, and named ranges
|
|
36
|
+
- Whole-number input validation, conditional formatting, freeze panes, and static pivot tables
|
|
37
|
+
- Line, bar, pie, donut, scatter, area, and stacked charts in the editor
|
|
38
|
+
- CSV/TSV import and export, plus searchable PDF export with an embedded font
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Rukbat requires Ruby 3.2 or newer. Install the published gem:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
gem install rukbat
|
|
46
|
+
rukbat --version
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or add `gem "rukbat"` to your Gemfile. The editor needs a supported desktop
|
|
50
|
+
display or an interactive terminal; the workbook API can be used without one.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
Open a CSV file, start a new one, or select tab-delimited input and output:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
rukbat sales.csv
|
|
58
|
+
rukbat new.csv
|
|
59
|
+
rukbat --tsv data.tsv
|
|
22
60
|
```
|
|
23
61
|
|
|
24
|
-
|
|
62
|
+
Use the mouse to select cells, arrow keys to move, and Enter or a double-click
|
|
63
|
+
to edit. The formula bar's **Apply** button commits its value. Save with
|
|
64
|
+
Ctrl-S (Cmd-S on macOS); Ctrl-Z/Cmd-Z undoes, and the shifted shortcut redoes.
|
|
65
|
+
The status bar shows the selected range's count, numeric count, sum, and average.
|
|
25
66
|
|
|
26
|
-
|
|
67
|
+
The toolbar provides formats, sorting, filtering, duplicate removal,
|
|
68
|
+
search/replace, comments, named ranges, conditional highlighting, freeze
|
|
69
|
+
panes, print areas, and charts.
|
|
70
|
+
|
|
71
|
+
## Workbook API
|
|
72
|
+
|
|
73
|
+
Coordinates and formula references are one-based:
|
|
27
74
|
|
|
28
75
|
```ruby
|
|
29
76
|
require "rukbat"
|
|
@@ -40,12 +87,28 @@ book.undo
|
|
|
40
87
|
book.redo
|
|
41
88
|
```
|
|
42
89
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
90
|
+
Denebola stores zero-based, immutable sheet snapshots internally. Undo and
|
|
91
|
+
redo retain prior roots instead of copying every cell; Rukbat feeds sparse
|
|
92
|
+
ranges to Furud through `CellSource`.
|
|
93
|
+
|
|
94
|
+
### Validation and pivots
|
|
95
|
+
|
|
96
|
+
Select a range in the editor, enter inclusive bounds, and choose **Apply
|
|
97
|
+
whole-number rule**. Nonblank edits, including calculated formula results,
|
|
98
|
+
must then be whole numbers within the bounds. Invalid edits are rejected
|
|
99
|
+
atomically; existing values are not changed when a rule is added. **Clear
|
|
100
|
+
rule** removes validation from the selected range. Rules follow structural
|
|
101
|
+
edits and undo/redo, but reset when a CSV/TSV workbook is reopened.
|
|
47
102
|
|
|
48
|
-
|
|
103
|
+
To create a pivot, select a rectangle including its header row, enter the
|
|
104
|
+
one-based key and value column positions within that selection, choose **Sum**
|
|
105
|
+
or **Count**, then **Create pivot**. The result is a static `Pivot` sheet (or
|
|
106
|
+
the next unused `Pivot2`, etc.), not a live link. Formula results are used;
|
|
107
|
+
groups retain first-seen order and match exact key values. Sum accepts finite
|
|
108
|
+
real numbers, while Count counts nonblank values. One Undo removes the
|
|
109
|
+
generated sheet.
|
|
110
|
+
|
|
111
|
+
## Files and export
|
|
49
112
|
|
|
50
113
|
```ruby
|
|
51
114
|
book = Rukbat::CSVFile.read("sales.csv", hint: "Windows-31J")
|
|
@@ -53,38 +116,34 @@ Rukbat::CSVFile.write(book, "sales-export.csv")
|
|
|
53
116
|
Rukbat::CSVFile.read("data.tsv", delimiter: :tsv)
|
|
54
117
|
```
|
|
55
118
|
|
|
56
|
-
Import
|
|
57
|
-
decimal literals
|
|
58
|
-
with CRLF row separators
|
|
59
|
-
default
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
PDF export embeds the supplied font, resolves formatted font families from the
|
|
85
|
-
system font database, and applies bold/italic along with cell formatting; bold
|
|
86
|
-
and italic are synthesized in the PDF when the selected face has no matching
|
|
87
|
-
variant. Charts are not yet rendered.
|
|
119
|
+
Import uses [Menkar](https://github.com/noxdea/menkar) to detect and decode
|
|
120
|
+
text, infers integer and decimal literals, and leaves other fields as strings.
|
|
121
|
+
Export writes UTF-8 with CRLF row separators and uses calculated formula
|
|
122
|
+
values by default; pass `values: :input` to export stored formulas. Saving an
|
|
123
|
+
opened file rejects external changes made since it was loaded.
|
|
124
|
+
|
|
125
|
+
CSV/TSV saves only the active sheet. Pivot creation leaves the source sheet
|
|
126
|
+
active, so activate the pivot sheet before exporting its summary. Formatting,
|
|
127
|
+
validation rules, print areas, and other workbook metadata are not stored in
|
|
128
|
+
CSV/TSV.
|
|
129
|
+
|
|
130
|
+
Set a print area in the editor to limit PDF output, then choose **Export PDF**
|
|
131
|
+
and supply an embeddable font. The command line can export directly:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
rukbat --export-pdf report.pdf --font /path/to/font.ttf sales.csv
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
PDF export draws formatted cells from Zaniah's vector recording and keeps text
|
|
138
|
+
searchable in Okab's PDF output. Charts are not rendered
|
|
139
|
+
in PDFs. Print areas reset when the workbook is reopened.
|
|
140
|
+
|
|
141
|
+
## Limits
|
|
142
|
+
|
|
143
|
+
- Workbooks support 1,048,576 rows and 16,384 columns; the grid renders visible cells rather than all rows at once.
|
|
144
|
+
- A workbook supports up to 256 validation ranges. A pivot handles up to 100,000 data rows and 10,000 groups.
|
|
145
|
+
- CSV/TSV export is limited to 10 million cells; PDF export is limited to 100,000 cells.
|
|
146
|
+
- Rukbat does not open or save XLSX/ODS files. CSV/TSV does not preserve multiple sheets or workbook metadata.
|
|
88
147
|
|
|
89
148
|
## Development
|
|
90
149
|
|
|
@@ -96,6 +155,6 @@ bundle exec rbs -I sig -I "$(bundle info --path furud)/sig" -I "$(bundle info --
|
|
|
96
155
|
gem build --strict rukbat.gemspec
|
|
97
156
|
```
|
|
98
157
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
Rukbat is released under the [MIT License](LICENSE.txt).
|
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/pdf_file.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "tempfile"
|
|
4
|
+
require "zaniah/vector"
|
|
5
|
+
require "okab/zaniah_vector"
|
|
4
6
|
|
|
5
7
|
module Rukbat
|
|
6
8
|
module PDFFile
|
|
@@ -25,14 +27,17 @@ module Rukbat
|
|
|
25
27
|
columns = area ? area.right - area.left + 1 : [current.column_count, 1].max
|
|
26
28
|
raise Error, "PDF export exceeds #{MAX_CELLS} cells" if rows * columns > MAX_CELLS
|
|
27
29
|
|
|
30
|
+
renderer = Zaniah::TextSystem::Renderer.new(font: font.face)
|
|
28
31
|
document = Okab::Document.new(title: "#{sheet} — Rukbat", author: "Yudai Takada", creator: "Rukbat")
|
|
29
32
|
(0...rows).step(ROWS_PER_PAGE) do |row_offset|
|
|
30
33
|
(0...columns).step(COLUMNS_PER_PAGE) do |column_offset|
|
|
31
|
-
render_page(document, workbook, font, font_db, font_cache, sheet, origin_row, origin_column, row_offset, column_offset,
|
|
34
|
+
render_page(document, workbook, font, renderer, font_db, font_cache, sheet, origin_row, origin_column, row_offset, column_offset,
|
|
32
35
|
[rows - row_offset, ROWS_PER_PAGE].min, [columns - column_offset, COLUMNS_PER_PAGE].min)
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
38
|
document.render
|
|
39
|
+
ensure
|
|
40
|
+
renderer&.close
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def write(workbook, path, font:, sheet: workbook.active_sheet, font_db: nil)
|
|
@@ -55,22 +60,35 @@ module Rukbat
|
|
|
55
60
|
raise Error, "cannot write PDF: #{error.message}"
|
|
56
61
|
end
|
|
57
62
|
|
|
58
|
-
def render_page(document, workbook, font, font_db, font_cache, sheet, origin_row, origin_column,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
def render_page(document, workbook, font, renderer, font_db, font_cache, sheet, origin_row, origin_column,
|
|
64
|
+
row_offset, column_offset, row_count, column_count)
|
|
65
|
+
styled_text = []
|
|
66
|
+
vector = Zaniah::Vector.record(width: PAGE_WIDTH, height: PAGE_HEIGHT, text_system: renderer) do
|
|
67
|
+
Zaniah::Canvas.new do |_bounds, cx|
|
|
68
|
+
scene = cx.scene
|
|
69
|
+
first_row = origin_row + row_offset + 1
|
|
70
|
+
last_row = first_row + row_count - 1
|
|
71
|
+
header = "#{sheet} — rows #{first_row}-#{last_row}"
|
|
72
|
+
renderer.paint_line(scene, renderer.layout_line(header, font: font.face, size: 10),
|
|
73
|
+
x: MARGIN, y: MARGIN, color: "#000000")
|
|
74
|
+
data_top = PAGE_HEIGHT - MARGIN - 24
|
|
75
|
+
(0..row_count).each do |row_index|
|
|
76
|
+
(0..column_count).each do |column_index|
|
|
77
|
+
x = MARGIN + (column_index.zero? ? 0 : 38 + (column_index - 1) * CELL_WIDTH)
|
|
78
|
+
width = column_index.zero? ? 38 : CELL_WIDTH
|
|
79
|
+
y = data_top - row_index * CELL_HEIGHT
|
|
80
|
+
text, style = cell_text(workbook, sheet, origin_row, origin_column,
|
|
81
|
+
row_offset, column_offset, row_index, column_index)
|
|
82
|
+
paint_cell(scene, styled_text, renderer, text, style,
|
|
83
|
+
font_for_style(style, font, font_db, font_cache), x, y, width)
|
|
84
|
+
end
|
|
73
85
|
end
|
|
86
|
+
end.w(PAGE_WIDTH).h(PAGE_HEIGHT)
|
|
87
|
+
end
|
|
88
|
+
document.page(width: PAGE_WIDTH, height: PAGE_HEIGHT) do |page|
|
|
89
|
+
Okab::ZaniahVector.draw(page, vector)
|
|
90
|
+
styled_text.each do |text, x, y, font, size, color, bold, italic|
|
|
91
|
+
page.text(text, x: x, y: y, font: font, size: size, color: color, bold: bold, italic: italic)
|
|
74
92
|
end
|
|
75
93
|
end
|
|
76
94
|
end
|
|
@@ -87,13 +105,11 @@ module Rukbat
|
|
|
87
105
|
end
|
|
88
106
|
private_class_method :cell_text
|
|
89
107
|
|
|
90
|
-
def paint_cell(
|
|
108
|
+
def paint_cell(scene, styled_text, renderer, text, style, font, x, top, width)
|
|
91
109
|
y = top - CELL_HEIGHT
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
page.rect(x, y, width, CELL_HEIGHT).stroke(rgb(style[:border_color] || "#B8BEC8"),
|
|
96
|
-
width: style[:border_width] || 0.5)
|
|
110
|
+
scene.quad(x, PAGE_HEIGHT - top, width, CELL_HEIGHT,
|
|
111
|
+
color: style[:background] || "#0000", border_color: style[:border_color] || "#B8BEC8",
|
|
112
|
+
border_width: style[:border_width] || 0.5)
|
|
97
113
|
text = text.to_s.lines.first.to_s.chomp
|
|
98
114
|
text = text.encode(Encoding::UTF_8)
|
|
99
115
|
# ponytail: fixed-height PDF rows cap text at 13pt; variable page geometry can lift the ceiling.
|
|
@@ -111,8 +127,15 @@ module Rukbat
|
|
|
111
127
|
when :bottom then y + 2
|
|
112
128
|
else y + (CELL_HEIGHT - size) / 2
|
|
113
129
|
end
|
|
114
|
-
|
|
115
|
-
|
|
130
|
+
color = style[:color] || "#20242C"
|
|
131
|
+
if style[:bold] || style[:italic]
|
|
132
|
+
# GlyphRun has no synthetic-style fields; retain Okab's searchable
|
|
133
|
+
# faux-bold/italic text operator for those cells only.
|
|
134
|
+
styled_text << [text, text_x, baseline, font, size, rgb(color), !!style[:bold], !!style[:italic]]
|
|
135
|
+
else
|
|
136
|
+
renderer.paint_line(scene, renderer.layout_line(text, font: font.face, size: size),
|
|
137
|
+
x: text_x, y: PAGE_HEIGHT - baseline, color: color)
|
|
138
|
+
end
|
|
116
139
|
end
|
|
117
140
|
end
|
|
118
141
|
private_class_method :paint_cell
|
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
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rukbat
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -100,14 +100,14 @@ dependencies:
|
|
|
100
100
|
requirements:
|
|
101
101
|
- - "~>"
|
|
102
102
|
- !ruby/object:Gem::Version
|
|
103
|
-
version: 0.
|
|
103
|
+
version: 0.2.0
|
|
104
104
|
type: :runtime
|
|
105
105
|
prerelease: false
|
|
106
106
|
version_requirements: !ruby/object:Gem::Requirement
|
|
107
107
|
requirements:
|
|
108
108
|
- - "~>"
|
|
109
109
|
- !ruby/object:Gem::Version
|
|
110
|
-
version: 0.
|
|
110
|
+
version: 0.2.0
|
|
111
111
|
- !ruby/object:Gem::Dependency
|
|
112
112
|
name: spica
|
|
113
113
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -142,14 +142,14 @@ dependencies:
|
|
|
142
142
|
requirements:
|
|
143
143
|
- - "~>"
|
|
144
144
|
- !ruby/object:Gem::Version
|
|
145
|
-
version: 0.
|
|
145
|
+
version: 0.9.0
|
|
146
146
|
type: :runtime
|
|
147
147
|
prerelease: false
|
|
148
148
|
version_requirements: !ruby/object:Gem::Requirement
|
|
149
149
|
requirements:
|
|
150
150
|
- - "~>"
|
|
151
151
|
- !ruby/object:Gem::Version
|
|
152
|
-
version: 0.
|
|
152
|
+
version: 0.9.0
|
|
153
153
|
description: A Ruby spreadsheet app built on persistent sparse sheets and the Furud
|
|
154
154
|
calculation engine.
|
|
155
155
|
email:
|