przn 0.5.0 → 0.6.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.
@@ -67,6 +67,14 @@ module Przn
67
67
  ENV['TERM_PROGRAM'] == 'Echoes'
68
68
  end
69
69
 
70
+ # Echoes-specific gate for OSC 7772 features (cell-alpha slide fades,
71
+ # bg-color / bg-gradient, hide-pointer). Strictly checked by
72
+ # TERM_PROGRAM since the OSC is private — other terminals would
73
+ # silently drop it but the renderer still spends time emitting bytes.
74
+ def echoes_terminal?
75
+ ENV['TERM_PROGRAM'] == 'Echoes'
76
+ end
77
+
70
78
  PNG_MAGIC = "\x89PNG\r\n\x1a\n".b.freeze
71
79
 
72
80
  def png?(path)
@@ -85,10 +93,55 @@ module Przn
85
93
  "\e_Ga=t,t=f,f=100,i=#{image_id},q=2;#{encoded}\e\\"
86
94
  end
87
95
 
96
+ # Kitty Graphics Protocol: upload arbitrary image bytes inline via
97
+ # the direct-data transmission mode (`t=d`). Used by the shape-
98
+ # drawing tags (`<rect>`, `<circle>`, `<line>`, …) which build a
99
+ # tiny self-contained SVG document per shape and ship it here.
100
+ # Echoes content-sniffs the payload and routes SVGs through its
101
+ # native CoreGraphics rasterizer (sub-millisecond for path-only
102
+ # SVGs — the case shape primitives always hit). `f=100` is the
103
+ # default PNG format code; the sniffer overrides it for SVG.
104
+ def kitty_upload_inline(bytes, image_id:)
105
+ encoded = [bytes.to_s].pack('m0')
106
+ "\e_Ga=t,t=d,f=100,i=#{image_id},q=2;#{encoded}\e\\"
107
+ end
108
+
88
109
  # Kitty Graphics Protocol: place a previously-uploaded image at the
89
110
  # current cursor position, scaled to fit `cols` x `rows` cells.
90
- def kitty_place(image_id:, cols:, rows:)
91
- "\e_Ga=p,i=#{image_id},c=#{cols},r=#{rows},q=2\e\\"
111
+ # `z` sets the z-index — negative values draw the image behind
112
+ # text cells (used by slide background images at z: -1).
113
+ # `x_off` / `y_off` add a sub-cell pixel offset from the top-left
114
+ # of the anchor cell — this is how `<img>` gets 1-px positioning
115
+ # without snapping to the cell grid. Omitted (or 0) means anchor
116
+ # to the cell's own top-left.
117
+ # `C=1` (suppress cursor movement / wrap / scroll) is always set
118
+ # — przn is a presentation tool, never the kind of context that
119
+ # wants an image landing near the bottom to scroll prior rows
120
+ # off-screen; oversize placements clip at the pane edge instead.
121
+ def kitty_place(image_id:, cols:, rows:, z: nil, x_off: 0, y_off: 0)
122
+ params = +"a=p,i=#{image_id},c=#{cols},r=#{rows},C=1,q=2"
123
+ params << ",X=#{x_off}" if x_off && x_off > 0
124
+ params << ",Y=#{y_off}" if y_off && y_off > 0
125
+ params << ",z=#{z}" if z
126
+ "\e_G#{params}\e\\"
127
+ end
128
+
129
+ # Kitty Graphics Protocol: delete every placement for a specific
130
+ # image id while keeping the stored image data alive (lowercase
131
+ # `d=i`). Used to wipe the previous slide's background image
132
+ # before placing a new one, without forcing a re-upload.
133
+ def kitty_delete_placements(image_id:)
134
+ "\e_Ga=d,d=i,i=#{image_id},q=2\e\\"
135
+ end
136
+
137
+ # Kitty Graphics Protocol: delete every placement on the screen
138
+ # while keeping the stored image data alive (lowercase `d=a`).
139
+ # Called at the start of each slide render to wipe the previous
140
+ # slide's `<img>` / shape placements; the new slide re-emits its
141
+ # own placements so cached images need only the small placement
142
+ # command, not a re-transmission.
143
+ def kitty_delete_all_placements
144
+ "\e_Ga=d,d=a,q=2\e\\"
92
145
  end
