przn 0.1.5 → 0.2.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 +132 -0
- data/default_theme.yml +16 -1
- data/exe/przn +10 -2
- data/lib/przn/controller.rb +27 -0
- data/lib/przn/image_util.rb +24 -0
- data/lib/przn/kitty_text.rb +4 -1
- data/lib/przn/parser.rb +46 -2
- data/lib/przn/pdf_exporter.rb +75 -6
- data/lib/przn/renderer.rb +245 -66
- data/lib/przn/terminal.rb +4 -0
- data/lib/przn/theme.rb +13 -1
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +2 -1
- metadata +54 -51
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bc9623dc391c5dee0540aeca9adb3dd20ad090299e5ff5f9395a37fe7643416
|
|
4
|
+
data.tar.gz: 743ecab777fa5729e4fb23f1b140963060335e8f88a03760524637195fd99b17
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8db1ed813eb6ba4d9e40f49bc31064a1acc736e3b37fc2bdf28dd11bacf79fe396cb01e92cc2956f80f25ee978b8a256c600c82289aeefde4601e59ace3448d2
|
|
7
|
+
data.tar.gz: a6fafabc2e40c4b8d1bb20e82ad0098a9b25b3e7413ffe3ba77bc64e04208510df2ffdfb5cb328953476a15b58fffada16c9d2d7c7a30421747145205e6d150d
|
data/README.md
CHANGED
|
@@ -15,6 +15,14 @@ gem install przn
|
|
|
15
15
|
przn your_slides.md
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
To open the presentation directly at a specific slide, append `@N` (1-based):
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
przn your_slides.md @42
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Out-of-range numbers are clamped to the last slide, so `@9999` jumps to the end.
|
|
25
|
+
|
|
18
26
|
### PDF export
|
|
19
27
|
|
|
20
28
|
```
|
|
@@ -35,6 +43,10 @@ Requires a TrueType font (with `glyf` outlines) for proper rendering. Prawn does
|
|
|
35
43
|
| `G` | Last slide |
|
|
36
44
|
| `q` `Ctrl-C` | Quit |
|
|
37
45
|
|
|
46
|
+
### Selecting and copying text
|
|
47
|
+
|
|
48
|
+
`przn` doesn't capture mouse events, so drag-to-select and the terminal's own copy shortcut (Kitty: `Cmd+C` on macOS, `Ctrl+Shift+C` on Linux) work normally on a slide. Mouse-tracking modes that may have leaked from a previously crashed program are explicitly disabled on entry, so drag selection is reliable.
|
|
49
|
+
|
|
38
50
|
## Markdown format
|
|
39
51
|
|
|
40
52
|
przn's Markdown format is compatible with [Rabbit](https://rabbit-shocker.org/)'s Markdown mode.
|
|
@@ -62,6 +74,8 @@ more content
|
|
|
62
74
|
`inline code`
|
|
63
75
|
```
|
|
64
76
|
|
|
77
|
+
Long lines wrap at whitespace boundaries (not mid-word) for English-style text. A single word that's longer than the line — a URL, a class name — still wraps at the character it has to. CJK runs without inter-character whitespace fall back to per-character splitting.
|
|
78
|
+
|
|
65
79
|
### Lists
|
|
66
80
|
|
|
67
81
|
```markdown
|
|
@@ -125,8 +139,41 @@ Uses Rabbit-compatible `{::tag}` notation. Supported size names: `xx-small`, `x-
|
|
|
125
139
|
{::tag name="7"}Maximum size{:/tag}
|
|
126
140
|
```
|
|
127
141
|
|
|
142
|
+
An XML-style alternative is also accepted:
|
|
143
|
+
|
|
144
|
+
```markdown
|
|
145
|
+
<size=x-large>Big text</size>
|
|
146
|
+
<size=7>Maximum size</size>
|
|
147
|
+
```
|
|
148
|
+
|
|
128
149
|
On [Kitty](https://sw.kovidgoyal.net/kitty/)-compatible terminals, sized text is rendered using the OSC 66 text sizing protocol. On other terminals, the markup is silently ignored.
|
|
129
150
|
|
|
151
|
+
### Color
|
|
152
|
+
|
|
153
|
+
Named ANSI colors (`red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, plus `bright_*` variants) and 6-digit hex. Use `{::tag name="..."}` (kramdown form) or the `color` attribute on `<font>` (see [Font](#font)).
|
|
154
|
+
|
|
155
|
+
```markdown
|
|
156
|
+
{::tag name="red"}warning{:/tag}
|
|
157
|
+
{::tag name="ff5555"}custom hex{:/tag}
|
|
158
|
+
|
|
159
|
+
<font color="red">warning</font>
|
|
160
|
+
<font color="ff5555">custom hex</font>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Font
|
|
164
|
+
|
|
165
|
+
HTML 4-style `<font>` tag with `face`, `size`, and `color` attributes. Any subset, in any order. The kramdown shape is also accepted.
|
|
166
|
+
|
|
167
|
+
```markdown
|
|
168
|
+
<font face="Helvetica Neue">Title</font>
|
|
169
|
+
<font face="Menlo" size="3">code</font>
|
|
170
|
+
<font face="Menlo" size="3" color="red">flagged</font>
|
|
171
|
+
|
|
172
|
+
{::font name="Helvetica Neue"}Title{:/font}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`face` requires a terminal that honors the OSC 66 `f=` extension (e.g. [Echoes](https://github.com/amatsuda/echoes)). For PDF export, the family is registered with Prawn via fontconfig — families that can't be found fall through to the default font.
|
|
176
|
+
|
|
130
177
|
### Alignment
|
|
131
178
|
|
|
132
179
|
```markdown
|
|
@@ -137,6 +184,33 @@ centered text
|
|
|
137
184
|
right-aligned text
|
|
138
185
|
```
|
|
139
186
|
|
|
187
|
+
XML form (single-line, paragraph-level):
|
|
188
|
+
|
|
189
|
+
```markdown
|
|
190
|
+
<center>centered <size=3>text</size></center>
|
|
191
|
+
<right>right-aligned</right>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Slide background
|
|
195
|
+
|
|
196
|
+
Set a per-slide background — solid color or linear gradient — via a self-closing block-level directive. Uses the [Echoes](https://github.com/amatsuda/echoes) OSC 7772 extension; other terminals ignore the escape sequence.
|
|
197
|
+
|
|
198
|
+
```markdown
|
|
199
|
+
# Title
|
|
200
|
+
|
|
201
|
+
<bg color="#1a1a2e"/>
|
|
202
|
+
|
|
203
|
+
content...
|
|
204
|
+
|
|
205
|
+
# Second slide
|
|
206
|
+
|
|
207
|
+
<bg from="#1a1a2e" to="#16213e" angle="90"/>
|
|
208
|
+
|
|
209
|
+
content...
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The previous slide's background is cleared on every navigation, and on `przn` exit, so your shell isn't left tinted.
|
|
213
|
+
|
|
140
214
|
### Comments
|
|
141
215
|
|
|
142
216
|
```markdown
|
|
@@ -149,8 +223,66 @@ This text is hidden from the presentation.
|
|
|
149
223
|
|
|
150
224
|
```markdown
|
|
151
225
|
Visible text {::note}(speaker note){:/note}
|
|
226
|
+
Visible text <note>(speaker note)</note>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Escaping `<`, `>`, `&`
|
|
230
|
+
|
|
231
|
+
To show literal markup characters that would otherwise be interpreted as a tag, use HTML-style entity references:
|
|
232
|
+
|
|
233
|
+
```markdown
|
|
234
|
+
<note> renders as: <note>
|
|
235
|
+
2 < 3 renders as: 2 < 3
|
|
236
|
+
A & B renders as: A & B
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
A bare `<` not followed by a recognized tag name renders literally as well, so most accidental `<` characters are fine. The entities are only needed when you'd otherwise hit one of the tag patterns (`<size=...>`, `<font ...>`, `<note>`, `<wait/>`, `<center>`, `<right>`, `<bg .../>`).
|
|
240
|
+
|
|
241
|
+
### Wait marker
|
|
242
|
+
|
|
243
|
+
Self-closing presentation flow marker, consumed at parse time:
|
|
244
|
+
|
|
245
|
+
```markdown
|
|
246
|
+
{::wait/}
|
|
247
|
+
<wait/>
|
|
152
248
|
```
|
|
153
249
|
|
|
250
|
+
## Theming
|
|
251
|
+
|
|
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`.
|
|
253
|
+
|
|
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"
|
|
262
|
+
|
|
263
|
+
font:
|
|
264
|
+
family: # body text font; terminal: OSC 66 f=, PDF: Prawn font
|
|
265
|
+
size: 18 # base PDF font size in pt
|
|
266
|
+
|
|
267
|
+
bullet: "・" # unordered-list marker; also h2–h6 prefix
|
|
268
|
+
bullet_size: # OSC 66 scale (1–7) for the bullet glyph
|
|
269
|
+
|
|
270
|
+
heading_face: # font family for h1 (slide titles)
|
|
271
|
+
|
|
272
|
+
bg: # default slide background (Echoes OSC 7772)
|
|
273
|
+
color: # solid, e.g. "#1a1a2e"
|
|
274
|
+
from: # gradient endpoint
|
|
275
|
+
to: # gradient endpoint
|
|
276
|
+
angle: # gradient angle in degrees
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Notes:
|
|
280
|
+
|
|
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.
|
|
282
|
+
- **`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.
|
|
285
|
+
|
|
154
286
|
## License
|
|
155
287
|
|
|
156
288
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/default_theme.yml
CHANGED
|
@@ -12,5 +12,20 @@ colors:
|
|
|
12
12
|
inline_code: "a6e3a1"
|
|
13
13
|
|
|
14
14
|
font:
|
|
15
|
-
family: # auto-
|
|
15
|
+
family: # body text font; terminal: OSC 66 f= (Echoes); PDF: Prawn font (auto-detected if blank)
|
|
16
16
|
size: 18 # base font size in pt for PDF export
|
|
17
|
+
|
|
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
|
|
20
|
+
|
|
21
|
+
heading_face: # font family for h1 (slide titles); inline <font face="..."> still wins per-run
|
|
22
|
+
|
|
23
|
+
# Default slide background, applied when a slide has no <bg .../> directive.
|
|
24
|
+
# Echoes-only (OSC 7772). Use either `color` (solid) or `from`/`to`/`angle`
|
|
25
|
+
# (linear gradient). Both unset = no background override.
|
|
26
|
+
bg:
|
|
27
|
+
color: # solid color, e.g. "#1a1a2e"
|
|
28
|
+
from: # gradient endpoint, e.g. "#1a1a2e"
|
|
29
|
+
to: # gradient endpoint, e.g. "#16213e"
|
|
30
|
+
angle: # gradient angle in degrees (0 = left→right, 90 = bottom→top)
|
|
31
|
+
type: # gradient type; defaults to "linear"
|
data/exe/przn
CHANGED
|
@@ -28,9 +28,17 @@ if options[:generate_theme]
|
|
|
28
28
|
exit
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Extract a leading-`@N` positional like `przn deck.md @42` and treat it as
|
|
32
|
+
# the slide to open the presentation at (1-based). Removed from ARGV so the
|
|
33
|
+
# remaining positional is the deck file.
|
|
34
|
+
start_at = nil
|
|
35
|
+
if (idx = ARGV.index { |a| a =~ /\A@(\d+)\z/ })
|
|
36
|
+
start_at = ARGV.delete_at(idx)[1..].to_i
|
|
37
|
+
end
|
|
38
|
+
|
|
31
39
|
file = ARGV[0]
|
|
32
40
|
unless file
|
|
33
|
-
$stderr.puts "Usage: przn [options] <presentation.md>"
|
|
41
|
+
$stderr.puts "Usage: przn [options] <presentation.md> [@N]"
|
|
34
42
|
exit 1
|
|
35
43
|
end
|
|
36
44
|
|
|
@@ -40,5 +48,5 @@ if options[:export] == 'pdf'
|
|
|
40
48
|
output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
|
|
41
49
|
Przn.export_pdf(file, output, theme: theme)
|
|
42
50
|
else
|
|
43
|
-
Przn.start(file, theme: theme).run
|
|
51
|
+
Przn.start(file, theme: theme, start_at: start_at).run
|
|
44
52
|
end
|
data/lib/przn/controller.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Przn
|
|
|
6
6
|
@presentation = presentation
|
|
7
7
|
@terminal = terminal
|
|
8
8
|
@renderer = renderer
|
|
9
|
+
@preload_gen = 0
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def run
|
|
@@ -34,6 +35,9 @@ module Przn
|
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
ensure
|
|
38
|
+
@preload_gen += 1
|
|
39
|
+
@preload_thread&.join
|
|
40
|
+
@terminal.write "\e]7772;bg-clear\a"
|
|
37
41
|
@terminal.show_cursor
|
|
38
42
|
@terminal.leave_alt_screen
|
|
39
43
|
end
|
|
@@ -46,6 +50,29 @@ module Przn
|
|
|
46
50
|
current: @presentation.current,
|
|
47
51
|
total: @presentation.total
|
|
48
52
|
)
|
|
53
|
+
schedule_preload
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Kick off a background thread that pre-uploads images for the slides the
|
|
57
|
+
# user is most likely to visit next (the immediate neighbors). Uses a
|
|
58
|
+
# generation counter so a navigation that lands while a preload is still
|
|
59
|
+
# running causes that preload to exit early instead of stacking work.
|
|
60
|
+
def schedule_preload
|
|
61
|
+
@preload_gen += 1
|
|
62
|
+
gen = @preload_gen
|
|
63
|
+
cur = @presentation.current
|
|
64
|
+
total = @presentation.total
|
|
65
|
+
indices = [cur + 1, cur - 1].select { |i| i.between?(0, total - 1) }
|
|
66
|
+
return if indices.empty?
|
|
67
|
+
|
|
68
|
+
@preload_thread = Thread.new do
|
|
69
|
+
indices.each do |idx|
|
|
70
|
+
break if gen != @preload_gen
|
|
71
|
+
@renderer.preload(@presentation.slides[idx])
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError
|
|
74
|
+
# Background work must not crash the presentation.
|
|
75
|
+
end
|
|
49
76
|
end
|
|
50
77
|
|
|
51
78
|
def read_key
|
data/lib/przn/image_util.rb
CHANGED
|
@@ -65,6 +65,30 @@ module Przn
|
|
|
65
65
|
ENV['TERM'] == 'xterm-kitty' || ENV['TERM_PROGRAM'] == 'kitty'
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
PNG_MAGIC = "\x89PNG\r\n\x1a\n".b.freeze
|
|
69
|
+
|
|
70
|
+
def png?(path)
|
|
71
|
+
File.open(path, 'rb') { |f| f.read(8)&.b == PNG_MAGIC }
|
|
72
|
+
rescue Errno::ENOENT
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Kitty Graphics Protocol: upload a PNG file by path with the given id.
|
|
77
|
+
# Kitty reads the file directly from disk; we just send a small APC
|
|
78
|
+
# control sequence with the base64-encoded path. Use this once per
|
|
79
|
+
# image; subsequent renders only need a placement command.
|
|
80
|
+
# https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
|
81
|
+
def kitty_upload_png(path, image_id:)
|
|
82
|
+
encoded = [File.expand_path(path)].pack('m0')
|
|
83
|
+
"\e_Ga=t,t=f,f=100,i=#{image_id},q=2;#{encoded}\e\\"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Kitty Graphics Protocol: place a previously-uploaded image at the
|
|
87
|
+
# current cursor position, scaled to fit `cols` x `rows` cells.
|
|
88
|
+
def kitty_place(image_id:, cols:, rows:)
|
|
89
|
+
"\e_Ga=p,i=#{image_id},c=#{cols},r=#{rows},q=2\e\\"
|
|
90
|
+
end
|
|
91
|
+
|
|
68
92
|
# Sixel via img2sixel
|
|
69
93
|
def sixel_available?
|
|
70
94
|
@sixel_available = system('command -v img2sixel > /dev/null 2>&1') if @sixel_available.nil?
|
data/lib/przn/kitty_text.rb
CHANGED
|
@@ -10,10 +10,13 @@ module Przn
|
|
|
10
10
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
def sized(text, s:, h: nil, v: nil)
|
|
13
|
+
def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil)
|
|
14
14
|
params = +"s=#{s}"
|
|
15
|
+
params << ":n=#{n}" if n
|
|
16
|
+
params << ":d=#{d}" if d
|
|
15
17
|
params << ":h=#{h}" if h
|
|
16
18
|
params << ":v=#{v}" if v
|
|
19
|
+
params << ":f=#{f}" if f
|
|
17
20
|
"\e]66;#{params};#{text}\a"
|
|
18
21
|
end
|
|
19
22
|
|
data/lib/przn/parser.rb
CHANGED
|
@@ -71,6 +71,17 @@ module Przn
|
|
|
71
71
|
when /\A\s*\{:\.(\w+)\}\s*\z/
|
|
72
72
|
blocks << {type: :align, align: Regexp.last_match(1).to_sym}
|
|
73
73
|
|
|
74
|
+
# Block alignment, XML form: <center>content</center> or <right>content</right>
|
|
75
|
+
when /\A\s*<(center|right)>(.*)<\/\1>\s*\z/
|
|
76
|
+
blocks << {type: :align, align: Regexp.last_match(1).to_sym}
|
|
77
|
+
blocks << {type: :paragraph, content: Regexp.last_match(2)}
|
|
78
|
+
|
|
79
|
+
# Slide background (Echoes OSC 7772):
|
|
80
|
+
# <bg color="#..."/> — solid (bg-color)
|
|
81
|
+
# <bg from="#..." to="#..." angle="N"/> — linear gradient (bg-gradient)
|
|
82
|
+
when /\A\s*<bg((?:\s+\w+="[^"]+")*)\s*\/>\s*\z/
|
|
83
|
+
blocks << {type: :bg, attrs: parse_xml_attrs(Regexp.last_match(1))}
|
|
84
|
+
|
|
74
85
|
# Fenced code block
|
|
75
86
|
when /\A\s*```(\w*)\s*\z/
|
|
76
87
|
lang = Regexp.last_match(1)
|
|
@@ -217,6 +228,25 @@ module Przn
|
|
|
217
228
|
Slide.new(blocks)
|
|
218
229
|
end
|
|
219
230
|
|
|
231
|
+
# HTML4-style <font face="..." size="..." color="..."> attributes.
|
|
232
|
+
# Kramdown's {::font name="..."} legacy spelling for the family is also
|
|
233
|
+
# accepted and folded into :face so the renderer has one shape to handle.
|
|
234
|
+
def parse_font_attrs(str)
|
|
235
|
+
attrs = parse_xml_attrs(str)
|
|
236
|
+
attrs[:face] = attrs.delete(:name) if attrs.key?(:name) && !attrs.key?(:face)
|
|
237
|
+
attrs.slice(:face, :size, :color)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Generic attribute scanner — `key="value"` pairs, returned as a hash with
|
|
241
|
+
# symbolized keys. Doesn't validate which keys are allowed; callers slice.
|
|
242
|
+
def parse_xml_attrs(str)
|
|
243
|
+
attrs = {}
|
|
244
|
+
str.scan(/(\w+)="([^"]+)"/) do |key, value|
|
|
245
|
+
attrs[key.to_sym] = value
|
|
246
|
+
end
|
|
247
|
+
attrs
|
|
248
|
+
end
|
|
249
|
+
|
|
220
250
|
def parse_image_attrs(str, attrs)
|
|
221
251
|
str = str.sub(/\A:?\s*/, '')
|
|
222
252
|
str.scan(/([\w-]+)=['"]([^'"]*)['"]/) do |key, value|
|
|
@@ -243,10 +273,24 @@ module Przn
|
|
|
243
273
|
until scanner.eos?
|
|
244
274
|
if scanner.scan(/\{::tag\s+name="([^"]+)"\}(.*?)\{:\/tag\}/)
|
|
245
275
|
segments << [:tag, scanner[2], scanner[1]]
|
|
276
|
+
elsif scanner.scan(/<size=([^>\s]+)>(.*?)<\/size>/)
|
|
277
|
+
segments << [:tag, scanner[2], scanner[1]]
|
|
278
|
+
elsif scanner.scan(/<font((?:\s+\w+="[^"]+")+)\s*>(.*?)<\/font>/)
|
|
279
|
+
segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
|
|
280
|
+
elsif scanner.scan(/\{::font((?:\s+\w+="[^"]+")+)\}(.*?)\{:\/font\}/)
|
|
281
|
+
segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
|
|
246
282
|
elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
|
|
247
283
|
segments << [:note, scanner[1]]
|
|
248
|
-
elsif scanner.scan(
|
|
284
|
+
elsif scanner.scan(/<note>(.*?)<\/note>/)
|
|
285
|
+
segments << [:note, scanner[1]]
|
|
286
|
+
elsif scanner.scan(/\{::wait\/\}/) || scanner.scan(/<wait\s*\/>/)
|
|
249
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, "&"]
|
|
250
294
|
elsif scanner.scan(/`([^`]+)`/)
|
|
251
295
|
segments << [:code, scanner[1]]
|
|
252
296
|
elsif scanner.scan(/\*\*(.+?)\*\*/)
|
|
@@ -256,7 +300,7 @@ module Przn
|
|
|
256
300
|
elsif scanner.scan(/~~(.+?)~~/)
|
|
257
301
|
segments << [:strikethrough, scanner[1]]
|
|
258
302
|
else
|
|
259
|
-
segments << [:text, scanner.scan(/[^`*~{]+|./)]
|
|
303
|
+
segments << [:text, scanner.scan(/[^`*~{<&]+|./)]
|
|
260
304
|
end
|
|
261
305
|
end
|
|
262
306
|
|
data/lib/przn/pdf_exporter.rb
CHANGED
|
@@ -9,6 +9,9 @@ module PrawnCJKLineWrap
|
|
|
9
9
|
CJK_CHARS = "\u3000-\u9FFF\uF900-\uFAFF\uFF01-\uFF60"
|
|
10
10
|
|
|
11
11
|
def scan_pattern(encoding = ::Encoding::UTF_8)
|
|
12
|
+
# CJK wrapping only applies to UTF-8; fall back to original for other encodings (e.g. AFM fonts)
|
|
13
|
+
return super unless encoding == ::Encoding::UTF_8
|
|
14
|
+
|
|
12
15
|
ebc = break_chars(encoding)
|
|
13
16
|
eshy = soft_hyphen(encoding)
|
|
14
17
|
ehy = hyphen(encoding)
|
|
@@ -24,11 +27,7 @@ module PrawnCJKLineWrap
|
|
|
24
27
|
eshy.to_s,
|
|
25
28
|
]
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
.map { |p| p.encode(encoding) }
|
|
29
|
-
.join('|')
|
|
30
|
-
|
|
31
|
-
Regexp.new(pattern)
|
|
30
|
+
Regexp.new(patterns.join('|'))
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
@@ -128,6 +127,57 @@ module Przn
|
|
|
128
127
|
@font_registered = true
|
|
129
128
|
|
|
130
129
|
register_emoji_fallback(pdf, font_path)
|
|
130
|
+
register_inline_fonts(pdf)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Pre-register every font family referenced by an inline <font face="..."> /
|
|
134
|
+
# {::font name="..."} tag, so build_formatted_text can use it as a Prawn
|
|
135
|
+
# font: option. Families that fontconfig can't locate are silently dropped
|
|
136
|
+
# — those runs render in the default font instead of erroring.
|
|
137
|
+
def register_inline_fonts(pdf)
|
|
138
|
+
@registered_inline_fonts = {}
|
|
139
|
+
inline_font_families.each do |family|
|
|
140
|
+
next if @registered_inline_fonts.key?(family) || family == 'CJK'
|
|
141
|
+
|
|
142
|
+
path = fc_find(family)
|
|
143
|
+
next unless path
|
|
144
|
+
bold_path = find_bold_font(path, family)
|
|
145
|
+
pdf.font_families.update(family => {
|
|
146
|
+
normal: path,
|
|
147
|
+
bold: bold_path,
|
|
148
|
+
italic: path,
|
|
149
|
+
})
|
|
150
|
+
@registered_inline_fonts[family] = true
|
|
151
|
+
rescue
|
|
152
|
+
next
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def inline_font_families
|
|
157
|
+
families = []
|
|
158
|
+
@presentation.slides.each do |slide|
|
|
159
|
+
slide.blocks.each do |b|
|
|
160
|
+
texts = []
|
|
161
|
+
texts << b[:content] if b[:content].is_a?(String)
|
|
162
|
+
texts << b[:term] if b[:term].is_a?(String)
|
|
163
|
+
texts << b[:definition] if b[:definition].is_a?(String)
|
|
164
|
+
if b[:items].is_a?(Array)
|
|
165
|
+
b[:items].each { |it| texts << it[:text] if it[:text].is_a?(String) }
|
|
166
|
+
end
|
|
167
|
+
if b[:rows].is_a?(Array)
|
|
168
|
+
(Array(b[:header]) + b[:rows].flatten).each { |c| texts << c if c.is_a?(String) }
|
|
169
|
+
end
|
|
170
|
+
texts.each do |text|
|
|
171
|
+
text.scan(/<font((?:\s+\w+="[^"]+")+)\s*>/) do
|
|
172
|
+
families << Parser.parse_font_attrs(Regexp.last_match(1))[:face]
|
|
173
|
+
end
|
|
174
|
+
text.scan(/\{::font((?:\s+\w+="[^"]+")+)\}/) do
|
|
175
|
+
families << Parser.parse_font_attrs(Regexp.last_match(1))[:face]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
families.compact.uniq
|
|
131
181
|
end
|
|
132
182
|
|
|
133
183
|
def find_font
|
|
@@ -456,6 +506,25 @@ module Przn
|
|
|
456
506
|
else
|
|
457
507
|
{text: content, size: default_pt, color: @fg_color}
|
|
458
508
|
end
|
|
509
|
+
when :font
|
|
510
|
+
attrs = segment[2].is_a?(Hash) ? segment[2] : {}
|
|
511
|
+
run = {text: content, size: default_pt, color: @fg_color}
|
|
512
|
+
if attrs[:face] && @registered_inline_fonts&.key?(attrs[:face])
|
|
513
|
+
run[:font] = attrs[:face]
|
|
514
|
+
end
|
|
515
|
+
if attrs[:size] && (scale = Parser::SIZE_SCALES[attrs[:size]])
|
|
516
|
+
run[:size] = @scale_to_pt[scale]
|
|
517
|
+
end
|
|
518
|
+
if attrs[:color]
|
|
519
|
+
run[:color] = if (hex = COLOR_MAP[attrs[:color]])
|
|
520
|
+
hex
|
|
521
|
+
elsif attrs[:color].match?(/\A[0-9a-fA-F]{6}\z/)
|
|
522
|
+
attrs[:color].upcase
|
|
523
|
+
else
|
|
524
|
+
@fg_color
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
run
|
|
459
528
|
when :bold
|
|
460
529
|
{text: content, size: default_pt, color: @fg_color, styles: [:bold]}
|
|
461
530
|
when :italic
|
|
@@ -539,7 +608,7 @@ module Przn
|
|
|
539
608
|
end
|
|
540
609
|
|
|
541
610
|
def bullet
|
|
542
|
-
@font_registered ?
|
|
611
|
+
@font_registered ? @theme.bullet : "-"
|
|
543
612
|
end
|
|
544
613
|
|
|
545
614
|
end
|
data/lib/przn/renderer.rb
CHANGED
|
@@ -18,37 +18,62 @@ module Przn
|
|
|
18
18
|
def initialize(terminal, base_dir: '.', theme: nil)
|
|
19
19
|
@terminal = terminal
|
|
20
20
|
@base_dir = base_dir
|
|
21
|
-
@theme = theme
|
|
21
|
+
@theme = theme || Theme.default
|
|
22
|
+
@image_cache = {}
|
|
23
|
+
@kitty_uploads = {}
|
|
24
|
+
@mutex = Mutex.new
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def render(slide, current:, total:)
|
|
25
|
-
@
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
@terminal.clear
|
|
30
|
+
apply_slide_background(slide)
|
|
31
|
+
w = @terminal.width
|
|
32
|
+
h = @terminal.height
|
|
33
|
+
|
|
34
|
+
row = if current == 0
|
|
35
|
+
content_height = calculate_height(slide.blocks, w)
|
|
36
|
+
usable_height = h - 1
|
|
37
|
+
[(usable_height - content_height) / 2 + 1, 1].max
|
|
38
|
+
else
|
|
39
|
+
2
|
|
40
|
+
end
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
pending_align = nil
|
|
43
|
+
slide.blocks.each do |block|
|
|
44
|
+
if block[:type] == :align
|
|
45
|
+
pending_align = block[:align]
|
|
46
|
+
else
|
|
47
|
+
row = render_block(block, w, row, align: pending_align)
|
|
48
|
+
pending_align = nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
status = " #{current + 1} / #{total} "
|
|
53
|
+
@terminal.move_to(h, w - status.size)
|
|
54
|
+
@terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
|
|
55
|
+
|
|
56
|
+
@terminal.flush
|
|
35
57
|
end
|
|
58
|
+
end
|
|
36
59
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
# Warm caches for a slide we expect to navigate to soon. Uploads any PNG
|
|
61
|
+
# images on the Kitty Graphics Protocol so the next render only needs a
|
|
62
|
+
# placement command. Safe to call from a background thread; serialized
|
|
63
|
+
# against `render` via the renderer's mutex so terminal writes don't
|
|
64
|
+
# interleave.
|
|
65
|
+
def preload(slide)
|
|
66
|
+
return unless ImageUtil.kitty_terminal?
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
slide.blocks.each do |block|
|
|
70
|
+
next unless block[:type] == :image
|
|
71
|
+
path = resolve_image_path(block[:path])
|
|
72
|
+
next unless File.exist?(path) && ImageUtil.png?(path)
|
|
73
|
+
ensure_kitty_uploaded(path)
|
|
44
74
|
end
|
|
75
|
+
@terminal.flush
|
|
45
76
|
end
|
|
46
|
-
|
|
47
|
-
status = " #{current + 1} / #{total} "
|
|
48
|
-
@terminal.move_to(h, w - status.size)
|
|
49
|
-
@terminal.write "#{ANSI[:dim]}#{status}#{ANSI[:reset]}"
|
|
50
|
-
|
|
51
|
-
@terminal.flush
|
|
52
77
|
end
|
|
53
78
|
|
|
54
79
|
private
|
|
@@ -65,32 +90,65 @@ module Przn
|
|
|
65
90
|
when :table then render_table(block, width, row)
|
|
66
91
|
when :image then render_image(block, width, row)
|
|
67
92
|
when :blank then row + DEFAULT_SCALE
|
|
93
|
+
when :bg then row
|
|
68
94
|
else row + 1
|
|
69
95
|
end
|
|
70
96
|
end
|
|
71
97
|
|
|
98
|
+
# Emit Echoes' OSC 7772 to set a slide-specific solid color or gradient,
|
|
99
|
+
# or clear any previous override. A `<bg .../>` block on the slide wins;
|
|
100
|
+
# otherwise the theme's `bg:` section is used as the deck-wide default.
|
|
101
|
+
# Other terminals ignore the OSC code, so this is a no-op outside Echoes.
|
|
102
|
+
def apply_slide_background(slide)
|
|
103
|
+
block = slide.blocks.find { |b| b[:type] == :bg }
|
|
104
|
+
attrs = block ? block[:attrs] : (@theme.bg || {})
|
|
105
|
+
|
|
106
|
+
@terminal.write "\e]7772;bg-clear\a"
|
|
107
|
+
return if attrs.empty?
|
|
108
|
+
|
|
109
|
+
if (color = attrs[:color])
|
|
110
|
+
@terminal.write "\e]7772;bg-color;#{color}\a"
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
colors = [attrs[:from], attrs[:to]].compact
|
|
115
|
+
return if colors.size < 2
|
|
116
|
+
|
|
117
|
+
type = attrs[:type] || 'linear'
|
|
118
|
+
angle = attrs[:angle] || 0
|
|
119
|
+
@terminal.write "\e]7772;bg-gradient;type=#{type}:angle=#{angle}:colors=#{colors.join(',')}\a"
|
|
120
|
+
end
|
|
121
|
+
|
|
72
122
|
def render_heading(block, width, row)
|
|
73
123
|
text = block[:content]
|
|
74
124
|
|
|
75
125
|
if block[:level] == 1
|
|
76
126
|
scale = KittyText::HEADING_SCALES[1]
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
127
|
+
face = @theme.heading_face
|
|
128
|
+
max_w = max_text_width(width, 0, scale)
|
|
129
|
+
segments = Parser.parse_inline(text)
|
|
130
|
+
wrapped = wrap_segments(segments, max_w, scale)
|
|
131
|
+
|
|
132
|
+
wrapped.each do |line_segs|
|
|
133
|
+
vis = segments_visible_cells(line_segs, scale)
|
|
134
|
+
pad = [(width - vis) / 2, 0].max
|
|
135
|
+
@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]}"
|
|
137
|
+
row += scale
|
|
138
|
+
end
|
|
139
|
+
row + 4
|
|
82
140
|
else
|
|
83
141
|
left = content_left(width)
|
|
84
|
-
prefix =
|
|
142
|
+
prefix = @theme.bullet
|
|
85
143
|
prefix_w = display_width(prefix)
|
|
86
144
|
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
87
145
|
segments = Parser.parse_inline(text)
|
|
88
|
-
wrapped = wrap_segments(segments, max_w)
|
|
146
|
+
wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
|
|
89
147
|
|
|
90
148
|
wrapped.each_with_index do |line_segs, li|
|
|
91
149
|
@terminal.move_to(row, left)
|
|
92
150
|
if li == 0
|
|
93
|
-
@terminal.write "#{
|
|
151
|
+
@terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
94
152
|
else
|
|
95
153
|
@terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
96
154
|
end
|
|
@@ -112,7 +170,7 @@ module Przn
|
|
|
112
170
|
|
|
113
171
|
max_w = max_text_width(width, left, scale)
|
|
114
172
|
segments = Parser.parse_inline(text)
|
|
115
|
-
wrapped = wrap_segments(segments, max_w)
|
|
173
|
+
wrapped = wrap_segments(segments, max_w, scale)
|
|
116
174
|
|
|
117
175
|
wrapped.each do |line_segs|
|
|
118
176
|
@terminal.move_to(row, left + 1)
|
|
@@ -147,17 +205,17 @@ module Przn
|
|
|
147
205
|
block[:items].each do |item|
|
|
148
206
|
depth = item[:depth] || 0
|
|
149
207
|
indent = " " * depth
|
|
150
|
-
prefix = "#{indent}
|
|
208
|
+
prefix = "#{indent}#{@theme.bullet}"
|
|
151
209
|
prefix_w = display_width(prefix)
|
|
152
210
|
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
153
211
|
|
|
154
212
|
segments = Parser.parse_inline(item[:text])
|
|
155
|
-
wrapped = wrap_segments(segments, max_w)
|
|
213
|
+
wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
|
|
156
214
|
|
|
157
215
|
wrapped.each_with_index do |line_segs, li|
|
|
158
216
|
@terminal.move_to(row, left)
|
|
159
217
|
if li == 0
|
|
160
|
-
@terminal.write "#{
|
|
218
|
+
@terminal.write "#{render_bullet(prefix)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
161
219
|
else
|
|
162
220
|
@terminal.write "#{KittyText.sized(" " * prefix_w, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
|
|
163
221
|
end
|
|
@@ -178,7 +236,7 @@ module Przn
|
|
|
178
236
|
max_w = max_text_width(width, left, DEFAULT_SCALE) - prefix_w
|
|
179
237
|
|
|
180
238
|
segments = Parser.parse_inline(item[:text])
|
|
181
|
-
wrapped = wrap_segments(segments, max_w)
|
|
239
|
+
wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
|
|
182
240
|
|
|
183
241
|
wrapped.each_with_index do |line_segs, li|
|
|
184
242
|
@terminal.move_to(row, left)
|
|
@@ -199,7 +257,7 @@ module Przn
|
|
|
199
257
|
max_w = max_text_width(width, left, DEFAULT_SCALE)
|
|
200
258
|
|
|
201
259
|
segments = Parser.parse_inline(block[:term])
|
|
202
|
-
wrapped = wrap_segments(segments, max_w)
|
|
260
|
+
wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
|
|
203
261
|
wrapped.each do |line_segs|
|
|
204
262
|
@terminal.move_to(row, left)
|
|
205
263
|
@terminal.write "#{ANSI[:bold]}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}#{ANSI[:reset]}"
|
|
@@ -209,7 +267,7 @@ module Przn
|
|
|
209
267
|
def_max_w = [max_w - 4, 1].max
|
|
210
268
|
block[:definition].each_line do |line|
|
|
211
269
|
segments = Parser.parse_inline(line.chomp)
|
|
212
|
-
wrapped = wrap_segments(segments, def_max_w)
|
|
270
|
+
wrapped = wrap_segments(segments, def_max_w, DEFAULT_SCALE)
|
|
213
271
|
wrapped.each do |line_segs|
|
|
214
272
|
@terminal.move_to(row, left + 4)
|
|
215
273
|
@terminal.write render_segments_scaled(line_segs, DEFAULT_SCALE)
|
|
@@ -228,7 +286,7 @@ module Przn
|
|
|
228
286
|
block[:content].each_line do |line|
|
|
229
287
|
text = line.chomp
|
|
230
288
|
segments = [[:text, text]]
|
|
231
|
-
wrapped = wrap_segments(segments, max_w)
|
|
289
|
+
wrapped = wrap_segments(segments, max_w, DEFAULT_SCALE)
|
|
232
290
|
|
|
233
291
|
wrapped.each_with_index do |line_segs, li|
|
|
234
292
|
@terminal.move_to(row, left + 1)
|
|
@@ -303,14 +361,18 @@ module Przn
|
|
|
303
361
|
|
|
304
362
|
x = [(width - target_cols) / 2, 0].max
|
|
305
363
|
|
|
306
|
-
if ImageUtil.kitty_terminal?
|
|
307
|
-
|
|
364
|
+
if ImageUtil.kitty_terminal? && ImageUtil.png?(path)
|
|
365
|
+
image_id = ensure_kitty_uploaded(path)
|
|
366
|
+
@terminal.move_to(row, x + 1)
|
|
367
|
+
@terminal.write ImageUtil.kitty_place(image_id: image_id, cols: target_cols, rows: target_rows)
|
|
368
|
+
elsif ImageUtil.kitty_terminal?
|
|
369
|
+
data = cached_kitty_icat(path, cols: target_cols, rows: target_rows, x: x, y: row - 1)
|
|
308
370
|
@terminal.write data if data && !data.empty?
|
|
309
371
|
elsif ImageUtil.sixel_available?
|
|
310
372
|
@terminal.move_to(row, x + 1)
|
|
311
373
|
target_pixel_w = target_cols * cell_w
|
|
312
374
|
target_pixel_h = target_rows * cell_h
|
|
313
|
-
sixel =
|
|
375
|
+
sixel = cached_sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
|
|
314
376
|
@terminal.write sixel if sixel && !sixel.empty?
|
|
315
377
|
end
|
|
316
378
|
|
|
@@ -322,6 +384,39 @@ module Przn
|
|
|
322
384
|
File.expand_path(path, @base_dir)
|
|
323
385
|
end
|
|
324
386
|
|
|
387
|
+
# Memoize the encoded escape-sequence bytes so revisiting a slide
|
|
388
|
+
# skips both the subprocess fork and the image decode/encode work.
|
|
389
|
+
# Keyed by file mtime so edits to the source image invalidate.
|
|
390
|
+
def cached_kitty_icat(path, cols:, rows:, x:, y:)
|
|
391
|
+
key = [:kitty, path, image_mtime(path), cols, rows, x, y]
|
|
392
|
+
return @image_cache[key] if @image_cache.key?(key)
|
|
393
|
+
@image_cache[key] = ImageUtil.kitty_icat(path, cols: cols, rows: rows, x: x, y: y)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def cached_sixel_encode(path, width:, height:)
|
|
397
|
+
key = [:sixel, path, image_mtime(path), width, height]
|
|
398
|
+
return @image_cache[key] if @image_cache.key?(key)
|
|
399
|
+
@image_cache[key] = ImageUtil.sixel_encode(path, width: width, height: height)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def image_mtime(path)
|
|
403
|
+
File.mtime(path).to_f
|
|
404
|
+
rescue Errno::ENOENT
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Upload a PNG to the Kitty terminal once and return the assigned image
|
|
409
|
+
# id. Subsequent renders of the same file (same mtime) reuse the id and
|
|
410
|
+
# only emit a small placement command, skipping the file-transfer cost.
|
|
411
|
+
def ensure_kitty_uploaded(path)
|
|
412
|
+
key = [path, image_mtime(path)]
|
|
413
|
+
return @kitty_uploads[key] if @kitty_uploads.key?(key)
|
|
414
|
+
|
|
415
|
+
image_id = @kitty_uploads.size + 1
|
|
416
|
+
@terminal.write ImageUtil.kitty_upload_png(path, image_id: image_id)
|
|
417
|
+
@kitty_uploads[key] = image_id
|
|
418
|
+
end
|
|
419
|
+
|
|
325
420
|
def content_left(width)
|
|
326
421
|
width / 16
|
|
327
422
|
end
|
|
@@ -364,7 +459,47 @@ module Przn
|
|
|
364
459
|
end
|
|
365
460
|
end
|
|
366
461
|
|
|
367
|
-
|
|
462
|
+
# Render the list/heading bullet. When `bullet_size` is smaller than the
|
|
463
|
+
# body scale, use OSC 66 fractional scaling (n/d) with v=2 to keep the
|
|
464
|
+
# glyph's cell footprint at body scale but draw a smaller dot vertically
|
|
465
|
+
# centered. Plain `s=N` for a smaller bullet would top-align it inside
|
|
466
|
+
# the row, which looks wrong against the larger body text.
|
|
467
|
+
def render_bullet(prefix)
|
|
468
|
+
size = @theme.bullet_size
|
|
469
|
+
if size && size < DEFAULT_SCALE
|
|
470
|
+
KittyText.sized(prefix, s: DEFAULT_SCALE, n: size, d: DEFAULT_SCALE, v: 2)
|
|
471
|
+
else
|
|
472
|
+
KittyText.sized(prefix, s: size || DEFAULT_SCALE)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Render a <font face="..." size="..." color="..."> run. The face goes out
|
|
477
|
+
# via OSC 66 f= (Echoes extension); the size resolves through the same
|
|
478
|
+
# SIZE_SCALES table that <size=N> uses; the color wraps in the same ANSI
|
|
479
|
+
# escape that <color=NAME> uses.
|
|
480
|
+
def render_font_segment(content, attrs, para_scale, default_face: nil, default_h: nil)
|
|
481
|
+
scale = (attrs[:size] && Parser::SIZE_SCALES[attrs[:size]]) || para_scale
|
|
482
|
+
base = KittyText.sized(content, s: scale, f: attrs[:face] || default_face, h: default_h)
|
|
483
|
+
color = attrs[:color]
|
|
484
|
+
return base unless color
|
|
485
|
+
"#{color_code(color)}#{base}#{ANSI[:reset]}"
|
|
486
|
+
end
|
|
487
|
+
|
|
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.
|
|
494
|
+
#
|
|
495
|
+
# `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
|
|
497
|
+
# centered within the reserved cell block — without it the glyphs left-
|
|
498
|
+
# align inside the block and the visible text drifts left of the center
|
|
499
|
+
# column we computed.
|
|
500
|
+
def render_segments_scaled(segments, para_scale, default_face: :body, default_h: nil)
|
|
501
|
+
f = default_face == :body ? @theme.font[:family] : default_face
|
|
502
|
+
h = default_h
|
|
368
503
|
segments.map { |segment|
|
|
369
504
|
type = segment[0]
|
|
370
505
|
content = segment[1]
|
|
@@ -372,18 +507,19 @@ module Przn
|
|
|
372
507
|
when :tag
|
|
373
508
|
tag_name = segment[2]
|
|
374
509
|
if (scale = Parser::SIZE_SCALES[tag_name])
|
|
375
|
-
KittyText.sized(content, s: scale)
|
|
510
|
+
KittyText.sized(content, s: scale, f: f, h: h)
|
|
376
511
|
elsif Parser::NAMED_COLORS.key?(tag_name)
|
|
377
|
-
"#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
|
|
512
|
+
"#{color_code(tag_name)}#{KittyText.sized(content, s: para_scale, f: f, h: h)}#{ANSI[:reset]}"
|
|
378
513
|
else
|
|
379
|
-
KittyText.sized(content, s: para_scale)
|
|
514
|
+
KittyText.sized(content, s: para_scale, f: f, h: h)
|
|
380
515
|
end
|
|
381
|
-
when :
|
|
382
|
-
when :
|
|
383
|
-
when :
|
|
384
|
-
when :
|
|
385
|
-
when :
|
|
386
|
-
when :
|
|
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]}"
|
|
522
|
+
when :text then KittyText.sized(content, s: para_scale, f: f, h: h)
|
|
387
523
|
end
|
|
388
524
|
}.join
|
|
389
525
|
end
|
|
@@ -392,53 +528,92 @@ module Przn
|
|
|
392
528
|
render_segments_scaled(Parser.parse_inline(text), para_scale)
|
|
393
529
|
end
|
|
394
530
|
|
|
395
|
-
# Wrap parsed inline segments into lines that fit within max_width
|
|
396
|
-
|
|
531
|
+
# Wrap parsed inline segments into lines that fit within max_width units,
|
|
532
|
+
# where 1 unit = `para_scale` terminal cells. Per-segment scaling (e.g.
|
|
533
|
+
# size tags) is honored so a span with a larger scale consumes more budget.
|
|
534
|
+
def wrap_segments(segments, max_width, para_scale = DEFAULT_SCALE)
|
|
397
535
|
return [segments] if max_width <= 0
|
|
398
536
|
|
|
537
|
+
max_cells = max_width * para_scale
|
|
399
538
|
lines = [[]]
|
|
400
|
-
|
|
539
|
+
used = 0
|
|
401
540
|
|
|
402
541
|
segments.each do |seg|
|
|
403
542
|
content = seg[1] || ""
|
|
404
|
-
|
|
543
|
+
next if content.empty?
|
|
544
|
+
|
|
545
|
+
seg_scale = effective_seg_scale(seg, para_scale)
|
|
546
|
+
seg_cells = display_width(content) * seg_scale
|
|
405
547
|
|
|
406
|
-
if
|
|
548
|
+
if used + seg_cells <= max_cells
|
|
407
549
|
lines.last << seg
|
|
408
|
-
|
|
550
|
+
used += seg_cells
|
|
409
551
|
next
|
|
410
552
|
end
|
|
411
553
|
|
|
412
554
|
remaining = content
|
|
413
555
|
loop do
|
|
414
|
-
|
|
415
|
-
if
|
|
556
|
+
space_cells = max_cells - used
|
|
557
|
+
if space_cells < seg_scale && used > 0
|
|
416
558
|
lines << []
|
|
417
|
-
|
|
418
|
-
|
|
559
|
+
used = 0
|
|
560
|
+
space_cells = max_cells
|
|
419
561
|
end
|
|
420
562
|
|
|
421
|
-
|
|
563
|
+
chunk_max_dw = [space_cells / seg_scale, 1].max
|
|
564
|
+
chunk, remaining = split_by_display_width(remaining, chunk_max_dw)
|
|
422
565
|
lines.last << [seg[0], chunk, *Array(seg[2..])]
|
|
423
|
-
|
|
566
|
+
used += display_width(chunk) * seg_scale
|
|
424
567
|
|
|
425
568
|
break unless remaining
|
|
426
569
|
lines << []
|
|
427
|
-
|
|
570
|
+
used = 0
|
|
428
571
|
end
|
|
429
572
|
end
|
|
430
573
|
|
|
431
574
|
lines
|
|
432
575
|
end
|
|
433
576
|
|
|
577
|
+
def effective_seg_scale(seg, para_scale)
|
|
578
|
+
case seg[0]
|
|
579
|
+
when :tag
|
|
580
|
+
Parser::SIZE_SCALES[seg[2]] || para_scale
|
|
581
|
+
when :font
|
|
582
|
+
size = seg[2].is_a?(Hash) ? seg[2][:size] : nil
|
|
583
|
+
(size && Parser::SIZE_SCALES[size]) || para_scale
|
|
584
|
+
else
|
|
585
|
+
para_scale
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def segments_visible_cells(segments, para_scale)
|
|
590
|
+
segments.sum { |seg|
|
|
591
|
+
content = seg[1] || ""
|
|
592
|
+
display_width(content) * effective_seg_scale(seg, para_scale)
|
|
593
|
+
}
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Split `text` so the first piece fits within `max_width` cells, preferring
|
|
597
|
+
# to break at the last whitespace before the overflow rather than mid-word.
|
|
598
|
+
# Falls back to a char-level split when no whitespace is available — single
|
|
599
|
+
# long words, CJK runs (no inter-character whitespace) — so a word that's
|
|
600
|
+
# itself longer than the line still wraps instead of overflowing.
|
|
434
601
|
def split_by_display_width(text, max_width)
|
|
435
602
|
w = 0
|
|
603
|
+
last_space = nil
|
|
436
604
|
text.each_char.with_index do |c, i|
|
|
437
605
|
cw = display_width(c)
|
|
438
606
|
if w + cw > max_width && w > 0
|
|
439
|
-
|
|
607
|
+
if c == ' '
|
|
608
|
+
return [text[0...i], text[(i + 1)..]]
|
|
609
|
+
elsif last_space && last_space > 0
|
|
610
|
+
return [text[0...last_space], text[(last_space + 1)..]]
|
|
611
|
+
else
|
|
612
|
+
return [text[0...i], text[i..]]
|
|
613
|
+
end
|
|
440
614
|
end
|
|
441
615
|
w += cw
|
|
616
|
+
last_space = i if c == ' '
|
|
442
617
|
end
|
|
443
618
|
[text, nil]
|
|
444
619
|
end
|
|
@@ -511,6 +686,7 @@ module Przn
|
|
|
511
686
|
(o >= 0xfe30 && o <= 0xfe6f) ||
|
|
512
687
|
(o >= 0xff00 && o <= 0xff60) ||
|
|
513
688
|
(o >= 0xffe0 && o <= 0xffe6) ||
|
|
689
|
+
(o >= 0x1f300 && o <= 0x1faff) || # emoji blocks; terminals render these as 2 cells
|
|
514
690
|
(o >= 0x20000 && o <= 0x2fffd) ||
|
|
515
691
|
(o >= 0x30000 && o <= 0x3fffd))
|
|
516
692
|
2
|
|
@@ -529,6 +705,7 @@ module Przn
|
|
|
529
705
|
.gsub(/\*(.+?)\*/, '\1')
|
|
530
706
|
.gsub(/~~(.+?)~~/, '\1')
|
|
531
707
|
.gsub(/`([^`]+)`/, '\1')
|
|
708
|
+
.gsub(/&(lt|gt|amp);/) { |_| {"lt" => "<", "gt" => ">", "amp" => "&"}[$1] }
|
|
532
709
|
end
|
|
533
710
|
|
|
534
711
|
def calculate_height(blocks, width)
|
|
@@ -572,6 +749,8 @@ module Przn
|
|
|
572
749
|
image_block_height(block, width)
|
|
573
750
|
when :align
|
|
574
751
|
0
|
|
752
|
+
when :bg
|
|
753
|
+
0
|
|
575
754
|
when :blank
|
|
576
755
|
s
|
|
577
756
|
else
|
data/lib/przn/terminal.rb
CHANGED
|
@@ -4,6 +4,8 @@ require 'io/console'
|
|
|
4
4
|
|
|
5
5
|
module Przn
|
|
6
6
|
class Terminal
|
|
7
|
+
MOUSE_OFF = "\e[?1006l\e[?1003l\e[?1002l\e[?1000l"
|
|
8
|
+
|
|
7
9
|
def initialize(input: $stdin, output: $stdout)
|
|
8
10
|
@in = input
|
|
9
11
|
@out = output
|
|
@@ -45,9 +47,11 @@ module Przn
|
|
|
45
47
|
|
|
46
48
|
def enter_alt_screen
|
|
47
49
|
write "\e[?1049h"
|
|
50
|
+
write MOUSE_OFF
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
def leave_alt_screen
|
|
54
|
+
write MOUSE_OFF
|
|
51
55
|
write "\e[?1049l"
|
|
52
56
|
end
|
|
53
57
|
|
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
|
|
9
|
+
attr_reader :colors, :font, :bullet, :bullet_size, :bg, :heading_face
|
|
10
10
|
|
|
11
11
|
def self.load(path)
|
|
12
12
|
raise ArgumentError, "Theme file not found: #{path}" unless File.exist?(path)
|
|
@@ -16,6 +16,10 @@ 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
23
|
}
|
|
20
24
|
new(merged)
|
|
21
25
|
end
|
|
@@ -29,6 +33,10 @@ module Przn
|
|
|
29
33
|
{
|
|
30
34
|
colors: data[:colors] || {},
|
|
31
35
|
font: data[:font] || {},
|
|
36
|
+
bullet: data[:bullet],
|
|
37
|
+
bullet_size: data[:bullet_size],
|
|
38
|
+
bg: (data[:bg] || {}).compact,
|
|
39
|
+
heading_face: data[:heading_face],
|
|
32
40
|
}
|
|
33
41
|
end
|
|
34
42
|
private_class_method :load_yaml
|
|
@@ -36,6 +44,10 @@ module Przn
|
|
|
36
44
|
def initialize(config)
|
|
37
45
|
@colors = config[:colors]
|
|
38
46
|
@font = config[:font]
|
|
47
|
+
@bullet = config[:bullet]
|
|
48
|
+
@bullet_size = config[:bullet_size]
|
|
49
|
+
@bg = config[:bg]
|
|
50
|
+
@heading_face = config[:heading_face]
|
|
39
51
|
end
|
|
40
52
|
end
|
|
41
53
|
end
|
data/lib/przn/version.rb
CHANGED
data/lib/przn.rb
CHANGED
|
@@ -15,9 +15,10 @@ require_relative "przn/theme"
|
|
|
15
15
|
module Przn
|
|
16
16
|
class Error < StandardError; end
|
|
17
17
|
|
|
18
|
-
def self.start(file, theme: nil)
|
|
18
|
+
def self.start(file, theme: nil, start_at: nil)
|
|
19
19
|
markdown = File.read(file)
|
|
20
20
|
presentation = Parser.parse(markdown)
|
|
21
|
+
presentation.go_to(start_at - 1) if start_at
|
|
21
22
|
terminal = Terminal.new
|
|
22
23
|
base_dir = File.dirname(File.expand_path(file))
|
|
23
24
|
renderer = Renderer.new(terminal, base_dir: base_dir, theme: theme)
|
metadata
CHANGED
|
@@ -1,76 +1,79 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: przn
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
- Akira Matsuda
|
|
7
|
+
- Akira Matsuda
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: prawn
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
-
|
|
17
|
+
- ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: "0"
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
-
|
|
25
|
+
- ">="
|
|
26
|
+
- !ruby/object:Gem::Version
|
|
27
|
+
version: "0"
|
|
28
|
+
description: A terminal-based presentation tool that renders Markdown slides with Kitty text sizing protocol support for beautifully scaled headers
|
|
28
29
|
email:
|
|
29
|
-
- ronnie@dio.jp
|
|
30
|
+
- ronnie@dio.jp
|
|
30
31
|
executables:
|
|
31
|
-
- przn
|
|
32
|
+
- przn
|
|
32
33
|
extensions: []
|
|
33
34
|
extra_rdoc_files: []
|
|
34
35
|
files:
|
|
35
|
-
- LICENSE.txt
|
|
36
|
-
- README.md
|
|
37
|
-
- Rakefile
|
|
38
|
-
- default_theme.yml
|
|
39
|
-
- exe/przn
|
|
40
|
-
- lib/przn.rb
|
|
41
|
-
- lib/przn/controller.rb
|
|
42
|
-
- lib/przn/image_util.rb
|
|
43
|
-
- lib/przn/kitty_text.rb
|
|
44
|
-
- lib/przn/parser.rb
|
|
45
|
-
- lib/przn/pdf_exporter.rb
|
|
46
|
-
- lib/przn/presentation.rb
|
|
47
|
-
- lib/przn/renderer.rb
|
|
48
|
-
- lib/przn/slide.rb
|
|
49
|
-
- lib/przn/terminal.rb
|
|
50
|
-
- lib/przn/theme.rb
|
|
51
|
-
- lib/przn/version.rb
|
|
52
|
-
- sample/sample.md
|
|
53
|
-
- sig/przn.rbs
|
|
54
|
-
homepage: https://github.com/amatsuda/przn
|
|
36
|
+
- LICENSE.txt
|
|
37
|
+
- README.md
|
|
38
|
+
- Rakefile
|
|
39
|
+
- default_theme.yml
|
|
40
|
+
- exe/przn
|
|
41
|
+
- lib/przn.rb
|
|
42
|
+
- lib/przn/controller.rb
|
|
43
|
+
- lib/przn/image_util.rb
|
|
44
|
+
- lib/przn/kitty_text.rb
|
|
45
|
+
- lib/przn/parser.rb
|
|
46
|
+
- lib/przn/pdf_exporter.rb
|
|
47
|
+
- lib/przn/presentation.rb
|
|
48
|
+
- lib/przn/renderer.rb
|
|
49
|
+
- lib/przn/slide.rb
|
|
50
|
+
- lib/przn/terminal.rb
|
|
51
|
+
- lib/przn/theme.rb
|
|
52
|
+
- lib/przn/version.rb
|
|
53
|
+
- sample/sample.md
|
|
54
|
+
- sig/przn.rbs
|
|
55
|
+
homepage: "https://github.com/amatsuda/przn"
|
|
55
56
|
licenses:
|
|
56
|
-
- MIT
|
|
57
|
+
- MIT
|
|
57
58
|
metadata:
|
|
58
|
-
allowed_push_host: https://rubygems.org
|
|
59
|
-
source_code_uri: https://github.com/amatsuda/przn
|
|
60
|
-
homepage_uri: https://github.com/amatsuda/przn
|
|
59
|
+
allowed_push_host: "https://rubygems.org"
|
|
60
|
+
source_code_uri: "https://github.com/amatsuda/przn"
|
|
61
|
+
homepage_uri: "https://github.com/amatsuda/przn"
|
|
61
62
|
rdoc_options: []
|
|
62
63
|
require_paths:
|
|
63
|
-
- lib
|
|
64
|
+
- lib
|
|
64
65
|
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
66
|
requirements:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
-
|
|
68
|
+
- ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: "0"
|
|
69
71
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
72
|
requirements:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
-
|
|
74
|
+
- ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: "0"
|
|
74
77
|
requirements: []
|
|
75
78
|
rubygems_version: 4.1.0.dev
|
|
76
79
|
specification_version: 4
|