przn 0.4.0 → 0.6.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.
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'tmpdir'
4
4
  require 'fileutils'
5
+ require 'digest'
6
+ require 'zlib'
5
7
 
6
8
  module Przn
7
9
  # Default PDF export: drives the live renderer, asks the terminal to save
@@ -10,11 +12,11 @@ module Przn
10
12
  # Requires Echoes (or any terminal that implements the same capture
11
13
  # command); use `export_pdf_prawn` instead for environments where that's
12
14
  # not possible (CI, headless).
13
- def self.export_pdf(file, output, theme: nil)
15
+ def self.export_pdf(file, output, theme: nil, quality: nil)
14
16
  markdown = File.read(file)
15
17
  presentation = Parser.parse(markdown)
16
18
  base_dir = File.dirname(File.expand_path(file))
17
- ScreenshotPdfExporter.new(presentation, base_dir: base_dir, theme: theme).export(output)
19
+ ScreenshotPdfExporter.new(presentation, base_dir: base_dir, theme: theme, quality: quality).export(output)
18
20
  puts "Generated: #{output}"
19
21
  end
20
22
 
@@ -43,12 +45,27 @@ module Przn
43
45
  POLL_INTERVAL = 0.05 # seconds between file-existence checks
44
46
  CAPTURE_TIMEOUT = 10 # seconds per slide before giving up
45
47
 
46
- def initialize(presentation, base_dir: '.', theme: nil, terminal: nil)
48
+ # Maps the user-facing `--pdf-quality` flag onto Ghostscript's
49
+ # `-dPDFSETTINGS` preset names. `:lossless` skips the gs pass
50
+ # entirely (lossless HexaPDF layers only — useful when image
51
+ # fidelity matters more than file size). `nil` means "use the
52
+ # default", which is `:medium`.
53
+ QUALITY_PRESETS = {
54
+ 'lossless' => :lossless,
55
+ 'low' => '/screen',
56
+ 'medium' => '/ebook',
57
+ 'high' => '/printer',
58
+ 'max' => '/prepress',
59
+ }.freeze
60
+ DEFAULT_QUALITY = 'medium'
61
+
62
+ def initialize(presentation, base_dir: '.', theme: nil, terminal: nil, quality: nil)
47
63
  @presentation = presentation
48
64
  @base_dir = base_dir
49
65
  @theme = theme || Theme.default
50
66
  @terminal = terminal || Terminal.new
51
- @renderer = Renderer.new(@terminal, base_dir: base_dir, theme: theme)
67
+ @renderer = Renderer.new(@terminal, base_dir: base_dir, theme: theme, export_mode: true, presentation: presentation)
68
+ @quality = resolve_quality(quality)
52
69
  end
53
70
 
54
71
  def export(output_path)
@@ -68,7 +85,10 @@ module Przn
68
85
  @terminal.hide_cursor
69
86
  @presentation.slides.each_with_index do |slide, i|
70
87
  pdf_path = File.join(dir, format('slide-%04d.pdf', i))
71
- @renderer.render(slide, current: i, total: @presentation.total)
88
+ # Render at the final reveal — a PDF is a static artifact, so
89
+ # every `<wait/>` step should already be visible on the page.
90
+ final_step = @renderer.step_count(slide) - 1
91
+ @renderer.render(slide, current: i, total: @presentation.total, step: final_step)
72
92
  request_capture(pdf_path)
73
93
  wait_for_capture(pdf_path)
74
94
  paths << pdf_path
@@ -76,6 +96,7 @@ module Przn
76
96
  paths
77
97
  ensure
78
98
  @terminal.write "#{OSC};bg-clear#{BEL}"
99
+ @terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
79
100
  @terminal.show_cursor
80
101
  @terminal.leave_alt_screen
81
102
  @terminal.flush
@@ -109,7 +130,154 @@ module Przn
109
130
  output.pages << output.import(page)
110
131
  end
111
132
  end
