rapicco 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: f75b483f11ab21e49743716cf5c8e41d66d906e59a06a6cd3d91e7a68951f5da
4
+ data.tar.gz: dfdd6c4403ca2cd7d652b1a4bdf13512919f4142c8e5836279142462583734fa
5
+ SHA512:
6
+ metadata.gz: 995b0755fb6634c30e0183b51cd7638da3fb1b6ba0f32eb56ddd4df030710fcae9c8da188a4ce698fcd7c80b10657edd6819d3cfd4e84ed093345f1f05a8885f
7
+ data.tar.gz: f277684e2d09d4446793ccbaffee37175cd2109838006a1d5d95d4c3a41ba510ff802101303975a9307a14208f19c30b6772534af3663639f422c8c5433c3242
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 PicoRuby
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Rapicco
2
+
3
+ A wrapper tool of PicoRuby Rapicco terminal-based presentation.
4
+
5
+ ## Overview
6
+
7
+ Rapicco is a tool that shows presentation slide on terminal emulator by running picoruby process.
8
+ It also converts Rapicco presentations into PDF documents capturing the ANSI terminal output from Rapicco and renders it as a high-quality PDF.
9
+
10
+ ## Requirements
11
+
12
+ - Ruby 3.0 or later
13
+ - Cairo graphics library
14
+ - PicoRuby with Rapicco installed
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ gem install rapicco
20
+ ```
21
+
22
+ Or add to your Gemfile:
23
+
24
+ ```ruby
25
+ gem 'rapicco'
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ **Show presentation:**
31
+ ```bash
32
+ bundle exec rapicco input.md
33
+ ```
34
+
35
+ **Generate PDF:**
36
+ ```bash
37
+ bundle exec rapicco --print input.md
38
+ ```
39
+
40
+ ## How it generates PDF
41
+
42
+ 1. Executes Rapicco with the input markdown file
43
+ 2. Captures each page of the presentation via PTY
44
+ 3. Parses ANSI escape sequences (colors, cursor positioning, block characters)
45
+ 4. Renders each page to PDF using Cairo graphics library
46
+ 5. Combines all pages into a single PDF document
47
+
48
+ ## License
49
+
50
+ Copyright © 2025 HASUMI Hitoshi. See MIT-LICENSE for further details.
data/bin/rapicco ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/rapicco'
4
+
5
+ def print_usage
6
+ puts "Usage: rapicco [command] [options] <slide.md>"
7
+ puts ""
8
+ puts "Commands:"
9
+ puts " rapicco new <directory> Create slide project template"
10
+ puts " rapicco new . Create template in current directory"
11
+ puts " rapicco <slide.md> Run presentation"
12
+ puts " rapicco --print <slide.md> Export to PDF (slide.pdf)"
13
+ puts " rapicco -p -o output.pdf <slide.md> Export to PDF with custom name"
14
+ puts ""
15
+ puts "Options:"
16
+ puts " -p, --print Export presentation to PDF instead of running"
17
+ puts " -o, --output-filename FILE Output PDF filename (default: <slide>.pdf)"
18
+ puts " --rapicco-command CMD Command to run Rapicco (default: use PICORUBY_PATH)"
19
+ puts " -h, --help Show this help message"
20
+ puts ""
21
+ puts "PDF export options:"
22
+ puts " --cols NUMBER Terminal columns for PDF capture (default: 500)"
23
+ puts " --rows NUMBER Terminal rows for PDF capture (default: 280)"
24
+ puts " --char-width NUMBER Character width in pixels (default: 5)"
25
+ puts " --char-height NUMBER Character height in pixels (default: 10)"
26
+ puts ""
27
+ puts "Examples:"
28
+ puts " rapicco new my-slide"
29
+ puts " rapicco new ."
30
+ puts " rapicco presentation.md"
31
+ puts " rapicco --print presentation.md"
32
+ puts " rapicco -p -o output.pdf presentation.md"
33
+ puts " rapicco -p --cols 600 --rows 150 presentation.md"
34
+ end
35
+
36
+ # Check for new command
37
+ if ARGV[0] == 'new'
38
+ if ARGV.length != 2
39
+ puts "Error: Please provide a directory name"
40
+ puts "Usage: rapicco new <directory>"
41
+ exit 1
42
+ end
43
+
44
+ require_relative '../lib/rapicco/installer'
45
+ installer = Rapicco::Installer.new(ARGV[1])
46
+ begin
47
+ installer.install
48
+ exit 0
49
+ rescue => e
50
+ puts "Error: #{e.message}"
51
+ exit 1
52
+ end
53
+ end
54
+
55
+ options = {}
56
+ args = []
57
+
58
+ print_mode = false
59
+
60
+ i = 0
61
+ while i < ARGV.length
62
+ case ARGV[i]
63
+ when '-h', '--help'
64
+ print_usage
65
+ exit 0
66
+ when '-p', '--print'
67
+ print_mode = true
68
+ when '-o', '--output-filename'
69
+ options[:output_filename] = ARGV[i + 1]
70
+ i += 1
71
+ when '--rapicco-command'
72
+ options[:rapicco_command] = ARGV[i + 1]
73
+ i += 1
74
+ when '--cols'
75
+ options[:cols] = ARGV[i + 1].to_i
76
+ i += 1
77
+ when '--rows'
78
+ # Rapicco uses half-block characters (2 vertical dots per character)
79
+ # so divide rows by 2 for internal processing
80
+ options[:rows] = ARGV[i + 1].to_i / 2
81
+ i += 1
82
+ when '--char-width'
83
+ options[:char_width] = ARGV[i + 1].to_i
84
+ i += 1
85
+ when '--char-height'
86
+ options[:char_height] = ARGV[i + 1].to_i
87
+ i += 1
88
+ else
89
+ args << ARGV[i]
90
+ end
91
+ i += 1
92
+ end
93
+
94
+ if args.length != 1
95
+ puts "Error: Please provide a slide file"
96
+ puts ""
97
+ print_usage
98
+ exit 1
99
+ end
100
+
101
+ slide_file = args[0]
102
+
103
+ unless File.exist?(slide_file)
104
+ puts "Error: Input file not found: #{slide_file}"
105
+ exit 1
106
+ end
107
+
108
+ if print_mode
109
+ # PDF export mode
110
+ output_pdf = options[:output_filename] || slide_file.sub(/\.[^.]+$/, '.pdf')
111
+
112
+ # Auto-calculate missing dimension for 16:9 aspect ratio
113
+ if options[:cols] && !options[:rows]
114
+ # cols specified, calculate rows for 16:9
115
+ # Divide by 2 because Rapicco uses half-block characters (2 vertical dots per character)
116
+ options[:rows] = (options[:cols] * 9.0 / 16.0 / 2.0).round
117
+ elsif !options[:cols] && options[:rows]
118
+ # rows specified (already divided by 2), calculate cols for 16:9
119
+ # Multiply by 2 to get actual vertical dots before calculating horizontal
120
+ options[:cols] = (options[:rows] * 2.0 * 16.0 / 9.0).round
121
+ end
122
+
123
+ begin
124
+ converter = Rapicco::PDF::Converter.new(slide_file, output_pdf, options)
125
+ converter.convert
126
+ rescue => e
127
+ puts "Error: #{e.message}"
128
+ puts e.backtrace if ENV['DEBUG']
129
+ exit 1
130
+ end
131
+ else
132
+ # Presentation mode
133
+ begin
134
+ presenter = Rapicco::Presenter.new(slide_file, options)
135
+ presenter.run
136
+ rescue => e
137
+ puts "Error: #{e.message}"
138
+ puts e.backtrace if ENV['DEBUG']
139
+ exit 1
140
+ end
141
+ end
@@ -0,0 +1,144 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ module Rapicco
5
+ class Installer
6
+ def initialize(dir_name, options = {})
7
+ @dir_name = dir_name
8
+ @options = options
9
+ end
10
+
11
+ def install
12
+ if @dir_name == '.'
13
+ # Install in current directory
14
+ target_dir = Dir.pwd
15
+ dir_display = 'current directory'
16
+ else
17
+ # Create new directory
18
+ if File.exist?(@dir_name)
19
+ raise "Directory '#{@dir_name}' already exists"
20
+ end
21
+ FileUtils.mkdir_p(@dir_name)
22
+ target_dir = @dir_name
23
+ dir_display = "'#{@dir_name}'"
24
+ end
25
+
26
+ FileUtils.mkdir_p(File.join(target_dir, 'pdf'))
27
+
28
+ create_gemfile(target_dir)
29
+ create_rakefile(target_dir)
30
+ create_slide(target_dir)
31
+ create_config(target_dir)
32
+ create_readme(target_dir)
33
+ create_gitignore(target_dir)
34
+
35
+ puts "Created slide template in #{dir_display}"
36
+ puts ""
37
+ puts "Next steps:"
38
+ unless @dir_name == '.'
39
+ puts " cd #{@dir_name}"
40
+ end
41
+ puts " bundle install"
42
+ puts " bundle exec rake run # Run presentation"
43
+ puts " bundle exec rake pdf # Generate PDF"
44
+ puts " bundle exec rake gem # Create gem package"
45
+ end
46
+
47
+ private
48
+
49
+ def create_gemfile(target_dir)
50
+ content = <<~GEMFILE
51
+ source 'https://rubygems.org'
52
+
53
+ gem 'rapicco'
54
+ GEMFILE
55
+
56
+ File.write(File.join(target_dir, 'Gemfile'), content)
57
+ end
58
+
59
+ def create_rakefile(target_dir)
60
+ content = <<~RAKEFILE
61
+ require 'rapicco/task/slide'
62
+ RAKEFILE
63
+
64
+ File.write(File.join(target_dir, 'Rakefile'), content)
65
+ end
66
+
67
+ def create_slide(target_dir)
68
+ content = <<~SLIDE
69
+ ---
70
+ duration: 300
71
+ sprite: hasumikin
72
+ title_font: terminus_8x16
73
+ font: terminus_6x12
74
+ bold_color: red
75
+ align: center
76
+ line_margin: 1
77
+ code_indent: 2
78
+ ---
79
+
80
+ # Title Slide
81
+ {align=center, scale=2}
82
+
83
+ Your presentation title
84
+
85
+ # Introduction
86
+
87
+ - Point 1
88
+ - Point 2
89
+ - Point 3
90
+
91
+ # Conclusion
92
+
93
+ Thank you!
94
+ SLIDE
95
+
96
+ File.write(File.join(target_dir, 'slide.md'), content)
97
+ end
98
+
99
+ def create_config(target_dir)
100
+ config = {
101
+ 'id' => nil,
102
+ 'base_name' => nil,
103
+ 'tags' => [],
104
+ 'version' => nil,
105
+ 'licenses' => [],
106
+ 'author' => {
107
+ 'name' => nil,
108
+ 'email' => nil,
109
+ 'rubygems_user' => nil
110
+ }
111
+ }
112
+
113
+ File.write(File.join(target_dir, 'config.yml'), YAML.dump(config))
114
+ end
115
+
116
+ def create_readme(target_dir)
117
+ content = <<~README
118
+ # Slide Title
119
+
120
+ Your slide description here.
121
+
122
+ ## Author
123
+
124
+ Your Name
125
+
126
+ ## License
127
+
128
+ CC BY-SA 4.0
129
+ README
130
+
131
+ File.write(File.join(target_dir, 'README.md'), content)
132
+ end
133
+
134
+ def create_gitignore(target_dir)
135
+ content = <<~GITIGNORE
136
+ pdf/
137
+ pkg/
138
+ .DS_Store
139
+ GITIGNORE
140
+
141
+ File.write(File.join(target_dir, '.gitignore'), content)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,245 @@
1
+ module Rapicco
2
+ module PDF
3
+ class AnsiParser
4
+ ANSI_CSI_PATTERN = /\e\[([0-9;]*)([A-Za-z@])/
5
+ ANSI_RESET = /\e\[0m/
6
+
7
+ BLOCK_CHARS = {
8
+ "\u2588" => :full,
9
+ "\u2580" => :upper,
10
+ "\u2584" => :lower,
11
+ " " => :empty
12
+ }
13
+
14
+ COLOR_256_TO_RGB = {}
15
+
16
+ def initialize(cols: 80, rows: 24)
17
+ @cols = cols
18
+ @rows = rows
19
+ @screen = Array.new(rows) { Array.new(cols) { { char: ' ', fg: nil, bg: nil } } }
20
+ @cursor_x = 0
21
+ @cursor_y = 0
22
+ @current_fg = nil
23
+ @current_bg = nil
24
+ setup_color_palette
25
+ end
26
+
27
+ attr_reader :screen, :cols, :rows
28
+
29
+ def parse(text)
30
+ reset_screen
31
+ text = text.force_encoding('UTF-8') unless text.encoding == Encoding::UTF_8
32
+ chars = text.chars
33
+ i = 0
34
+ while i < chars.length
35
+ if chars[i] == "\e"
36
+ i = parse_escape_sequence_from_chars(chars, i)
37
+ else
38
+ write_char(chars[i])
39
+ i += 1
40
+ end
41
+ end
42
+ self
43
+ end
44
+
45
+ def reset_screen
46
+ @screen = Array.new(@rows) { Array.new(@cols) { { char: ' ', fg: nil, bg: nil } } }
47
+ @cursor_x = 0
48
+ @cursor_y = 0
49
+ @current_fg = nil
50
+ @current_bg = nil
51
+ end
52
+
53
+ private
54
+
55
+ def parse_escape_sequence_from_chars(chars, start)
56
+ if start + 1 < chars.length && chars[start + 1] == '['
57
+ # Build string from chars for regex matching
58
+ remaining = chars[start..-1].join
59
+ if match = remaining.match(ANSI_CSI_PATTERN)
60
+ params = match[1].split(';').map(&:to_i)
61
+ command = match[2]
62
+ handle_csi_command(command, params)
63
+ return start + match[0].length
64
+ end
65
+ end
66
+ start + 1
67
+ end
68
+
69
+ def handle_csi_command(command, params)
70
+ case command
71
+ when 'H', 'f'
72
+ row = params[0] || 1
73
+ col = params[1] || 1
74
+ @cursor_y = [row - 1, 0].max
75
+ @cursor_x = [col - 1, 0].max
76
+ when 'A'
77
+ @cursor_y = [@cursor_y - (params[0] || 1), 0].max
78
+ when 'B'
79
+ @cursor_y = [@cursor_y + (params[0] || 1), @rows - 1].min
80
+ when 'C'
81
+ @cursor_x = [@cursor_x + (params[0] || 1), @cols - 1].min
82
+ when 'D'
83
+ @cursor_x = [@cursor_x - (params[0] || 1), 0].max
84
+ when 'E'
85
+ @cursor_y = [@cursor_y + (params[0] || 1), @rows - 1].min
86
+ @cursor_x = 0
87
+ when 'F'
88
+ @cursor_y = [@cursor_y - (params[0] || 1), 0].max
89
+ @cursor_x = 0
90
+ when 'J'
91
+ clear_screen(params[0] || 0)
92
+ when 'K'
93
+ clear_line(params[0] || 0)
94
+ when '@'
95
+ # ICH - Insert Character: Insert blank characters at cursor position
96
+ # This shifts existing content to the right
97
+ count = params[0] || 1
98
+ return if @cursor_y >= @rows || @cursor_x >= @cols
99
+
100
+ # Shift existing characters to the right
101
+ count = [count, @cols - @cursor_x].min
102
+ if count > 0
103
+ # Move existing characters to the right
104
+ (@cols - 1).downto(@cursor_x + count) do |x|
105
+ if x >= @cursor_x + count && x - count >= @cursor_x
106
+ @screen[@cursor_y][x] = @screen[@cursor_y][x - count]
107
+ end
108
+ end
109
+ # Insert blank characters
110
+ (@cursor_x...[@cursor_x + count, @cols].min).each do |x|
111
+ @screen[@cursor_y][x] = { char: ' ', fg: @current_fg, bg: @current_bg }
112
+ end
113
+ end
114
+ # Cursor position does not change
115
+ when 'm'
116
+ handle_sgr(params.empty? ? [0] : params)
117
+ end
118
+ end
119
+
120
+ def handle_sgr(params)
121
+ i = 0
122
+ while i < params.length
123
+ case params[i]
124
+ when 0
125
+ @current_fg = nil
126
+ @current_bg = nil
127
+ when 31
128
+ @current_fg = [1.0, 0.0, 0.0]
129
+ when 32
130
+ @current_fg = [0.0, 1.0, 0.0]
131
+ when 33
132
+ @current_fg = [1.0, 1.0, 0.0]
133
+ when 34
134
+ @current_fg = [0.0, 0.0, 1.0]
135
+ when 35
136
+ @current_fg = [1.0, 0.0, 1.0]
137
+ when 36
138
+ @current_fg = [0.0, 1.0, 1.0]
139
+ when 37
140
+ @current_fg = [1.0, 1.0, 1.0]
141
+ when 38
142
+ if params[i + 1] == 5 && params[i + 2]
143
+ @current_fg = color_256_to_rgb(params[i + 2])
144
+ i += 2
145
+ end
146
+ when 48
147
+ if params[i + 1] == 5 && params[i + 2]
148
+ @current_bg = color_256_to_rgb(params[i + 2])
149
+ i += 2
150
+ end
151
+ end
152
+ i += 1
153
+ end
154
+ end
155
+
156
+ def clear_screen(mode)
157
+ case mode
158
+ when 0
159
+ (@cursor_y...@rows).each do |y|
160
+ start_x = y == @cursor_y ? @cursor_x : 0
161
+ (start_x...@cols).each do |x|
162
+ @screen[y][x] = { char: ' ', fg: nil, bg: nil }
163
+ end
164
+ end
165
+ when 1
166
+ (0..@cursor_y).each do |y|
167
+ end_x = y == @cursor_y ? @cursor_x : @cols - 1
168
+ (0..end_x).each do |x|
169
+ @screen[y][x] = { char: ' ', fg: nil, bg: nil }
170
+ end
171
+ end
172
+ when 2
173
+ @screen = Array.new(@rows) { Array.new(@cols) { { char: ' ', fg: nil, bg: nil } } }
174
+ end
175
+ end
176
+
177
+ def clear_line(mode)
178
+ case mode
179
+ when 0
180
+ (@cursor_x...@cols).each do |x|
181
+ @screen[@cursor_y][x] = { char: ' ', fg: nil, bg: nil }
182
+ end
183
+ when 1
184
+ (0..@cursor_x).each do |x|
185
+ @screen[@cursor_y][x] = { char: ' ', fg: nil, bg: nil }
186
+ end
187
+ when 2
188
+ (0...@cols).each do |x|
189
+ @screen[@cursor_y][x] = { char: ' ', fg: nil, bg: nil }
190
+ end
191
+ end
192
+ end
193
+
194
+ def write_char(char)
195
+ return if @cursor_y >= @rows
196
+ if char == "\n"
197
+ @cursor_y += 1
198
+ @cursor_x = 0
199
+ elsif char == "\r"
200
+ @cursor_x = 0
201
+ elsif char.ord >= 32
202
+ if @cursor_x < @cols
203
+ @screen[@cursor_y][@cursor_x] = {
204
+ char: char,
205
+ fg: @current_fg,
206
+ bg: @current_bg
207
+ }
208
+ @cursor_x += 1
209
+ end
210
+ end
211
+ end
212
+
213
+ def setup_color_palette
214
+ (0..255).each do |i|
215
+ COLOR_256_TO_RGB[i] = xterm_256_to_rgb(i)
216
+ end
217
+ end
218
+
219
+ def color_256_to_rgb(index)
220
+ COLOR_256_TO_RGB[index] || [1.0, 1.0, 1.0]
221
+ end
222
+
223
+ def xterm_256_to_rgb(index)
224
+ if index < 16
225
+ basic_colors = [
226
+ [0.0, 0.0, 0.0], [0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [0.5, 0.5, 0.0],
227
+ [0.0, 0.0, 0.5], [0.5, 0.0, 0.5], [0.0, 0.5, 0.5], [0.75, 0.75, 0.75],
228
+ [0.5, 0.5, 0.5], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0],
229
+ [0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [0.0, 1.0, 1.0], [1.0, 1.0, 1.0]
230
+ ]
231
+ basic_colors[index]
232
+ elsif index < 232
233
+ i = index - 16
234
+ r = (i / 36) * 51
235
+ g = ((i % 36) / 6) * 51
236
+ b = (i % 6) * 51
237
+ [r / 255.0, g / 255.0, b / 255.0]
238
+ else
239
+ gray = 8 + (index - 232) * 10
240
+ [gray / 255.0, gray / 255.0, gray / 255.0]
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,99 @@
1
+ require 'pty'
2
+ require 'io/console'
3
+ require 'timeout'
4
+
5
+ module Rapicco
6
+ module PDF
7
+ class PageCapturer
8
+ def initialize(rapicco_command, slide_file, cols: 80, rows: 24)
9
+ @rapicco_command = rapicco_command
10
+ @slide_file = slide_file
11
+ @cols = cols
12
+ @rows = rows
13
+ @pages = []
14
+ end
15
+
16
+ attr_reader :pages
17
+
18
+ def capture_all_pages(max_pages: 100)
19
+ expected_pages = count_slides(@slide_file)
20
+ puts "Detected #{expected_pages} slides in presentation"
21
+
22
+ PTY.spawn("#{@rapicco_command} #{@slide_file}") do |stdout, stdin, pid|
23
+ stdin.winsize = [@rows, @cols]
24
+
25
+ puts "Waiting for Rapicco to start and render..."
26
+ sleep 3
27
+
28
+ current_page = capture_current_screen(stdout, timeout: 2.0)
29
+ if current_page
30
+ @pages << current_page
31
+ puts "Captured page 1/#{expected_pages} (#{current_page.length} bytes)"
32
+ end
33
+
34
+ (expected_pages - 1).times do |i|
35
+ puts "Sending 'l' for next page..."
36
+ stdin.write('l')
37
+ stdin.flush
38
+
39
+ sleep 1
40
+
41
+ page = capture_current_screen(stdout, timeout: 2.0)
42
+ unless page
43
+ puts "No output received, stopping"
44
+ break
45
+ end
46
+
47
+ @pages << page
48
+ puts "Captured page #{@pages.length}/#{expected_pages} (#{page.length} bytes)"
49
+ end
50
+
51
+ stdin.write("\x03")
52
+ rescue Errno::EIO => e
53
+ puts "PTY error: #{e.message}"
54
+ ensure
55
+ Process.kill('TERM', pid) rescue nil
56
+ Process.wait(pid) rescue nil
57
+ end
58
+
59
+ @pages
60
+ end
61
+
62
+ private
63
+
64
+ def count_slides(slide_file)
65
+ return 1 unless File.exist?(slide_file)
66
+
67
+ content = File.read(slide_file, encoding: 'UTF-8')
68
+ slide_count = content.lines.count { |line| line =~ /\A# / }
69
+
70
+ [slide_count, 1].max
71
+ end
72
+
73
+ def capture_current_screen(stdout, timeout: 2.0)
74
+ output = String.new(encoding: Encoding::BINARY)
75
+
76
+ begin
77
+ Timeout.timeout(timeout) do
78
+ loop do
79
+ output << stdout.read_nonblock(10000)
80
+ end
81
+ end
82
+ rescue Timeout::Error
83
+ rescue IO::WaitReadable
84
+ rescue EOFError, Errno::EIO
85
+ return nil
86
+ end
87
+
88
+ return nil if output.empty?
89
+ output.force_encoding(Encoding::UTF_8)
90
+
91
+ # Extract only the last frame to avoid animation artifacts
92
+ # Split by cursor home sequences which indicate a new frame
93
+ frames = output.split(/(?=\e\[H|\e\[1;1H)/)
94
+
95
+ frames.length > 1 ? frames.last : output
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,74 @@
1
+ require 'cairo'
2
+
3
+ module Rapicco
4
+ module PDF
5
+ class Renderer
6
+ BLOCK_CHARS = {
7
+ "\u2588" => :full,
8
+ "\u2580" => :upper,
9
+ "\u2584" => :lower,
10
+ " " => :empty
11
+ }
12
+
13
+ def initialize(char_width: 10, char_height: 20)
14
+ @char_width = char_width
15
+ @char_height = char_height
16
+ end
17
+
18
+ def render(pages, output_path, parser_cols: 80, parser_rows: 24)
19
+ width = parser_cols * @char_width
20
+ height = parser_rows * @char_height
21
+
22
+ Cairo::PDFSurface.new(output_path, width, height) do |surface|
23
+ pages.each do |page_data|
24
+ context = Cairo::Context.new(surface)
25
+ render_page(page_data, context)
26
+ context.show_page
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def render_page(screen, context)
34
+ context.set_source_rgb(0, 0, 0)
35
+ context.paint
36
+
37
+ screen.each_with_index do |line, y|
38
+ line.each_with_index do |cell, x|
39
+ render_cell(cell, x, y, context)
40
+ end
41
+ end
42
+ end
43
+
44
+ def render_cell(cell, x, y, context)
45
+ px = x * @char_width
46
+ py = y * @char_height
47
+
48
+ if cell[:bg]
49
+ context.set_source_rgb(*cell[:bg])
50
+ context.rectangle(px, py, @char_width, @char_height)
51
+ context.fill
52
+ end
53
+
54
+ char_type = BLOCK_CHARS[cell[:char]]
55
+ return unless char_type && cell[:fg]
56
+
57
+ context.set_source_rgb(*cell[:fg])
58
+
59
+ case char_type
60
+ when :full
61
+ context.rectangle(px, py, @char_width, @char_height)
62
+ context.fill
63
+ when :upper
64
+ context.rectangle(px, py, @char_width, @char_height / 2.0)
65
+ context.fill
66
+ when :lower
67
+ context.rectangle(px, py + @char_height / 2.0, @char_width, @char_height / 2.0)
68
+ context.fill
69
+ when :empty
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,72 @@
1
+ require_relative 'pdf/ansi_parser'
2
+ require_relative 'pdf/page_capturer'
3
+ require_relative 'pdf/renderer'
4
+
5
+ module Rapicco
6
+ module PDF
7
+ class Converter
8
+ def initialize(slide_file, output_pdf, options = {})
9
+ @slide_file = slide_file
10
+ @output_pdf = output_pdf
11
+ @cols = options[:cols] || 500
12
+ @rows = options[:rows] || 140
13
+ @char_width = options[:char_width] || 5
14
+ @char_height = options[:char_height] || 10
15
+ @rapicco_command = options[:rapicco_command] || detect_picoruby_command
16
+ end
17
+
18
+ private
19
+
20
+ def detect_picoruby_command
21
+ unless ENV['PICORUBY_PATH']
22
+ raise <<~ERROR
23
+ PICORUBY_PATH environment variable is not set.
24
+
25
+ Please set it to the path of your picoruby executable:
26
+ export PICORUBY_PATH=/path/to/picoruby
27
+
28
+ Or use the --rapicco-command option:
29
+ rapicco --rapicco-command "/path/to/picoruby -e ..." <slide.md> <output.pdf>
30
+
31
+ To install PicoRuby, see: https://github.com/picoruby/picoruby
32
+ ERROR
33
+ end
34
+
35
+ unless File.executable?(ENV['PICORUBY_PATH'])
36
+ raise "PICORUBY_PATH is set to '#{ENV['PICORUBY_PATH']}' but it is not executable"
37
+ end
38
+
39
+ picoruby = ENV['PICORUBY_PATH']
40
+ "#{picoruby} -e \"require 'rapicco'; Rapicco.new(ARGV[0], cols: #{@cols}, rows: #{@rows}).run\""
41
+ end
42
+
43
+ public
44
+
45
+ def convert
46
+ puts "Capturing pages from Rapicco presentation via PTY..."
47
+ capturer = PageCapturer.new(@rapicco_command, @slide_file, cols: @cols, rows: @rows)
48
+ raw_pages = capturer.capture_all_pages
49
+
50
+ if raw_pages.empty?
51
+ raise "No pages captured. Check if Rapicco is working correctly."
52
+ end
53
+
54
+ puts "Captured #{raw_pages.length} raw pages"
55
+
56
+ puts "Parsing ANSI escape sequences..."
57
+ parsed_pages = raw_pages.map do |raw_page|
58
+ parser = AnsiParser.new(cols: @cols, rows: @rows)
59
+ parser.parse(raw_page)
60
+ parser.screen
61
+ end
62
+
63
+ puts "Rendering PDF..."
64
+ renderer = Renderer.new(char_width: @char_width, char_height: @char_height)
65
+ renderer.render(parsed_pages, @output_pdf, parser_cols: @cols, parser_rows: @rows)
66
+
67
+ puts "PDF created: #{@output_pdf}"
68
+ end
69
+ end
70
+ end
71
+ end
72
+
@@ -0,0 +1,52 @@
1
+ require 'shellwords'
2
+
3
+ module Rapicco
4
+ class Presenter
5
+ def initialize(slide_file, options = {})
6
+ @slide_file = slide_file
7
+ @rapicco_command = options[:rapicco_command] || detect_picoruby_command(options)
8
+ end
9
+
10
+ def run
11
+ # Disable terminal echo during presentation
12
+ system("stty -echo -icanon")
13
+
14
+ begin
15
+ system("#{@rapicco_command} #{Shellwords.shellescape(@slide_file)}")
16
+ ensure
17
+ # Restore terminal settings
18
+ system("stty echo icanon")
19
+ # Show cursor (CSI ? 25 h)
20
+ print "\e[?25h"
21
+ # Exit alternate screen buffer (CSI ? 1049 l)
22
+ print "\e[?1049l"
23
+ $stdout.flush
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def detect_picoruby_command(options)
30
+ unless ENV['PICORUBY_PATH']
31
+ raise <<~ERROR
32
+ PICORUBY_PATH environment variable is not set.
33
+
34
+ Please set it to the path of your picoruby executable:
35
+ export PICORUBY_PATH=/path/to/picoruby
36
+
37
+ Or use the --rapicco-command option:
38
+ rapicco --rapicco-command "/path/to/picoruby -e ..." <slide.md>
39
+
40
+ To install PicoRuby, see: https://github.com/picoruby/picoruby
41
+ ERROR
42
+ end
43
+
44
+ unless File.executable?(ENV['PICORUBY_PATH'])
45
+ raise "PICORUBY_PATH is set to '#{ENV['PICORUBY_PATH']}' but it is not executable"
46
+ end
47
+
48
+ picoruby = ENV['PICORUBY_PATH']
49
+ "#{picoruby} -e \"require 'rapicco'; Rapicco.new(ARGV[0]).run\""
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,132 @@
1
+ require 'fileutils'
2
+ require 'rubygems/package'
3
+
4
+ module Rapicco
5
+ class SlideGemBuilder
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ def build
11
+ validate_config
12
+
13
+ FileUtils.mkdir_p('pkg')
14
+
15
+ spec = build_gemspec
16
+ gem_file = File.join('pkg', "#{spec.full_name}.gem")
17
+
18
+ if File.exist?(gem_file)
19
+ FileUtils.rm(gem_file)
20
+ end
21
+
22
+ Gem::Package.build(spec)
23
+ FileUtils.mv("#{spec.full_name}.gem", gem_file)
24
+
25
+ gem_file
26
+ end
27
+
28
+ private
29
+
30
+ def validate_config
31
+ errors = []
32
+
33
+ # Required fields
34
+ errors << "config.yml: 'id' is required" if @config['id'].nil? || @config['id'].empty?
35
+ errors << "config.yml: 'base_name' is required" if @config['base_name'].nil? || @config['base_name'].empty?
36
+ errors << "config.yml: 'version' is required" if @config['version'].nil? || @config['version'].empty?
37
+
38
+ # Licenses
39
+ if @config['licenses'].nil? || @config['licenses'].empty?
40
+ errors << "config.yml: 'licenses' must have at least one license"
41
+ end
42
+
43
+ # Author fields
44
+ if @config['author'].nil?
45
+ errors << "config.yml: 'author' section is required"
46
+ else
47
+ errors << "config.yml: 'author.name' is required" if @config['author']['name'].nil? || @config['author']['name'].empty?
48
+ errors << "config.yml: 'author.email' is required" if @config['author']['email'].nil? || @config['author']['email'].empty?
49
+ errors << "config.yml: 'author.rubygems_user' is required" if @config['author']['rubygems_user'].nil? || @config['author']['rubygems_user'].empty?
50
+ end
51
+
52
+ unless errors.empty?
53
+ raise <<~ERROR
54
+ Configuration validation failed:
55
+
56
+ #{errors.map { |e| " - #{e}" }.join("\n")}
57
+
58
+ Please edit config.yml and fill in all required fields.
59
+ ERROR
60
+ end
61
+ end
62
+
63
+ def build_gemspec
64
+ config = @config
65
+ slide_file = find_slide_file
66
+ pdf_file = "pdf/#{config['id']}-#{config['base_name']}.pdf"
67
+
68
+ Gem::Specification.new do |spec|
69
+ spec.name = "rabbit-slide-#{config['author']['rubygems_user']}-#{config['id']}"
70
+ spec.version = config['version']
71
+ spec.authors = [config['author']['name']]
72
+ spec.email = [config['author']['email']]
73
+
74
+ spec.summary = "Rapicco slide: #{config['id']}"
75
+ spec.description = read_description
76
+ spec.homepage = "https://slide.rabbit-shocker.org/authors/#{config['author']['rubygems_user']}/#{config['id']}/"
77
+ spec.license = config['licenses'].first
78
+
79
+ spec.metadata = {
80
+ "rapicco.slide.id" => config['id'],
81
+ "rapicco.slide.base_name" => config['base_name'],
82
+ "rapicco.slide.tags" => config['tags'].join(',')
83
+ }
84
+
85
+ spec.files = [
86
+ slide_file,
87
+ pdf_file,
88
+ 'config.yml',
89
+ 'Rakefile',
90
+ 'README.md'
91
+ ].select { |f| File.exist?(f) }
92
+
93
+ # Slide gems don't have Ruby code to require, but RubyGems requires at least one path
94
+ spec.require_paths = ["."]
95
+ end
96
+ end
97
+
98
+ def find_slide_file
99
+ if File.exist?('slide.md')
100
+ 'slide.md'
101
+ else
102
+ Dir.glob('*.md').reject { |f| f == 'README.md' }.first
103
+ end
104
+ end
105
+
106
+ def read_description
107
+ return @config['id'] unless File.exist?('README.md')
108
+
109
+ readme = File.read('README.md')
110
+ # Extract first paragraph after title
111
+ lines = readme.lines
112
+ description_lines = []
113
+ found_title = false
114
+
115
+ lines.each do |line|
116
+ if line.start_with?('#')
117
+ found_title = true
118
+ next
119
+ end
120
+
121
+ next if line.strip.empty? && !found_title
122
+
123
+ if found_title
124
+ break if line.strip.empty? && !description_lines.empty?
125
+ description_lines << line.strip
126
+ end
127
+ end
128
+
129
+ description_lines.join(' ').strip[0..200]
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,80 @@
1
+ require 'rake'
2
+ require 'rake/tasklib'
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require_relative '../slide_gem_builder'
6
+
7
+ module Rapicco
8
+ module Task
9
+ class Slide < Rake::TaskLib
10
+ def initialize
11
+ @config = load_config
12
+ define_tasks
13
+ end
14
+
15
+ private
16
+
17
+ def load_config
18
+ unless File.exist?('config.yml')
19
+ raise "config.yml not found. Please create it first."
20
+ end
21
+ YAML.load_file('config.yml')
22
+ end
23
+
24
+ def define_tasks
25
+ desc "Run presentation"
26
+ task :run do
27
+ slide_file = find_slide_file
28
+ sh "rapicco #{slide_file}"
29
+ end
30
+
31
+ desc "Generate PDF"
32
+ task :pdf do
33
+ slide_file = find_slide_file
34
+ pdf_dir = 'pdf'
35
+ FileUtils.mkdir_p(pdf_dir)
36
+
37
+ pdf_name = "#{@config['id']}-#{@config['base_name']}.pdf"
38
+ pdf_path = File.join(pdf_dir, pdf_name)
39
+
40
+ sh "rapicco -p -o #{pdf_path} #{slide_file}"
41
+ puts "PDF created: #{pdf_path}"
42
+ end
43
+
44
+ desc "Create gem package"
45
+ task gem: :pdf do
46
+ builder = Rapicco::SlideGemBuilder.new(@config)
47
+ gem_file = builder.build
48
+ puts "Gem created: #{gem_file}"
49
+ end
50
+
51
+ desc "Publish gem to RubyGems.org"
52
+ task publish: :gem do
53
+ puts "Publishing gem is currently disabled."
54
+ #gem_file = gem_filename
55
+ #sh "gem push #{gem_file}"
56
+ end
57
+ end
58
+
59
+ def find_slide_file
60
+ # Look for .md files, prefer slide.md if it exists
61
+ if File.exist?('slide.md')
62
+ 'slide.md'
63
+ else
64
+ md_files = Dir.glob('*.md').reject { |f| f == 'README.md' }
65
+ if md_files.empty?
66
+ raise "No slide file found. Please create a .md file."
67
+ end
68
+ md_files.first
69
+ end
70
+ end
71
+
72
+ def gem_filename
73
+ author_name = @config['author']['rubygems_user'].gsub(/[^a-z0-9\-_]/i, '-')
74
+ "pkg/rabbit-slide-#{author_name}-#{@config['id']}-#{@config['version']}.gem"
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ Rapicco::Task::Slide.new
@@ -0,0 +1,3 @@
1
+ module Rapicco
2
+ VERSION = "0.1.0"
3
+ end
data/lib/rapicco.rb ADDED
@@ -0,0 +1,6 @@
1
+ require_relative 'rapicco/version'
2
+ require_relative 'rapicco/presenter'
3
+ require_relative 'rapicco/pdf'
4
+ require_relative 'rapicco/installer'
5
+ require_relative 'rapicco/slide_gem_builder'
6
+
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rapicco
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - HASUMI Hitoshi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cairo
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.17'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.17'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ffi
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.15'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.15'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ description: A wrapper tool of PicoRuby Rapicco terminal-based presentation
55
+ email: []
56
+ executables:
57
+ - rapicco
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE
62
+ - README.md
63
+ - bin/rapicco
64
+ - lib/rapicco.rb
65
+ - lib/rapicco/installer.rb
66
+ - lib/rapicco/pdf.rb
67
+ - lib/rapicco/pdf/ansi_parser.rb
68
+ - lib/rapicco/pdf/page_capturer.rb
69
+ - lib/rapicco/pdf/renderer.rb
70
+ - lib/rapicco/presenter.rb
71
+ - lib/rapicco/slide_gem_builder.rb
72
+ - lib/rapicco/task/slide.rb
73
+ - lib/rapicco/version.rb
74
+ homepage: https://github.com/picoruby/rapicco
75
+ licenses:
76
+ - MIT
77
+ metadata: {}
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: 3.0.0
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.7.0.dev
93
+ specification_version: 4
94
+ summary: Rabbit-like presentation tool by PicoRuby
95
+ test_files: []