93
146
 
94
147
  # Kitty Graphics Protocol: delete every placement and free the
@@ -19,8 +19,9 @@ module Przn
19
19
  # 66, which renders without the flip / custom font on any kitty-
20
20
  # compatible terminal (better than emitting an OSC 7772 frame the
21
21
  # terminal would ignore entirely).
22
- def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil, flip: nil)
22
+ def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil, flip: nil, w: nil)
23
23
  params = +"s=#{s}"
24
+ params << ":w=#{w}" if w
24
25
  params << ":n=#{n}" if n
25
26
  params << ":d=#{d}" if d
26
27
  params << ":h=#{h}" if h
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'tmpdir'
5
+ require 'fileutils'
6
+ require 'shellwords'
7
+
8
+ module Przn
9
+ # Shell-out wrapper around the Mermaid CLI (`mmdc`). Given a Mermaid
10
+ # source string, returns the path to a rendered PNG — cached for the
11
+ # life of the session in a tmpdir keyed by SHA256 of the source so
12
+ # the same diagram on multiple slides (or after `r` reload) shells
13
+ # out exactly once.
14
+ #
15
+ # The PNG is rendered with `-b transparent` so the slide background
16
+ # (theme `bg`, gradient, image) shows through. Width is fixed at
17
+ # 1600 px so the rendered image is high-res enough for any cell
18
+ # placement; the Kitty Graphics protocol scales to whatever cell
19
+ # rect the placement carves out.
20
+ #
21
+ # When `mmdc` is missing or the render fails for any reason, returns
22
+ # nil and the caller falls back to rendering the source as a plain
23
+ # fenced block.
24
+ module MermaidRenderer
25
+ CANVAS_WIDTH_PX = 1600
26
+
27
+ @cache = {}
28
+ @cache_mutex = Mutex.new
29
+ @tmpdir = nil
30
+ @tmpdir_mutex = Mutex.new
31
+
32
+ module_function
33
+
34
+ # Returns the path to a rendered PNG, or nil if rendering failed.
35
+ # `theme` defaults to mermaid's `default`; future hook for a theme
36
+ # knob (theme.yml `mermaid.theme`).
37
+ def render(source, theme: 'default')
38
+ return nil if source.nil? || source.strip.empty?
39
+ key = Digest::SHA256.hexdigest("#{theme}:#{source}")
40
+ cached = @cache_mutex.synchronize { @cache[key] }
41
+ return cached if cached
42
+
43
+ dir = ensure_tmpdir
44
+ in_path = File.join(dir, "#{key}.mmd")
45
+ out_path = File.join(dir, "#{key}.png")
46
+ File.write(in_path, source)
47
+
48
+ cmd = ['mmdc',
49
+ '--quiet',
50
+ '-i', in_path,
51
+ '-o', out_path,
52
+ '-t', theme,
53
+ '-b', 'transparent',
54
+ '-w', CANVAS_WIDTH_PX.to_s]
55
+ ok = system(*cmd, out: File::NULL, err: File::NULL)
56
+
57
+ return nil unless ok && File.exist?(out_path) && File.size(out_path).positive?
58
+
59
+ @cache_mutex.synchronize { @cache[key] = out_path }
60
+ out_path
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ # Bare check for whether the CLI is reachable. Used by the
66
+ # renderer to decide whether to even try (or to short-circuit to
67
+ # the plain-fence fallback).
68
+ def available?
69
+ return @available unless @available.nil?
70
+ @available = system('mmdc', '--version', out: File::NULL, err: File::NULL)
71
+ end
72
+
73
+ def reset!
74
+ @cache_mutex.synchronize { @cache.clear }
75
+ @tmpdir_mutex.synchronize do
76
+ FileUtils.remove_entry(@tmpdir) if @tmpdir && File.directory?(@tmpdir)
77
+ @tmpdir = nil
78
+ end
79
+ @available = nil
80
+ end
81
+
82
+ def ensure_tmpdir
83
+ @tmpdir_mutex.synchronize do
84
+ @tmpdir ||= Dir.mktmpdir('przn-mermaid-')
85
+ end
86
+ end
87
+ end
88
+ end