133
+
134
+ # Three lossless passes before write. Page surfaces stay
135
+ # byte-identical; only the encoding of the underlying objects
136
+ # shrinks.
137
+ # 1. Dedup image XObjects — repeated logos / shared backgrounds
138
+ # become a single shared object.
139
+ # 2. Re-Flate uncompressed image streams.
140
+ # 3. HexaPDF's task(:optimize) collapses metadata into object
141
+ # streams and rewrites xref as binary streams.
142
+ dedup_image_xobjects(output)
143
+ reflate_uncompressed_images(output)
144
+ output.task(:optimize,
145
+ compact: true,
146
+ object_streams: :generate,
147
+ xref_streams: :generate)
148
+
112
149
  output.write(output_path)
150
+
151
+ # Optional lossy post-process. Echoes' OSC 7772 capture embeds
152
+ # `<img>` and shape rasters at their NATIVE source resolution
153
+ # (a 24-megapixel phone photo embedded as a slide thumbnail
154
+ # stays at 24MP in the captured PDF). Re-Flating that pixel
155
+ # data, as the three lossless passes above do, doesn't help —
156
+ # photographic content compresses 10-50× better as JPEG.
157
+ #
158
+ # Ghostscript's `-dPDFSETTINGS=/ebook` downsamples raster
159
+ # images to 150 DPI and re-encodes them as JPEG. We shell out
160
+ # if `gs` is on PATH; if not, the file is left as-is with a
161
+ # friendly hint on stderr. Lossy in the strict sense, but the
162
+ # `/ebook` preset is widely considered visually indistinguishable
163
+ # at presentation viewing distances.
164
+ shrink_with_ghostscript(output_path)
165
+ end
166
+
167
+ # Resolve the Ghostscript binary path the recompression pass will
168
+ # use. Three layers, in order:
169
+ # 1. `PRZN_GS=` (explicitly empty) — opt out, no warning. Used
170
+ # by tests and by anyone who wants the lossless-only pipeline.
171
+ # 2. `PRZN_GS=<path>` — explicit override (custom build, brew
172
+ # keg-only path, etc).
173
+ # 3. Default: probe PATH for `gs`. Missing binary → returns
174
+ # :missing so the caller can print the install hint once.
175
+ def gs_path
176
+ env = ENV['PRZN_GS']
177
+ return :disabled if env == ''
178
+ bin = env && !env.empty? ? env : 'gs'
179
+ found = `which #{bin} 2>/dev/null`.chomp
180
+ found.empty? ? :missing : found
181
+ end
182
+
183
+ def shrink_with_ghostscript(path)
184
+ return if @quality == :lossless
185
+ gs = gs_path
186
+ case gs
187
+ when :disabled then return
188
+ when :missing
189
+ warn 'Hint: install Ghostscript (`brew install ghostscript`) for a 5-20× ' \
190
+ 'size reduction on image-heavy decks. Skipping recompression. ' \
191
+ 'Pass --pdf-quality lossless to silence this hint.'
192
+ return
193
+ end
194
+
195
+ tmp = "#{path}.gs.tmp"
196
+ ok = system(gs,
197
+ '-q',
198
+ '-dQUIET', '-dNOPAUSE', '-dBATCH', '-dSAFER',
199
+ '-sDEVICE=pdfwrite',
200
+ "-dPDFSETTINGS=#{@quality}",
201
+ '-dCompatibilityLevel=1.5',
202
+ "-sOutputFile=#{tmp}",
203
+ path,
204
+ out: File::NULL, err: File::NULL)
205
+ if ok && File.exist?(tmp) && File.size(tmp).positive? && File.size(tmp) < File.size(path)
206
+ FileUtils.mv(tmp, path)
207
+ else
208
+ FileUtils.rm_f(tmp)
209
+ end
210
+ end
211
+
212
+ def resolve_quality(raw)
213
+ key = (raw || DEFAULT_QUALITY).to_s.downcase
214
+ preset = QUALITY_PRESETS[key]
215
+ unless preset
216
+ raise ArgumentError,
217
+ "unknown --pdf-quality #{raw.inspect}; expected one of " \
218
+ "#{QUALITY_PRESETS.keys.join(', ')}"
219
+ end
220
+ preset
221
+ end
222
+
223
+ # Find image XObjects whose stream bytes are byte-for-byte identical
224
+ # and collapse them into a single shared object — the case being:
225
+ # a slide background or repeated logo gets captured into N
226
+ # per-slide PDFs independently, then HexaPDF imports each one as
227
+ # its own object. Hashing the stream bytes is enough to recognise
228
+ # them; the later pages' XObject resource entries are pointed at
229
+ # the first occurrence's indirect reference and the duplicate
230
+ # objects are swept by the subsequent compact-optimize pass.
231
+ def dedup_image_xobjects(doc)
232
+ by_hash = {}
233
+ doc.pages.each do |page|
234
+ resources = page[:Resources]
235
+ next unless resources && resources[:XObject]
236
+ xobjects = resources[:XObject]
237
+ xobjects.each do |name, xobj|
238
+ next unless image_xobject?(xobj)
239
+ h = Digest::SHA256.hexdigest(xobj.stream.to_s)
240
+ if (canonical = by_hash[h])
241
+ xobjects[name] = canonical unless canonical.equal?(xobj)
242
+ else
243
+ by_hash[h] = xobj
244
+ end
245
+ end
246
+ end
247
+ # Sweep the now-orphaned duplicates.
248
+ doc.task(:optimize, compact: true)
249
+ end
250
+
251
+ # Apply best-strength Flate to image streams that arrived
252
+ # uncompressed (or compressed by a filter Flate can outperform).
253
+ # JPEG (`DCTDecode`) and JPEG-2000 (`JPXDecode`) streams are
254
+ # already entropy-coded — re-encoding them losslessly cannot
255
+ # help and lossy re-encoding would need an external image
256
+ # library. Streams that grow under Flate (rare; happens when
257
+ # the input is already near maximum entropy) are left alone.
258
+ def reflate_uncompressed_images(doc)
259
+ doc.each do |obj|
260
+ next unless image_xobject?(obj)
261
+ filter = Array(obj[:Filter])
262
+ next if filter.include?(:FlateDecode) ||
263
+ filter.include?(:DCTDecode) ||
264
+ filter.include?(:JPXDecode)
265
+ raw = obj.stream.to_s
266
+ next if raw.empty?
267
+ deflated = Zlib::Deflate.deflate(raw, Zlib::BEST_COMPRESSION)
268
+ next if deflated.bytesize >= raw.bytesize
269
+ obj.stream = deflated
270
+ obj[:Filter] = :FlateDecode
271
+ end
272
+ end
273
+
274
+ def image_xobject?(obj)
275
+ # `doc.each` walks every indirect object, including HexaPDF::PDFArray
276
+ # whose `[]` raises on a Symbol key. Gate on the underlying value
277
+ # being a dict (Hash) before any keyed lookup so arrays and plain
278
+ # scalars are silently skipped.
279
+ return false unless obj.respond_to?(:value) && obj.value.is_a?(Hash)
280
+ obj[:Type] == :XObject && obj[:Subtype] == :Image
113
281
  end
