przn 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +55 -18
- data/Rakefile +5 -5
- data/default_theme.yml +26 -14
- data/exe/przn +32 -7
- data/lib/przn/audience_link.rb +51 -0
- data/lib/przn/controller.rb +15 -2
- data/lib/przn/echoes_client.rb +83 -0
- data/lib/przn/image_util.rb +5 -3
- data/lib/przn/kitty_text.rb +23 -4
- data/lib/przn/parser.rb +12 -12
- data/lib/przn/{pdf_exporter.rb → prawn_pdf_exporter.rb} +30 -17
- data/lib/przn/presenter_renderer.rb +71 -0
- data/lib/przn/renderer.rb +132 -48
- data/lib/przn/screenshot_pdf_exporter.rb +115 -0
- data/lib/przn/slide.rb +25 -0
- data/lib/przn/theme.rb +49 -12
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +81 -14
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
- data/sample/sample.md +8 -0
- metadata +24 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Przn
|
|
7
|
+
# Default PDF export: drives the live renderer, asks the terminal to save
|
|
8
|
+
# each rendered slide as a one-page vector PDF via OSC 7772 `capture`,
|
|
9
|
+
# then concatenates the per-slide PDFs into a single multi-page PDF.
|
|
10
|
+
# Requires Echoes (or any terminal that implements the same capture
|
|
11
|
+
# command); use `export_pdf_prawn` instead for environments where that's
|
|
12
|
+
# not possible (CI, headless).
|
|
13
|
+
def self.export_pdf(file, output, theme: nil)
|
|
14
|
+
markdown = File.read(file)
|
|
15
|
+
presentation = Parser.parse(markdown)
|
|
16
|
+
base_dir = File.dirname(File.expand_path(file))
|
|
17
|
+
ScreenshotPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
|
|
18
|
+
puts "Generated: #{output}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Renders each slide live to the user's terminal, asks the terminal to save
|
|
22
|
+
# the current pane as a vector PDF via Echoes' OSC 7772 `capture` command,
|
|
23
|
+
# then concatenates the per-slide PDFs into a single multi-page PDF.
|
|
24
|
+
#
|
|
25
|
+
# Trade-off vs the Prawn-based PdfExporter:
|
|
26
|
+
# - Pixel-perfect match with what's on screen (gradients, fonts, OSC 66
|
|
27
|
+
# sized text, bullet glyphs, the lot) — but vector, so the result is
|
|
28
|
+
# small, sharp at any zoom, and text stays selectable.
|
|
29
|
+
# - Requires running inside a terminal that implements OSC 7772 capture
|
|
30
|
+
# to a `.pdf` path (i.e. Echoes). Won't work in CI or any terminal that
|
|
31
|
+
# doesn't honor the command.
|
|
32
|
+
#
|
|
33
|
+
# Echoes-side wire format (independent of przn):
|
|
34
|
+
# ESC ] 7772 ; capture ; <absolute_path> BEL
|
|
35
|
+
# On receipt, Echoes saves the current pane to the path. The file
|
|
36
|
+
# extension picks the format — `.pdf` produces a single-page vector PDF
|
|
37
|
+
# by replaying the same drawing pipeline into a CGPDFContext instead of
|
|
38
|
+
# the screen's NSGraphicsContext.
|
|
39
|
+
class ScreenshotPdfExporter
|
|
40
|
+
OSC = "\e]7772".freeze
|
|
41
|
+
BEL = "\a".freeze
|
|
42
|
+
|
|
43
|
+
POLL_INTERVAL = 0.05 # seconds between file-existence checks
|
|
44
|
+
CAPTURE_TIMEOUT = 10 # seconds per slide before giving up
|
|
45
|
+
|
|
46
|
+
def initialize(presentation, base_dir: '.', theme: nil, terminal: nil)
|
|
47
|
+
@presentation = presentation
|
|
48
|
+
@base_dir = base_dir
|
|
49
|
+
@theme = theme || Theme.default
|
|
50
|
+
@terminal = terminal || Terminal.new
|
|
51
|
+
@renderer = Renderer.new(@terminal, base_dir: base_dir, theme: theme)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def export(output_path)
|
|
55
|
+
require 'hexapdf'
|
|
56
|
+
|
|
57
|
+
Dir.mktmpdir('przn-capture') do |dir|
|
|
58
|
+
pdf_paths = capture_all_slides(dir)
|
|
59
|
+
merge_pdfs(pdf_paths, output_path)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def capture_all_slides(dir)
|
|
66
|
+
paths = []
|
|
67
|
+
@terminal.enter_alt_screen
|
|
68
|
+
@terminal.hide_cursor
|
|
69
|
+
@presentation.slides.each_with_index do |slide, i|
|
|
70
|
+
pdf_path = File.join(dir, format('slide-%04d.pdf', i))
|
|
71
|
+
@renderer.render(slide, current: i, total: @presentation.total)
|
|
72
|
+
request_capture(pdf_path)
|
|
73
|
+
wait_for_capture(pdf_path)
|
|
74
|
+
paths << pdf_path
|
|
75
|
+
end
|
|
76
|
+
paths
|
|
77
|
+
ensure
|
|
78
|
+
@terminal.write "#{OSC};bg-clear#{BEL}"
|
|
79
|
+
@terminal.show_cursor
|
|
80
|
+
@terminal.leave_alt_screen
|
|
81
|
+
@terminal.flush
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def request_capture(path)
|
|
85
|
+
@terminal.write "#{OSC};capture;#{path}#{BEL}"
|
|
86
|
+
@terminal.flush
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def wait_for_capture(path)
|
|
90
|
+
deadline = Time.now + CAPTURE_TIMEOUT
|
|
91
|
+
until File.exist?(path) && File.size?(path).to_i.positive?
|
|
92
|
+
if Time.now > deadline
|
|
93
|
+
raise "Capture timed out for #{path}. " \
|
|
94
|
+
'Is Echoes running and recent enough to honor OSC 7772 `capture` to a .pdf path?'
|
|
95
|
+
end
|
|
96
|
+
sleep POLL_INTERVAL
|
|
97
|
+
end
|
|
98
|
+
# Small grace period to ensure the PDF write is fully flushed.
|
|
99
|
+
sleep POLL_INTERVAL
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def merge_pdfs(pdf_paths, output_path)
|
|
103
|
+
raise 'No slides captured' if pdf_paths.empty?
|
|
104
|
+
|
|
105
|
+
output = HexaPDF::Document.new
|
|
106
|
+
pdf_paths.each do |path|
|
|
107
|
+
src = HexaPDF::Document.open(path)
|
|
108
|
+
src.pages.each do |page|
|
|
109
|
+
output.pages << output.import(page)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
output.write(output_path)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/przn/slide.rb
CHANGED
|
@@ -7,5 +7,30 @@ module Przn
|
|
|
7
7
|
def initialize(blocks)
|
|
8
8
|
@blocks = blocks.freeze
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
# Aggregate every `{::note}` / `<note>` segment in the slide's text-bearing
|
|
12
|
+
# fields. The presenter view renders these in its side panel; the audience
|
|
13
|
+
# renderer strips them from the rendered output.
|
|
14
|
+
def notes
|
|
15
|
+
out = []
|
|
16
|
+
blocks.each do |b|
|
|
17
|
+
texts = []
|
|
18
|
+
texts << b[:content] if b[:content].is_a?(String)
|
|
19
|
+
texts << b[:term] if b[:term].is_a?(String)
|
|
20
|
+
texts << b[:definition] if b[:definition].is_a?(String)
|
|
21
|
+
if b[:items].is_a?(Array)
|
|
22
|
+
b[:items].each { |it| texts << it[:text] if it.is_a?(Hash) && it[:text].is_a?(String) }
|
|
23
|
+
end
|
|
24
|
+
if b[:rows].is_a?(Array)
|
|
25
|
+
(Array(b[:header]) + b[:rows].flatten).each { |c| texts << c if c.is_a?(String) }
|
|
26
|
+
end
|
|
27
|
+
texts.each do |text|
|
|
28
|
+
Parser.parse_inline(text).each do |seg|
|
|
29
|
+
out << seg[1] if seg[0] == :note && seg[1] && !seg[1].empty?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
out
|
|
34
|
+
end
|
|
10
35
|
end
|
|
11
36
|
end
|
data/lib/przn/theme.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Przn
|
|
|
6
6
|
class Theme
|
|
7
7
|
DEFAULT_PATH = File.expand_path('../../../default_theme.yml', __FILE__)
|
|
8
8
|
|
|
9
|
-
attr_reader :colors, :font, :bullet, :
|
|
9
|
+
attr_reader :colors, :font, :bullet, :background, :title, :rabbit
|
|
10
10
|
|
|
11
11
|
def self.load(path)
|
|
12
12
|
raise ArgumentError, "Theme file not found: #{path}" unless File.exist?(path)
|
|
@@ -16,27 +16,64 @@ module Przn
|
|
|
16
16
|
merged = {
|
|
17
17
|
colors: defaults[:colors].merge(overrides[:colors] || {}),
|
|
18
18
|
font: defaults[:font].merge(overrides[:font] || {}),
|
|
19
|
-
bullet: overrides[:bullet] ||
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
bullet: defaults[:bullet].merge(overrides[:bullet] || {}),
|
|
20
|
+
background: defaults[:background].merge(overrides[:background] || {}),
|
|
21
|
+
title: defaults[:title].merge(overrides[:title] || {}),
|
|
22
|
+
# `rabbit` is opt-in: absent → nil → renderer uses the plain N/M footer.
|
|
23
|
+
# Present (even as an empty block) → hash → renderer uses the runner bar.
|
|
24
|
+
rabbit: defaults[:rabbit] || overrides[:rabbit] ?
|
|
25
|
+
(defaults[:rabbit] || {}).merge(overrides[:rabbit] || {}) :
|
|
26
|
+
nil
|
|
23
27
|
}
|
|
24
28
|
new(merged)
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
# Convert a human-friendly duration string to seconds.
|
|
32
|
+
# "30m" -> 1800
|
|
33
|
+
# "1h30m" -> 5400
|
|
34
|
+
# "1h2m3s" -> 3723
|
|
35
|
+
# "45" -> 45 (bare integers are seconds)
|
|
36
|
+
# 45 -> 45 (already a number)
|
|
37
|
+
# nil / "" -> nil
|
|
38
|
+
# "garbage" -> nil
|
|
39
|
+
def self.parse_duration(input)
|
|
40
|
+
return nil if input.nil?
|
|
41
|
+
return input.to_i if input.is_a?(Numeric)
|
|
42
|
+
|
|
43
|
+
s = input.to_s.strip
|
|
44
|
+
return nil if s.empty?
|
|
45
|
+
return s.to_i if s =~ /\A\d+\z/
|
|
46
|
+
|
|
47
|
+
m = s.match(/\A(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?\z/)
|
|
48
|
+
return nil unless m && m[0] != ''
|
|
49
|
+
h, mi, se = m[1].to_i, m[2].to_i, m[3].to_i
|
|
50
|
+
h * 3600 + mi * 60 + se
|
|
51
|
+
end
|
|
52
|
+
|
|
27
53
|
def self.default
|
|
28
54
|
new(load_yaml(DEFAULT_PATH))
|
|
29
55
|
end
|
|
30
56
|
|
|
57
|
+
# Look for a sibling `theme.yml` next to the given file and load it if
|
|
58
|
+
# present, so a deck can ship its theme alongside the markdown without
|
|
59
|
+
# the user having to pass `--theme` explicitly. Returns nil if no file
|
|
60
|
+
# is found.
|
|
61
|
+
def self.auto_discover(near:)
|
|
62
|
+
candidate = File.join(File.dirname(File.expand_path(near)), 'theme.yml')
|
|
63
|
+
File.exist?(candidate) ? load(candidate) : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
31
66
|
def self.load_yaml(path)
|
|
32
67
|
data = YAML.safe_load_file(path, symbolize_names: true) || {}
|
|
33
68
|
{
|
|
34
69
|
colors: data[:colors] || {},
|
|
35
70
|
font: data[:font] || {},
|
|
36
|
-
bullet: data[:bullet],
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
71
|
+
bullet: (data[:bullet] || {}).compact,
|
|
72
|
+
background: (data[:background] || {}).compact,
|
|
73
|
+
title: (data[:title] || {}).compact,
|
|
74
|
+
# nil when the `rabbit:` key isn't in the YAML at all (opt-in feature);
|
|
75
|
+
# empty hash when it's present but childless.
|
|
76
|
+
rabbit: data.key?(:rabbit) ? (data[:rabbit] || {}).compact : nil
|
|
40
77
|
}
|
|
41
78
|
end
|
|
42
79
|
private_class_method :load_yaml
|
|
@@ -45,9 +82,9 @@ module Przn
|
|
|
45
82
|
@colors = config[:colors]
|
|
46
83
|
@font = config[:font]
|
|
47
84
|
@bullet = config[:bullet]
|
|
48
|
-
@
|
|
49
|
-
@
|
|
50
|
-
@
|
|
85
|
+
@background = config[:background]
|
|
86
|
+
@title = config[:title]
|
|
87
|
+
@rabbit = config[:rabbit]
|
|
51
88
|
end
|
|
52
89
|
end
|
|
53
90
|
end
|
data/lib/przn/version.rb
CHANGED
data/lib/przn.rb
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
require_relative
|
|
7
|
-
require_relative
|
|
8
|
-
require_relative
|
|
9
|
-
require_relative
|
|
10
|
-
require_relative
|
|
11
|
-
require_relative
|
|
12
|
-
require_relative
|
|
13
|
-
require_relative
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
require_relative 'przn/version'
|
|
7
|
+
require_relative 'przn/kitty_text'
|
|
8
|
+
require_relative 'przn/image_util'
|
|
9
|
+
require_relative 'przn/parser'
|
|
10
|
+
require_relative 'przn/slide'
|
|
11
|
+
require_relative 'przn/presentation'
|
|
12
|
+
require_relative 'przn/terminal'
|
|
13
|
+
require_relative 'przn/renderer'
|
|
14
|
+
require_relative 'przn/presenter_renderer'
|
|
15
|
+
require_relative 'przn/audience_link'
|
|
16
|
+
require_relative 'przn/echoes_client'
|
|
17
|
+
require_relative 'przn/controller'
|
|
18
|
+
require_relative 'przn/screenshot_pdf_exporter'
|
|
19
|
+
require_relative 'przn/theme'
|
|
14
20
|
|
|
15
21
|
module Przn
|
|
16
22
|
class Error < StandardError; end
|
|
@@ -25,11 +31,72 @@ module Przn
|
|
|
25
31
|
Controller.new(presentation, terminal, renderer)
|
|
26
32
|
end
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
# Audience-side entry: opens the file, listens on `socket`, and renders
|
|
35
|
+
# whatever slide the presenter sends a `goto` for. Notes are stripped.
|
|
36
|
+
# Spawned by Echoes when the presenter requests an extended-display window.
|
|
37
|
+
def self.audience(file, socket:, theme: nil)
|
|
29
38
|
markdown = File.read(file)
|
|
30
39
|
presentation = Parser.parse(markdown)
|
|
40
|
+
terminal = Terminal.new
|
|
41
|
+
base_dir = File.dirname(File.expand_path(file))
|
|
42
|
+
renderer = Renderer.new(terminal, base_dir: base_dir, theme: theme, mode: :audience)
|
|
43
|
+
|
|
44
|
+
terminal.enter_alt_screen
|
|
45
|
+
terminal.hide_cursor
|
|
46
|
+
begin
|
|
47
|
+
render = ->(idx, started_at) {
|
|
48
|
+
presentation.go_to(idx)
|
|
49
|
+
renderer.render(presentation.current_slide,
|
|
50
|
+
current: presentation.current,
|
|
51
|
+
total: presentation.total,
|
|
52
|
+
started_at: started_at)
|
|
53
|
+
}
|
|
54
|
+
render.call(0, nil)
|
|
55
|
+
AudienceLink.serve(socket) do |msg|
|
|
56
|
+
next unless msg[:type] == "goto" && msg[:index].is_a?(Integer)
|
|
57
|
+
started_at = msg[:started_at] ? Time.at(msg[:started_at]) : nil
|
|
58
|
+
render.call(msg[:index], started_at)
|
|
59
|
+
end
|
|
60
|
+
ensure
|
|
61
|
+
terminal.write "\e]7772;bg-clear\a"
|
|
62
|
+
terminal.show_cursor
|
|
63
|
+
terminal.leave_alt_screen
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Presenter-side entry: detects a second display via Echoes, spawns the
|
|
68
|
+
# audience window on it, connects to the spawned process over a Unix
|
|
69
|
+
# socket, and returns a Controller wired up to drive both sides.
|
|
70
|
+
# Falls back to today's mirror-mode (`start`) when only one display is
|
|
71
|
+
# attached or Echoes is not the host terminal.
|
|
72
|
+
def self.present(file, theme: nil, theme_path: nil)
|
|
73
|
+
info = EchoesClient.display_info
|
|
74
|
+
if info.nil? || info.size < 2
|
|
75
|
+
warn "przn: extended-display unavailable (no secondary display detected), falling back to mirror mode"
|
|
76
|
+
return start(file, theme: theme)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
socket_path = File.join(Dir.tmpdir, "przn-#{Process.pid}-#{SecureRandom.hex(4)}.sock")
|
|
80
|
+
audience_argv = [File.expand_path($PROGRAM_NAME), '--audience', '--socket', socket_path]
|
|
81
|
+
audience_argv += ['--theme', theme_path] if theme_path
|
|
82
|
+
audience_argv << File.expand_path(file)
|
|
83
|
+
EchoesClient.open_window(display: info.last[:index], argv: audience_argv)
|
|
84
|
+
|
|
85
|
+
deadline = Time.now + 5
|
|
86
|
+
sleep 0.1 until File.exist?(socket_path) || Time.now > deadline
|
|
87
|
+
unless File.exist?(socket_path)
|
|
88
|
+
warn "przn: audience window did not come up within 5s, falling back to mirror mode"
|
|
89
|
+
return start(file, theme: theme)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
link = AudienceLink.connect(socket_path)
|
|
93
|
+
link.gets # discard the {"type":"ready"} handshake
|
|
94
|
+
|
|
95
|
+
markdown = File.read(file)
|
|
96
|
+
presentation = Parser.parse(markdown)
|
|
97
|
+
terminal = Terminal.new
|
|
31
98
|
base_dir = File.dirname(File.expand_path(file))
|
|
32
|
-
|
|
33
|
-
|
|
99
|
+
renderer = PresenterRenderer.new(terminal, presentation: presentation, base_dir: base_dir, theme: theme)
|
|
100
|
+
Controller.new(presentation, terminal, renderer, audience_link: link)
|
|
34
101
|
end
|
|
35
102
|
end
|
data/sample/doge.jpg
ADDED
|
Binary file
|
data/sample/doge.png
ADDED
|
Binary file
|
data/sample/sample.md
CHANGED
|
@@ -40,6 +40,14 @@ This is **bold**, this is *italic*, and this is `inline code`.
|
|
|
40
40
|
|
|
41
41
|
normal and {::tag name="red"}red text{:/tag} mixed
|
|
42
42
|
|
|
43
|
+
# Image (PNG)
|
|
44
|
+
|
|
45
|
+
{:relative_height="70"}
|
|
46
|
+
|
|
47
|
+
# Image (JPG)
|
|
48
|
+
|
|
49
|
+
{:relative_height="70"}
|
|
50
|
+
|
|
43
51
|
# Thank You!
|
|
44
52
|
|
|
45
53
|
That's all! Enjoy!
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: przn
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Akira Matsuda
|
|
@@ -25,6 +25,22 @@ dependencies:
|
|
|
25
25
|
- ">="
|
|
26
26
|
- !ruby/object:Gem::Version
|
|
27
27
|
version: "0"
|
|
28
|
+
- !ruby/object:Gem::Dependency
|
|
29
|
+
name: hexapdf
|
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
|
31
|
+
requirements:
|
|
32
|
+
-
|
|
33
|
+
- ">="
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: "0"
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
-
|
|
41
|
+
- ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: "0"
|
|
28
44
|
description: A terminal-based presentation tool that renders Markdown slides with Kitty text sizing protocol support for beautifully scaled headers
|
|
29
45
|
email:
|
|
30
46
|
- ronnie@dio.jp
|
|
@@ -39,17 +55,23 @@ files:
|
|
|
39
55
|
- default_theme.yml
|
|
40
56
|
- exe/przn
|
|
41
57
|
- lib/przn.rb
|
|
58
|
+
- lib/przn/audience_link.rb
|
|
42
59
|
- lib/przn/controller.rb
|
|
60
|
+
- lib/przn/echoes_client.rb
|
|
43
61
|
- lib/przn/image_util.rb
|
|
44
62
|
- lib/przn/kitty_text.rb
|
|
45
63
|
- lib/przn/parser.rb
|
|
46
|
-
- lib/przn/
|
|
64
|
+
- lib/przn/prawn_pdf_exporter.rb
|
|
47
65
|
- lib/przn/presentation.rb
|
|
66
|
+
- lib/przn/presenter_renderer.rb
|
|
48
67
|
- lib/przn/renderer.rb
|
|
68
|
+
- lib/przn/screenshot_pdf_exporter.rb
|
|
49
69
|
- lib/przn/slide.rb
|
|
50
70
|
- lib/przn/terminal.rb
|
|
51
71
|
- lib/przn/theme.rb
|
|
52
72
|
- lib/przn/version.rb
|
|
73
|
+
- sample/doge.jpg
|
|
74
|
+
- sample/doge.png
|
|
53
75
|
- sample/sample.md
|
|
54
76
|
- sig/przn.rbs
|
|
55
77
|
homepage: "https://github.com/amatsuda/przn"
|