hadar 0.1.0 → 0.3.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: b0bc8dd031f42ad45785dbcf360c9f075e8d71758be0a5e2df0e158abc77ef8d
4
+ data.tar.gz: 0702a59cfdcfd8987311f08e178a9390ec31b3442de35b4414fb1b3dc5c301d3
5
5
  SHA512:
6
- metadata.gz: 6f3a0b49bcd1a6a8d436421cc05965bcf6e6ca71910810d2e0c05a49b3e97949dc49248e7352b184b251ca3f5d654a1a844683791238e4c6684abb03abb9daeb
7
- data.tar.gz: c79aaf224353506b80a6a7d0162fcc935fa9201d39a55e877495c1807b47980d2d9788ecff65ae04810563aa828f1a10c0cdca138e0b1fd01ad13bda5c0c3364
6
+ metadata.gz: a234432c82f1b580bc19b3f4c29211a98556f277f28f5cdbd9da7c1f08172b9fcddea6f64c9a9dba1bcbe3204e071391f20ad6c48a8aa87ec04f19643f59fc83
7
+ data.tar.gz: ca5aadb8917b2aa2761592ecec9a7adc98e0d22b24fe1e30f20d398afac625c6ba5b10e5c60f13b84ceeff3e2a44769e732eea2fa55d8f060705ceb90bc0c0c0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-09-24
4
+
5
+ - Add opt-in freeform slide placement with explicit Markdown directives and an editor warning.
6
+
7
+ ## 0.2.0 - 2026-09-24
8
+
9
+ - Add APNG export for slide decks.
10
+
3
11
  ## 0.1.0 - 2026-09-23
4
12
 
5
13
  - 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:
@@ -215,19 +222,42 @@ the referenced assets are not copied. If an absolute selected asset lives
215
222
  outside the deck directory, its saved relative reference points outside that
216
223
  directory rather than copying the file.
217
224
 
225
+ Freeform placement is an explicit per-slide opt-in. Add `<!-- layout: freeform -->`
226
+ and one `<!-- place: x,y,width,height -->` immediately before each Markdown
227
+ block. Coordinates are percentages of the slide's inner canvas (after theme
228
+ margins); every rectangle must fit inside 0–100%. An image-only paragraph is
229
+ placed as an image. For example:
230
+
231
+ ```markdown
232
+ <!-- layout: freeform -->
233
+
234
+ <!-- place: 5,8,90,20 -->
235
+ # Quarterly report
236
+
237
+ <!-- place: 10,35,80,50 -->
238
+ Revenue increased **18%**.
239
+ ```
240
+
241
+ The content stays readable in a plain Markdown viewer, but its placement does
242
+ not. Hadar marks freeform slides in the editor with a compatibility warning.
243
+ Missing, malformed, or overflowing positions are errors; Hadar never silently
244
+ drops a block. Edit the directives in the Markdown source to reposition items.
245
+ After an external edit reloads a freeform slide, select its block again before
246
+ editing; source-order item numbers may have changed.
247
+
218
248
  `SlideList` creates thumbnail rows only for the visible viewport, using
219
249
  `Zaniah::UniformList`; `build(width:, height:)` returns the Zaniah element for
220
250
  embedding in an application layout. Its `select(index)` method updates the
221
251
  selection and invokes the optional `on_select` callback. Wezen is not involved
222
- in live thumbnails; it encodes raster frames for export workflows.
252
+ in live thumbnails; it encodes rendered slide frames as APNG.
223
253
 
224
254
  ## Development
225
255
 
226
256
  Run the specs with `bundle exec rake`. Check the 100-slide virtual-list
227
257
  layout/scene-build budget with `BUDGET=1 bundle exec ruby bench/slide_list.rb`;
228
258
  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.
259
+ Beid, Antares, Kochab, Okab, Spica, Wezen, Xamidimura, and Zaniah; its
260
+ declarative `Describe`, `UniformList`, and input keymap APIs are reused directly.
231
261
 
