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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-23
4
+
5
+ - Initial release.
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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rukbat
4
+ module AtomicFile
5
+ module_function
6
+
7
+ def install(source, target, replace:)
8
+ replace ? File.rename(source, target) : File.link(source, target)
9
+ end
10
+ end
11
+ 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