przn 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +55 -18
- data/Rakefile +5 -5
- data/default_theme.yml +26 -14
- data/exe/przn +32 -7
- data/lib/przn/audience_link.rb +51 -0
- data/lib/przn/controller.rb +15 -2
- data/lib/przn/echoes_client.rb +83 -0
- data/lib/przn/image_util.rb +5 -3
- data/lib/przn/kitty_text.rb +23 -4
- data/lib/przn/parser.rb +12 -12
- data/lib/przn/{pdf_exporter.rb → prawn_pdf_exporter.rb} +30 -17
- data/lib/przn/presenter_renderer.rb +71 -0
- data/lib/przn/renderer.rb +132 -48
- data/lib/przn/screenshot_pdf_exporter.rb +115 -0
- data/lib/przn/slide.rb +25 -0
- data/lib/przn/theme.rb +49 -12
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +81 -14
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
- data/sample/sample.md +8 -0
- metadata +24 -2
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,15 +23,39 @@ 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
|
|
|
45
|
+
Two flavors:
|
|
46
|
+
|
|
28
47
|
```
|
|
29
|
-
przn --export your_slides.md
|
|
48
|
+
przn --export your_slides.md # vector capture (default)
|
|
30
49
|
przn --export pdf your_slides.md
|
|
31
50
|
przn --export pdf -o output.pdf your_slides.md
|
|
51
|
+
|
|
52
|
+
przn --export prawn your_slides.md # Prawn (headless fallback)
|
|
53
|
+
przn --export prawn -o output.pdf your_slides.md
|
|
32
54
|
```
|
|
33
55
|
|
|
34
|
-
|
|
56
|
+
**`--export pdf`** (default) drives the live renderer for each slide and asks the terminal to save the rendered pane as a one-page **vector PDF**, then concatenates the per-slide PDFs into a single multi-page PDF. Output is an exact match of what's on screen — gradients, proportional fonts, OSC 66 sized text, custom bullets, all show up exactly as you'd see them — but vector, so the file stays small, scales infinitely, and text remains selectable. Requires running inside a terminal that implements the OSC 7772 `capture` command to a `.pdf` path (currently [Echoes](https://github.com/amatsuda/echoes)). The slides flicker through the visible pane during export.
|
|
57
|
+
|
|
58
|
+
**`--export prawn`** is the headless fallback: it renders the deck directly into a vector PDF via Prawn, without touching the terminal. Useful for CI or environments where Echoes isn't available, but diverges from the on-screen rendering for any feature the live renderer adds (OSC 66 sized text, OSC 7772 backgrounds, proportional fonts). Requires a TrueType font (with `glyf` outlines) for proper rendering — Prawn does not support CFF-based fonts (most `.otf` files). Fonts are auto-detected in this order: NotoSansJP TTF, HackGen, Arial Unicode.
|
|
35
59
|
|
|
36
60
|
### Key bindings
|
|
37
61
|
|
|
@@ -249,39 +273,52 @@ Self-closing presentation flow marker, consumed at parse time:
|
|
|
249
273
|
|
|
250
274
|
## Theming
|
|
251
275
|
|
|
252
|
-
|
|
276
|
+
Theme resolution:
|
|
253
277
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
code_bg: "313244"
|
|
260
|
-
dim: "6c7086"
|
|
261
|
-
inline_code: "a6e3a1"
|
|
278
|
+
1. **`theme.yml` in the deck's directory** — loaded automatically if present. No flag needed.
|
|
279
|
+
2. **`--theme path/to/your.yml`** — overrides step 1 with any other file you point to.
|
|
280
|
+
3. **`default_theme.yml`** (the file bundled with the gem) — used when neither of the above is found.
|
|
281
|
+
|
|
282
|
+
All keys are optional — anything you don't set falls back to the bundled defaults.
|
|
262
283
|
|
|
284
|
+
```yaml
|
|
263
285
|
font:
|
|
264
286
|
family: # body text font; terminal: OSC 66 f=, PDF: Prawn font
|
|
265
287
|
size: 18 # base PDF font size in pt
|
|
288
|
+
color: # body text color; named ANSI or 6-digit hex
|
|
266
289
|
|
|
267
|
-
|
|
268
|
-
|
|
290
|
+
title: # h1 typography (slide titles)
|
|
291
|
+
family: # font family
|
|
292
|
+
size: # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default x-large
|
|
293
|
+
color: # named ANSI or 6-digit hex
|
|
269
294
|
|
|
270
|
-
|
|
295
|
+
bullet: # unordered-list marker; also h2–h6 prefix
|
|
296
|
+
text: "・" # the glyph
|
|
297
|
+
size: # OSC 66 scale (1–7) for the bullet; default = body text's scale
|
|
271
298
|
|
|
272
|
-
|
|
299
|
+
background: # default slide background (Echoes OSC 7772)
|
|
273
300
|
color: # solid, e.g. "#1a1a2e"
|
|
274
301
|
from: # gradient endpoint
|
|
275
302
|
to: # gradient endpoint
|
|
276
303
|
angle: # gradient angle in degrees
|
|
304
|
+
|
|
305
|
+
# rabbit: # opt into the 🐇 / 🐢 bottom progress indicator
|
|
306
|
+
# duration: "30m" # "1h30m", "1800s", or plain integer seconds; turtle hides when unset
|
|
307
|
+
|
|
308
|
+
colors:
|
|
309
|
+
code_bg: "313244"
|
|
310
|
+
dim: "6c7086"
|
|
311
|
+
inline_code: "a6e3a1"
|
|
277
312
|
```
|
|
278
313
|
|
|
279
314
|
Notes:
|
|
280
315
|
|
|
281
|
-
- **`
|
|
316
|
+
- **`font.color`** — deck-wide default text color (terminal: ANSI fg; PDF: Prawn fg). Inline `<color=...>` / `<font color="...">` runs still win per-segment.
|
|
317
|
+
- **`bullet`** — `bullet.text` is the character; `bullet.size` is the OSC 66 scale used to render it. When `bullet.size` is smaller than the body text scale, the bullet is rendered with fractional scaling and vertical centering so it still aligns with the body line.
|
|
282
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.
|
|
283
|
-
- **`
|
|
284
|
-
- **`
|
|
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.
|
|
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).
|
|
285
322
|
|
|
286
323
|
## License
|
|
287
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
|
@@ -1,31 +1,43 @@
|
|
|
1
|
-
# Default przn theme
|
|
1
|
+
# Default przn theme. Used when there's neither a `theme.yml` alongside the
|
|
2
|
+
# deck nor a `--theme path/to/file.yml` flag.
|
|
2
3
|
#
|
|
3
|
-
#
|
|
4
|
+
# To customize: drop a `theme.yml` next to your deck (loaded automatically),
|
|
5
|
+
# or pass `--theme path/to/your.yml` to override that.
|
|
4
6
|
# All keys are optional — unspecified values fall back to these defaults.
|
|
5
7
|
|
|
6
|
-
colors:
|
|
7
|
-
background: "000000"
|
|
8
|
-
foreground: "ffffff"
|
|
9
|
-
heading: # falls back to foreground
|
|
10
|
-
code_bg: "313244"
|
|
11
|
-
dim: "6c7086"
|
|
12
|
-
inline_code: "a6e3a1"
|
|
13
|
-
|
|
14
8
|
font:
|
|
15
9
|
family: # body text font; terminal: OSC 66 f= (Echoes); PDF: Prawn font (auto-detected if blank)
|
|
16
10
|
size: 18 # base font size in pt for PDF export
|
|
11
|
+
color: # body text color (terminal: ANSI fg, PDF: Prawn fg); named ANSI or 6-digit hex
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
title: # h1 typography (slide titles); each attribute is independent of `font`
|
|
14
|
+
family: # font family (terminal: OSC 66 f=, requires Echoes)
|
|
15
|
+
size: # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default x-large (4)
|
|
16
|
+
color: # named ANSI or 6-digit hex; falls back to the terminal's default fg when unset
|
|
20
17
|
|
|
21
|
-
|
|
18
|
+
bullet: # unordered-list marker; also h2–h6 heading prefix
|
|
19
|
+
text: "・" # the glyph
|
|
20
|
+
size: # OSC 66 scale (1–7) for the bullet; default = body text's scale
|
|
22
21
|
|
|
23
22
|
# Default slide background, applied when a slide has no <bg .../> directive.
|
|
24
23
|
# Echoes-only (OSC 7772). Use either `color` (solid) or `from`/`to`/`angle`
|
|
25
24
|
# (linear gradient). Both unset = no background override.
|
|
26
|
-
|
|
25
|
+
background:
|
|
27
26
|
color: # solid color, e.g. "#1a1a2e"
|
|
28
27
|
from: # gradient endpoint, e.g. "#1a1a2e"
|
|
29
28
|
to: # gradient endpoint, e.g. "#16213e"
|
|
30
29
|
angle: # gradient angle in degrees (0 = left→right, 90 = bottom→top)
|
|
31
30
|
type: # gradient type; defaults to "linear"
|
|
31
|
+
|
|
32
|
+
colors:
|
|
33
|
+
code_bg: "313244"
|
|
34
|
+
dim: "6c7086"
|
|
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,8 +7,8 @@ require 'optparse'
|
|
|
7
7
|
|
|
8
8
|
options = {}
|
|
9
9
|
OptionParser.new do |opts|
|
|
10
|
-
opts.banner =
|
|
11
|
-
opts.on('--export [FORMAT]', 'Export to a format (default: pdf)') { |v|
|
|
10
|
+
opts.banner = 'Usage: przn [options] <presentation.md>'
|
|
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)
|
|
14
14
|
options[:export] = 'pdf'
|
|
@@ -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,15 +41,37 @@ 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
|
|
|
45
|
-
theme =
|
|
48
|
+
theme = if options[:theme]
|
|
49
|
+
Przn::Theme.load(options[:theme])
|
|
50
|
+
else
|
|
51
|
+
Przn::Theme.auto_discover(near: file)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
case options[:export]
|
|
55
|
+
when 'pdf'
|
|
56
|
+
require_relative '../lib/przn/screenshot_pdf_exporter'
|
|
46
57
|
|
|
47
|
-
if options[:export] == 'pdf'
|
|
48
58
|
output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
|
|
49
59
|
Przn.export_pdf(file, output, theme: theme)
|
|
60
|
+
when 'prawn'
|
|
61
|
+
require_relative '../lib/przn/prawn_pdf_exporter'
|
|
62
|
+
|
|
63
|
+
output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
|
|
64
|
+
Przn.export_pdf_prawn(file, output, theme: theme)
|
|
50
65
|
else
|
|
51
|
-
|
|
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
|
|
52
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(/\*\*(.+?)\*\*/)
|