przn 0.2.0 → 0.3.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: b33677433cd4802ee75ef9d7eb821d2dbb6faf8b1d2364d535b6aa28bffa6eb3
4
+ data.tar.gz: 930ecad39096eea771759134cd6c7e4a83c55a00ae98c59ead5afea1dcc46f17
5
5
  SHA512:
6
- metadata.gz: 8db1ed813eb6ba4d9e40f49bc31064a1acc736e3b37fc2bdf28dd11bacf79fe396cb01e92cc2956f80f25ee978b8a256c600c82289aeefde4601e59ace3448d2
7
- data.tar.gz: a6fafabc2e40c4b8d1bb20e82ad0098a9b25b3e7413ffe3ba77bc64e04208510df2ffdfb5cb328953476a15b58fffada16c9d2d7c7a30421747145205e6d150d
6
+ metadata.gz: 08eafdea3c2f55a29ccfd879b3bfa1541576be74fbb042cc33c527e39289e3dcf1375a10df033820e37f69e9f6726f580cd19ae468d633d73c9d65a23736674b
7
+ data.tar.gz: fcbf194e2a090de86a823fd8a0076977db73cf69f857cea6f4406f9fe31c2f44dab60120d61dbda7f10545e2fd750089df8e34233f0808fefd096bcec3cc25e1
data/README.md CHANGED
@@ -25,13 +25,20 @@ Out-of-range numbers are clamped to the last slide, so `@9999` jumps to the end.
25
25
 
26
26
  ### PDF export
27
27
 
28
+ Two flavors:
29
+
28
30
  ```
29
- przn --export your_slides.md
31
+ przn --export your_slides.md # vector capture (default)
30
32
  przn --export pdf your_slides.md
31
33
  przn --export pdf -o output.pdf your_slides.md
34
+
35
+ przn --export prawn your_slides.md # Prawn (headless fallback)
36
+ przn --export prawn -o output.pdf your_slides.md
32
37
  ```
33
38
 
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.
39
+ **`--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.
40
+
41
+ **`--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
42
 
36
43
  ### Key bindings
37
44
 
@@ -249,39 +256,48 @@ Self-closing presentation flow marker, consumed at parse time:
249
256
 
250
257
  ## Theming
251
258
 
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`.
259
+ Theme resolution:
253
260
 
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"
261
+ 1. **`theme.yml` in the deck's directory** — loaded automatically if present. No flag needed.
262
+ 2. **`--theme path/to/your.yml`** — overrides step 1 with any other file you point to.
263
+ 3. **`default_theme.yml`** (the file bundled with the gem) — used when neither of the above is found.
262
264
 
265
+ All keys are optional — anything you don't set falls back to the bundled defaults.
266
+
267
+ ```yaml
263
268
  font:
264
269
  family: # body text font; terminal: OSC 66 f=, PDF: Prawn font
265
270
  size: 18 # base PDF font size in pt
271
+ color: # body text color; named ANSI or 6-digit hex
266
272
 
267
- bullet: "・" # unordered-list marker; also h2–h6 prefix
268
- bullet_size: # OSC 66 scale (1–7) for the bullet glyph
273
+ title: # h1 typography (slide titles)
274
+ family: # font family
275
+ size: # OSC 66 scale: numeric (1–7) or named (xx-small … xxxx-large); default x-large
276
+ color: # named ANSI or 6-digit hex
269
277
 
270
- heading_face: # font family for h1 (slide titles)
278
+ bullet: # unordered-list marker; also h2–h6 prefix
279
+ text: "・" # the glyph
280
+ size: # OSC 66 scale (1–7) for the bullet; default = body text's scale
271
281
 
272
- bg: # default slide background (Echoes OSC 7772)
282
+ background: # default slide background (Echoes OSC 7772)
273
283
  color: # solid, e.g. "#1a1a2e"
274
284
  from: # gradient endpoint
275
285
  to: # gradient endpoint
276
286
  angle: # gradient angle in degrees