114
282
  end
115
283
  end
data/lib/przn/slide.rb CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Przn
4
4
  class Slide
5
- attr_reader :blocks
5
+ attr_reader :blocks, :layout, :attrs
6
6
 
7
- def initialize(blocks)
7
+ def initialize(blocks, layout: nil, attrs: {})
8
8
  @blocks = blocks.freeze
9
+ @layout = layout
10
+ @attrs = attrs.freeze
9
11
  end
10
12
 
11
13
  # Aggregate every `{::note}` / `<note>` segment in the slide's text-bearing
data/lib/przn/terminal.rb CHANGED
@@ -5,6 +5,10 @@ require 'io/console'
5
5
  module Przn
6
6
  class Terminal
7
7
  MOUSE_OFF = "\e[?1006l\e[?1003l\e[?1002l\e[?1000l"
8
+ # Button-event tracking (1002: presses, releases, and motion only
9
+ # while a button is held — for dragging the 🐇) in SGR encoding
10
+ # (1006). Bare hovering is never reported.
11
+ MOUSE_ON = "\e[?1002h\e[?1006h"
8
12
 
9
13
  def initialize(input: $stdin, output: $stdout)
10
14
  @in = input
@@ -50,6 +54,12 @@ module Przn
50
54
  write MOUSE_OFF
