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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbd7509f4fe7e264acc4fe9dc0f51fedfc0c953a15f115046c4dd54557540263
4
- data.tar.gz: 750029435921d213b829a14a87ece225e926c9d0aa4d876138071f01405b09ef
3
+ metadata.gz: 3a2a475aecf838cbaf8d29c8f72a546dfb56bf3db1fe006693ce4f7dd6a9edc3
4
+ data.tar.gz: 85e3dd7726823898c5f92072752c97ada40deb073d7f4c557a1f0792c6bf4327
5
5
  SHA512:
6
- metadata.gz: 6659e6022995046877f65163bea1ecd7412181591b69376d0b369013832c2507e75576c038914a33b86a3871fabdf86390bd32840eca70c647daa9b955b3f085
7
- data.tar.gz: 489ff8a98679c21b4d7f70481ad161cae366e94e7b0b9acec22d82a02f8c45b4d6df37229e6860faebe5fefd681544c9c532e5e315ae7f4052d72c9641b35b04
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
- # Rukbat
1
+ <h1 align="center">Rukbat</h1>
2
2
 
3
- Rukbat (α Sagittarii), from Arabic *rukbat al-rāmī* (“the archer’s knee”), is
4
- a spreadsheet application built on a sparse, persistent Denebola sheet and the
5
- Furud formula engine.
3
+ <p align="center">
4
+ <strong>Ruby spreadsheet editor with sparse sheets, live formulas, and CSV/TSV workflows</strong>
5
+ </p>
6
6
 
7
- The current implementation includes a virtualized million-row grid, formula
8
- editing and completion, multi-sheet formulas, range summaries, undo/redo,
9
- structural row/column edits, formatting, sorting, filtering, duplicate removal,
10
- find/replace, comments, named ranges, conditional formatting, line/bar/pie,
11
- donut/scatter/area/stacked charts, CSV/TSV import/export, and searchable PDF
12
- export. On arm64 macOS
13
- with Ruby 4.0.6, the million-row integrated scroll benchmark measured 11.383 ms
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
- ## Install
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
- ```ruby
21
- gem "rukbat"
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
- Rukbat requires Ruby 3.2 or newer.
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
- ## Workbooks
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
- Workbook coordinates and Furud references are one-based. Denebola storage is
44
- zero-based internally. Sheets are immutable persistent snapshots, so undo and
45
- redo retain prior roots instead of copying all cells. The formula engine is
46
- storage-agnostic; Rukbat adapts sparse range iteration through `CellSource`.
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
- ## CSV and TSV
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 detects and strictly decodes text with Menkar, then infers integer and
57
- decimal literals while leaving other fields as strings. Export writes UTF-8
58
- with CRLF row separators; formula cells export their calculated values by
59
- default. Pass `values: :input` to export stored formulas/inputs instead.
60
- The application refuses to replace an existing file unless it is the exact
61
- file read into the workbook and has not changed since loading.
62
-
63
- Run `bundle exec rukbat sales.csv` to open the graphical editor. A missing CSV
64
- path starts a new workbook; `--tsv` selects tab-delimited input and output.
65
- Use the mouse to select ranges, arrow keys to move, Enter or a double-click to
66
- edit, and Apply to commit the formula bar value. `Ctrl-S`/`Cmd-S` saves;
67
- `Ctrl-Z`/`Cmd-Z` undo and the shifted shortcut redoes. The selected range's
68
- count, numeric count, sum, and average appear in the status bar.
69
-
70
- The toolbar provides common number, font, alignment, fill, and border formats;
71
- sorting, filtering, duplicate removal, search/replace, comments, named ranges,
72
- positive-value highlighting, freeze/unfreeze panes, print areas, and line, bar,
73
- pie, donut, scatter, area, stacked-area, and stacked-bar charts.
74
- Freeze panes are kept per sheet and restored by undo/redo; the selected cell and
75
- the row/column headers before it stay visible while scrolling. **Unfreeze** removes
76
- all frozen rows and columns. Clear highlights removes conditional formatting
77
- from the active sheet.
78
- Select a range and choose **Set print area** to constrain PDF export; **Clear
79
- print area** restores full-sheet output. **Export PDF** prompts for an embeddable
80
- font and output path, applying the current print area. The CLI can also export
81
- with `bundle exec rukbat --export-pdf report.pdf --font /path/to/font.ttf`.
82
- Print areas are session metadata; CSV has no place to store them, so they reset
83
- when the workbook is reopened.
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
- The implementation is not yet a `0.1.0` release. Local performance gates pass;
100
- check the workplan's M17 acceptance gate for remaining compatibility, public
101
- CI, and dependency-release checks.
158
+ ## License
159
+
160
+ Rukbat is released under the [MIT License](LICENSE.txt).
@@ -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
@@ -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, row_offset, column_offset, row_count, column_count)
59
- document.page(width: PAGE_WIDTH, height: PAGE_HEIGHT) do |page|
60
- first_row = origin_row + row_offset + 1
61
- last_row = first_row + row_count - 1
62
- page.text("#{sheet} — rows #{first_row}-#{last_row}",
63
- x: MARGIN, y: PAGE_HEIGHT - MARGIN, font: font, size: 10)
64
- data_top = PAGE_HEIGHT - MARGIN - 24
65
- (0..row_count).each do |row_index|
66
- (0..column_count).each do |column_index|
67
- x = MARGIN + (column_index.zero? ? 0 : 38 + (column_index - 1) * CELL_WIDTH)
68
- width = column_index.zero? ? 38 : CELL_WIDTH
69
- y = data_top - row_index * CELL_HEIGHT
70
- text, style = cell_text(workbook, sheet, origin_row, origin_column,
71
- row_offset, column_offset, row_index, column_index)
72
- paint_cell(page, text, style, font_for_style(style, font, font_db, font_cache), x, y, width)
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(page, text, style, font, x, top, width)
108
+ def paint_cell(scene, styled_text, renderer, text, style, font, x, top, width)
91
109
  y = top - CELL_HEIGHT
92
- if style[:background]
93
- page.rect(x, y, width, CELL_HEIGHT).fill(rgb(style[:background]))
94
- end
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
- page.text(text, x: text_x, y: baseline, font: font, size: size,
115
- color: rgb(style[:color] || "#20242C"), bold: style[:bold] || false, italic: style[:italic] || false)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rukbat
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -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(@engine.recalculate)
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
- return self if changes_to_apply.empty?
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(@engine.recalculate)
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
- @engine.clear(ref)
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
- record_history
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
- update_calculated(@engine.recalculate)
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
- record_history
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
- update_calculated(@engine.recalculate)
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, @hidden_columns, @frozen_panes, @print_areas = state
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.2.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.1.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.1.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.6.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.6.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: