rukbat 0.3.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: fd6ea385045b568f6f5bae52724b003fcff14728cbcd3906eff5f7fd93e16ec9
4
- data.tar.gz: 3e89c333f415e01537198f72e4c54e668da7664d4a3d561e867c2ae95cb6445f
3
+ metadata.gz: 3a2a475aecf838cbaf8d29c8f72a546dfb56bf3db1fe006693ce4f7dd6a9edc3
4
+ data.tar.gz: 85e3dd7726823898c5f92072752c97ada40deb073d7f4c557a1f0792c6bf4327
5
5
  SHA512:
6
- metadata.gz: 546c1d4e7ee470e5cd0b58bd3f28a4c8d74fb50eacefcb4c3875569409770a1195c2da8bcbe83ea61e985f6f093813100baf441db040a91b3baf97871c1d9a0d
7
- data.tar.gz: 23b96cef205cd02e05b743423c9c7e2b3220487fabef9810b4389d807cf93aa4b7f959dc5ce3eb0d2c7e97d75bc0b2b2d3117eb4e4b184aabd4be420cd04ceaa
6
+ metadata.gz: 7c3b8efedcdcc9792c74fbcd3df9d038534b2dddaff73df32feb1e322261a4d2550d642e0ef6989d2c2de714ba8e2fb9dc219ae3f5ad55cf803382575c5b8305
7
+ data.tar.gz: 89ebb5d63ac35ae5bae9425103bf78a2f3f09039fd152c734d5d5e12b0cae0bd2f40469d88d8deebdf460104ca2261c0634ca8974ff5d9e4042ea19b64a07a7d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 0.3.0 — 2026-09-24
4
8
 
5
9
  - Add range-based whole-number input validation with undo support.
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, basic pivot tables, 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,57 +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
- Select a range, enter inclusive minimum/maximum values, and choose **Apply whole-number rule**
75
- to require each nonblank edited value (including a formula's calculated result) to be
76
- a whole number in that range. Invalid edits are rejected atomically; clearing cells is
77
- allowed. **Clear rule** removes validation from the selected range, including only the
78
- selected portion of a larger rule. Rules follow row/column insertions and deletions and
79
- are included in undo/redo. Existing values are not retroactively changed when a rule is
80
- applied. Input validations are session metadata: CSV/TSV has no place to store them, so
81
- they reset when the workbook is reopened. A workbook supports up to 256 validation
82
- ranges.
83
- For a pivot, select a source rectangle including its header row, enter the one-based
84
- key and value column positions relative to that selection, choose **Sum** or **Count**,
85
- and choose **Create pivot**. Formula results are used; groups match exact key values
86
- (text is case-sensitive), retain their first-seen order, and blank keys form their own
87
- group. **Sum** adds finite real numbers only, and **Count**
88
- counts nonblank values in the selected value column. The output is static data in a new
89
- `Pivot` sheet (or the next unused `Pivot2`, etc.), not a live link to the source. At most
90
- 100,000 data rows and 10,000 groups can be summarized.
91
- Creating a pivot leaves its source sheet active. CSV/TSV saves the active sheet only;
92
- select the pivot sheet before saving if you want to export the summary instead. One
93
- Undo removes the generated pivot sheet.
94
- Freeze panes are kept per sheet and restored by undo/redo; the selected cell and
95
- the row/column headers before it stay visible while scrolling. **Unfreeze** removes
96
- all frozen rows and columns. Clear highlights removes conditional formatting
97
- from the active sheet.
98
- Select a range and choose **Set print area** to constrain PDF export; **Clear
99
- print area** restores full-sheet output. **Export PDF** prompts for an embeddable
100
- font and output path, applying the current print area. The CLI can also export
101
- with `bundle exec rukbat --export-pdf report.pdf --font /path/to/font.ttf`.
102
- Print areas are also session metadata and reset when the workbook is reopened.
103
- PDF export embeds the supplied font, resolves formatted font families from the
104
- system font database, and applies bold/italic along with cell formatting; bold
105
- and italic are synthesized in the PDF when the selected face has no matching
106
- 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.
107
147
 
108
148
  ## Development
109
149
 
@@ -115,6 +155,6 @@ bundle exec rbs -I sig -I "$(bundle info --path furud)/sig" -I "$(bundle info --
115
155
  gem build --strict rukbat.gemspec
116
156
  ```
117
157
 
118
- The implementation is not yet a `0.1.0` release. Local performance gates pass;
119
- check the workplan's M17 acceptance gate for remaining compatibility, public
120
- CI, and dependency-release checks.
158
+ ## License
159
+
160
+ Rukbat is released under the [MIT License](LICENSE.txt).
@@ -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.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.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: