rukbat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +100 -0
- data/exe/rukbat +37 -0
- data/lib/rukbat/application.rb +37 -0
- data/lib/rukbat/atomic_file.rb +11 -0
- data/lib/rukbat/cell_source.rb +25 -0
- data/lib/rukbat/csv_file.rb +125 -0
- data/lib/rukbat/grid_view.rb +847 -0
- data/lib/rukbat/pdf_file.rb +163 -0
- data/lib/rukbat/version.rb +5 -0
- data/lib/rukbat/workbook.rb +1045 -0
- data/lib/rukbat.rb +23 -0
- data/sig/rukbat.rbs +118 -0
- metadata +201 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9e1adcf1d5362c707d385caf7e0035b9aa358286bc45752d67abe012606a9961
|
|
4
|
+
data.tar.gz: ec59e5a8ac486e57bded8e8f3a54129ac7514321722132edbae4181aabcefc4f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 207d10683c00d7ce74ac3c101f0facd2c8e39385ee186f09e2c38dbbb15e19e7ee603e3b9f6a0ff528a5d46006a52693e12511ebc36981324e6c469d61bea5ef
|
|
7
|
+
data.tar.gz: f2ee8e18a439ae0fc7a7ad271aa44ea783250504b1362afe82cae72af1461c3f5304861bd3334e1bec3880fe1689d9b52d7f2451b1deda883609b61e8f58f2e9
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Rukbat
|
|
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.
|
|
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
|
+
charts, CSV/TSV import/export, and searchable PDF export. The application is
|
|
12
|
+
still under development and has not been released as `0.1.0`. 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.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "rukbat"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Rukbat requires Ruby 3.2 or newer.
|
|
25
|
+
|
|
26
|
+
## Workbooks
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "rukbat"
|
|
30
|
+
|
|
31
|
+
book = Rukbat::Workbook.new
|
|
32
|
+
book.set(1, 1, 12)
|
|
33
|
+
book.set(2, 1, 30)
|
|
34
|
+
book.set(1, 2, "=SUM(A1:A2)")
|
|
35
|
+
book[1, 2] # => 42
|
|
36
|
+
|
|
37
|
+
book.add_sheet("Annual Plan")
|
|
38
|
+
book.set(1, 1, 2026, sheet: "Annual Plan")
|
|
39
|
+
book.undo
|
|
40
|
+
book.redo
|
|
41
|
+
```
|
|
42
|
+
|
|
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`.
|
|
47
|
+
|
|
48
|
+
## CSV and TSV
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
book = Rukbat::CSVFile.read("sales.csv", hint: "Windows-31J")
|
|
52
|
+
Rukbat::CSVFile.write(book, "sales-export.csv")
|
|
53
|
+
Rukbat::CSVFile.read("data.tsv", delimiter: :tsv)
|
|
54
|
+
```
|
|
55
|
+
|
|
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/pie charts.
|
|
73
|
+
Freeze panes are kept per sheet and restored by undo/redo; the selected cell and
|
|
74
|
+
the row/column headers before it stay visible while scrolling. **Unfreeze** removes
|
|
75
|
+
all frozen rows and columns. Clear highlights removes conditional formatting
|
|
76
|
+
from the active sheet.
|
|
77
|
+
Select a range and choose **Set print area** to constrain PDF export; **Clear
|
|
78
|
+
print area** restores full-sheet output. **Export PDF** prompts for an embeddable
|
|
79
|
+
font and output path, applying the current print area. The CLI can also export
|
|
80
|
+
with `bundle exec rukbat --export-pdf report.pdf --font /path/to/font.ttf`.
|
|
81
|
+
Print areas are session metadata; CSV has no place to store them, so they reset
|
|
82
|
+
when the workbook is reopened.
|
|
83
|
+
PDF export embeds the supplied font, resolves formatted font families from the
|
|
84
|
+
system font database, and applies bold/italic along with cell formatting; bold
|
|
85
|
+
and italic are synthesized in the PDF when the selected face has no matching
|
|
86
|
+
variant. Charts are not yet rendered.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
bundle install
|
|
92
|
+
bundle exec rake spec
|
|
93
|
+
BUDGET=1 bundle exec rake bench
|
|
94
|
+
bundle exec rbs -I sig -I "$(bundle info --path furud)/sig" -I "$(bundle info --path denebola)/sig" validate
|
|
95
|
+
gem build --strict rukbat.gemspec
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The implementation is not yet a `0.1.0` release. Local performance gates pass;
|
|
99
|
+
check the workplan's M17 acceptance gate for remaining compatibility, public
|
|
100
|
+
CI, and dependency-release checks.
|
data/exe/rukbat
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "rukbat"
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
options = {delimiter: :comma}
|
|
9
|
+
parser = OptionParser.new do |opts|
|
|
10
|
+
opts.banner = "Usage: rukbat [options] FILE.csv|FILE.tsv"
|
|
11
|
+
opts.on("-t", "--tsv", "Use tab-separated values") { options[:delimiter] = :tsv }
|
|
12
|
+
opts.on("--export-pdf PATH", "Write the workbook as PDF and exit") { |path| options[:pdf] = path }
|
|
13
|
+
opts.on("--font PATH", "TrueType font to embed in PDF output") { |path| options[:font] = path }
|
|
14
|
+
opts.on("-v", "--version", "Print version") { puts Rukbat::VERSION; exit }
|
|
15
|
+
opts.on("-h", "--help", "Show help") { puts opts; exit }
|
|
16
|
+
end
|
|
17
|
+
parser.parse!
|
|
18
|
+
abort parser.to_s unless ARGV.length == 1
|
|
19
|
+
|
|
20
|
+
path = File.expand_path(ARGV.fetch(0))
|
|
21
|
+
delimiter = options[:delimiter] == :tsv || File.extname(path).downcase == ".tsv" ? :tsv : :comma
|
|
22
|
+
workbook = File.file?(path) ? Rukbat::CSVFile.read(path, delimiter: delimiter) : Rukbat::Workbook.new
|
|
23
|
+
if options[:pdf]
|
|
24
|
+
abort "--font PATH is required for PDF export" unless options[:font]
|
|
25
|
+
Rukbat::PDFFile.write(workbook, options[:pdf], font: options[:font])
|
|
26
|
+
puts "Exported #{File.basename(options[:pdf])}"
|
|
27
|
+
exit
|
|
28
|
+
end
|
|
29
|
+
Rukbat::Application.new(workbook: workbook, path: path, delimiter: delimiter).run
|
|
30
|
+
rescue OptionParser::ParseError => error
|
|
31
|
+
warn "rukbat: #{error.message}"
|
|
32
|
+
warn parser
|
|
33
|
+
exit 2
|
|
34
|
+
rescue Rukbat::Error, Menkar::Error, SystemCallError => error
|
|
35
|
+
warn "rukbat: #{error.message}"
|
|
36
|
+
exit 1
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rukbat
|
|
4
|
+
class Application
|
|
5
|
+
attr_reader :workbook, :path, :view
|
|
6
|
+
|
|
7
|
+
def initialize(workbook:, path:, delimiter: :comma, backend: :auto)
|
|
8
|
+
@workbook, @path, @delimiter = workbook, File.expand_path(path), delimiter
|
|
9
|
+
@backend = backend == :auto ? default_backend : backend
|
|
10
|
+
@view = GridView.new(workbook, on_save: method(:save))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
app = Zaniah::App.new
|
|
15
|
+
window = app.open_window(backend: @backend, width: 1100, height: 720,
|
|
16
|
+
title: "Rukbat — #{File.basename(@path)}") { @view }
|
|
17
|
+
window.on_input { |event| @view.handle_shortcut(event, window) }
|
|
18
|
+
app.run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def save
|
|
22
|
+
CSVFile.write(@workbook, @path, delimiter: @delimiter)
|
|
23
|
+
"Saved #{File.basename(@path)}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def default_backend
|
|
29
|
+
return :mac if RUBY_PLATFORM.include?("darwin")
|
|
30
|
+
return :windows if RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
31
|
+
return :linux if ENV["DISPLAY"] || ENV["WAYLAND_DISPLAY"]
|
|
32
|
+
return :tui if $stdin.tty? && $stdout.tty?
|
|
33
|
+
|
|
34
|
+
raise Error, "no display or interactive terminal is available"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rukbat
|
|
4
|
+
class CellSource
|
|
5
|
+
def initialize(workbook)
|
|
6
|
+
@workbook = workbook
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def value_at(reference)
|
|
10
|
+
@workbook.input_at(reference.row, reference.column, sheet: reference.sheet)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each_in(area)
|
|
14
|
+
return enum_for(__method__, area) unless block_given?
|
|
15
|
+
|
|
16
|
+
sheet_name = area.sheet || @workbook.active_sheet
|
|
17
|
+
return self unless @workbook.sheet_names.include?(sheet_name)
|
|
18
|
+
|
|
19
|
+
@workbook.sheet(sheet_name).each_in(area.top - 1, area.left - 1, area.bottom - 1, area.right - 1) do |point, value|
|
|
20
|
+
yield Furud::Reference.new(sheet: sheet_name, row: point.row + 1, column: point.column + 1), value
|
|
21
|
+
end
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Rukbat
|
|
7
|
+
module CSVFile
|
|
8
|
+
module_function
|
|
9
|
+
MAX_EXPORT_CELLS = 10_000_000
|
|
10
|
+
|
|
11
|
+
def read(path, delimiter: :comma, sheet: "Sheet1", hint: nil)
|
|
12
|
+
snapshot = Xamidimura::SourceRevision.read(path)
|
|
13
|
+
bytes = snapshot.bytes
|
|
14
|
+
detection = Menkar.detect(bytes, hint: hint)
|
|
15
|
+
raise Error, "CSV input is binary" if detection.binary
|
|
16
|
+
|
|
17
|
+
text = Menkar.decode(bytes, detection)
|
|
18
|
+
delimiter = delimiter_for(delimiter)
|
|
19
|
+
rows = CSV.parse(text, col_sep: delimiter).map { |row| row.map { |value| value && parse_cell(value) } }
|
|
20
|
+
Workbook.from_rows(rows, sheet: sheet).tap do |workbook|
|
|
21
|
+
workbook.__send__(:bind_source, path, snapshot.digest)
|
|
22
|
+
end
|
|
23
|
+
rescue CSV::MalformedCSVError => error
|
|
24
|
+
raise Error, "invalid CSV: #{error.message}"
|
|
25
|
+
rescue Menkar::Error, SystemCallError => error
|
|
26
|
+
raise Error, "cannot read CSV: #{error.message}"
|
|
27
|
+
rescue Xamidimura::Error => error
|
|
28
|
+
raise Error, "cannot read CSV: #{error.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write(workbook, path, delimiter: :comma, sheet: workbook.active_sheet, values: :calculated,
|
|
32
|
+
expected_digest: :auto)
|
|
33
|
+
col_sep = delimiter_for(delimiter)
|
|
34
|
+
raise ArgumentError, "values must be :calculated or :input" unless %i[calculated input].include?(values)
|
|
35
|
+
expected_digest = workbook.source_digest_for(path) if expected_digest == :auto
|
|
36
|
+
unless expected_digest.nil? || expected_digest == :absent || expected_digest.is_a?(String)
|
|
37
|
+
raise ArgumentError, "expected_digest must be :auto, :absent, a String, or nil"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
current = workbook.sheet(sheet)
|
|
41
|
+
rows, columns = current.row_count, current.column_count
|
|
42
|
+
exported_cells = rows * [columns, 1].max
|
|
43
|
+
raise Error, "CSV export exceeds #{MAX_EXPORT_CELLS} cells" if exported_cells > MAX_EXPORT_CELLS
|
|
44
|
+
|
|
45
|
+
digest = Digest::SHA256.new
|
|
46
|
+
write_atomically(path, expected_digest) do |file|
|
|
47
|
+
current.row_count.times do |row|
|
|
48
|
+
row_cells = Array.new(current.column_count) do |column|
|
|
49
|
+
input = workbook.input_at(row + 1, column + 1, sheet: sheet)
|
|
50
|
+
value = values == :input || !input.is_a?(String) || !input.start_with?("=") ? input : workbook[row + 1, column + 1, sheet: sheet]
|
|
51
|
+
value.is_a?(Furud::ErrorValue) ? value.to_s : value
|
|
52
|
+
end
|
|
53
|
+
line = CSV.generate_line(row_cells, col_sep: col_sep, row_sep: "\r\n").encode(Encoding::UTF_8).b
|
|
54
|
+
file.write(line)
|
|
55
|
+
digest.update(line)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
workbook.__send__(:bind_source, path, digest.hexdigest)
|
|
59
|
+
path
|
|
60
|
+
rescue Xamidimura::Error, SystemCallError, EncodingError => error
|
|
61
|
+
raise Error, "cannot write CSV: #{error.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def infer(value)
|
|
65
|
+
return Integer(value, 10) if value.match?(/\A[+-]?(?:0|[1-9]\d*)\z/)
|
|
66
|
+
if value.match?(/\A[+-]?(?:(?:0|[1-9]\d*)(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?\z/i)
|
|
67
|
+
number = Float(value)
|
|
68
|
+
return number if number.finite?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
value
|
|
72
|
+
end
|
|
73
|
+
private_class_method :infer
|
|
74
|
+
|
|
75
|
+
def parse_cell(value)
|
|
76
|
+
return nil if value.empty?
|
|
77
|
+
|
|
78
|
+
infer(value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def delimiter_for(value)
|
|
82
|
+
case value
|
|
83
|
+
when :comma then ","
|
|
84
|
+
when :tab, :tsv then "\t"
|
|
85
|
+
when String then value
|
|
86
|
+
else raise ArgumentError, "delimiter must be :comma, :tab, :tsv, or a String"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
private_class_method :delimiter_for
|
|
90
|
+
|
|
91
|
+
def write_atomically(path, expected_digest)
|
|
92
|
+
target = File.expand_path(path)
|
|
93
|
+
Tempfile.create([".rukbat-", ".tmp"], File.dirname(target), binmode: true) do |file|
|
|
94
|
+
yield file
|
|
95
|
+
file.flush
|
|
96
|
+
file.fsync
|
|
97
|
+
stat = begin
|
|
98
|
+
File.lstat(target)
|
|
99
|
+
rescue Errno::ENOENT
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
raise Error, "CSV target must be a regular file" if stat && !stat.file?
|
|
103
|
+
if expected_digest == :absent && stat
|
|
104
|
+
raise Error, "CSV file appeared since the workbook was opened"
|
|
105
|
+
elsif expected_digest.is_a?(String) && (!stat || Xamidimura::SourceRevision.file_digest(target) != expected_digest)
|
|
106
|
+
raise Error, "CSV file changed since it was loaded"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
mode = stat ? stat.mode & 0o7777 : 0o666 & ~File.umask
|
|
110
|
+
# ponytail: the digest check is advisory; an external writer racing the final rename needs OS-specific compare-and-swap locking.
|
|
111
|
+
file.chmod(mode)
|
|
112
|
+
file.close
|
|
113
|
+
AtomicFile.install(file.path, target, replace: expected_digest != :absent)
|
|
114
|
+
end
|
|
115
|
+
begin
|
|
116
|
+
File.open(File.dirname(target), "r", &:fsync)
|
|
117
|
+
rescue SystemCallError, IOError
|
|
118
|
+
# Directory fsync is unavailable on some supported platforms.
|
|
119
|
+
end
|
|
120
|
+
rescue Errno::ENOENT
|
|
121
|
+
raise Error, "CSV target directory does not exist"
|
|
122
|
+
end
|
|
123
|
+
private_class_method :write_atomically
|
|
124
|
+
end
|
|
125
|
+
end
|