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 +7 -0
- data/LICENSE +21 -0
- data/README.md +50 -0
- data/bin/rapicco +141 -0
- data/lib/rapicco/installer.rb +144 -0
- data/lib/rapicco/pdf/ansi_parser.rb +245 -0
- data/lib/rapicco/pdf/page_capturer.rb +99 -0
- data/lib/rapicco/pdf/renderer.rb +74 -0
- data/lib/rapicco/pdf.rb +72 -0
- data/lib/rapicco/presenter.rb +52 -0
- data/lib/rapicco/slide_gem_builder.rb +132 -0
- data/lib/rapicco/task/slide.rb +80 -0
- data/lib/rapicco/version.rb +3 -0
- data/lib/rapicco.rb +6 -0
- metadata +95 -0
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
|
data/lib/rapicco/pdf.rb
ADDED
|
@@ -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
|
data/lib/rapicco.rb
ADDED
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: []
|