287
+
288
+ colors:
289
+ code_bg: "313244"
290
+ dim: "6c7086"
291
+ inline_code: "a6e3a1"
277
292
  ```
278
293
 
279
294
  Notes:
280
295
 
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.
296
+ - **`font.color`** — deck-wide default text color (terminal: ANSI fg; PDF: Prawn fg). Inline `<color=...>` / `<font color="...">` runs still win per-segment.
297
+ - **`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
298
  - **`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.
299
+ - **`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
+ - **`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).
285
301
 
286
302
  ## License
287
303
 
data/default_theme.yml CHANGED
@@ -1,31 +1,35 @@
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"
data/exe/przn CHANGED
@@ -8,7 +8,7 @@ require 'optparse'
8
8
  options = {}
9
9
  OptionParser.new do |opts|
10
10
  opts.banner = "Usage: przn [options] <presentation.md>"
11
- opts.on('--export [FORMAT]', 'Export to a format (default: pdf)') { |v|
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'
@@ -42,11 +42,19 @@ unless file
42
42
  exit 1
43
43
  end
44
44
 
45
- theme = options[:theme] ? Przn::Theme.load(options[:theme]) : nil
45
+ theme = if options[:theme]
46
+ Przn::Theme.load(options[:theme])
47
+ else
48
+ Przn::Theme.auto_discover(near: file)
49
+ end
46
50
 
47
- if options[:export] == 'pdf'
51
+ case options[:export]
52
+ when 'pdf'
48
53
  output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
49
54
  Przn.export_pdf(file, output, theme: theme)
55
+ when 'prawn'
56
+ output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
57
+ Przn.export_pdf_prawn(file, output, theme: theme)
50
58
  else
51
59
  Przn.start(file, theme: theme, start_at: start_at).run
52
60
  end
@@ -55,12 +55,11 @@ module Przn
55
55
  @presentation = presentation
56
56
  @base_dir = base_dir
57
57
  @theme = theme || Theme.default
58
- @bg_color = @theme.colors[:background]
59
- @fg_color = @theme.colors[:foreground]
58
+ @bg_color = @theme.background && @theme.background[:color]
59
+ @fg_color = @theme.font[:color] || "000000"
60
60
  @code_bg = @theme.colors[:code_bg]
61
61
  @dim_color = @theme.colors[:dim]
62
62
  @inline_code_color = @theme.colors[:inline_code]
63
- @heading_color = @theme.colors[:heading] || @fg_color
64
63
  base = (@theme.font[:size] || DEFAULT_FONT_SIZE).to_f
65
64
  ratio = base / DEFAULT_FONT_SIZE
66
65
  @scale_to_pt = DEFAULT_SCALE_TO_PT.transform_values { |v| v * ratio }
@@ -288,6 +287,8 @@ module Przn
288
287
  end
289
288
 
290
289
  def draw_background(pdf)
290
+ return unless @bg_color
291
+
291
292
  pdf.canvas do
292
293
  pdf.fill_color @bg_color
293
294
  pdf.fill_rectangle [0, PAGE_HEIGHT], PAGE_WIDTH, PAGE_HEIGHT
@@ -321,7 +322,7 @@ module Przn
321
322
  y - h - heading_margin(pt)
322
323
  else
323
324
  pt = @scale_to_pt[DEFAULT_SCALE]
324
- prefix = [{text: bullet, size: pt, color: @heading_color, styles: [:bold]}]
325
+ prefix = [{text: bullet, size: pt, color: @fg_color, styles: [:bold]}]
325
326
  formatted = prefix + build_formatted_text(text, pt)
326
327
  h = render_formatted(pdf, formatted, at: [margin_x, y], width: content_width)
327
328
  y - h - 4
@@ -608,7 +609,7 @@ module Przn
608
609
  end
609
610
 
610
611
  def bullet
611
- @font_registered ? @theme.bullet : "-"
612
+ @font_registered ? @theme.bullet[:text] : "-"
612
613
  end
613
614
 
614
615
  end
data/lib/przn/renderer.rb CHANGED
@@ -101,7 +101,7 @@ module Przn
101
101
  # Other terminals ignore the OSC code, so this is a no-op outside Echoes.
102
102
  def apply_slide_background(slide)
103
103
  block = slide.blocks.find { |b| b[:type] == :bg }
104
- attrs = block ? block[:attrs] : (@theme.bg || {})
104
+ attrs = block ? block[:attrs] : (@theme.background || {})
105
105
 
106
106
  @terminal.write "\e]7772;bg-clear\a"
107
107
  return if attrs.empty?
@@ -123,8 +123,10 @@ module Przn
123
123
  text = block[:content]
124
124
 
125
125
  if block[:level] == 1
126
- scale = KittyText::HEADING_SCALES[1]
127
- face = @theme.heading_face
126
+ title = @theme.title || {}
127
+ scale = (title[:size] && Parser::SIZE_SCALES[title[:size].to_s]) || KittyText::HEADING_SCALES[1]
128
+ face = title[:family]
129
+ color = title[:color]
128
130
  max_w = max_text_width(width, 0, scale)
129
131
  segments = Parser.parse_inline(text)
130
132
  wrapped = wrap_segments(segments, max_w, scale)
@@ -133,13 +135,13 @@ module Przn
133
135
  vis = segments_visible_cells(line_segs, scale)
134
136
  pad = [(width - vis) / 2, 0].max
135
137
  @terminal.move_to(row, pad + 1)
136
- @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, scale, default_face: face, default_h: 2)}#{ANSI[:reset]}"
138
+ @terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, scale, default_face: face, default_h: 2, default_color: color)}#{ANSI[:reset]}"
137
139
  row += scale
138
140
  end
139
141
  row + 4
140
142
  else
141
143
  left = content_left(width)
142
- prefix = @theme.bullet
144
+ prefix = @theme.bullet[:text]
143
145
  prefix_w = display_width(prefix)
144
146
  max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
145
147
  segments = Parser.parse_inline(text)
@@ -205,7 +207,7 @@ module Przn
205
207
  block[:items].each do |item|
206
208
  depth = item[:depth] || 0
207
209
  indent = " " * depth
208
- prefix = "#{indent}#{@theme.bullet}"
210
+ prefix = "#{indent}#{@theme.bullet[:text]}"
209
211
  prefix_w = display_width(prefix)
210
212
  max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
211
213
 
@@ -465,7 +467,7 @@ module Przn
465
467
  # centered. Plain `s=N` for a smaller bullet would top-align it inside
466
468
  # the row, which looks wrong against the larger body text.
467
469
  def render_bullet(prefix)
468
- size = @theme.bullet_size
470
+ size = @theme.bullet[:size]
469
471
  if size && size < DEFAULT_SCALE
470
472
  KittyText.sized(prefix, s: DEFAULT_SCALE, n: size, d: DEFAULT_SCALE, v: 2)
471
473
  else
@@ -485,22 +487,25 @@ module Przn
485
487
  "#{color_code(color)}#{base}#{ANSI[:reset]}"
486
488
  end
487
489
 
488
- # `default_face:` lets a caller (currently h1 rendering) override every
489
- # OSC 66 emit's `f=` attribute. When unset, body text falls back to
490
- # `theme.font.family`. To opt out of that body fallback (so a heading can
491
- # render in the terminal's default font when its own `heading_face` is
492
- # unset), pass `default_face:` explicitly even `nil` is honored.
493
- # Inline `<font face="...">` runs still win for their own segments.
490
+ # `default_face:` / `default_color:` let a caller (currently h1 rendering)
491
+ # override the OSC 66 `f=` and the ANSI fg for every emit on the line.
492
+ # When unset, body text falls back to `theme.font.family` / `theme.font.color`.
493
+ # To opt out of that body fallback (so a heading can render in the
494
+ # terminal's defaults even when body text is themed), pass the keyword
495
+ # explicitly even `nil` is honored. Inline `<font face/color>` and
496
+ # `<color=...>` runs still win for their own segments.
494
497
  #
495
498
  # `default_h:` threads an OSC 66 `h=` (horizontal alignment) into every
496
- # emit on the line. h1 uses h=2 so a proportional `heading_face` is
499
+ # emit on the line. h1 uses h=2 so a proportional `title.family` is
497
500
  # centered within the reserved cell block — without it the glyphs left-
498
501
  # align inside the block and the visible text drifts left of the center
499
502
  # column we computed.
500
- def render_segments_scaled(segments, para_scale, default_face: :body, default_h: nil)
503
+ def render_segments_scaled(segments, para_scale, default_face: :body, default_h: nil, default_color: :body)
501
504
  f = default_face == :body ? @theme.font[:family] : default_face
502
505
  h = default_h
503
- segments.map { |segment|
506
+ c = default_color == :body ? @theme.font[:color] : default_color
507
+ body_open = c ? color_code(c) : ""
508
+ inner = segments.map { |segment|
504
509
  type = segment[0]
505
510
  content = segment[1]
506
511
  case type
@@ -509,19 +514,20 @@ module Przn
509
514
  if (scale = Parser::SIZE_SCALES[tag_name])
510
515
  KittyText.sized(content, s: scale, f: f, h: h)
511
516
  elsif Parser::NAMED_COLORS.key?(tag_name)
512
- "#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
517
+ "#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
513
518
  else
514
519
  KittyText.sized(content, s: para_scale, f: f, h: h)
515
520
  end
516
- when :font then render_font_segment(content, segment[2] || {}, para_scale, default_face: f, default_h: h)
517
- when :note then "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
518
- when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
519
- when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
520
- when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
521
- when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
521
+ 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}"
523
+ when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
524
+ when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
525
+ when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
526
+ when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale, f: f, h: h)}#{ANSI[:reset]}#{body_open}"
522
527
  when :text then KittyText.sized(content, s: para_scale, f: f, h: h)
523
528
  end
524
529
  }.join
530
+ body_open.empty? ? inner : "#{body_open}#{inner}#{ANSI[:reset]}"
525
531
  end
526
532
 
527
533
  def render_inline_scaled(text, para_scale)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+
6
+ module Przn
7
+ # Renders each slide live to the user's terminal, asks the terminal to save
8
+ # the current pane as a vector PDF via Echoes' OSC 7772 `capture` command,
9
+ # then concatenates the per-slide PDFs into a single multi-page PDF.
10
+ #
11
+ # Trade-off vs the Prawn-based PdfExporter:
12
+ # - Pixel-perfect match with what's on screen (gradients, fonts, OSC 66
13
+ # sized text, bullet glyphs, the lot) — but vector, so the result is
14
+ # small, sharp at any zoom, and text stays selectable.
15
+ # - Requires running inside a terminal that implements OSC 7772 capture
16
+ # to a `.pdf` path (i.e. Echoes). Won't work in CI or any terminal that
17
+ # doesn't honor the command.
18
+ #
19
+ # Echoes-side wire format (independent of przn):
20
+ # ESC ] 7772 ; capture ; <absolute_path> BEL
21
+ # On receipt, Echoes saves the current pane to the path. The file
22
+ # extension picks the format — `.pdf` produces a single-page vector PDF
23
+ # by replaying the same drawing pipeline into a CGPDFContext instead of
24
+ # the screen's NSGraphicsContext.
25
+ class ScreenshotPdfExporter
26
+ OSC = "\e]7772".freeze
27
+ BEL = "\a".freeze
28
+
29
+ POLL_INTERVAL = 0.05 # seconds between file-existence checks
30
+ CAPTURE_TIMEOUT = 10 # seconds per slide before giving up
31
+
32
+ def initialize(presentation, base_dir: '.', theme: nil, terminal: nil)
33
+ @presentation = presentation
34
+ @base_dir = base_dir
35
+ @theme = theme || Theme.default
36
+ @terminal = terminal || Terminal.new
37
+ @renderer = Renderer.new(@terminal, base_dir: base_dir, theme: theme)
38
+ end
39
+
40
+ def export(output_path)
41
+ require 'hexapdf'
42
+
43
+ Dir.mktmpdir('przn-capture') do |dir|
44
+ pdf_paths = capture_all_slides(dir)
45
+ merge_pdfs(pdf_paths, output_path)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def capture_all_slides(dir)
52
+ paths = []
53
+ @terminal.enter_alt_screen
54
+ @terminal.hide_cursor
55
+ @presentation.slides.each_with_index do |slide, i|
56
+ pdf_path = File.join(dir, format("slide-%04d.pdf", i))
57
+ @renderer.render(slide, current: i, total: @presentation.total)
58
+ request_capture(pdf_path)
59
+ wait_for_capture(pdf_path)
60
+ paths << pdf_path
61
+ end
62
+ paths
63
+ ensure
64
+ @terminal.write "#{OSC};bg-clear#{BEL}"
65
+ @terminal.show_cursor
66
+ @terminal.leave_alt_screen
67
+ @terminal.flush
68
+ end
69
+
70
+ def request_capture(path)
71
+ @terminal.write "#{OSC};capture;#{path}#{BEL}"
72
+ @terminal.flush
73
+ end
74
+
75
+ def wait_for_capture(path)
76
+ deadline = Time.now + CAPTURE_TIMEOUT
77
+ until File.exist?(path) && File.size?(path).to_i.positive?
78
+ if Time.now > deadline
79
+ raise "Capture timed out for #{path}. " \
80
+ "Is Echoes running and recent enough to honor OSC 7772 `capture` to a .pdf path?"
81
+ end
82
+ sleep POLL_INTERVAL
83
+ end
84
+ # Small grace period to ensure the PDF write is fully flushed.
85
+ sleep POLL_INTERVAL
86
+ end
87
+
88
+ def merge_pdfs(pdf_paths, output_path)
89
+ raise "No slides captured" if pdf_paths.empty?
90
+
91
+ output = HexaPDF::Document.new
92
+ pdf_paths.each do |path|
93
+ src = HexaPDF::Document.open(path)
94
+ src.pages.each do |page|
95
+ output.pages << output.import(page)
96
+ end
97
+ end
98
+ output.write(output_path)
99
+ end
100
+ end
101
+ 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
10
10
 
11
11
  def self.load(path)
12
12
  raise ArgumentError, "Theme file not found: #{path}" unless File.exist?(path)
@@ -16,10 +16,9 @@ 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] || {}),
23
22
  }
24
23
  new(merged)
25
24
  end
@@ -28,15 +27,23 @@ module Przn
28
27
  new(load_yaml(DEFAULT_PATH))
29
28
  end
30
29
 
30
+ # Look for a sibling `theme.yml` next to the given file and load it if
31
+ # present, so a deck can ship its theme alongside the markdown without
32
+ # the user having to pass `--theme` explicitly. Returns nil if no file
33
+ # is found.
34
+ def self.auto_discover(near:)
35
+ candidate = File.join(File.dirname(File.expand_path(near)), 'theme.yml')
36
+ File.exist?(candidate) ? load(candidate) : nil
37
+ end
38
+
31
39
  def self.load_yaml(path)
32
40
  data = YAML.safe_load_file(path, symbolize_names: true) || {}
33
41
  {
34
42
  colors: data[:colors] || {},
35
43
  font: data[:font] || {},
36
- bullet: data[:bullet],
37
- bullet_size: data[:bullet_size],
38
- bg: (data[:bg] || {}).compact,
39
- heading_face: data[:heading_face],
44
+ bullet: (data[:bullet] || {}).compact,
45
+ background: (data[:background] || {}).compact,
46
+ title: (data[:title] || {}).compact,
40
47
  }
41
48
  end
42
49
  private_class_method :load_yaml
@@ -45,9 +52,8 @@ module Przn
45
52
  @colors = config[:colors]
46
53
  @font = config[:font]
47
54
  @bullet = config[:bullet]
48
- @bullet_size = config[:bullet_size]
49
- @bg = config[:bg]
50
- @heading_face = config[:heading_face]
55
+ @background = config[:background]
56
+ @title = config[:title]
51
57
  end
52
58
  end
53
59
  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.3.0"
5
5
  end
data/lib/przn.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "przn/terminal"
10
10
  require_relative "przn/renderer"
11
11
  require_relative "przn/controller"
12
12
  require_relative "przn/pdf_exporter"
13
+ require_relative "przn/screenshot_pdf_exporter"
13
14
  require_relative "przn/theme"
14
15
 
15
16
  module Przn
@@ -25,7 +26,25 @@ module Przn
25
26
  Controller.new(presentation, terminal, renderer)
26
27
  end
27
28
 
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).
28
35
  def self.export_pdf(file, output, theme: nil)
36
+ markdown = File.read(file)
37
+ presentation = Parser.parse(markdown)
38
+ base_dir = File.dirname(File.expand_path(file))
39
+ ScreenshotPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
40
+ puts "Generated: #{output}"
41
+ end
42
+
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)
29
48
  markdown = File.read(file)
30
49
  presentation = Parser.parse(markdown)
31
50
  base_dir = File.dirname(File.expand_path(file))
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.3.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
@@ -46,6 +62,7 @@ files:
46
62
  - lib/przn/pdf_exporter.rb
47
63
  - lib/przn/presentation.rb
48
64
  - lib/przn/renderer.rb
65
+ - lib/przn/screenshot_pdf_exporter.rb
49
66
  - lib/przn/slide.rb
50
67
  - lib/przn/terminal.rb
51
68
  - lib/przn/theme.rb