232
262
  ## License
233
263
 
@@ -20,3 +20,5 @@ one from the parsed Markdown structure. A free-form canvas is out of scope.
20
20
  Template content can remain a projection of the source AST and can be edited
21
21
  without duplicating slide state. Users do not get arbitrary element placement;
22
22
  reconsider only for a later format that can represent positions explicitly.
23
+
24
+ The later opt-in format is specified in [ADR 002](002-opt-in-freeform-layout.md).
@@ -0,0 +1,23 @@
1
+ # ADR 002: Opt-in freeform placement in Markdown
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ ADR 001 kept the initial release template-only. Later freeform slides still
9
+ need a source-backed position format and a visible compatibility warning.
10
+
11
+ ## Decision
12
+
13
+ `<!-- layout: freeform -->` opts in one slide. Each Markdown block has a
14
+ preceding `<!-- place: x,y,width,height -->` comment, measured as percentages
15
+ of the inner slide canvas. Positions must be finite, positive-sized rectangles
16
+ entirely within the canvas. Missing or malformed positions are rejected rather
17
+ than ignored. Hadar's editor warns that a plain Markdown viewer loses layout.
18
+
19
+ ## Consequences
20
+
21
+ Content remains ordinary Markdown and edits still pass through Beid. Plain
22
+ viewers display the content in source order without the Hadar layout. Positions
23
+ are edited in Markdown; drag-and-drop or independent canvas state is not added.
@@ -100,6 +100,9 @@ module Hadar
100
100
  editor_pane = Zaniah::Div.new.w(editor_width).h_full.p(12).gap(8)
101
101
  .style(flex_direction: :column, border: 1, border_color: deck.theme.colors.fetch("muted"))
102
102
  editor_pane.child(Zaniah::UI::Label.new(slot_title, size: :sm))
103
+ if selected_index && deck.slide(selected_index).layout == :freeform
104
+ editor_pane.child(Zaniah::UI::Label.new("Freeform — このスライドは素の Markdown ビューアでは崩れます", tone: :muted))
105
+ end
103
106
  editor_pane.child(slot_selector) if selected_index && deck.slide(selected_index).slots.any?
104
107
  editor_pane.child(editor.w_full.flex_1) if editor
105
108
  editor_pane.child(Zaniah::UI::Label.new("Editing unavailable: #{error}", tone: :muted)) if error
@@ -115,7 +118,12 @@ module Hadar
115
118
  def command_palette = @command_palette
116
119
 
117
120
  def export_png_sequence(directory, width: 1280, height: 720)
118
- PNGSequence.write(deck, directory, renderer: renderer, width: width, height: height)
121
+ Export::PNGSequence.write(deck, directory, renderer: renderer, width: width, height: height)
122
+ end
123
+
124
+ def export_apng(path, width: 1280, height: 720, duration_ms: Export::APNG::DEFAULT_DURATION_MS, loop: 0, overwrite: false)
125
+ Export::APNG.write(deck, path, renderer: renderer, width: width, height: height,
126
+ duration_ms: duration_ms, loop: loop, overwrite: overwrite)
119
127
  end
120
128
 
121
129
  def save(path = nil, overwrite: false)
@@ -161,7 +169,7 @@ module Hadar
161
169
 
162
170
  def insert_or_replace_selected_image(path)
163
171
  slot = selected_slot || raise(Error, "there is no selected slot")
164
- raise Error, "select the image slot first" unless slot.name == :image
172
+ raise Error, "select the image slot first" unless image_slot?(slot)
165
173
 
166
174
  updated = slot.empty? ? slot.insert_image(path) : slot.replace_image(path)
167
175
  finish_slot_edit
@@ -393,7 +401,8 @@ module Hadar
393
401
  "Toggle presentation" => ->(*) { presenter.started? ? stop_presentation : start_presentation },
394
402
  "Toggle fullscreen" => ->(*) { toggle_fullscreen(window: :main) },
395
403
  "Save" => ->(*) { save },
