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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b33677433cd4802ee75ef9d7eb821d2dbb6faf8b1d2364d535b6aa28bffa6eb3
4
- data.tar.gz: 930ecad39096eea771759134cd6c7e4a83c55a00ae98c59ead5afea1dcc46f17
3
+ metadata.gz: a02ff63ef48dadc49d46767f2f60e32457cd9562dffa726e6a6b3a811c3cf68d
4
+ data.tar.gz: 0a4c431ac13a990c06397626749de0b495e47efa3af5c3233800868b7fb56b41
5
5
  SHA512:
6
- metadata.gz: 08eafdea3c2f55a29ccfd879b3bfa1541576be74fbb042cc33c527e39289e3dcf1375a10df033820e37f69e9f6726f580cd19ae468d633d73c9d65a23736674b
7
- data.tar.gz: fcbf194e2a090de86a823fd8a0076977db73cf69f857cea6f4406f9fe31c2f44dab60120d61dbda7f10545e2fd750089df8e34233f0808fefd096bcec3cc25e1
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 "bundler/gem_tasks"
4
- require "rake/testtask"
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
5
 
6
6
  Rake::TestTask.new(:test) do |t|
7
- t.libs << "test"
8
- t.libs << "lib"
9
- t.test_files = FileList["test/**/*_test.rb"]
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 = "Usage: przn [options] <presentation.md>"
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 "Generated: theme.yml"
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 "Usage: przn [options] <presentation.md> [@N]"
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
- Przn.start(file, theme: theme, start_at: start_at).run
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
@@ -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
@@ -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?("GIF8")
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' || ENV['TERM_PROGRAM'] == '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
@@ -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
- def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil)
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
- params << ":f=#{f}" if f
20
- "\e]66;#{params};#{text}\a"
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] << " " << Regexp.last_match(1) if items.last
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 << " " << lines[i].strip
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(/\{::wait\/\}/) || scanner.scan(/<wait\s*\/>/)
286
+ elsif scanner.scan('{::wait/}') || scanner.scan(/<wait\s*\/>/)
287
287
  # skip wait markers in inline text
288
- elsif scanner.scan(/&lt;/)
289
- segments << [:text, "<"]
290
- elsif scanner.scan(/&gt;/)
291
- segments << [:text, ">"]
292
- elsif scanner.scan(/&amp;/)
293
- segments << [:text, "&"]
288
+ elsif scanner.scan('&lt;')
289
+ segments << [:text, '<']
290
+ elsif scanner.scan('&gt;')
291
+ segments << [:text, '>']
292
+ elsif scanner.scan('&amp;')
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
- class PdfExporter
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] || "000000"
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
- def initialize(terminal, base_dir: '.', theme: nil)
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
- status = " #{current + 1} / #{total} "
53
- @terminal.move_to(h, w - status.size)
54
- @terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
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(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
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 = " " * depth
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(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
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 = " " * depth
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(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
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 : " " * prefix_w
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| "-" * w }.join("--+--"), s: DEFAULT_SCALE)
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
- if (rh = block[:attrs]['relative_height'])
351
- target_rows = (@terminal.height * rh.to_i / 100.0).to_i
352
- available_rows = [target_rows, available_rows].min
353
- end
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 + " " * [target_width - current, 0].max
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(/\{::wait\/\}/, '')
787
+ .gsub('{::wait/}', '')
710
788
  .gsub(/\*\*(.+?)\*\*/, '\1')
711
789
  .gsub(/\*(.+?)\*/, '\1')
712
790
  .gsub(/~~(.+?)~~/, '\1')
713
791
  .gsub(/`([^`]+)`/, '\1')
714
- .gsub(/&(lt|gt|amp);/) { |_| {"lt" => "<", "gt" => ">", "amp" => "&"}[$1] }
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("slide-%04d.pdf", i))
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
- "Is Echoes running and recent enough to honor OSC 7772 `capture` to a .pdf path?"
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 "No slides captured" if pdf_paths.empty?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Przn
4
- VERSION = "0.3.0"
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/przn.rb CHANGED
@@ -1,17 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "przn/version"
4
- require_relative "przn/kitty_text"
5
- require_relative "przn/image_util"
6
- require_relative "przn/slide"
7
- require_relative "przn/parser"
8
- require_relative "przn/presentation"
9
- require_relative "przn/terminal"
10
- require_relative "przn/renderer"
11
- require_relative "przn/controller"
12
- require_relative "przn/pdf_exporter"
13
- require_relative "przn/screenshot_pdf_exporter"
14
- require_relative "przn/theme"
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
- # Default PDF export: drives the live renderer, asks the terminal to save
30
- # each rendered slide as a one-page vector PDF via OSC 7772 `capture`,
31
- # then concatenates the per-slide PDFs into a single multi-page PDF.
32
- # Requires Echoes (or any terminal that implements the same capture
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
- ScreenshotPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
40
- puts "Generated: #{output}"
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
- # Legacy PDF export via Prawn renders the deck directly into a vector
44
- # PDF without touching the terminal. Diverges from what's on screen for
45
- # any feature the live renderer adds (OSC 66 sized text, OSC 7772
46
- # backgrounds, proportional fonts) but works headlessly.
47
- def self.export_pdf_prawn(file, output, theme: nil)
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
- PdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
52
- puts "Generated: #{output}"
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
+ ![](doge.png){:relative_height="70"}
46
+
47
+ # Image (JPG)
48
+
49
+ ![](doge.jpg){: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.3.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/pdf_exporter.rb
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"