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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bc9623dc391c5dee0540aeca9adb3dd20ad090299e5ff5f9395a37fe7643416
4
- data.tar.gz: 743ecab777fa5729e4fb23f1b140963060335e8f88a03760524637195fd99b17
3
+ metadata.gz: a02ff63ef48dadc49d46767f2f60e32457cd9562dffa726e6a6b3a811c3cf68d
4
+ data.tar.gz: 0a4c431ac13a990c06397626749de0b495e47efa3af5c3233800868b7fb56b41
5
5
  SHA512:
6
- metadata.gz: 8db1ed813eb6ba4d9e40f49bc31064a1acc736e3b37fc2bdf28dd11bacf79fe396cb01e92cc2956f80f25ee978b8a256c600c82289aeefde4601e59ace3448d2
7
- data.tar.gz: a6fafabc2e40c4b8d1bb20e82ad0098a9b25b3e7413ffe3ba77bc64e04208510df2ffdfb5cb328953476a15b58fffada16c9d2d7c7a30421747145205e6d150d
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
- 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.
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
- Pass a YAML file via `--theme path/to/theme.yml`. All keys are optional — anything you don't set falls back to the defaults baked in at `default_theme.yml`.
276
+ Theme resolution:
253
277
 
254
- ```yaml
255
- colors:
256
- background: "000000"
257
- foreground: "ffffff"
258
- heading: # falls back to foreground
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
- bullet: "・" # unordered-list marker; also h2–h6 prefix
268
- bullet_size: # OSC 66 scale (1–7) for the bullet glyph
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
- heading_face: # font family for h1 (slide titles)
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
- bg: # default slide background (Echoes OSC 7772)
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
- - **`bullet`** / **`bullet_size`** `bullet` is the character; `bullet_size` is the OSC 66 scale used to render it. When smaller than the body text scale, the bullet is rendered with fractional scaling and vertical centering so it still aligns with the body line.
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
- - **`heading_face`** — independent from `font.family`. h1 uses `heading_face` if set, else falls back to the terminal's default font (it does **not** silently inherit `font.family`). h2–h6 are body text. When the chosen face 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.
284
- - **`bg`** — the deck-wide default background. A per-slide `<bg .../>` directive overrides it for that slide.
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 "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
@@ -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
- # Copy this file and pass your copy via --theme to customize.
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
- bullet: "・" # used as the unordered-list marker and the h2–h6 heading prefix
19
- bullet_size: # OSC 66 scale (1–7) for the bullet glyph; default = the body text's scale
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
- heading_face: # font family for h1 (slide titles); inline <font face="..."> still wins per-run
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
- bg:
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 = "Usage: przn [options] <presentation.md>"
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 "Generated: theme.yml"
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 "Usage: przn [options] <presentation.md> [@N]"
44
+ $stderr.puts 'Usage: przn [options] <presentation.md> [@N]'
42
45
  exit 1
43
46
  end
44
47
 
45
- theme = options[:theme] ? Przn::Theme.load(options[:theme]) : nil
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
- 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
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
@@ -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(/\*\*(.+?)\*\*/)