396
- "Export PNG sequence…" => ->(*) { prompt_png_sequence_directory }
404
+ "Export PNG sequence…" => ->(*) { prompt_png_sequence_directory },
405
+ "Export APNG…" => ->(*) { prompt_apng_path }
397
406
  }
398
407
  end
399
408
 
@@ -406,15 +415,28 @@ module Hadar
406
415
  export_png_sequence(directory) if directory && !directory.empty?
407
416
  end
408
417
 
418
+ def prompt_apng_path
419
+ unless @main_window.respond_to?(:prompt_for_paths)
420
+ raise Error, "this window backend cannot choose a file; call export_apng(path)"
421
+ end
422
+
423
+ path = @main_window.prompt_for_paths(save: true).first
424
+ return unless path && !path.empty?
425
+
426
+ path = "#{path}.apng" if File.extname(path).empty?
427
+ export_apng(path, overwrite: true)
428
+ end
429
+
409
430
  def slot_editor_for_selected_slide
410
431
  return [nil, nil] unless selected_index
411
432
  unless selected_slot_name
412
- @body_editor_error ||= "this slide has no editable slots"
433
+ @body_editor_error ||= deck.slide(selected_index).layout == :freeform ?
434
+ "select a block to edit after reloading this freeform slide" : "this slide has no editable slots"
413
435
  return [nil, @body_editor_error]
414
436
  end
415
437
 
416
438
  slot = selected_slot
417
- if slot.name == :image
439
+ if image_slot?(slot)
418
440
  pending_body_editor_focus(:clear) if @pending_body_editor_reload && @pending_body_editor_reload[:slot] == slot.name
419
441
  [image_slot_editor(slot), @body_editor_error]
420
442
  elsif slot.nodes.any? { |node| %i[table code_block].include?(node.type) }
@@ -462,7 +484,7 @@ module Hadar
462
484
  end
463
485
 
464
486
  def image_slot_editor(slot)
465
- return Zaniah::UI::Label.new("Select an image slot to insert or replace an image.") unless slot.name == :image
487
+ return Zaniah::UI::Label.new("Select an image slot to insert or replace an image.") unless image_slot?(slot)
466
488
  if !slot.empty? && !(slot.nodes.one? && slot.nodes.first.type == :image)
467
489
  return Zaniah::UI::Label.new("This image slot contains multiple images and cannot be edited safely.", tone: :muted)
468
490
  end
@@ -588,6 +610,10 @@ module Hadar
588
610
  slide.slots.key?(:body) ? :body : slide.slots.keys.first
589
611
  end
590
612
 
613
+ def image_slot?(slot)
614
+ slot.name == :image || (slot.nodes.one? && slot.nodes.first.type == :image)
615
+ end
616
+
591
617
  def key_context(window)
592
618
  (window.dispatcher.focused&.ancestors || []).reverse.each_with_object({}) do |handle, context|
593
619
  context.merge!(handle.context)
@@ -663,8 +689,13 @@ module Hadar
663
689
  [[selected_index || 0, 0].max, deck.length - 1].min
664
690
  end
665
691
  @pending_body_editor_reload = nil if @selected_index != previous_index
666
- @selected_slot_name = @selected_index && deck.slide(@selected_index).slots.key?(previous_slot) ? previous_slot :
667
- (@selected_index && default_slot_name(deck.slide(@selected_index)))
692
+ @selected_slot_name = if @selected_index && deck.slide(@selected_index).layout == :freeform
693
+ nil # Positional item IDs may now refer to different blocks; require explicit reselection.
694
+ elsif @selected_index && deck.slide(@selected_index).slots.key?(previous_slot)
695
+ previous_slot
696
+ else
697
+ @selected_index && default_slot_name(deck.slide(@selected_index))
698
+ end
668
699
  reset_slot_editor_state_after_reload
669
700
  presenter.reconcile!
670
701
  rebuild_slide_list
@@ -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/layout.rb CHANGED
@@ -12,7 +12,8 @@ module Hadar
12
12
  Definition.new(:full_bleed_image, %i[image]),