51
55
  end
52
56
 
57
+ # Report clicks so the controller can page with the mouse. Cleared
58
+ # by `leave_alt_screen`'s MOUSE_OFF.
59
+ def enable_mouse
60
+ write MOUSE_ON
61
+ end
62
+
53
63
  def leave_alt_screen
54
64
  write MOUSE_OFF
55
65
  write "\e[?1049l"
data/lib/przn/theme.rb CHANGED
@@ -6,7 +6,26 @@ module Przn
6
6
  class Theme
7
7
  DEFAULT_PATH = File.expand_path('../../../default_theme.yml', __FILE__)
8
8
 
9
- attr_reader :colors, :font, :bullet, :background, :title, :rabbit
9
+ # A rectangular region inside a slide. `x` / `y` are stored as the
10
+ # raw strings from the YAML and resolved at render time against
11
+ # the current terminal width / height by the same helper that
12
+ # handles `<at x y>` and `<img x y>` coords — so `x` accepts the
13
+ # full vocabulary (cell, `%`, `c`, `px`, and the keyword forms
14
+ # `left` / `center` / `right`). When `x` is a keyword, the slot's
15
+ # effective starting column is computed against `width` AND the
16
+ # alignment used for blocks routed into the slot is derived from
17
+ # the same keyword (`x: center` → block align: center, slot
18
+ # positioned so its centre lands on the slide's centre).
19
+ #
20
+ # `size` / `family` / `color` are per-slot text-style overrides:
21
+ # `size` sets the base scale for h1 and paragraphs (accepts the
22
+ # same names as inline `<size>`); `family` and `color` cascade
23
+ # through render_segments_scaled to every text run in the slot.
24
+ # Inline tags (`<size>`, `<font>`, `<color>`) still win
25
+ # per-segment.
26
+ Slot = Struct.new(:name, :x, :y, :width, :height, :size, :family, :color)
27
+
28
+ attr_reader :colors, :font, :bullet, :background, :title, :counter, :code, :layouts, :transition, :fragment
10
29
 
11
30
  def self.load(path)
12
31
  raise ArgumentError, "Theme file not found: #{path}" unless File.exist?(path)
@@ -19,11 +38,30 @@ module Przn
19
38
  bullet: defaults[:bullet].merge(overrides[:bullet] || {}),
20
39
  background: defaults[:background].merge(overrides[:background] || {}),
21
40
  title: defaults[:title].merge(overrides[:title] || {}),
22
- # `rabbit` is opt-in: absent → nil → renderer uses the plain N/M footer.
23
- # Present (even as an empty block) → hash → renderer uses the runner bar.
24
- rabbit: defaults[:rabbit] || overrides[:rabbit] ?
25
- (defaults[:rabbit] || {}).merge(overrides[:rabbit] || {}) :
26
- nil
41
+ # `code` styles fenced code blocks (family / size / color / bg).
42
+ # Unset keys fall through to: family → font.family, size →
43
+ # body_scale, color → terminal default fg, bg → the legacy dim
44
+ # gray ANSI 236.
45
+ code: defaults[:code].merge(overrides[:code] || {}),
46
+ # `counter` is always a hash; empty by default. `counter.duration`
47
+ # opts into the 🐇 runner bar; without it the renderer shows the
48
+ # plain " N / M " footer. `counter.color` styles both modes.
49
+ counter: defaults[:counter].merge(overrides[:counter] || {}),
50
+ # `transition` carries the slide-fade `duration:` (ms) used when
51
+ # a slide opts in via `# Title {transition=fade}`. Empty by
52
+ # default — the renderer's fallback duration kicks in.
53
+ transition: defaults[:transition].merge(overrides[:transition] || {}),
54
+ # `fragment` carries the per-fragment animation `duration:` (ms)
55
+ # used by `<at fragment="fade-up">` and friends. Empty hash if the
56
+ # user opts out entirely; the renderer falls back to its hardcoded
57
+ # 300ms when the key is missing.
58
+ fragment: defaults[:fragment].merge(overrides[:fragment] || {}),
59
+ # Layouts: per-name replacement (no deep merge across slot lists —
60
+ # an override redefines that layout's slot list end-to-end). The
61
+ # name `default` is special: when a slide has no `{layout=...}`
62
+ # IAL the renderer looks it up here. Themes that don't define
63
+ # `layouts.default` keep the legacy flow behavior.
64
+ layouts: defaults[:layouts].merge(overrides[:layouts] || {})
27
65
  }
