przn 0.3.0 → 0.5.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.
data/lib/przn/parser.rb CHANGED
@@ -14,7 +14,7 @@ module Przn
14
14
  'xx-large' => 5,
15
15
  'xxx-large' => 6,
16
16
  'xxxx-large' => 7,
17
- '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7,
17
+ '1' => 1, '2' => 2, '3' => 3, '4' => 4, '5' => 5, '6' => 6, '7' => 7
18
18
  }.freeze
19
19
 
20
20
  NAMED_COLORS = {
@@ -22,9 +22,18 @@ module Przn
22
22
  'magenta' => 35, 'cyan' => 36, 'white' => 37,
23
23
  'bright_red' => 91, 'bright_green' => 92, 'bright_yellow' => 93,
24
24
  'bright_blue' => 94, 'bright_magenta' => 95, 'bright_cyan' => 96,
25
- 'bright_white' => 97,
25
+ 'bright_white' => 97
26
26
  }.freeze
27
27
 
28
+ # HTML-ish attribute, three accepted value forms:
29
+ # key="value" key='value' key=bareword
30
+ # The unquoted token excludes whitespace, `=`, `<`, `>`, `"`, `'` and
31
+ # backtick, matching the spirit of HTML5's unquoted-attribute grammar.
32
+ # `/` is intentionally NOT excluded so paths like `src=path/to/file`
33
+ # work — which means self-closing tags need a space before `/>` when
34
+ # the last attribute is unquoted (`<img src=foo.png />`).
35
+ ATTR_RE_SRC = '\w+=(?:"[^"]*"|\'[^\']*\'|[^\s=<>"\'`]+)'
36
+
28
37
  module_function
29
38
 
30
39
  def parse(markdown)
@@ -35,7 +44,7 @@ module Przn
35
44
  # Split on h1 headings (Rabbit-compatible)
36
45
  def split_slides(markdown)
37
46
  chunks = []
38
- current = +""
47
+ current = +''
39
48
  in_fence = false
40
49
 
41
50
  markdown.each_line do |line|
@@ -79,9 +88,20 @@ module Przn
79
88
  # Slide background (Echoes OSC 7772):
80
89
  # <bg color="#..."/> — solid (bg-color)
81
90
  # <bg from="#..." to="#..." angle="N"/> — linear gradient (bg-gradient)