13
13
  Definition.new(:quote, %i[quote attribution]),
14
14
  Definition.new(:code, %i[title code]),
15
- Definition.new(:blank, [])
15
+ Definition.new(:blank, []),
16
+ Definition.new(:freeform, [])
16
17
  ].freeze
17
18
  private_constant :Definition
18
19
 
@@ -41,6 +41,12 @@ module Hadar
41
41
  node :stack, props: {gap: :number} do |props, children|
42
42
  Zaniah::Element.new.flex_col.style(gap: props.fetch(:gap)).children(children)
43
43
  end
44
+ node :placed, props: {x: :number, y: :number, width: :number, height: :number} do |props, children|
45
+ Zaniah::Element.new.flex_col.style(position: :absolute,
46
+ left: Zaniah.percent(props.fetch(:x)), top: Zaniah.percent(props.fetch(:y)),
47
+ width: Zaniah.percent(props.fetch(:width)), height: Zaniah.percent(props.fetch(:height)))
48
+ .children(children)
49
+ end
44
50
  node :columns, props: {gap: :number} do |props, children|
45
51
  Zaniah::Element.new.flex_row.style(gap: props.fetch(:gap)).children(children)
46
52
  end
@@ -96,6 +102,21 @@ module Hadar
96
102
  theme = slide_theme(slide)
97
103
  gap = theme.spacing.fetch("gap")
98
104
  case slide.layout
105
+ when :freeform
106
+ slide.placements.map do |name, (x, y, width, height)|
107
+ slot = slide.slot(name)
108
+ entry = slot.nodes.first
109
+ children = if entry.type == :image
110
+ [image_node(slot)]
111
+ elsif entry.type == :heading
112
+ [text_node(slot.text, theme.font.fetch("title_size"), theme),
113
+ *images_in(entry).map { |image| image_node(slot, image: image) }]
114
+ else
115
+ [*render_blocks(slot, theme), *images_in(entry).map { |image| image_node(slot, image: image) }]
116
+ end
117
+ node(:placed, {x: x, y: y, width: width, height: height}, children.compact,
118
+ "slide-#{slide.index}-#{name}")
119
+ end
99
120
  when :title
