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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +35 -5
- data/docs/adr/001-template-layouts.md +2 -0
- data/docs/adr/002-opt-in-freeform-layout.md +23 -0
- data/lib/hadar/application.rb +39 -8
- data/lib/hadar/export/apng.rb +69 -0
- data/lib/hadar/layout.rb +2 -1
- data/lib/hadar/renderer.rb +27 -2
- data/lib/hadar/slide.rb +53 -2
- data/lib/hadar/slot.rb +18 -2
- data/lib/hadar/version.rb +1 -1
- data/lib/hadar.rb +2 -0
- data/sig/hadar.rbs +13 -0
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0bc8dd031f42ad45785dbcf360c9f075e8d71758be0a5e2df0e158abc77ef8d
|
|
4
|
+
data.tar.gz: 0702a59cfdcfd8987311f08e178a9390ec31b3442de35b4414fb1b3dc5c301d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a234432c82f1b580bc19b3f4c29211a98556f277f28f5cdbd9da7c1f08172b9fcddea6f64c9a9dba1bcbe3204e071391f20ad6c48a8aa87ec04f19643f59fc83
|
|
7
|
+
data.tar.gz: ca5aadb8917b2aa2761592ecec9a7adc98e0d22b24fe1e30f20d398afac625c6ba5b10e5c60f13b84ceeff3e2a44769e732eea2fa55d8f060705ceb90bc0c0c0
|
data/CHANGELOG.md
CHANGED
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,
|
|
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
|
|
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
|
|
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.
|
data/lib/hadar/application.rb
CHANGED
|
@@ -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
|
|
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 ||=
|
|
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
|
|
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
|
|
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).
|
|
667
|
-
|
|
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
|
|
data/lib/hadar/renderer.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
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.
|
|
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:
|
|
181
|
+
rubygems_version: 3.6.9
|
|
166
182
|
specification_version: 4
|
|
167
183
|
summary: Markdown-backed presentation editor and preview
|
|
168
184
|
test_files: []
|