82
- when /\A\s*<bg((?:\s+\w+="[^"]+")*)\s*\/>\s*\z/
91
+ # Attribute values may be double-quoted, single-quoted, or
92
+ # unquoted (HTML5-ish — see ATTR_RE_SRC).
93
+ when %r{\A\s*<bg((?:\s+#{ATTR_RE_SRC})*)\s*/>\s*\z}o
83
94
  blocks << {type: :bg, attrs: parse_xml_attrs(Regexp.last_match(1))}
84
95
 
96
+ # Absolute-position text:
97
+ # <at x="N" y="N">content</at>
98
+ # {::at x="N" y="N"}content{:/at}
99
+ # Content can include inline markup (size, color, font, bold, …).
100
+ when %r{\A\s*<at((?:\s+#{ATTR_RE_SRC})+)\s*>(.*)</at>\s*\z}o
101
+ blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
102
+ when %r{\A\s*\{::at((?:\s+#{ATTR_RE_SRC})+)\}(.*)\{:/at\}\s*\z}o
103
+ blocks << {type: :at, attrs: parse_xml_attrs(Regexp.last_match(1)), content: Regexp.last_match(2)}
104
+
85
105
  # Fenced code block
86
106
  when /\A\s*```(\w*)\s*\z/
87
107
  lang = Regexp.last_match(1)
@@ -155,7 +175,7 @@ module Przn
155
175
  items << {text: Regexp.last_match(2), depth: depth}
156
176
  elsif lines[i].match(/\A {2,}(\S.*)/)
157
177
  # Continuation line
158
- items.last[:text] << " " << Regexp.last_match(1) if items.last
178
+ items.last[:text] << ' ' << Regexp.last_match(1) if items.last
159
179
  else
160
180
  break
161
181
  end
@@ -176,6 +196,23 @@ module Przn
176
196
  i -= 1
177
197
  blocks << {type: :ordered_list, items: items}
178
198
 
199
+ # Image, XML form: <img src="path" alt="..." title="..." {:attrs}/>
200
+ # Equivalent to the markdown `![alt](src "title"){:attrs}` form below
201
+ # — emits the same `:image` block so the renderer handles both
202
+ # identically. `src` is required; all other attributes pass through
203
+ # to `block[:attrs]` (string-keyed, matching markdown's IAL parse) so
204
+ # `relative_height`, `width`, etc. work the same way.
205
+ when %r{\A\s*<img((?:\s+#{ATTR_RE_SRC})+)\s*/>\s*\z}o
206
+ raw = parse_xml_attrs(Regexp.last_match(1))
207
+ path = raw.delete(:src)
208
+ if path
209
+ alt = raw.delete(:alt).to_s
210
+ title = raw.delete(:title)
211
+ attrs = raw.transform_keys(&:to_s)
212
+ normalize_image_attrs!(attrs)
213
+ blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
214
+ end
215
+
179
216
  # Image: ![alt](path "title"){:attrs}
180
217
  when /\A!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]*)")?\)(.*)/
181
218
  alt = Regexp.last_match(1)
@@ -189,11 +226,12 @@ module Przn
189
226
  attr_str = rest.sub(/\A\{:?\s*/, '')
190
227
  while !attr_str.include?('}') && (i + 1) < lines.size
191
228
  i += 1
192
- attr_str << " " << lines[i].strip
229
+ attr_str << ' ' << lines[i].strip
193
230
  end
194
231
  attr_str = attr_str.sub(/\}\s*\z/, '')
195
232
  parse_image_attrs(attr_str, attrs)
196
233
  end
234
+ normalize_image_attrs!(attrs)
197
235
  blocks << {type: :image, path: path, alt: alt, title: title, attrs: attrs}
198
236
 
199
237
  # Definition list: term on one line, : definition on next
@@ -237,12 +275,14 @@ module Przn
237
275
  attrs.slice(:face, :size, :color)
238
276
  end
239
277
 
240
- # Generic attribute scanner — `key="value"` pairs, returned as a hash with
241
- # symbolized keys. Doesn't validate which keys are allowed; callers slice.
278
+ # Generic attribute scanner — three value forms accepted:
279
+ # key="value" key='value' key=bareword
280
+ # Returns a hash with symbolized keys. Doesn't validate which keys
281
+ # are allowed; callers slice.
242
282
  def parse_xml_attrs(str)
243
283
  attrs = {}
244
- str.scan(/(\w+)="([^"]+)"/) do |key, value|
245
- attrs[key.to_sym] = value
284
+ str.scan(/(\w+)=(?:"([^"]*)"|'([^']*)'|([^\s=<>"'`]+))/) do |key, dq, sq, uq|
285
+ attrs[key.to_sym] = dq || sq || uq
246
286
  end
247
287
  attrs
248
288
  end
@@ -254,6 +294,22 @@ module Przn
254
294
  end
255
295
  end
256
296
 
297
+ # Rewrite `height="N%"` / `width="N%"` into the canonical
298
+ # `relative_height="N"` / `relative_width="N"` the renderer reads.
299
+ # Values without a `%` suffix pass through unchanged (and are
300
+ # ignored downstream); an explicit `relative_*` already on the
301
+ # block wins so authors can mix forms without surprise.
302
+ def normalize_image_attrs!(attrs)
303
+ if (h = attrs['height']) && (m = h.match(/\A(\d+)%\z/))
304
+ attrs.delete('height')
305
+ attrs['relative_height'] ||= m[1]
306
+ end
307
+ if (w = attrs['width']) && (m = w.match(/\A(\d+)%\z/))
308
+ attrs.delete('width')
309
+ attrs['relative_width'] ||= m[1]
310
+ end
311
+ end
312
+
257
313
  def parse_table(lines)
258
314
  rows = []
259
315
  lines.each do |line|
@@ -275,22 +331,22 @@ module Przn
275
331
  segments << [:tag, scanner[2], scanner[1]]
276
332
  elsif scanner.scan(/<size=([^>\s]+)>(.*?)<\/size>/)
277
333
  segments << [:tag, scanner[2], scanner[1]]
278
- elsif scanner.scan(/<font((?:\s+\w+="[^"]+")+)\s*>(.*?)<\/font>/)
334
+ elsif scanner.scan(%r{<font((?:\s+#{ATTR_RE_SRC})+)\s*>(.*?)</font>}o)
279
335
  segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
280
- elsif scanner.scan(/\{::font((?:\s+\w+="[^"]+")+)\}(.*?)\{:\/font\}/)
336
+ elsif scanner.scan(%r{\{::font((?:\s+#{ATTR_RE_SRC})+)\}(.*?)\{:/font\}}o)
281
337
  segments << [:font, scanner[2], parse_font_attrs(scanner[1])]
282
338
  elsif scanner.scan(/\{::note\}(.*?)\{:\/note\}/)
283
339
  segments << [:note, scanner[1]]
284
340
  elsif scanner.scan(/<note>(.*?)<\/note>/)
285
341
  segments << [:note, scanner[1]]
286
- elsif scanner.scan(/\{::wait\/\}/) || scanner.scan(/<wait\s*\/>/)
342
+ elsif scanner.scan('{::wait/}') || scanner.scan(/<wait\s*\/>/)
287
343
  # 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, "&"]
344
+ elsif scanner.scan('&lt;')
345
+ segments << [:text, '<']
346
+ elsif scanner.scan('&gt;')
347
+ segments << [:text, '>']
348
+ elsif scanner.scan('&amp;')
349
+ segments << [:text, '&']
294
350
  elsif scanner.scan(/`([^`]+)`/)
295
351
  segments << [:code, scanner[1]]
296
352
  elsif scanner.scan(/\*\*(.+?)\*\*/)
@@ -304,7 +360,21 @@ module Przn
304
360
  end
305
361
  end
306
362
 
307
- segments
363
+ # Coalesce adjacent :text segments. The scanner has to bail to a
364
+ # single-character `.` when it sees `&` so the `&lt;` / `&gt;` /
365
+ # `&amp;` entity matches can run on the next iteration, which
366
+ # leaves a bare `&` as its own segment and fragments the
367
+ # surrounding text. Merging them back together means one OSC 66
368
+ # multicell sequence per typeset run — important for h1 titles
369
+ # under a proportional font, where Echoes pads each run
370
+ # independently and stray segments become visible gaps.
371
+ segments.each_with_object([]) do |seg, acc|
372
+ if seg[0] == :text && acc.last && acc.last[0] == :text
373
+ acc.last[1] = acc.last[1] + seg[1]
374
+ else
375
+ acc << seg
376
+ end
377
+ end
308
378
  end
309
379
  end
310
380
  end
@@ -24,7 +24,7 @@ module PrawnCJKLineWrap
24
24
  "[#{CJK_CHARS}]",
25
25
  "[#{ews}]+",
26
26
  "#{ehy}+[^#{ebc}]*",
27
- eshy.to_s,
27
+ eshy.to_s
28
28
  ]
29
29
 
30
30
  Regexp.new(patterns.join('|'))
@@ -32,13 +32,25 @@ module PrawnCJKLineWrap
32
32
  end
33
33
 
34
34
  module Przn
35
- class PdfExporter
35
+ # Legacy PDF export via Prawn — renders the deck directly into a vector
36
+ # PDF without touching the terminal. Diverges from what's on screen for
37
+ # any feature the live renderer adds (OSC 66 sized text, OSC 7772
38
+ # backgrounds, proportional fonts) but works headlessly.
39
+ def self.export_pdf_prawn(file, output, theme: nil)
40
+ markdown = File.read(file)
41
+ presentation = Parser.parse(markdown)
42
+ base_dir = File.dirname(File.expand_path(file))
43
+ PrawnPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
44
+ puts "Generated: #{output}"
45
+ end
46
+
47
+ class PrawnPdfExporter
36
48
  PAGE_WIDTH = 960
37
49
  PAGE_HEIGHT = 540
38
50
  DEFAULT_FONT_SIZE = 18
39
51
  DEFAULT_SCALE_TO_PT = {
40
52
  1 => 10, 2 => 18, 3 => 24, 4 => 32,
41
- 5 => 40, 6 => 48, 7 => 56,
53
+ 5 => 40, 6 => 48, 7 => 56
42
54
  }.freeze
43
55
 
44
56
  DEFAULT_SCALE = Renderer::DEFAULT_SCALE
@@ -48,7 +60,7 @@ module Przn
48
60
  'magenta' => 'FF79C6', 'cyan' => '8BE9FD', 'white' => 'F8F8F2',
49
61
  'bright_red' => 'FF6E6E', 'bright_green' => '69FF94', 'bright_yellow' => 'FFFFA5',
50
62
  'bright_blue' => 'D6ACFF', 'bright_magenta' => 'FF92DF', 'bright_cyan' => 'A4FFFF',
51
- 'bright_white' => 'FFFFFF',
63
+ 'bright_white' => 'FFFFFF'
52
64
  }.freeze
53
65
 
54
66
  def initialize(presentation, base_dir: '.', theme: nil)
@@ -56,7 +68,7 @@ module Przn
56
68
  @base_dir = base_dir
57
69
  @theme = theme || Theme.default
58
70
  @bg_color = @theme.background && @theme.background[:color]
59
- @fg_color = @theme.font[:color] || "000000"
71
+ @fg_color = @theme.font[:color] || '000000'
60
72
  @code_bg = @theme.colors[:code_bg]
61
73
  @dim_color = @theme.colors[:dim]
62
74
  @inline_code_color = @theme.colors[:inline_code]
@@ -73,7 +85,7 @@ module Przn
73
85
  -> { Dir.glob('/usr/share/fonts/**/NotoSansJP-Regular.ttf').first },
74
86
  -> { File.join(Dir.home, 'Library/Fonts/HackGen-Regular.ttf') },
75
87
  -> { '/Library/Fonts/Arial Unicode.ttf' },
76
- -> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' },
88
+ -> { '/System/Library/Fonts/Supplemental/Arial Unicode.ttf' }
77
89
  ].freeze
78
90
 
79
91
  FALLBACK_FONT_FAMILIES = %w[NotoSansCJK NotoSansJP HackGen].freeze
@@ -84,7 +96,7 @@ module Przn
84
96
 
85
97
  pdf = Prawn::Document.new(
86
98
  page_size: [PAGE_WIDTH, PAGE_HEIGHT],
87
- margin: 0,
99
+ margin: 0
88
100
  )
89
101
 
90
102
  register_fonts(pdf)
@@ -108,7 +120,7 @@ module Przn
108
120
  'CJK' => {
109
121
  normal: {file: font_path, font: 0},
110
122
  bold: {file: font_path.sub('W3', 'W6').then { |p| File.exist?(p) ? p : font_path }, font: 0},
111
- italic: {file: font_path, font: 0},
123
+ italic: {file: font_path, font: 0}
112
124
  }
113
125
  )
114
126
  else
@@ -117,7 +129,7 @@ module Przn
117
129
  'CJK' => {
118
130
  normal: font_path,
119
131
  bold: bold_path,
120
- italic: font_path,
132
+ italic: font_path
121
133
  }
122
134
  )
123
135
  end
@@ -144,10 +156,10 @@ module Przn
144
156
  pdf.font_families.update(family => {
145
157
  normal: path,
146
158
  bold: bold_path,
147
- italic: path,
159
+ italic: path
148
160
  })
149
161
  @registered_inline_fonts[family] = true
150
- rescue
162
+ rescue StandardError
151
163
  next
152
164
  end
153
165
  end
@@ -218,7 +230,7 @@ module Przn
218
230
  'Emoji' => { normal: emoji_path, bold: emoji_path, italic: emoji_path }
219
231
  )
220
232
  pdf.fallback_fonts = ['Emoji']
221
- rescue
233
+ rescue StandardError
222
234
  nil
223
235
  end
224
236
 
@@ -231,7 +243,7 @@ module Przn
231
243
  # Only use fonts with glyf outlines (not SBIX/COLR bitmap-only fonts)
232
244
  ttf = TTFunk::File.open(path)
233
245
  return path if ttf.directory.tables.key?('glyf')
234
- rescue
246
+ rescue StandardError
235
247
  next
236
248
  end
237
249
  nil
@@ -609,7 +621,7 @@ module Przn
609
621
  end
610
622
 
611
623
  def bullet
612
- @font_registered ? @theme.bullet[:text] : "-"
624
+ @font_registered ? @theme.bullet[:text] : '-'
613
625
  end
614
626
 
615
627
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Przn
4
+ # Drives the laptop-side view in extended-display mode. Reuses the existing
5
+ # Renderer to draw the current slide (notes still rendered dim-inline so the
6
+ # presenter sees them in context), then overlays a three-line strip at the
7
+ # bottom of the terminal: speaker notes summary, next-slide preview, and a
8
+ # footer with the slide counter + elapsed time.
9
+ class PresenterRenderer < Renderer
10
+ def initialize(terminal, presentation:, base_dir: '.', theme: nil)
11
+ super(terminal, base_dir: base_dir, theme: theme, mode: :presenter)
12
+ @presentation = presentation
13
+ @started_at = Time.now
14
+ end
15
+
16
+ def render(slide, current:, total:, started_at: nil)
17
+ super(slide, current: current, total: total, started_at: started_at)
18
+ @mutex.synchronize { draw_presenter_strip(current, total) }
19
+ end
20
+
21
+ private
22
+
23
+ def draw_presenter_strip(current, total)
24
+ w = @terminal.width
25
+ h = @terminal.height
26
+ slide = @presentation.slides[current]
27
+ notes_text = slide.notes.join(' / ')
28
+ next_title = current + 1 < total ? preview_title(@presentation.slides[current + 1]) : nil
29
+ elapsed = format_elapsed(Time.now - @started_at)
30
+ footer = "Slide #{current + 1} / #{total} #{elapsed}"
31
+
32
+ # When the theme opts into the rabbit/turtle indicator, the parent
33
+ # renderer has already drawn it on rows h-1 and h. Lift the strip up
34
+ # so it doesn't clobber the runner bar; the indicator itself replaces
35
+ # the strip's own footer line (slide #, elapsed time are visible there
36
+ # anyway via rabbit position and turtle position).
37
+ rabbit_mode = !@theme.rabbit.nil?
38
+ notes_row = rabbit_mode ? h - 3 : h - 2
39
+ next_row = rabbit_mode ? h - 2 : h - 1
40
+
41
+ @terminal.move_to(notes_row, 1)
42
+ @terminal.write "#{ANSI[:dim]}Notes: #{truncate_to_width(notes_text, [w - 8, 1].max)}#{ANSI[:reset]}"
43
+ @terminal.move_to(next_row, 1)
44
+ @terminal.write "#{ANSI[:dim]}Next: #{truncate_to_width(next_title || '—', [w - 8, 1].max)}#{ANSI[:reset]}"
45
+
46
+ unless rabbit_mode
47
+ @terminal.move_to(h, 1)
48
+ @terminal.write "#{ANSI[:dim]}#{truncate_to_width(footer, w)}#{ANSI[:reset]}"
49
+ end
50
+
51
+ @terminal.flush
52
+ end
53
+
54
+ def preview_title(slide)
55
+ return nil unless slide
56
+ slide.blocks.each do |b|
57
+ case b[:type]
58
+ when :heading, :paragraph then return strip_markup(b[:content].to_s)
59
+ end
60
+ end
61
+ nil
62
+ end
63
+
64
+ def format_elapsed(seconds)
65
+ h = (seconds / 3600).to_i
66
+ m = ((seconds % 3600) / 60).to_i
67
+ s = (seconds % 60).to_i
68
+ format('%02d:%02d:%02d', h, m, s)
69
+ end
70
+ end
71
+ end