28
66
  new(merged)
29
67
  end
@@ -71,20 +109,57 @@ module Przn
71
109
  bullet: (data[:bullet] || {}).compact,
72
110
  background: (data[:background] || {}).compact,
73
111
  title: (data[:title] || {}).compact,
74
- # nil when the `rabbit:` key isn't in the YAML at all (opt-in feature);
75
- # empty hash when it's present but childless.
76
- rabbit: data.key?(:rabbit) ? (data[:rabbit] || {}).compact : nil
112
+ code: (data[:code] || {}).compact,
113
+ counter: (data[:counter] || {}).compact,
114
+ transition: (data[:transition] || {}).compact,
115
+ fragment: (data[:fragment] || {}).compact,
116
+ layouts: parse_layouts(data[:layouts])
77
117
  }
78
118
  end
79
119
  private_class_method :load_yaml
80
120
 
121
+ # Turn the raw `layouts:` YAML into a `{name => [Slot, ...]}` map.
122
+ # Each layout's value is just the ordered slot list (no enclosing
123
+ # `slots:` key in v1 since there's no other per-layout metadata
124
+ # — promoting to a hash later is a one-line shape change).
125
+ def self.parse_layouts(raw)
126
+ return {} unless raw.is_a?(Hash)
127
+
128
+ raw.each_with_object({}) do |(name, slot_list), out|
129
+ next unless slot_list.is_a?(Array)
130
+ out[name.to_s] = slot_list.map { |s|
131
+ h = s.transform_keys(&:to_sym)
132
+ # Back-compat: a legacy `align: center` (etc.) on a slot
133
+ # used to be a separate knob from `x:`. The two were always
134
+ # used together in practice — every built-in layout that
135
+ # set `align:` paired it with an `x:` whose only job was
136
+ # to leave room for the centred content. The new shape
137
+ # collapses them into `x: <keyword>`, so an old `align:`
138
+ # value overrides any numeric `x:` written alongside it.
139
+ x = h[:align] ? h[:align].to_s : h[:x].to_s
140
+ Slot.new(
141
+ h[:name].to_s,
142
+ x, h[:y].to_s, h[:width].to_s, h[:height].to_s,
143
+ h[:size]&.to_s,
144
+ h[:family]&.to_s,
145
+ h[:color]&.to_s
146
+ )
147
+ }
148
+ end
149
+ end
150
+ private_class_method :parse_layouts
151
+
81
152
  def initialize(config)
82
153
  @colors = config[:colors]
83
154
  @font = config[:font]
84
155
  @bullet = config[:bullet]
85
156
  @background = config[:background]
86
157
  @title = config[:title]
87
- @rabbit = config[:rabbit]
158
+ @code = config[:code] || {}
159
+ @counter = config[:counter] || {}
160
+ @transition = config[:transition] || {}
161
+ @fragment = config[:fragment] || {}
162
+ @layouts = config[:layouts] || {}
88
163
  end
89
164
  end
90
165
  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.4.0'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/przn.rb CHANGED
@@ -7,6 +7,8 @@ require_relative 'przn/version'
7
7
  require_relative 'przn/kitty_text'
