przn 0.2.0 → 0.4.0

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