przn 0.1.6 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c6b2f14ade1a9803cb8d50af8ea637db4cc4455bd07c4274acadef61c0c24d4
4
- data.tar.gz: 3cf2e893b448133d50c7d4283838d521b06ed1e9d129ea620e799925edfe0284
3
+ metadata.gz: 9bc9623dc391c5dee0540aeca9adb3dd20ad090299e5ff5f9395a37fe7643416
4
+ data.tar.gz: 743ecab777fa5729e4fb23f1b140963060335e8f88a03760524637195fd99b17
5
5
  SHA512:
6
- metadata.gz: 0b0b41885598b6176fa0de0363f683ded12937a0f0365462fbfb9cffeb867f7c126dadaaecc93244b20e9ceebf94a7d582c076080176d40f7486a28d4792fc66
7
- data.tar.gz: 814096bacf4013f4b225b30615176dc2579199bce5c08b8bfa718b00407c28c14a7e8b06958808e24a28b04dd4762c46d6baca8b1340ee2074e48f968c0a2b3e
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
+ &lt;note&gt; renders as: <note>
235
+ 2 &lt; 3 renders as: 2 < 3
236
+ A &amp; 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-detect
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
@@ -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
@@ -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?
@@ -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(/\{::wait\/\}/)
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(/&lt;/)
289
+ segments << [:text, "<"]
290
+ elsif scanner.scan(/&gt;/)
291
+ segments << [:text, ">"]
292
+ elsif scanner.scan(/&amp;/)
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
 
@@ -127,6 +127,57 @@ module Przn
127
127
  @font_registered = true
128
128
 
129
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
130
181
  end
131
182
 
132
183
  def find_font
@@ -455,6 +506,25 @@ module Przn
455
506
  else
456
507
  {text: content, size: default_pt, color: @fg_color}
457
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
458
528
  when :bold
459
529
  {text: content, size: default_pt, color: @fg_color, styles: [:bold]}
460
530
  when :italic
@@ -538,7 +608,7 @@ module Przn
538
608
  end
539
609
 
540
610
  def bullet
541
- @font_registered ? "\u30FB" : "-"
611
+ @font_registered ? @theme.bullet : "-"
542
612
  end
543
613
 
544
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
- @terminal.clear
26
- w = @terminal.width
27
- h = @terminal.height
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
- row = if current == 0
30
- content_height = calculate_height(slide.blocks, w)
31
- usable_height = h - 1
32
- [(usable_height - content_height) / 2 + 1, 1].max
33
- else
34
- 2
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
- pending_align = nil
38
- slide.blocks.each do |block|
39
- if block[:type] == :align
40
- pending_align = block[:align]
41
- else
42
- row = render_block(block, w, row, align: pending_align)
43
- pending_align = nil
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
- visible_width = display_width(text) * scale
78
- pad = [(width - visible_width) / 2, 0].max
79
- @terminal.move_to(row, pad + 1)
80
- @terminal.write "#{ANSI[:bold]}#{KittyText.sized(text, s: scale)}#{ANSI[:reset]}"
81
- row + scale + 4
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 "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
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 "#{KittyText.sized(prefix, s: DEFAULT_SCALE)}#{render_segments_scaled(line_segs, DEFAULT_SCALE)}"
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
- data = ImageUtil.kitty_icat(path, cols: target_cols, rows: target_rows, x: x, y: row - 1)
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 = ImageUtil.sixel_encode(path, width: target_pixel_w, height: target_pixel_h)
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
- def render_segments_scaled(segments, para_scale)
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 :note then "#{ANSI[:dim]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
382
- when :bold then "#{ANSI[:bold]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
383
- when :italic then "#{ANSI[:italic]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
384
- when :strikethrough then "#{ANSI[:strikethrough]}#{KittyText.sized(content, s: para_scale)}#{ANSI[:reset]}"
385
- when :code then "#{ANSI[:gray_bg]}#{KittyText.sized(" #{content} ", s: para_scale)}#{ANSI[:reset]}"
386
- when :text then KittyText.sized(content, s: para_scale)
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 display units
396
- def wrap_segments(segments, max_width)
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
- width = 0
539
+ used = 0
401
540
 
402
541
  segments.each do |seg|
403
542
  content = seg[1] || ""
404
- seg_w = display_width(content)
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 width + seg_w <= max_width
548
+ if used + seg_cells <= max_cells
407
549
  lines.last << seg
408
- width += seg_w
550
+ used += seg_cells
409
551
  next
410
552
  end
411
553
 
412
554
  remaining = content
413
555
  loop do
414
- space = max_width - width
415
- if space <= 0
556
+ space_cells = max_cells - used
557
+ if space_cells < seg_scale && used > 0
416
558
  lines << []
417
- width = 0
418
- space = max_width
559
+ used = 0
560
+ space_cells = max_cells
419
561
  end
420
562
 
421
- chunk, remaining = split_by_display_width(remaining, space)
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
- width += display_width(chunk)
566
+ used += display_width(chunk) * seg_scale
424
567
 
425
568
  break unless remaining
426
569
  lines << []
427
- width = 0
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
- return [text[0...i], text[i..]]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Przn
4
- VERSION = "0.1.6"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.6
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
- name: prawn
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
26
- description: A terminal-based presentation tool that renders Markdown slides with
27
- Kitty text sizing protocol support for beautifully scaled headers
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
- - !ruby/object:Gem::Version
68
- version: '0'
67
+ -
68
+ - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: "0"
69
71
  required_rubygems_version: !ruby/object:Gem::Requirement
70
72
  requirements:
71
- - - ">="
72
- - !ruby/object:Gem::Version
73
- version: '0'
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