100
121
  stack([
101
122
  text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
@@ -226,10 +247,14 @@ module Hadar
226
247
  Zaniah::Describe::Node.new(type, props, children, key)
227
248
  end
228
249
 
229
- def image_node(slot)
250
+ def images_in(entry)
251
+ [entry, *entry.children.flat_map { |child| images_in(child) }].select { |node| node.type == :image }
252
+ end
253
+
254
+ def image_node(slot, image: nil)
230
255
  return if slot.empty?
231
256
 
232
- path = slot.resolved_image_path
257
+ path = image ? slot.resolved_image_path_for(image) : slot.resolved_image_path
233
258
  node(:image, {path: path}, [], "slide-#{slot.slide_index}-#{slot.name}-image")
234
259
  end
235
260
 
data/lib/hadar/slide.rb CHANGED
@@ -2,13 +2,15 @@
2
2
 
3
3
  module Hadar
4
4
  class Slide
5
- attr_reader :index, :document, :nodes, :layout, :slots, :theme, :notes
5
+ attr_reader :index, :document, :nodes, :layout, :slots, :theme, :notes, :placements
6
6
 
7
7
  def initialize(index:, document:, nodes:, layout: nil, theme:, deck:)
8
8
  @index, @document, @nodes, @theme, @deck = index, document, nodes.freeze, theme, deck
9
9
  @notes, @notes_ranges = extract_notes
10
10
  @layout = Layout.select(content_nodes, requested: layout)
11
+ @placements = {}
11
12
  @slots = build_slots.freeze
13
+ @placements.freeze
12
14
  freeze
13
15
  end
14
16
 
@@ -18,7 +20,11 @@ module Hadar
18
20
  end
19
21
  end
20
22
 
21
- def title = slot(:title).text
23
+ def title
24
+ return slot(:title).text unless layout == :freeform
25
+
26
+ slots.values.find { |entry| entry.nodes.first&.type == :heading }&.text.to_s
27
+ end
22
28
 
23
29
  private
24
30
 
@@ -75,6 +81,8 @@ module Hadar
75
81
 
76
82
  def build_slots
77
83
  content = content_nodes
84
+ return build_freeform_slots(content) if layout == :freeform
85
+
78
86
  definition = Layout.fetch(layout)
79
87
  mapping = definition.slots.to_h { |name| [name, []] }
80
88
  case layout
@@ -114,6 +122,49 @@ module Hadar
114
122
  end
115
123
  end
116
124
 
125
+ def build_freeform_slots(content)
126
+ pending = nil
127
+ slots = {}
128
+ content.each do |node|
129
+ next if node.type == :link_definition
130
+
131
+ if placement_directive?(node)
132
+ raise Error, "freeform placement must be followed by one content block" if pending
133
+
134
+ pending = parse_placement(node.attributes.dig(:values, "place"))
135
+ next
136
+ end
137
+ raise Error, "freeform content requires a preceding place directive" unless pending
138
+
139
+ name = :"item_#{slots.length + 1}"
140
+ body = node.type == :paragraph && node.children.one? && node.children.first.type == :image ? node.children.first : node
141
+ slots[name] = Slot.new(name: name, nodes: [body], document: document,
142
+ deck: @deck, slide_index: index)
143
+ @placements[name] = pending.freeze
144
+ pending = nil
145
+ end
146
+ raise Error, "freeform placement must be followed by one content block" if pending
147
+
148
+ slots
149
+ end
150
+
151
+ def placement_directive?(node)
152
+ node.type == :directive && node.attributes[:kind] == :html_comment &&
153
+ node.attributes[:values]&.key?("place")
154
+ end
155
+
156
+ def parse_placement(value)
157
+ unless value.to_s.match?(/\A\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*\z/)
158
+ raise Error, "place must contain x,y,width,height percentages"
159
+ end
160
+ x, y, width, height = value.split(",").map { |part| Float(part) }
161
+ unless width.positive? && height.positive? && x + width <= 100 && y + height <= 100
162
+ raise Error, "freeform placement must fit within the slide (0–100%)"
163
+ end
164
+
165
+ [x, y, width, height]
166
+ end
167
+
117
168
  def layout_directive?(node)
118
169
  node.type == :directive && node.attributes[:kind] == :html_comment &&
119
170
  node.attributes[:values]&.key?("layout")
data/lib/hadar/slot.rb CHANGED
@@ -44,8 +44,7 @@ module Hadar
44
44
  return if nodes.empty?
45
45
  raise Error, "image_path requires a slot containing exactly one image" unless nodes.one? && nodes.first.type == :image
46
46
 
47
- destination = nodes.first.attributes.fetch(:destination)
48
- destination.match?(/\Ahttps?:\/\//i) ? destination : URI::DEFAULT_PARSER.unescape(destination)
47
+ destination_path(nodes.first)
49
48
  end
50
49
 
51
50
  def resolved_image_path
@@ -53,6 +52,14 @@ module Hadar
53
52
  path && @deck.resolve_image_path(path)
54
53
  end
55
54
 
55
+ def resolved_image_path_for(node)
56
+ unless node.is_a?(Beid::Node) && node.type == :image && nodes.any? { |entry| contains_node?(entry, node) }
57
+ raise Error, "image is not part of this slot"
58
+ end
59
+
60
+ @deck.resolve_image_path(destination_path(node))
61
+ end
62
+
56
63
  def replace_text(text)
57
64
  @deck.replace_text(self, text)
58
65
  end
@@ -95,6 +102,15 @@ module Hadar
95
102
 
96
103
  private
97
104
 
105
+ def contains_node?(entry, target)
106
+ entry.equal?(target) || entry.children.any? { |child| contains_node?(child, target) }
107
+ end
108
+
109
+ def destination_path(node)
110
+ destination = node.attributes.fetch(:destination)
111
+ destination.match?(/\Ahttps?:\/\//i) ? destination : URI::DEFAULT_PARSER.unescape(destination)
112
+ end
113
+
98
114
  def table_nodes = nodes.select { |node| node.type == :table }
99
115
 
100
116
  def valid_index!(index, name)
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.3.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
@@ -30,6 +30,7 @@ module Hadar
30
30
  def text_for: (Beid::Node node) -> String
31
31
  def image_path: () -> String?
32
32
  def resolved_image_path: () -> String?
33
+ def resolved_image_path_for: (Beid::Node node) -> String
33
34
  def table_rows: (?table: Integer) -> Array[Array[String]]
34
35
  def replace_text: (String text) -> Slot
35
36
  def rich_text: (?editable: bool) -> Zaniah::UI::RichText
@@ -49,6 +50,7 @@ module Hadar
49
50
  attr_reader slots: Hash[Symbol, Slot]
50
51
  attr_reader theme: Theme
51
52
  attr_reader notes: String?
53
+ attr_reader placements: Hash[Symbol, Array[Float]]
52
54
 
53
55
  def initialize: (index: Integer, document: Beid::Document, nodes: Array[Beid::Node],
54
56
  ?layout: String | Symbol | nil, theme: Theme, deck: Deck) -> void
@@ -131,6 +133,7 @@ module Hadar
131
133
  def main_view: (width: Numeric, height: Numeric) -> Zaniah::Element
132
134
  def command_palette: () -> CommandPalette?
133
135
  def export_png_sequence: (String directory, ?width: Integer, ?height: Integer) -> Array[String]
136
+ def export_apng: (String path, ?width: Integer, ?height: Integer, ?duration_ms: Integer, ?loop: Integer, ?overwrite: bool) -> String
134
137
  def save: (?String path, ?overwrite: bool) -> Deck
135
138
  def selected_slot: () -> Slot?
136
139
  def select_slot: (String | Symbol name) -> Slot
@@ -201,6 +204,16 @@ module Hadar
201
204
  end
202
205
 
203
206
  module Export
207
+ class APNG
208
+ MAX_PIXELS: Integer
209
+ DEFAULT_DURATION_MS: Integer
210
+
211
+ def self.render: (Deck deck, ?renderer: Renderer, ?width: Integer, ?height: Integer,
212
+ ?duration_ms: Integer, ?loop: Integer) -> String
213
+ def self.write: (Deck deck, String path, ?overwrite: bool, ?renderer: Renderer, ?width: Integer,
214
+ ?height: Integer, ?duration_ms: Integer, ?loop: Integer) -> String
215
+ end
216
+
204
217
  class PNGSequence
205
218
  MAX_PIXELS: Integer
206
219
 
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.3.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
@@ -122,11 +136,13 @@ files:
122
136
  - assets/themes/minimal.jsonc
123
137
  - assets/themes/warm.jsonc
124
138
  - docs/adr/001-template-layouts.md
139
+ - docs/adr/002-opt-in-freeform-layout.md
125
140
  - lib/hadar.rb
126
141
  - lib/hadar/application.rb
127
142
  - lib/hadar/command_palette.rb
128
143
  - lib/hadar/deck.rb
129
144
  - lib/hadar/deck_watcher.rb
145
+ - lib/hadar/export/apng.rb
130
146
  - lib/hadar/export/pdf.rb
131
147
  - lib/hadar/export/png_sequence.rb
132
148
  - lib/hadar/layout.rb
@@ -162,7 +178,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
162
178
  - !ruby/object:Gem::Version
163
179
  version: '0'
164
180
  requirements: []
165
- rubygems_version: 4.0.16
181
+ rubygems_version: 3.6.9
166
182
  specification_version: 4
167
183
  summary: Markdown-backed presentation editor and preview
168
184
  test_files: []