8
8
  require_relative 'przn/image_util'
9
9
  require_relative 'przn/parser'
10
+ require_relative 'przn/code_highlighter'
11
+ require_relative 'przn/mermaid_renderer'
10
12
  require_relative 'przn/slide'
11
13
  require_relative 'przn/presentation'
12
14
  require_relative 'przn/terminal'
@@ -14,6 +16,7 @@ require_relative 'przn/renderer'
14
16
  require_relative 'przn/presenter_renderer'
15
17
  require_relative 'przn/audience_link'
16
18
  require_relative 'przn/echoes_client'
19
+ require_relative 'przn/file_watcher'
17
20
  require_relative 'przn/controller'
18
21
  require_relative 'przn/screenshot_pdf_exporter'
19
22
  require_relative 'przn/theme'
@@ -21,14 +24,15 @@ require_relative 'przn/theme'
21
24
  module Przn
22
25
  class Error < StandardError; end
23
26
 
24
- def self.start(file, theme: nil, start_at: nil)
27
+ def self.start(file, theme: nil, theme_path: nil, start_at: nil, watch: true)
25
28
  markdown = File.read(file)
26
29
  presentation = Parser.parse(markdown)
27
30
  presentation.go_to(start_at - 1) if start_at
28
31
  terminal = Terminal.new
29
32
  base_dir = File.dirname(File.expand_path(file))
30
- renderer = Renderer.new(terminal, base_dir: base_dir, theme: theme)
31
- Controller.new(presentation, terminal, renderer)
33
+ renderer = Renderer.new(terminal, base_dir: base_dir, theme: theme, presentation: presentation)
34
+ Controller.new(presentation, terminal, renderer,
35
+ source_file: file, theme_path: theme_path, watch: watch)
32
36
  end
33
37
 
34
38
  # Audience-side entry: opens the file, listens on `socket`, and renders
@@ -39,7 +43,7 @@ module Przn
39
43
  presentation = Parser.parse(markdown)
40
44
  terminal = Terminal.new
41
45
  base_dir = File.dirname(File.expand_path(file))
42
- renderer = Renderer.new(terminal, base_dir: base_dir, theme: theme, mode: :audience)
46
+ renderer = Renderer.new(terminal, base_dir: base_dir, theme: theme, mode: :audience, presentation: presentation)
43
47
 
44
48
  terminal.enter_alt_screen
45
49
  terminal.hide_cursor
@@ -59,6 +63,7 @@ module Przn
59
63
  end
60
64
  ensure
61
65
  terminal.write "\e]7772;bg-clear\a"
66
+ terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
62
67
  terminal.show_cursor
63
68
  terminal.leave_alt_screen
64
69
  end
@@ -97,6 +102,7 @@ module Przn
97
102
  terminal = Terminal.new
98
103
  base_dir = File.dirname(File.expand_path(file))
99
104
  renderer = PresenterRenderer.new(terminal, presentation: presentation, base_dir: base_dir, theme: theme)
100
- Controller.new(presentation, terminal, renderer, audience_link: link)
105
+ Controller.new(presentation, terminal, renderer, audience_link: link,
106
+ source_file: file, theme_path: theme_path)
101
107
  end
102
108
  end
@@ -0,0 +1,15 @@
1
+ The Ruby Logo is Copyright (c) 2006, Yukihiro Matsumoto. It is licensed
2
+ under the terms of the Creative Commons Attribution-ShareAlike 2.5
3
+ agreement:
4
+
5
+ http://creativecommons.org/licenses/by-sa/2.5/
6
+
7
+ We ask that you do not use the logo to represent something other than
8
+ the Ruby Programming language. If you have questions about the logo,
9
+ please join the VIT-Discuss mailing list and ask your questions there:
10
+
11
+ http://rubyforge.org/mailman/listinfo/vit-discuss/
12
+
13
+ Thank you,
14
+
15
+ The Ruby Visual Identity Team
data/sample/ruby.jpg ADDED
Binary file
data/sample/ruby.png ADDED
Binary file