hadar 0.1.0 → 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: 4faa68921b499534d3fd9985fa24680fa9a63bd95664bb808e7f1d072527ea75
4
- data.tar.gz: e9c02868a90d8e5f479cef86f1b1a76d826c34509c71a2c6ef9c1dda76b10c2f
3
+ metadata.gz: d7b4c769b40f5836617885858a50bc58e93ef8072b97430520ec2315ed81edbb
4
+ data.tar.gz: e8379cf1cc2f8039429d0fba6a650d8d7f059801e30c0df69864bf5ed94be8b0
5
5
  SHA512:
6
- metadata.gz: 6f3a0b49bcd1a6a8d436421cc05965bcf6e6ca71910810d2e0c05a49b3e97949dc49248e7352b184b251ca3f5d654a1a844683791238e4c6684abb03abb9daeb
7
- data.tar.gz: c79aaf224353506b80a6a7d0162fcc935fa9201d39a55e877495c1807b47980d2d9788ecff65ae04810563aa828f1a10c0cdca138e0b1fd01ad13bda5c0c3364
6
+ metadata.gz: b44313439e6fcc8a411c8b7fbc379a2050c843b8758dfa42352c4f007f5334141b45ced44d8d139a14dc030332d826744feaab621678e7e671760710f2ee1edd
7
+ data.tar.gz: 46f5d21160a6b804ba95067906abcc9f0277bf263a5aa51281ebe93ae0c1712982d8ebf0f328a3ca0dc2670a3f0a7573d67509b75f1787f6f477a64de1714d61
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-09-24
4
+
5
+ - Add APNG export for slide decks.
6
+
3
7
  ## 0.1.0 - 2026-09-23
4
8
 
5
9
  - Initial release.
data/README.md CHANGED
@@ -23,8 +23,8 @@ Missing, unreadable, and unsupported images fail preview construction with a
23
23
  saving continues to write only the Markdown source. Its window host polls for
24
24
  external Markdown edits and can show a next-slide, notes, and elapsed-time
25
25
  presenter view. The host supports slide navigation, fullscreen, a fuzzy command
26
- palette, and PNG-sequence export. When a secondary display is available, the
27
- owned presenter window opens there fullscreen.
26
+ palette, PNG-sequence, and APNG export. When a secondary display is available,
27
+ the owned presenter window opens there fullscreen.
28
28
 
29
29
  ## Installation
30
30
 
@@ -160,6 +160,13 @@ Zaniah's headless renderer. It refuses to overwrite existing frames:
160
160
  app.export_png_sequence("slides-png", width: 1280, height: 720)
