przn 0.3.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 +21 -0
- data/Rakefile +5 -5
- data/default_theme.yml +8 -0
- data/exe/przn +21 -4
- 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} +26 -14
- data/lib/przn/presenter_renderer.rb +71 -0
- data/lib/przn/renderer.rb +105 -27
- data/lib/przn/screenshot_pdf_exporter.rb +17 -3
- data/lib/przn/slide.rb +25 -0
- data/lib/przn/theme.rb +32 -1
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +76 -28
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
- data/sample/sample.md +8 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a02ff63ef48dadc49d46767f2f60e32457cd9562dffa726e6a6b3a811c3cf68d
|
|
4
|
+
data.tar.gz: 0a4c431ac13a990c06397626749de0b495e47efa3af5c3233800868b7fb56b41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98e45b37b5125ece026f9140dbd68225beddde0eb1eac76ad0e614b28610898580acc5a9e03a3cd36313740165216cec646e1224f80f2e951a4c82d3dde89d9d
|
|
7
|
+
data.tar.gz: eef32162b80aedbef88fafbc3834e85e12eda8db63918a762183f341f45ee694397aa39407c5887f7fb004ccbd0276cf59beded6a695a8441bcefceb3f8416ff
|
data/README.md
CHANGED
|
@@ -23,6 +23,23 @@ przn your_slides.md @42
|
|
|
23
23
|
|
|
24
24
|
Out-of-range numbers are clamped to the last slide, so `@9999` jumps to the end.
|
|
25
25
|
|
|
26
|
+
### Extended-display presenter mode
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
przn --present your_slides.md
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
On a setup with a secondary display (projector / external monitor) and running inside [Echoes](https://github.com/amatsuda/echoes), `--present` auto-spawns an **audience window** on the second display showing the clean current slide, while the laptop pane becomes the **presenter view**:
|
|
33
|
+
|
|
34
|
+
- Current slide rendered as normal
|
|
35
|
+
- Speaker notes (`{::note}` / `<note>` markup) shown in a side strip — stripped from the audience view
|
|
36
|
+
- Next slide's title hint
|
|
37
|
+
- Elapsed-time clock (or, when `rabbit:` is themed, the runner-bar visualization)
|
|
38
|
+
|
|
39
|
+
If only one display is attached or Echoes isn't the host terminal, `--present` falls back to today's mirror mode with a one-line warning on stderr.
|
|
40
|
+
|
|
41
|
+
Implementation: the two `przn` processes coordinate over a Unix socket. The presenter forwards every slide navigation as a `goto` message; the audience renders and otherwise stays silent. Notes are not transmitted to the audience side.
|
|
42
|
+
|
|
26
43
|
### PDF export
|
|
27
44
|
|
|
28
45
|
Two flavors:
|
|
@@ -285,6 +302,9 @@ background: # default slide background (Echoes OSC 7772)
|
|
|
285
302
|
to: # gradient endpoint
|
|
286
303
|
angle: # gradient angle in degrees
|
|
287
304
|
|
|
305
|
+
# rabbit: # opt into the 🐇 / 🐢 bottom progress indicator
|
|
306
|
+
# duration: "30m" # "1h30m", "1800s", or plain integer seconds; turtle hides when unset
|
|
307
|
+
|
|
288
308
|
colors:
|
|
289
309
|
code_bg: "313244"
|
|
290
310
|
dim: "6c7086"
|
|
@@ -298,6 +318,7 @@ Notes:
|
|
|
298
318
|
- **`font.family`** — applied to body text (terminal: via OSC 66 `f=`, requires Echoes; PDF: registered via fontconfig). Inline `<font face="...">` runs override it per-segment.
|
|
299
319
|
- **`title`** — h1 typography. Each attribute is independent from `font`: `title.family` does **not** inherit `font.family`, `title.color` does **not** inherit `font.color`. `title.size` defaults to x-large (OSC 66 `s=4`). When `title.family` is proportional, every h1 OSC 66 sequence is emitted with `h=2` so a terminal that honors centered horizontal alignment ([Echoes](https://github.com/amatsuda/echoes)) keeps the title visually centered against its reserved cell block. h2–h6 stay body text.
|
|
300
320
|
- **`background`** — the deck-wide default background. A per-slide `<bg .../>` directive overrides it for that slide. The Prawn fallback paints the PDF page in `background.color` when set; otherwise it leaves the page Prawn's default (white).
|
|
321
|
+
- **`rabbit`** — opt-in Rabbit-style bottom-row progress indicator. With the key absent, przn shows the simple `N / M` counter at the bottom-right. With the key present, the bottom row becomes: current slide # at the very left, total at the very right, 🐇 running between them tracking slide progress. Set `rabbit.duration` to also show 🐢 tracking elapsed time against the goal; without a duration the turtle stays hidden. Inside [Echoes](https://github.com/amatsuda/echoes) the emojis are emitted via OSC 7772 `;multicell` with `flip=h` so they face rightward; outside Echoes they fall back to standard OSC 66 and render unflipped (left-facing).
|
|
301
322
|
|
|
302
323
|
## License
|
|
303
324
|
|
data/Rakefile
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rake/testtask'
|
|
5
5
|
|
|
6
6
|
Rake::TestTask.new(:test) do |t|
|
|
7
|
-
t.libs <<
|
|
8
|
-
t.libs <<
|
|
9
|
-
t.test_files = FileList[
|
|
7
|
+
t.libs << 'test'
|
|
8
|
+
t.libs << 'lib'
|
|
9
|
+
t.test_files = FileList['test/**/*_test.rb']
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
task default: :test
|
data/default_theme.yml
CHANGED
|
@@ -33,3 +33,11 @@ colors:
|
|
|
33
33
|
code_bg: "313244"
|
|
34
34
|
dim: "6c7086"
|
|
35
35
|
inline_code: "a6e3a1"
|
|
36
|
+
|
|
37
|
+
# Bottom-of-screen progress indicator (a nod to the Rabbit presentation tool).
|
|
38
|
+
# Without this key, przn shows a simple " N / M " counter at the bottom-right.
|
|
39
|
+
# To opt in: uncomment the block. The 🐇 rabbit anchors slide progress between
|
|
40
|
+
# the left (current) and right (total) numbers. Set `duration:` to enable the
|
|
41
|
+
# 🐢 turtle tracking elapsed time against the goal.
|
|
42
|
+
# rabbit:
|
|
43
|
+
# duration: # "30m", "1h30m", "1800s", or plain integer seconds
|
data/exe/przn
CHANGED
|
@@ -7,7 +7,7 @@ require 'optparse'
|
|
|
7
7
|
|
|
8
8
|
options = {}
|
|
9
9
|
OptionParser.new do |opts|
|
|
10
|
-
opts.banner =
|
|
10
|
+
opts.banner = 'Usage: przn [options] <presentation.md>'
|
|
11
11
|
opts.on('--export [FORMAT]', 'Export to a format (pdf | prawn; default: pdf)') { |v|
|
|
12
12
|
if v && v.end_with?('.md')
|
|
13
13
|
ARGV.unshift(v)
|
|
@@ -19,12 +19,15 @@ OptionParser.new do |opts|
|
|
|
19
19
|
opts.on('-o', '--output FILE', 'Output file path for export') { |v| options[:output] = v }
|
|
20
20
|
opts.on('--theme FILE', 'Theme file (YAML)') { |v| options[:theme] = v }
|
|
21
21
|
opts.on('--generate-theme', 'Generate theme.yml in current directory') { options[:generate_theme] = true }
|
|
22
|
+
opts.on('--present', 'Open the deck in presenter mode (auto-spawns an audience window on the secondary display)') { options[:present] = true }
|
|
23
|
+
opts.on('--audience', 'Run as the audience receiver (spawned by --present; expects --socket)') { options[:audience] = true }
|
|
24
|
+
opts.on('--socket PATH', 'Unix socket path used by --present/--audience to coordinate') { |v| options[:socket] = v }
|
|
22
25
|
end.parse!
|
|
23
26
|
|
|
24
27
|
if options[:generate_theme]
|
|
25
28
|
content = File.read(Przn::Theme::DEFAULT_PATH).sub(/\A(#[^\n]*\n)+\n/, '')
|
|
26
29
|
File.write('theme.yml', content)
|
|
27
|
-
puts
|
|
30
|
+
puts 'Generated: theme.yml'
|
|
28
31
|
exit
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -38,7 +41,7 @@ end
|
|
|
38
41
|
|
|
39
42
|
file = ARGV[0]
|
|
40
43
|
unless file
|
|
41
|
-
$stderr.puts
|
|
44
|
+
$stderr.puts 'Usage: przn [options] <presentation.md> [@N]'
|
|
42
45
|
exit 1
|
|
43
46
|
end
|
|
44
47
|
|
|
@@ -50,11 +53,25 @@ end
|
|
|
50
53
|
|
|
51
54
|
case options[:export]
|
|
52
55
|
when 'pdf'
|
|
56
|
+
require_relative '../lib/przn/screenshot_pdf_exporter'
|
|
57
|
+
|
|
53
58
|
output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
|
|
54
59
|
Przn.export_pdf(file, output, theme: theme)
|
|
55
60
|
when 'prawn'
|
|
61
|
+
require_relative '../lib/przn/prawn_pdf_exporter'
|
|
62
|
+
|
|
56
63
|
output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
|
|
57
64
|
Przn.export_pdf_prawn(file, output, theme: theme)
|
|
58
65
|
else
|
|
59
|
-
|
|
66
|
+
if options[:audience]
|
|
67
|
+
unless options[:socket]
|
|
68
|
+
$stderr.puts 'przn: --audience requires --socket PATH'
|
|
69
|
+
exit 1
|
|
70
|
+
end
|
|
71
|
+
Przn.audience(file, socket: options[:socket], theme: theme)
|
|
72
|
+
elsif options[:present]
|
|
73
|
+
Przn.present(file, theme: theme, theme_path: options[:theme]).run
|
|
74
|
+
else
|
|
75
|
+
Przn.start(file, theme: theme, start_at: start_at).run
|
|
76
|
+
end
|
|
60
77
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
module Przn
|
|
7
|
+
# Tiny line-delimited JSON protocol over a Unix-domain socket, joining the
|
|
8
|
+
# presenter and audience `przn` processes in extended-display mode.
|
|
9
|
+
#
|
|
10
|
+
# Messages currently exchanged:
|
|
11
|
+
# {"type": "ready"} audience -> presenter
|
|
12
|
+
# {"type": "goto", "index": N} presenter -> audience
|
|
13
|
+
# {"type": "quit"} presenter -> audience
|
|
14
|
+
module AudienceLink
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Audience-side: open a UNIXServer at `path`, wait for the presenter to
|
|
18
|
+
# connect, then yield each decoded message until EOF or {"type":"quit"}.
|
|
19
|
+
# The socket file is unlinked on exit.
|
|
20
|
+
def serve(path)
|
|
21
|
+
File.unlink(path) if File.exist?(path)
|
|
22
|
+
server = UNIXServer.new(path)
|
|
23
|
+
client = server.accept
|
|
24
|
+
send(client, {type: "ready"})
|
|
25
|
+
while (line = client.gets)
|
|
26
|
+
msg = JSON.parse(line.chomp, symbolize_names: true)
|
|
27
|
+
break if msg[:type] == "quit"
|
|
28
|
+
yield msg
|
|
29
|
+
end
|
|
30
|
+
rescue Errno::EPIPE, EOFError, IOError
|
|
31
|
+
# Presenter went away; let the caller exit cleanly.
|
|
32
|
+
ensure
|
|
33
|
+
client&.close
|
|
34
|
+
server&.close
|
|
35
|
+
File.unlink(path) if path && File.exist?(path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Presenter-side: connect to an audience socket at `path` and return a
|
|
39
|
+
# client object that responds to `#send` and `#close`. Caller drives the
|
|
40
|
+
# protocol from the controller.
|
|
41
|
+
def connect(path)
|
|
42
|
+
UNIXSocket.new(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def send(io, msg)
|
|
46
|
+
io.puts(JSON.generate(msg))
|
|
47
|
+
rescue Errno::EPIPE, IOError
|
|
48
|
+
# Other side hung up — caller decides whether to keep going.
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/przn/controller.rb
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Przn
|
|
4
4
|
class Controller
|
|
5
|
-
def initialize(presentation, terminal, renderer)
|
|
5
|
+
def initialize(presentation, terminal, renderer, audience_link: nil)
|
|
6
6
|
@presentation = presentation
|
|
7
7
|
@terminal = terminal
|
|
8
8
|
@renderer = renderer
|
|
9
|
+
@audience_link = audience_link
|
|
9
10
|
@preload_gen = 0
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def run
|
|
14
|
+
@started_at = Time.now
|
|
13
15
|
@terminal.enter_alt_screen
|
|
14
16
|
@terminal.hide_cursor
|
|
15
17
|
render_current
|
|
@@ -37,6 +39,10 @@ module Przn
|
|
|
37
39
|
ensure
|
|
38
40
|
@preload_gen += 1
|
|
39
41
|
@preload_thread&.join
|
|
42
|
+
if @audience_link
|
|
43
|
+
AudienceLink.send(@audience_link, type: "quit")
|
|
44
|
+
@audience_link.close
|
|
45
|
+
end
|
|
40
46
|
@terminal.write "\e]7772;bg-clear\a"
|
|
41
47
|
@terminal.show_cursor
|
|
42
48
|
@terminal.leave_alt_screen
|
|
@@ -48,8 +54,15 @@ module Przn
|
|
|
48
54
|
@renderer.render(
|
|
49
55
|
@presentation.current_slide,
|
|
50
56
|
current: @presentation.current,
|
|
51
|
-
total: @presentation.total
|
|
57
|
+
total: @presentation.total,
|
|
58
|
+
started_at: @started_at
|
|
52
59
|
)
|
|
60
|
+
if @audience_link
|
|
61
|
+
AudienceLink.send(@audience_link,
|
|
62
|
+
type: "goto",
|
|
63
|
+
index: @presentation.current,
|
|
64
|
+
started_at: @started_at.to_f)
|
|
65
|
+
end
|
|
53
66
|
schedule_preload
|
|
54
67
|
end
|
|
55
68
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module Przn
|
|
8
|
+
# Thin wrappers around Echoes-specific OSC 7772 commands the presenter uses
|
|
9
|
+
# to set up extended-display mode. Other terminals ignore OSC 7772, so each
|
|
10
|
+
# method silently fails (returns nil / false) when not running inside Echoes.
|
|
11
|
+
module EchoesClient
|
|
12
|
+
OSC = "\e]7772"
|
|
13
|
+
BEL = "\a"
|
|
14
|
+
REPLY_TIMEOUT_S = 0.5
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Ask Echoes how many displays are attached and a tiny descriptor for each.
|
|
19
|
+
# Returns an Array of Hashes like [{index: 0, w: 1920, h: 1080}, ...] or
|
|
20
|
+
# nil when no reply arrives within the timeout (non-Echoes terminal, or
|
|
21
|
+
# an Echoes that doesn't speak this command yet).
|
|
22
|
+
def display_info(io_in: $stdin, io_out: $stdout)
|
|
23
|
+
io_out.write("#{OSC};display-info#{BEL}")
|
|
24
|
+
io_out.flush if io_out.respond_to?(:flush)
|
|
25
|
+
reply = read_osc_reply(io_in)
|
|
26
|
+
return nil unless reply
|
|
27
|
+
JSON.parse(reply, symbolize_names: true)
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Open a new Echoes window on the given display, running `argv` (an
|
|
33
|
+
# Array of strings — argv[0] is the executable). `fullscreen:` is a hint.
|
|
34
|
+
# Returns true if the request was emitted; nothing in the protocol confirms
|
|
35
|
+
# success synchronously.
|
|
36
|
+
def open_window(display:, argv:, fullscreen: true, io_out: $stdout)
|
|
37
|
+
# `pack('m0')` is strict (no-newline) base64 — same as
|
|
38
|
+
# Base64.strict_encode64 but without pulling in the base64 stdlib,
|
|
39
|
+
# which is no longer a default gem in Ruby 3.4+.
|
|
40
|
+
payload = [JSON.generate(argv)].pack('m0')
|
|
41
|
+
args = "display=#{display}:program=#{payload}:fullscreen=#{fullscreen ? 'yes' : 'no'}"
|
|
42
|
+
io_out.write("#{OSC};open-window;#{args}#{BEL}")
|
|
43
|
+
io_out.flush if io_out.respond_to?(:flush)
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Read an OSC reply up to ST or BEL. Returns the payload string or nil on
|
|
48
|
+
# timeout. Echoes replies follow the same `\e]7772;...\a` shape it accepts.
|
|
49
|
+
#
|
|
50
|
+
# Stdin defaults to canonical (line-buffered) mode in a shell context, so
|
|
51
|
+
# `getc` would block waiting for a newline that an OSC reply never sends.
|
|
52
|
+
# Put the input in raw mode for the duration of the read; IO#raw saves and
|
|
53
|
+
# restores termios automatically.
|
|
54
|
+
def read_osc_reply(io_in)
|
|
55
|
+
if io_in.respond_to?(:raw) && io_in.respond_to?(:tty?) && io_in.tty?
|
|
56
|
+
io_in.raw { read_osc_reply_inner(io_in) }
|
|
57
|
+
else
|
|
58
|
+
read_osc_reply_inner(io_in)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_osc_reply_inner(io_in)
|
|
63
|
+
Timeout.timeout(REPLY_TIMEOUT_S) do
|
|
64
|
+
buf = +""
|
|
65
|
+
loop do
|
|
66
|
+
c = io_in.getc
|
|
67
|
+
return nil if c.nil?
|
|
68
|
+
break if c == BEL
|
|
69
|
+
if c == "\e"
|
|
70
|
+
nxt = io_in.getc
|
|
71
|
+
break if nxt == "\\"
|
|
72
|
+
buf << c << nxt
|
|
73
|
+
else
|
|
74
|
+
buf << c
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
buf.sub(/\A\e?\]?7772;[\w-]+;/, '')
|
|
78
|
+
end
|
|
79
|
+
rescue Timeout::Error
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/przn/image_util.rb
CHANGED
|
@@ -42,14 +42,14 @@ module Przn
|
|
|
42
42
|
# GIF
|
|
43
43
|
f.seek(0)
|
|
44
44
|
sig = f.read(6)
|
|
45
|
-
if sig&.start_with?(
|
|
45
|
+
if sig&.start_with?('GIF8')
|
|
46
46
|
w = f.read(2)&.unpack1('v')
|
|
47
47
|
h = f.read(2)&.unpack1('v')
|
|
48
48
|
return [w, h] if w && h
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
nil
|
|
52
|
-
rescue
|
|
52
|
+
rescue StandardError
|
|
53
53
|
nil
|
|
54
54
|
end
|
|
55
55
|
|
|
@@ -62,7 +62,9 @@ module Przn
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def kitty_terminal?
|
|
65
|
-
ENV['TERM'] == 'xterm-kitty' ||
|
|
65
|
+
ENV['TERM'] == 'xterm-kitty' ||
|
|
66
|
+
ENV['TERM_PROGRAM'] == 'kitty' ||
|
|
67
|
+
ENV['TERM_PROGRAM'] == 'Echoes'
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
PNG_MAGIC = "\x89PNG\r\n\x1a\n".b.freeze
|
data/lib/przn/kitty_text.rb
CHANGED
|
@@ -5,19 +5,38 @@ module Przn
|
|
|
5
5
|
HEADING_SCALES = {
|
|
6
6
|
1 => 4,
|
|
7
7
|
2 => 3,
|
|
8
|
-
3 => 2
|
|
8
|
+
3 => 2
|
|
9
9
|
}.freeze
|
|
10
10
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# Emit sized multicell text. The `s/w/n/d/v/h` params are standard kitty
|
|
14
|
+
# OSC 66 (portable). The `f=` (font family) and `flip=` params are
|
|
15
|
+
# Echoes-only extensions — when one of them is set AND we're running
|
|
16
|
+
# inside Echoes, they ride on the private OSC 7772 ;multicell frame so
|
|
17
|
+
# that strict kitty terminals never see unknown params on OSC 66.
|
|
18
|
+
# Otherwise the extensions are silently dropped and we emit plain OSC
|
|
19
|
+
# 66, which renders without the flip / custom font on any kitty-
|
|
20
|
+
# compatible terminal (better than emitting an OSC 7772 frame the
|
|
21
|
+
# terminal would ignore entirely).
|
|
22
|
+
def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil, flip: nil)
|
|
14
23
|
params = +"s=#{s}"
|
|
15
24
|
params << ":n=#{n}" if n
|
|
16
25
|
params << ":d=#{d}" if d
|
|
17
26
|
params << ":h=#{h}" if h
|
|
18
27
|
params << ":v=#{v}" if v
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
|
|
29
|
+
if (f || flip) && echoes?
|
|
30
|
+
params << ":f=#{f}" if f
|
|
31
|
+
params << ":flip=#{flip}" if flip
|
|
32
|
+
"\e]7772;multicell;#{params};#{text}\a"
|
|
33
|
+
else
|
|
34
|
+
"\e]66;#{params};#{text}\a"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def echoes?
|
|
39
|
+
ENV['TERM_PROGRAM'] == 'Echoes'
|
|
21
40
|
end
|
|
22
41
|
|
|
23
42
|
def heading(text, level:)
|
data/lib/przn/parser.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Przn
|
|
|
14
14
|
'xx-large' => 5,
|
|
15
15
|
'xxx-large' => 6,
|
|
16
16
|
'xxxx-large' => 7,
|
|
17
|
-
'1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7
|
|
17
|
+
'1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
20
|
NAMED_COLORS = {
|
|
@@ -22,7 +22,7 @@ module Przn
|
|
|
22
22
|
'magenta' => 35, 'cyan' => 36, 'white' => 37,
|
|
23
23
|
'bright_red' => 91, 'bright_green' => 92, 'bright_yellow' => 93,
|
|
24
24
|
'bright_blue' => 94, 'bright_magenta' => 95, 'bright_cyan' => 96,
|
|
25
|
-
'bright_white' => 97
|
|
25
|
+
'bright_white' => 97
|
|
26
26
|
}.freeze
|
|
27
27
|
|
|
28
28
|
module_function
|
|
@@ -35,7 +35,7 @@ module Przn
|
|
|
35
35
|
# Split on h1 headings (Rabbit-compatible)
|
|
36
36
|
def split_slides(markdown)
|
|
37
37
|
chunks = []
|
|
38
|
-
current = +
|
|
38
|
+
current = +''
|
|
39
39
|
in_fence = false
|
|
40
40
|
|
|
41
41
|
markdown.each_line do |line|
|
|
@@ -155,7 +155,7 @@ module Przn
|
|
|
155
155
|
items << {text: Regexp.last_match(2), depth: depth}
|
|
156
156
|
elsif lines[i].match(/\A {2,}(\S.*)/)
|
|
157
157
|
# Continuation line
|
|
158
|
-
items.last[:text] <<
|
|
158
|
+
items.last[:text] << ' ' << Regexp.last_match(1) if items.last
|
|
159
159
|
else
|
|
160
160
|
break
|
|
161
161
|
end
|
|
@@ -189,7 +189,7 @@ module Przn
|
|
|
189
189
|
attr_str = rest.sub(/\A\{:?\s*/, '')
|
|
190
190
|
while !attr_str.include?('}') && (i + 1) < lines.size
|
|
191
191
|
i += 1
|
|
192
|
-
attr_str <<
|
|
192
|
+
attr_str << ' ' << lines[i].strip
|
|
193
193
|
end
|
|
194
194
|
attr_str = attr_str.sub(/\}\s*\z/, '')
|
|
195
195
|
parse_image_attrs(attr_str, attrs)
|
|
@@ -283,14 +283,14 @@ module Przn
|
|
|
283
283
|
segments << [:note, scanner[1]]
|
|
284
284
|
elsif scanner.scan(/<note>(.*?)<\/note>/)
|
|
285
285
|
segments << [:note, scanner[1]]
|
|
286
|
-
elsif scanner.scan(
|
|
286
|
+
elsif scanner.scan('{::wait/}') || scanner.scan(/<wait\s*\/>/)
|
|
287
287
|
# skip wait markers in inline text
|
|
288
|
-
elsif scanner.scan(
|
|
289
|
-
segments << [:text,
|
|
290
|
-
elsif scanner.scan(
|
|
291
|
-
segments << [:text,
|
|
292
|
-
elsif scanner.scan(
|
|
293
|
-
segments << [:text,
|
|
288
|
+
elsif scanner.scan('<')
|
|
289
|
+
segments << [:text, '<']
|
|
290
|
+
elsif scanner.scan('>')
|
|
291
|
+
segments << [:text, '>']
|
|
292
|
+
elsif scanner.scan('&')
|
|
293
|
+
segments << [:text, '&']
|
|
294
294
|
elsif scanner.scan(/`([^`]+)`/)
|
|
295
295
|
segments << [:code, scanner[1]]
|
|
296
296
|
elsif scanner.scan(/\*\*(.+?)\*\*/)
|
|
@@ -24,7 +24,7 @@ module PrawnCJKLineWrap
|
|
|
24
24
|
"[#{CJK_CHARS}]",
|
|
25
25
|
"[#{ews}]+",
|
|
26
26
|
"#{ehy}+[^#{ebc}]*",
|
|
27
|
-
eshy.to_s
|
|
27
|
+
eshy.to_s
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
Regexp.new(patterns.join('|'))
|
|
@@ -32,13 +32,25 @@ module PrawnCJKLineWrap
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
module Przn
|
|
35
|
-
|
|
35
|
+
# Legacy PDF export via Prawn — renders the deck directly into a vector
|
|
36
|
+
# PDF without touching the terminal. Diverges from what's on screen for
|
|
37
|
+
# any feature the live renderer adds (OSC 66 sized text, OSC 7772
|
|
38
|
+
# backgrounds, proportional fonts) but works headlessly.
|
|
39
|
+
def self.export_pdf_prawn(file, output, theme: nil)
|
|
40
|
+
markdown = File.read(file)
|
|
41
|
+
presentation = Parser.parse(markdown)
|
|
42
|
+
base_dir = File.dirname(File.expand_path(file))
|
|
43
|
+
PrawnPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
|
|
44
|
+
puts "Generated: #{output}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class PrawnPdfExporter
|
|
36
48
|
PAGE_WIDTH = 960
|
|
37
49
|
PAGE_HEIGHT = 540
|
|
38
50
|
DEFAULT_FONT_SIZE = 18
|
|
39
51
|
DEFAULT_SCALE_TO_PT = {
|
|
40
52
|
1 => 10, 2 => 18, 3 => 24, 4 => 32,
|
|
41
|
-
5 => 40, 6 => 48, 7 => 56
|
|
53
|
+
5 => 40, 6 => 48, 7 => 56
|
|
42
54
|
}.freeze
|
|
43
55
|
|
|
44
56
|
DEFAULT_SCALE = Renderer::DEFAULT_SCALE
|
|
@@ -48,7 +60,7 @@ module Przn
|
|
|
48
60
|
'magenta' => 'FF79C6', 'cyan' => '8BE9FD', 'white' => 'F8F8F2',
|
|
49
61
|
'bright_red' => 'FF6E6E', 'bright_green' => '69FF94', 'bright_yellow' => 'FFFFA5',
|
|
50
62
|
'bright_blue' => 'D6ACFF', 'bright_magenta' => 'FF92DF', 'bright_cyan' => 'A4FFFF',
|
|
51
|
-
'bright_white' => 'FFFFFF'
|
|
63
|
+
'bright_white' => 'FFFFFF'
|
|
52
64
|
}.freeze
|
|
53
65
|
|
|
54
66
|
def initialize(presentation, base_dir: '.', theme: nil)
|
|
@@ -56,7 +68,7 @@ module Przn
|
|
|
56
68
|
@base_dir = base_dir
|
|
57
69
|
@theme = theme || Theme.default
|
|
58
70
|
@bg_color = @theme.background && @theme.background[:color]
|
|
59
|
-
@fg_color = @theme.font[:color] ||
|
|
71
|
+
@fg_color = @theme.font[:color] || '000000'
|
|
60
72
|
@code_bg = @theme.colors[:code_bg]
|
|
61
73
|
@dim_color = @theme.colors[:dim]
|
|
62
74
|
@inline_code_color = @theme.colors[:inline_code]
|
|
@@ -73,7 +85,7 @@ module Przn
|
|
|
73
85
|
-> { Dir.glob('/usr/share/fonts/**/NotoSansJP-Regular.ttf').first },
|
|
74
86
|
-> { File.join(Dir.home, 'Library/Fonts/HackGen-Regular.ttf') },
|
|
75
87
|
-> { '/Library/Fonts/Arial Unicode.ttf' },
|
|
76
|
-
-> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' }
|
|
88
|
+
-> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' }
|
|
77
89
|
].freeze
|
|
78
90
|
|
|
79
91
|
FALLBACK_FONT_FAMILIES = %w[NotoSansCJK NotoSansJP HackGen].freeze
|
|
@@ -84,7 +96,7 @@ module Przn
|
|
|
84
96
|
|
|
85
97
|
pdf = Prawn::Document.new(
|
|
86
98
|
page_size: [PAGE_WIDTH, PAGE_HEIGHT],
|
|
87
|
-
margin: 0
|
|
99
|
+
margin: 0
|
|
88
100
|
)
|
|
89
101
|
|
|
90
102
|
register_fonts(pdf)
|
|
@@ -108,7 +120,7 @@ module Przn
|
|
|
108
120
|
'CJK' => {
|
|
109
121
|
normal: {file: font_path, font: 0},
|
|
110
122
|
bold: {file: font_path.sub('W3', 'W6').then { |p| File.exist?(p) ? p : font_path }, font: 0},
|
|
111
|
-
italic: {file: font_path, font: 0}
|
|
123
|
+
italic: {file: font_path, font: 0}
|
|
112
124
|
}
|
|
113
125
|
)
|
|
114
126
|
else
|
|
@@ -117,7 +129,7 @@ module Przn
|
|
|
117
129
|
'CJK' => {
|
|
118
130
|
normal: font_path,
|
|
119
131
|
bold: bold_path,
|
|
120
|
-
italic: font_path
|
|
132
|
+
italic: font_path
|
|
121
133
|
}
|
|
122
134
|
)
|
|
123
135
|
end
|
|
@@ -144,10 +156,10 @@ module Przn
|
|
|
144
156
|
pdf.font_families.update(family => {
|
|
145
157
|
normal: path,
|
|
146
158
|
bold: bold_path,
|
|
147
|
-
italic: path
|
|
159
|
+
italic: path
|
|
148
160
|
})
|
|
149
161
|
@registered_inline_fonts[family] = true
|
|
150
|
-
rescue
|
|
162
|
+
rescue StandardError
|
|
151
163
|
next
|
|
152
164
|
end
|
|
153
165
|
end
|
|
@@ -218,7 +230,7 @@ module Przn
|
|
|
218
230
|
'Emoji' => { normal: emoji_path, bold: emoji_path, italic: emoji_path }
|
|
219
231
|
)
|
|
220
232
|
pdf.fallback_fonts = ['Emoji']
|
|
221
|
-
rescue
|
|
233
|
+
rescue StandardError
|
|
222
234
|
nil
|
|
223
235
|
end
|
|
224
236
|
|
|
@@ -231,7 +243,7 @@ module Przn
|
|
|
231
243
|
# Only use fonts with glyf outlines (not SBIX/COLR bitmap-only fonts)
|
|
232
244
|
ttf = TTFunk::File.open(path)
|
|
233
245
|
return path if ttf.directory.tables.key?('glyf')
|
|
234
|
-
rescue
|
|
246
|
+
rescue StandardError
|
|
235
247
|
next
|
|
236
248
|
end
|
|
237
249
|
nil
|
|
@@ -609,7 +621,7 @@ module Przn
|
|
|
609
621
|
end
|
|
610
622
|
|
|
611
623
|
def bullet
|
|
612
|
-
@font_registered ? @theme.bullet[:text] :
|
|
624
|
+
@font_registered ? @theme.bullet[:text] : '-'
|
|
613
625
|
end
|
|
614
626
|
|
|
615
627
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Przn
|
|
4
|
+
# Drives the laptop-side view in extended-display mode. Reuses the existing
|
|
5
|
+
# Renderer to draw the current slide (notes still rendered dim-inline so the
|
|
6
|
+
# presenter sees them in context), then overlays a three-line strip at the
|
|
7
|
+
# bottom of the terminal: speaker notes summary, next-slide preview, and a
|
|
8
|
+
# footer with the slide counter + elapsed time.
|
|
9
|
+
class PresenterRenderer < Renderer
|
|
10
|
+
def initialize(terminal, presentation:, base_dir: '.', theme: nil)
|
|
11
|
+
super(terminal, base_dir: base_dir, theme: theme, mode: :presenter)
|
|
12
|
+
@presentation = presentation
|
|
13
|
+
@started_at = Time.now
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render(slide, current:, total:, started_at: nil)
|
|
17
|
+
super(slide, current: current, total: total, started_at: started_at)
|
|
18
|
+
@mutex.synchronize { draw_presenter_strip(current, total) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def draw_presenter_strip(current, total)
|
|
24
|
+
w = @terminal.width
|
|
25
|
+
h = @terminal.height
|
|
26
|
+
slide = @presentation.slides[current]
|
|
27
|
+
notes_text = slide.notes.join(' / ')
|
|
28
|
+
next_title = current + 1 < total ? preview_title(@presentation.slides[current + 1]) : nil
|
|
29
|
+
elapsed = format_elapsed(Time.now - @started_at)
|
|
30
|
+
footer = "Slide #{current + 1} / #{total} #{elapsed}"
|
|
31
|
+
|
|
32
|
+
# When the theme opts into the rabbit/turtle indicator, the parent
|
|
33
|
+
# renderer has already drawn it on rows h-1 and h. Lift the strip up
|
|
34
|
+
# so it doesn't clobber the runner bar; the indicator itself replaces
|
|
35
|
+
# the strip's own footer line (slide #, elapsed time are visible there
|
|
36
|
+
# anyway via rabbit position and turtle position).
|
|
37
|
+
rabbit_mode = !@theme.rabbit.nil?
|
|
38
|
+
notes_row = rabbit_mode ? h - 3 : h - 2
|
|
39
|
+
next_row = rabbit_mode ? h - 2 : h - 1
|
|
40
|
+
|
|
41
|
+
@terminal.move_to(notes_row, 1)
|
|
42
|
+
@terminal.write "#{ANSI[:dim]}Notes: #{truncate_to_width(notes_text, [w - 8, 1].max)}#{ANSI[:reset]}"
|
|
43
|
+
@terminal.move_to(next_row, 1)
|
|
44
|
+
@terminal.write "#{ANSI[:dim]}Next: #{truncate_to_width(next_title || '—', [w - 8, 1].max)}#{ANSI[:reset]}"
|
|
45
|
+
|
|
46
|
+
unless rabbit_mode
|
|
47
|
+
@terminal.move_to(h, 1)
|
|
48
|
+
@terminal.write "#{ANSI[:dim]}#{truncate_to_width(footer, w)}#{ANSI[:reset]}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@terminal.flush
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def preview_title(slide)
|
|
55
|
+
return nil unless slide
|
|
56
|
+
slide.blocks.each do |b|
|
|
57
|
+
case b[:type]
|
|
58
|
+
when :heading, :paragraph then return strip_markup(b[:content].to_s)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_elapsed(seconds)
|
|
65
|
+
h = (seconds / 3600).to_i
|
|
66
|
+
m = ((seconds % 3600) / 60).to_i
|
|
67
|
+
s = (seconds % 60).to_i
|
|
68
|
+
format('%02d:%02d:%02d', h, m, s)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/przn/renderer.rb
CHANGED
|
@@ -10,21 +10,35 @@ module Przn
|
|
|
10
10
|
dim: "\e[2m",
|
|
11
11
|
cyan: "\e[36m",
|
|
12
12
|
gray_bg: "\e[48;5;236m",
|
|
13
|
-
reset: "\e[0m"
|
|
13
|
+
reset: "\e[0m"
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
16
|
DEFAULT_SCALE = 2
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# Default `relative_height` (as a percent of terminal height) applied to
|
|
19
|
+
# image blocks that don't carry an explicit one. Caps how much of the
|
|
20
|
+
# screen a single image can occupy; the rest leaves predictable margin
|
|
21
|
+
# for the slide footer and avoids placement-clearing edge cases in some
|
|
22
|
+
# terminals when an image lands right against the bottom row.
|
|
23
|
+
DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT = 70
|
|
24
|
+
|
|
25
|
+
# `mode:` controls whether `{::note}` / `<note>` segments are rendered:
|
|
26
|
+
# :solo — dim-inline (today's behavior), default for stand-alone runs.
|
|
27
|
+
# :audience — stripped from output; the projector view never shows notes.
|
|
28
|
+
# :presenter — dim-inline (so the presenter sees them in context) and
|
|
29
|
+
# ALSO aggregated separately for the side panel via
|
|
30
|
+
# Slide#notes; this renderer just keeps the inline copy.
|
|
31
|
+
def initialize(terminal, base_dir: '.', theme: nil, mode: :solo)
|
|
19
32
|
@terminal = terminal
|
|
20
33
|
@base_dir = base_dir
|
|
21
34
|
@theme = theme || Theme.default
|
|
35
|
+
@mode = mode
|
|
22
36
|
@image_cache = {}
|
|
23
37
|
@kitty_uploads = {}
|
|
24
38
|
@mutex = Mutex.new
|
|
25
39
|
end
|
|
26
40
|
|
|
27
|
-
def render(slide, current:, total:)
|
|
41
|
+
def render(slide, current:, total:, started_at: nil)
|
|
28
42
|
@mutex.synchronize do
|
|
29
43
|
@terminal.clear
|
|
30
44
|
apply_slide_background(slide)
|
|
@@ -49,9 +63,13 @@ module Przn
|
|
|
49
63
|
end
|
|
50
64
|
end
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
if @theme.rabbit
|
|
67
|
+
draw_runner_bar(h, w, current, total, started_at)
|
|
68
|
+
else
|
|
69
|
+
status = " #{current + 1} / #{total} "
|
|
70
|
+
@terminal.move_to(h, w - status.size)
|
|
71
|
+
@terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
|
|
72
|
+
end
|
|
55
73
|
|
|
56
74
|
@terminal.flush
|
|
57
75
|
end
|
|
@@ -119,6 +137,60 @@ module Przn
|
|
|
119
137
|
@terminal.write "\e]7772;bg-gradient;type=#{type}:angle=#{angle}:colors=#{colors.join(',')}\a"
|
|
120
138
|
end
|
|
121
139
|
|
|
140
|
+
# Bottom-row progress indicator (Rabbit-style):
|
|
141
|
+
#
|
|
142
|
+
# 1 🐢 🐇 9
|
|
143
|
+
# └ current slide # └ elapsed time └ slide progress └ goal (total slides)
|
|
144
|
+
#
|
|
145
|
+
# The anchor numbers (current at the left, total at the right) sit on the
|
|
146
|
+
# very bottom row; the emojis render at OSC 66 scale 2 and are anchored at
|
|
147
|
+
# row `h-1` so their bottom half lands on row `h` next to the numbers,
|
|
148
|
+
# making them visibly twice as large as the labels without needing more
|
|
149
|
+
# vertical screen real-estate. The turtle is hidden when
|
|
150
|
+
# `theme.rabbit.duration` is unset / unparseable. `flip=h` mirrors each
|
|
151
|
+
# glyph horizontally on terminals that honor it (Echoes); others ignore
|
|
152
|
+
# the parameter and the emojis face left.
|
|
153
|
+
EMOJI_RUNNER_CELLS = 4 # 🐇/🐢 are 2 source cells wide, rendered at s=2 → 4 cells
|
|
154
|
+
|
|
155
|
+
def draw_runner_bar(h, w, current, total, started_at)
|
|
156
|
+
left = (current + 1).to_s
|
|
157
|
+
right = total.to_s
|
|
158
|
+
track_left = left.size + 2 # 1 cell gap after the left number
|
|
159
|
+
track_right = w - right.size - 1 # 1 cell gap before the right number
|
|
160
|
+
return if track_right - track_left < EMOJI_RUNNER_CELLS
|
|
161
|
+
|
|
162
|
+
@terminal.move_to(h, 1)
|
|
163
|
+
@terminal.write "#{ANSI[:dim]}#{left}#{ANSI[:reset]}"
|
|
164
|
+
@terminal.move_to(h, w - right.size + 1)
|
|
165
|
+
@terminal.write "#{ANSI[:dim]}#{right}#{ANSI[:reset]}"
|
|
166
|
+
|
|
167
|
+
rabbit_row = [h - 1, 1].max
|
|
168
|
+
rabbit_col = runner_col(current, [total - 1, 1].max, track_left, track_right)
|
|
169
|
+
@terminal.move_to(rabbit_row, rabbit_col)
|
|
170
|
+
@terminal.write KittyText.sized('🐇', s: 2, flip: 'h')
|
|
171
|
+
|
|
172
|
+
duration_s = Theme.parse_duration(@theme.rabbit[:duration])
|
|
173
|
+
return unless started_at && duration_s && duration_s.positive?
|
|
174
|
+
|
|
175
|
+
elapsed = Time.now - started_at
|
|
176
|
+
frac = (elapsed / duration_s).clamp(0.0, 1.0)
|
|
177
|
+
span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
|
|
178
|
+
turtle_col = track_left + (frac * [span, 0].max).round
|
|
179
|
+
@terminal.move_to(rabbit_row, turtle_col)
|
|
180
|
+
@terminal.write KittyText.sized('🐢', s: 2, flip: 'h')
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Linear-interpolate a runner's column inside the track. `step` is 0..max
|
|
184
|
+
# (e.g. current slide index 0..total-1), and the returned column leaves
|
|
185
|
+
# enough room for an emoji `EMOJI_RUNNER_CELLS` cells wide before the
|
|
186
|
+
# right-anchor number.
|
|
187
|
+
def runner_col(step, max, track_left, track_right)
|
|
188
|
+
return track_left if max <= 0
|
|
189
|
+
span = (track_right - (EMOJI_RUNNER_CELLS - 1)) - track_left
|
|
190
|
+
span = 0 if span < 0
|
|
191
|
+
track_left + (step.to_f / max * span).round
|
|
192
|
+
end
|
|
193
|
+
|
|
122
194
|
def render_heading(block, width, row)
|
|
123
195
|
text = block[:content]
|
|
124
196
|
|
|
@@ -152,7 +224,7 @@ module Przn
|
|
|
152
224
|
if li == 0
|
|
153
225
|
@terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
154
226
|
else
|
|
155
|
-
@terminal.write "#{KittyText.sized(
|
|
227
|
+
@terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
156
228
|
end
|
|
157
229
|
row += DEFAULT_SCALE
|
|
158
230
|
end
|
|
@@ -206,7 +278,7 @@ module Przn
|
|
|
206
278
|
left = content_left(width)
|
|
207
279
|
block[:items].each do |item|
|
|
208
280
|
depth = item[:depth] || 0
|
|
209
|
-
indent =
|
|
281
|
+
indent = ' ' * depth
|
|
210
282
|
prefix = "#{indent}#{@theme.bullet[:text]}"
|
|
211
283
|
prefix_w = display_width(prefix)
|
|
212
284
|
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
@@ -219,7 +291,7 @@ module Przn
|
|
|
219
291
|
if li == 0
|
|
220
292
|
@terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
221
293
|
else
|
|
222
|
-
@terminal.write "#{KittyText.sized(
|
|
294
|
+
@terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
223
295
|
end
|
|
224
296
|
row += DEFAULT_SCALE
|
|
225
297
|
end
|
|
@@ -232,7 +304,7 @@ module Przn
|
|
|
232
304
|
left = content_left(width)
|
|
233
305
|
block[:items].each_with_index do |item, i|
|
|
234
306
|
depth = item[:depth] || 0
|
|
235
|
-
indent =
|
|
307
|
+
indent = ' ' * depth
|
|
236
308
|
prefix = "#{indent}#{i + 1}. "
|
|
237
309
|
prefix_w = display_width(prefix)
|
|
238
310
|
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
@@ -245,7 +317,7 @@ module Przn
|
|
|
245
317
|
if li == 0
|
|
246
318
|
@terminal.write "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
247
319
|
else
|
|
248
|
-
@terminal.write "#{KittyText.sized(
|
|
320
|
+
@terminal.write "#{KittyText.sized(' ' * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
249
321
|
end
|
|
250
322
|
row += DEFAULT_SCALE
|
|
251
323
|
end
|
|
@@ -281,7 +353,7 @@ module Przn
|
|
|
281
353
|
|
|
282
354
|
def render_blockquote(block, width, row)
|
|
283
355
|
left = content_left(width)
|
|
284
|
-
prefix =
|
|
356
|
+
prefix = '| '
|
|
285
357
|
prefix_w = display_width(prefix)
|
|
286
358
|
max_w = max_text_width(width, left + 1, DEFAULT_SCALE) - prefix_w
|
|
287
359
|
|
|
@@ -292,7 +364,7 @@ module Przn
|
|
|
292
364
|
|
|
293
365
|
wrapped.each_with_index do |line_segs, li|
|
|
294
366
|
@terminal.move_to(row, left + 1)
|
|
295
|
-
p = li == 0 ? prefix :
|
|
367
|
+
p = li == 0 ? prefix : ' ' * prefix_w
|
|
296
368
|
@terminal.write "#{ANSI[:dim]}#{KittyText.sized(p, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
297
369
|
row += DEFAULT_SCALE
|
|
298
370
|
end
|
|
@@ -316,7 +388,7 @@ module Przn
|
|
|
316
388
|
@terminal.move_to(row, left)
|
|
317
389
|
line = cells.each_with_index.map { |cell, ci|
|
|
318
390
|
pad_to_width(cell, col_widths[ci] || 0)
|
|
319
|
-
}.join(
|
|
391
|
+
}.join(' | ')
|
|
320
392
|
if ri == 0
|
|
321
393
|
@terminal.write "#{ANSI[:bold]}#{KittyText.sized(line, s: DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
322
394
|
else
|
|
@@ -326,7 +398,7 @@ module Przn
|
|
|
326
398
|
|
|
327
399
|
if ri == 0
|
|
328
400
|
@terminal.move_to(row, left)
|
|
329
|
-
@terminal.write KittyText.sized(col_widths.map { |w|
|
|
401
|
+
@terminal.write KittyText.sized(col_widths.map { |w| '-' * w }.join('--+--'), s: DEFAULT_SCALE)
|
|
330
402
|
row += DEFAULT_SCALE
|
|
331
403
|
end
|
|
332
404
|
end
|
|
@@ -347,10 +419,16 @@ module Przn
|
|
|
347
419
|
left = content_left(width)
|
|
348
420
|
available_cols = width - left * 2
|
|
349
421
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
422
|
+
# Cap the default vertical area to 70 % of the screen, matching what
|
|
423
|
+
# `{:relative_height="70"}` would do explicitly. Large images that
|
|
424
|
+
# extend to within a couple of rows of the screen edge render
|
|
425
|
+
# unreliably in some terminals — they're known-good at 70 %, and
|
|
426
|
+
# smaller images sit well within this cap so they're unaffected.
|
|
427
|
+
# An explicit `relative_height` still overrides.
|
|
428
|
+
default_rh = DEFAULT_IMAGE_RELATIVE_HEIGHT_PERCENT
|
|
429
|
+
rh = block[:attrs]['relative_height'] || default_rh
|
|
430
|
+
target_rows = (@terminal.height * rh.to_i / 100.0).to_i
|
|
431
|
+
available_rows = [target_rows, available_rows].min
|
|
354
432
|
|
|
355
433
|
# Calculate target cell size maintaining aspect ratio
|
|
356
434
|
img_cell_w = img_w.to_f / cell_w
|
|
@@ -504,7 +582,7 @@ module Przn
|
|
|
504
582
|
f = default_face == :body ? @theme.font[:family] : default_face
|
|
505
583
|
h = default_h
|
|
506
584
|
c = default_color == :body ? @theme.font[:color] : default_color
|
|
507
|
-
body_open = c ? color_code(c) :
|
|
585
|
+
body_open = c ? color_code(c) : ''
|
|
508
586
|
inner = segments.map { |segment|
|
|
509
587
|
type = segment[0]
|
|
510
588
|
content = segment[1]
|
|
@@ -519,7 +597,7 @@ module Przn
|
|
|
519
597
|
KittyText.sized(content, s: para_scale, f: f, h: h)
|
|
520
598
|
end
|
|
521
599
|
when :font then "#{render_font_segment(content, segment[2] || {}, para_scale, default_face: f, default_h: h)}#{(segment[2] || {})[:color] ? body_open : ''}"
|
|
522
|
-
when :note then "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
600
|
+
when :note then @mode == :audience ? "" : "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
523
601
|
when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
524
602
|
when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
525
603
|
when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
|
|
@@ -545,7 +623,7 @@ module Przn
|
|
|
545
623
|
used = 0
|
|
546
624
|
|
|
547
625
|
segments.each do |seg|
|
|
548
|
-
content = seg[1] ||
|
|
626
|
+
content = seg[1] || ''
|
|
549
627
|
next if content.empty?
|
|
550
628
|
|
|
551
629
|
seg_scale = effective_seg_scale(seg, para_scale)
|
|
@@ -594,7 +672,7 @@ module Przn
|
|
|
594
672
|
|
|
595
673
|
def segments_visible_cells(segments, para_scale)
|
|
596
674
|
segments.sum { |seg|
|
|
597
|
-
content = seg[1] ||
|
|
675
|
+
content = seg[1] || ''
|
|
598
676
|
display_width(content) * effective_seg_scale(seg, para_scale)
|
|
599
677
|
}
|
|
600
678
|
end
|
|
@@ -636,7 +714,7 @@ module Przn
|
|
|
636
714
|
|
|
637
715
|
def pad_to_width(text, target_width)
|
|
638
716
|
current = display_width(text)
|
|
639
|
-
text +
|
|
717
|
+
text + ' ' * [target_width - current, 0].max
|
|
640
718
|
end
|
|
641
719
|
|
|
642
720
|
def max_inline_scale(text)
|
|
@@ -669,7 +747,7 @@ module Przn
|
|
|
669
747
|
r, g, b = color.scan(/../).map { |h| h.to_i(16) }
|
|
670
748
|
"\e[38;2;#{r};#{g};#{b}m"
|
|
671
749
|
else
|
|
672
|
-
|
|
750
|
+
''
|
|
673
751
|
end
|
|
674
752
|
end
|
|
675
753
|
|
|
@@ -706,12 +784,12 @@ module Przn
|
|
|
706
784
|
text
|
|
707
785
|
.gsub(/\{::tag\s+name="[^"]+"\}(.*?)\{:\/tag\}/, '\1')
|
|
708
786
|
.gsub(/\{::note\}(.*?)\{:\/note\}/, '\1')
|
|
709
|
-
.gsub(
|
|
787
|
+
.gsub('{::wait/}', '')
|
|
710
788
|
.gsub(/\*\*(.+?)\*\*/, '\1')
|
|
711
789
|
.gsub(/\*(.+?)\*/, '\1')
|
|
712
790
|
.gsub(/~~(.+?)~~/, '\1')
|
|
713
791
|
.gsub(/`([^`]+)`/, '\1')
|
|
714
|
-
.gsub(/&(lt|gt|amp);/) { |_| {
|
|
792
|
+
.gsub(/&(lt|gt|amp);/) { |_| {'lt' => '<', 'gt' => '>', 'amp' => '&'}[$1] }
|
|
715
793
|
end
|
|
716
794
|
|
|
717
795
|
def calculate_height(blocks, width)
|
|
@@ -4,6 +4,20 @@ require 'tmpdir'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
|
|
6
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
|
+
|
|
7
21
|
# Renders each slide live to the user's terminal, asks the terminal to save
|
|
8
22
|
# the current pane as a vector PDF via Echoes' OSC 7772 `capture` command,
|
|
9
23
|
# then concatenates the per-slide PDFs into a single multi-page PDF.
|
|
@@ -53,7 +67,7 @@ module Przn
|
|
|
53
67
|
@terminal.enter_alt_screen
|
|
54
68
|
@terminal.hide_cursor
|
|
55
69
|
@presentation.slides.each_with_index do |slide, i|
|
|
56
|
-
pdf_path = File.join(dir, format(
|
|
70
|
+
pdf_path = File.join(dir, format('slide-%04d.pdf', i))
|
|
57
71
|
@renderer.render(slide, current: i, total: @presentation.total)
|
|
58
72
|
request_capture(pdf_path)
|
|
59
73
|
wait_for_capture(pdf_path)
|
|
@@ -77,7 +91,7 @@ module Przn
|
|
|
77
91
|
until File.exist?(path) && File.size?(path).to_i.positive?
|
|
78
92
|
if Time.now > deadline
|
|
79
93
|
raise "Capture timed out for #{path}. " \
|
|
80
|
-
|
|
94
|
+
'Is Echoes running and recent enough to honor OSC 7772 `capture` to a .pdf path?'
|
|
81
95
|
end
|
|
82
96
|
sleep POLL_INTERVAL
|
|
83
97
|
end
|
|
@@ -86,7 +100,7 @@ module Przn
|
|
|
86
100
|
end
|
|
87
101
|
|
|
88
102
|
def merge_pdfs(pdf_paths, output_path)
|
|
89
|
-
raise
|
|
103
|
+
raise 'No slides captured' if pdf_paths.empty?
|
|
90
104
|
|
|
91
105
|
output = HexaPDF::Document.new
|
|
92
106
|
pdf_paths.each do |path|
|
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, :background, :title
|
|
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)
|
|
@@ -19,10 +19,37 @@ module Przn
|
|
|
19
19
|
bullet: defaults[:bullet].merge(overrides[:bullet] || {}),
|
|
20
20
|
background: defaults[:background].merge(overrides[:background] || {}),
|
|
21
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
|
|
22
27
|
}
|
|
23
28
|
new(merged)
|
|
24
29
|
end
|
|
25
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
|
+
|
|
26
53
|
def self.default
|
|
27
54
|
new(load_yaml(DEFAULT_PATH))
|
|
28
55
|
end
|
|
@@ -44,6 +71,9 @@ module Przn
|
|
|
44
71
|
bullet: (data[:bullet] || {}).compact,
|
|
45
72
|
background: (data[:background] || {}).compact,
|
|
46
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
|
|
47
77
|
}
|
|
48
78
|
end
|
|
49
79
|
private_class_method :load_yaml
|
|
@@ -54,6 +84,7 @@ module Przn
|
|
|
54
84
|
@bullet = config[:bullet]
|
|
55
85
|
@background = config[:background]
|
|
56
86
|
@title = config[:title]
|
|
87
|
+
@rabbit = config[:rabbit]
|
|
57
88
|
end
|
|
58
89
|
end
|
|
59
90
|
end
|
data/lib/przn/version.rb
CHANGED
data/lib/przn.rb
CHANGED
|
@@ -1,17 +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
|
|
14
|
-
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'
|
|
15
20
|
|
|
16
21
|
module Przn
|
|
17
22
|
class Error < StandardError; end
|
|
@@ -26,29 +31,72 @@ module Przn
|
|
|
26
31
|
Controller.new(presentation, terminal, renderer)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
# command); use `export_pdf_prawn` instead for environments where that's
|
|
34
|
-
# not possible (CI, headless).
|
|
35
|
-
def self.export_pdf(file, output, theme: nil)
|
|
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)
|
|
36
38
|
markdown = File.read(file)
|
|
37
39
|
presentation = Parser.parse(markdown)
|
|
40
|
+
terminal = Terminal.new
|
|
38
41
|
base_dir = File.dirname(File.expand_path(file))
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
41
65
|
end
|
|
42
66
|
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
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
|
+
|
|
48
95
|
markdown = File.read(file)
|
|
49
96
|
presentation = Parser.parse(markdown)
|
|
97
|
+
terminal = Terminal.new
|
|
50
98
|
base_dir = File.dirname(File.expand_path(file))
|
|
51
|
-
|
|
52
|
-
|
|
99
|
+
renderer = PresenterRenderer.new(terminal, presentation: presentation, base_dir: base_dir, theme: theme)
|
|
100
|
+
Controller.new(presentation, terminal, renderer, audience_link: link)
|
|
53
101
|
end
|
|
54
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
|
|
@@ -55,18 +55,23 @@ files:
|
|
|
55
55
|
- default_theme.yml
|
|
56
56
|
- exe/przn
|
|
57
57
|
- lib/przn.rb
|
|
58
|
+
- lib/przn/audience_link.rb
|
|
58
59
|
- lib/przn/controller.rb
|
|
60
|
+
- lib/przn/echoes_client.rb
|
|
59
61
|
- lib/przn/image_util.rb
|
|
60
62
|
- lib/przn/kitty_text.rb
|
|
61
63
|
- lib/przn/parser.rb
|
|
62
|
-
- lib/przn/
|
|
64
|
+
- lib/przn/prawn_pdf_exporter.rb
|
|
63
65
|
- lib/przn/presentation.rb
|
|
66
|
+
- lib/przn/presenter_renderer.rb
|
|
64
67
|
- lib/przn/renderer.rb
|
|
65
68
|
- lib/przn/screenshot_pdf_exporter.rb
|
|
66
69
|
- lib/przn/slide.rb
|
|
67
70
|
- lib/przn/terminal.rb
|
|
68
71
|
- lib/przn/theme.rb
|
|
69
72
|
- lib/przn/version.rb
|
|
73
|
+
- sample/doge.jpg
|
|
74
|
+
- sample/doge.png
|
|
70
75
|
- sample/sample.md
|
|
71
76
|
- sig/przn.rbs
|
|
72
77
|
homepage: "https://github.com/amatsuda/przn"
|