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 +4 -4
- data/README.md +34 -18
- data/default_theme.yml +18 -14
- data/exe/przn +11 -3
- data/lib/przn/pdf_exporter.rb +6 -5
- data/lib/przn/renderer.rb +29 -23
- data/lib/przn/screenshot_pdf_exporter.rb +101 -0
- data/lib/przn/theme.rb +18 -12
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +19 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b33677433cd4802ee75ef9d7eb821d2dbb6faf8b1d2364d535b6aa28bffa6eb3
|
|
4
|
+
data.tar.gz: 930ecad39096eea771759134cd6c7e4a83c55a00ae98c59ead5afea1dcc46f17
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
259
|
+
Theme resolution:
|
|
253
260
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **`
|
|
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
|
-
- **`
|
|
284
|
-
- **`
|
|
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
|
-
#
|
|
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"
|
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 =
|
|
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
|
-
|
|
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
|
data/lib/przn/pdf_exporter.rb
CHANGED
|
@@ -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.
|
|
59
|
-
@fg_color = @theme.
|
|
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: @
|
|
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.
|
|
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
|
-
|
|
127
|
-
|
|
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.
|
|
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:`
|
|
489
|
-
# OSC 66
|
|
490
|
-
#
|
|
491
|
-
#
|
|
492
|
-
#
|
|
493
|
-
#
|
|
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 `
|
|
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
|
-
|
|
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, :
|
|
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] ||
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
@
|
|
49
|
-
@
|
|
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
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.
|
|
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
|