161
161
  ```
162
162
 
163
+ Animated PNG export holds each slide for three seconds by default, loops forever,
164
+ and refuses to replace an existing target unless `overwrite: true` is passed:
165
+
166
+ ```ruby
167
+ app.export_apng("slides.apng", width: 1280, height: 720, duration_ms: 2500)
168
+ ```
169
+
163
170
  Speaker notes can be written as a one-line or multiline HTML comment. Their
164
171
  Markdown remains in the source unchanged and does not appear in slide slots or
165
172
  the preview tree:
@@ -219,15 +226,15 @@ directory rather than copying the file.
219
226
  `Zaniah::UniformList`; `build(width:, height:)` returns the Zaniah element for
220
227
  embedding in an application layout. Its `select(index)` method updates the
221
228
  selection and invokes the optional `on_select` callback. Wezen is not involved
222
- in live thumbnails; it encodes raster frames for export workflows.
229
+ in live thumbnails; it encodes rendered slide frames as APNG.
223
230
 
224
231
  ## Development
225
232
 
226
233
  Run the specs with `bundle exec rake`. Check the 100-slide virtual-list
227
234
  layout/scene-build budget with `BUDGET=1 bundle exec ruby bench/slide_list.rb`;
228
235
  the headless benchmark skips software pixel rasterization. Hadar depends on
229
- Beid, Antares, Kochab, Okab, Spica, Xamidimura, and Zaniah; its declarative `Describe`,
230
- `UniformList`, and input keymap APIs are reused directly.
236
+ Beid, Antares, Kochab, Okab, Spica, Wezen, Xamidimura, and Zaniah; its
237
+ declarative `Describe`, `UniformList`, and input keymap APIs are reused directly.
231
238
 
232
239
  ## License
233
240
 
@@ -115,7 +115,12 @@ module Hadar
115
115
  def command_palette = @command_palette
116
116
 
117
117
  def export_png_sequence(directory, width: 1280, height: 720)
118
- PNGSequence.write(deck, directory, renderer: renderer, width: width, height: height)
118
+ Export::PNGSequence.write(deck, directory, renderer: renderer, width: width, height: height)
119
+ end
120
+
121
+ def export_apng(path, width: 1280, height: 720, duration_ms: Export::APNG::DEFAULT_DURATION_MS, loop: 0, overwrite: false)
122
+ Export::APNG.write(deck, path, renderer: renderer, width: width, height: height,
123
+ duration_ms: duration_ms, loop: loop, overwrite: overwrite)
119
124
  end
120
125
 
121
126
  def save(path = nil, overwrite: false)
@@ -393,7 +398,8 @@ module Hadar
393
398
  "Toggle presentation" => ->(*) { presenter.started? ? stop_presentation : start_presentation },
394
399
  "Toggle fullscreen" => ->(*) { toggle_fullscreen(window: :main) },
395
400
  "Save" => ->(*) { save },
396
- "Export PNG sequence…" => ->(*) { prompt_png_sequence_directory }
401
+ "Export PNG sequence…" => ->(*) { prompt_png_sequence_directory },
402
+ "Export APNG…" => ->(*) { prompt_apng_path }
397
403
  }
398
404
  end
399
405
 
@@ -406,6 +412,18 @@ module Hadar
406
412
  export_png_sequence(directory) if directory && !directory.empty?
407
413
  end
408
414
 
415
+ def prompt_apng_path
416
+ unless @main_window.respond_to?(:prompt_for_paths)
417
+ raise Error, "this window backend cannot choose a file; call export_apng(path)"
418
+ end
419
+
420
+ path = @main_window.prompt_for_paths(save: true).first
421
+ return unless path && !path.empty?
422
+
423
+ path = "#{path}.apng" if File.extname(path).empty?
424
+ export_apng(path, overwrite: true)
425
+ end
426
+
409
427
  def slot_editor_for_selected_slide
410
428
  return [nil, nil] unless selected_index
411
429
  unless selected_slot_name
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module Hadar
6
+ module Export
7
+ class APNG
8
+ MAX_PIXELS = 32_000_000
9
+ DEFAULT_DURATION_MS = 3_000
10
+
11
+ def self.render(deck, renderer: Renderer.new, width: 1280, height: 720,
12
+ duration_ms: DEFAULT_DURATION_MS, loop: 0)
13
+ raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
14
+ raise Error, "cannot export an empty deck" if deck.empty?
15
+ raise TypeError, "renderer must respond to build" unless renderer.respond_to?(:build)
16
+ unless [width, height].all? { |dimension| dimension.is_a?(Integer) && dimension.positive? } && width * height <= MAX_PIXELS
17
+ raise ArgumentError, "APNG dimensions must be positive integers totaling at most #{MAX_PIXELS} pixels"
18
+ end
19
+ raise ArgumentError, "duration_ms must be a positive Integer" unless duration_ms.is_a?(Integer) && duration_ms.positive?
20
+ if duration_ms / duration_ms.gcd(1_000) > 65_535
21
+ raise ArgumentError, "duration_ms exceeds the APNG frame-delay limit"
22
+ end
23
+ raise ArgumentError, "loop must be a non-negative Integer" unless loop.is_a?(Integer) && loop >= 0
24
+
25
+ animation = Wezen::Animation.new(width: width, height: height, loop: loop)
26
+ window = Zaniah::Platform.open_window(backend: :headless, width: width, height: height)
27
+ window.text_system = Zaniah::TextSystem::Renderer.new
28
+ deck.slides.each do |slide|
29
+ window.render(renderer.build(slide), clear: slide.theme.colors.fetch("background"))
30
+ animation.add(window.device.pixels, delay_ms: duration_ms)
31
+ end
32
+ Wezen::APNG.encode(animation)
33
+ ensure
34
+ window&.close
35
+ end
36
+
37
+ def self.write(deck, path, overwrite: false, **options)
38
+ raise TypeError, "path must be a String" unless path.is_a?(String)
39
+ raise ArgumentError, "path must not be empty or contain NUL" if path.empty? || path.include?("\0")
40
+ raise TypeError, "overwrite must be true or false" unless overwrite == true || overwrite == false
41
+
42
+ target = File.expand_path(path)
43
+ existing = begin
44
+ File.lstat(target)
45
+ rescue Errno::ENOENT
46
+ nil
47
+ end
48
+ raise Error, "refusing to write through a symbolic link" if existing&.symlink?
49
+ raise Error, "APNG target must be a regular file" if existing && !existing.file?
50
+ raise Error, "APNG target already exists; pass overwrite: true to replace it" if existing && !overwrite
51
+
52
+ bytes = render(deck, **options)
53
+ mode = existing ? existing.mode & 0o777 : (0o666 & ~File.umask)
54
+ Tempfile.create([".#{File.basename(target)}.", ".tmp"], File.dirname(target)) do |file|
55
+ file.binmode
56
+ file.write(bytes)
57
+ file.flush
58
+ file.chmod(mode)
59
+ file.fsync
60
+ file.close
61
+ overwrite ? File.rename(file.path, target) : File.link(file.path, target)
62
+ end
63
+ path
64
+ rescue SystemCallError => error
65
+ raise Error, "cannot write APNG: #{error.message}"
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/hadar/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hadar
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/hadar.rb CHANGED
@@ -7,6 +7,7 @@ require "antares"
7
7
  require "kochab"
8
8
  require "okab"
9
9
  require "xamidimura"
10
+ require "wezen"
10
11
  require "zaniah"
11
12
  require "zaniah/ui"
12
13
 
@@ -29,3 +30,4 @@ end
29
30
 
30
31
  require_relative "hadar/export/pdf"
31
32
  require_relative "hadar/export/png_sequence"
33
+ require_relative "hadar/export/apng"
data/sig/hadar.rbs CHANGED
@@ -131,6 +131,7 @@ module Hadar
131
131
  def main_view: (width: Numeric, height: Numeric) -> Zaniah::Element
132
132
  def command_palette: () -> CommandPalette?
133
133
  def export_png_sequence: (String directory, ?width: Integer, ?height: Integer) -> Array[String]
134
+ def export_apng: (String path, ?width: Integer, ?height: Integer, ?duration_ms: Integer, ?loop: Integer, ?overwrite: bool) -> String
134
135
  def save: (?String path, ?overwrite: bool) -> Deck
135
136
  def selected_slot: () -> Slot?
136
137
  def select_slot: (String | Symbol name) -> Slot
@@ -201,6 +202,16 @@ module Hadar
201
202
  end
202
203
 
203
204
  module Export
205
+ class APNG
206
+ MAX_PIXELS: Integer
207
+ DEFAULT_DURATION_MS: Integer
208
+
209
+ def self.render: (Deck deck, ?renderer: Renderer, ?width: Integer, ?height: Integer,
210
+ ?duration_ms: Integer, ?loop: Integer) -> String
211
+ def self.write: (Deck deck, String path, ?overwrite: bool, ?renderer: Renderer, ?width: Integer,
212
+ ?height: Integer, ?duration_ms: Integer, ?loop: Integer) -> String
213
+ end
214
+
204
215
  class PNGSequence
205
216
  MAX_PIXELS: Integer
206
217
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hadar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: 0.1.0
96
+ - !ruby/object:Gem::Dependency
97
+ name: wezen
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: 0.1.1
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: 0.1.1
96
110
  - !ruby/object:Gem::Dependency
97
111
  name: zaniah
98
112
  requirement: !ruby/object:Gem::Requirement
@@ -127,6 +141,7 @@ files:
127
141
  - lib/hadar/command_palette.rb
128
142
  - lib/hadar/deck.rb
129
143
  - lib/hadar/deck_watcher.rb
144
+ - lib/hadar/export/apng.rb
130
145
  - lib/hadar/export/pdf.rb
131
146
  - lib/hadar/export/png_sequence.rb
132
147
  - lib/hadar/layout.rb
@@ -162,7 +177,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
162
177
  - !ruby/object:Gem::Version
163
178
  version: '0'
164
179
  requirements: []
165
- rubygems_version: 4.0.16
180
+ rubygems_version: 3.6.9
166
181
  specification_version: 4
167
182
  summary: Markdown-backed presentation editor and preview
168
183
  test_files: []