hadar 0.2.0 → 0.4.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 +144 -102
- 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 +19 -6
- data/lib/hadar/export/pdf.rb +15 -88
- data/lib/hadar/layout.rb +2 -1
- data/lib/hadar/renderer.rb +31 -3
- data/lib/hadar/slide.rb +53 -2
- data/lib/hadar/slot.rb +18 -2
- data/lib/hadar/version.rb +1 -1
- data/sig/hadar.rbs +3 -1
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca92c68fe912ebaddf03792345b9747c02f994a1134f47c4481313f8312d0fe2
|
|
4
|
+
data.tar.gz: 5e1e1e5dd08e6516af6a540dd86c262a6da4ba175e532301a4e3c7fd5d9ebc7a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa6fe7bc92f62e5d4780e6e2b6236a82949f6bab93858243d734a4953146028028ff8b45345829c774b0d35277f9bb459b005178133768559ab473c22d6ddcab
|
|
7
|
+
data.tar.gz: 62d2be12ec8b34ab18bcc71866edd8a4ce8e40d0fc67f3cb5d853dab520a5e4f8fd28cda7e8ea4648946f471f9b410aec0c1e07d85cb6dff9ac6cc8c368fded8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-09-24
|
|
4
|
+
|
|
5
|
+
- Use the preview's Zaniah element tree for searchable vector PDF export, including freeform slides.
|
|
6
|
+
|
|
7
|
+
## 0.3.0 - 2026-09-24
|
|
8
|
+
|
|
9
|
+
- Add opt-in freeform slide placement with explicit Markdown directives and an editor warning.
|
|
10
|
+
|
|
3
11
|
## 0.2.0 - 2026-09-24
|
|
4
12
|
|
|
5
13
|
- Add APNG export for slide decks.
|
data/README.md
CHANGED
|
@@ -1,38 +1,58 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
the
|
|
1
|
+
<h1 align="center">Hadar</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Markdown-backed slide decks with source-preserving editing, live preview, and presentation export</strong>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/hadar"><img src="https://img.shields.io/gem/v/hadar.svg" alt="Gem version"></a>
|
|
9
|
+
<a href="https://rubygems.org/gems/hadar"><img src="https://img.shields.io/gem/dt/hadar.svg" alt="Gem downloads"></a>
|
|
10
|
+
<a href="https://github.com/noxdea/hadar/actions/workflows/main.yml"><img src="https://github.com/noxdea/hadar/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/CRuby-%3E%3D%203.2-cc342d.svg" alt="CRuby 3.2 or newer">
|
|
12
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="#features">Features</a> ·
|
|
17
|
+
<a href="#installation">Installation</a> ·
|
|
18
|
+
<a href="#quick-start">Quick start</a> ·
|
|
19
|
+
<a href="#editing-and-presenting">Editing and presenting</a> ·
|
|
20
|
+
<a href="#export">Export</a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Hadar is a Ruby presentation library built around ordinary Markdown files.
|
|
26
|
+
[Beid](https://github.com/noxdea/beid) keeps the source-positioned document as
|
|
27
|
+
the only editable copy; Hadar projects it into slides, slots, and a live
|
|
28
|
+
[Zaniah](https://github.com/noxdea/zaniah) preview. Use its API to embed a
|
|
29
|
+
presentation window in an app or export a deck without a GUI.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- Eight automatic or explicitly selected slide templates, plus opt-in freeform placement
|
|
34
|
+
- Source-backed text, image, table-cell, and fenced-code editing with speaker notes
|
|
35
|
+
- Three built-in JSONC themes (`minimal`, `dark`, `warm`) and custom themes
|
|
36
|
+
- Virtualized slide thumbnails, keyboard navigation, command palette, and a secondary-display presenter view
|
|
37
|
+
- Searchable PDF, PNG-sequence, and animated PNG (APNG) export
|
|
28
38
|
|
|
29
39
|
## Installation
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
Hadar requires CRuby 3.2 or newer. Install the gem or add `gem "hadar"` to
|
|
42
|
+
your Gemfile:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
gem install hadar
|
|
33
46
|
```
|
|
34
47
|
|
|
35
|
-
|
|
48
|
+
Hadar is a library; it does not install a `hadar` command. Windowed use
|
|
49
|
+
requires a Zaniah-supported display backend. PNG and APNG export use Zaniah's
|
|
50
|
+
headless renderer.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
Separate slides with Markdown thematic breaks. Hadar selects a layout from
|
|
55
|
+
each slide's content unless you add a `layout` comment:
|
|
36
56
|
|
|
37
57
|
```ruby
|
|
38
58
|
require "hadar"
|
|
@@ -59,21 +79,16 @@ deck = Hadar::Deck.parse(<<~MARKDOWN)
|
|
|
59
79
|
:::
|
|
60
80
|
MARKDOWN
|
|
61
81
|
|
|
62
|
-
deck.slide(1).layout
|
|
63
|
-
deck.slide(1).slot(:left).text
|
|
64
|
-
deck.slide(0).notes # => nil when no speaker notes are present
|
|
65
|
-
|
|
82
|
+
deck.slide(1).layout # => :two_column
|
|
83
|
+
deck.slide(1).slot(:left).text # => "Revenue rose 18% year over year."
|
|
66
84
|
tree = Hadar::Renderer.new.describe(deck.slide(0))
|
|
67
|
-
element = Hadar::Renderer.new.build(deck.slide(0))
|
|
68
|
-
|
|
69
|
-
thumbnails = Hadar::SlideList.new(deck, selected: 0,
|
|
70
|
-
on_select: ->(slide, index) { puts "Selected slide #{index + 1}: #{slide.title}" })
|
|
71
|
-
list_element = thumbnails.build(width: 280, height: 640)
|
|
72
85
|
```
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
`Renderer#build` returns a Zaniah element for embedding in a preview.
|
|
88
|
+
|
|
89
|
+
## Editing and presenting
|
|
90
|
+
|
|
91
|
+
### Source-backed slots
|
|
77
92
|
|
|
78
93
|
Slots remain projections of the current Beid document. `slot.rich_text` returns
|
|
79
94
|
a Zaniah rich-text editor whose bold, italic, link, and code spans come from the
|
|
@@ -89,6 +104,7 @@ paragraph styles are rejected rather than flattening or normalizing markup.
|
|
|
89
104
|
Rich-text projection covers headings, paragraphs, block quotes, and text lists;
|
|
90
105
|
tables, fenced code blocks, and Markdown strikethrough are not editable through
|
|
91
106
|
this API yet.
|
|
107
|
+
|
|
92
108
|
Slots returned before a successful edit are stale snapshots. Opened decks save
|
|
93
109
|
atomically, preserve file permissions, and refuse to overwrite external changes:
|
|
94
110
|
|
|
@@ -101,22 +117,22 @@ deck.save
|
|
|
101
117
|
```
|
|
102
118
|
|
|
103
119
|
Opened decks can detect and reload external changes directly or through the
|
|
104
|
-
Zaniah platform watcher.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
versions untouched. The host can keep its current slide selection and rebuild
|
|
108
|
-
the preview after the callback:
|
|
120
|
+
Zaniah platform watcher. Reload refuses to discard unsaved local edits. A
|
|
121
|
+
watcher is polled by the host's UI loop; successful reloads update the same
|
|
122
|
+
deck object and invoke `on_reload`:
|
|
109
123
|
|
|
110
124
|
```ruby
|
|
111
125
|
watcher = deck.watch(on_reload: ->(_deck) { window.request_frame })
|
|
112
126
|
watcher.poll(timeout: 0)
|
|
113
127
|
```
|
|
114
128
|
|
|
115
|
-
`deck.reload_if_changed` performs the same safe check without a
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
129
|
+
`deck.reload_if_changed` performs the same safe check without a watcher.
|
|
130
|
+
|
|
131
|
+
### Application and controls
|
|
132
|
+
|
|
133
|
+
`Hadar::Application` wires the watcher into attached windows and keeps the
|
|
134
|
+
selected slide index on reload. Its presenter view shows the next slide,
|
|
135
|
+
current notes, and elapsed time:
|
|
120
136
|
|
|
121
137
|
```ruby
|
|
122
138
|
app = Hadar::Application.new(deck)
|
|
@@ -125,47 +141,17 @@ app.attach(main_window: main)
|
|
|
125
141
|
app.run
|
|
126
142
|
```
|
|
127
143
|
|
|
128
|
-
`Application#run` polls
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
used as-is and is never moved or closed by Hadar. The automatically created
|
|
134
|
-
presenter closes when the main window closes. Rich-text slot editors are backed
|
|
135
|
-
by the current Markdown source and recreated after an external reload.
|
|
136
|
-
They keep the caret or selection (and editor focus) when selected text maps
|
|
137
|
-
unambiguously around one contiguous external edit; if an edit overlaps the
|
|
138
|
-
selection or makes the mapping ambiguous, the selection and focus are cleared
|
|
139
|
-
rather than moved to unrelated text. Arrow, Page Up/Down, Home, and End navigate
|
|
140
|
-
slides; `P` or `F5` starts presentation,
|
|
141
|
-
`F11` toggles fullscreen, and `Escape` exits presentation or fullscreen.
|
|
142
|
-
`Ctrl/Cmd-K` opens the fuzzy command palette; `Ctrl/Cmd-S` saves an opened deck
|
|
143
|
-
through its conflict-aware atomic writer.
|
|
144
|
-
|
|
145
|
-
PDF export produces one searchable 16:9 page for every slide, including all
|
|
146
|
-
eight layouts. Pass a TrueType font that contains every visible character; the
|
|
147
|
-
default font is selected from Zaniah's local font database:
|
|
148
|
-
|
|
149
|
-
```ruby
|
|
150
|
-
Hadar::Export::PDF.write(deck, "slides.pdf", font: "/path/to/font.ttf")
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Okab currently requires TrueType outlines for PDF embedding. Hadar exports local
|
|
154
|
-
PNG and JPEG images; remote images and other image formats are rejected.
|
|
155
|
-
|
|
156
|
-
PNG sequence export renders all slides at the requested dimensions using
|
|
157
|
-
Zaniah's headless renderer. It refuses to overwrite existing frames:
|
|
144
|
+
`Application#run` polls and ticks its windows. With a second display, Hadar
|
|
145
|
+
opens its own fullscreen presenter window there; an explicitly passed
|
|
146
|
+
`presenter_window:` stays under the host's control. After an external reload,
|
|
147
|
+
rich-text editors are recreated. Their selection and focus survive only when
|
|
148
|
+
they map unambiguously around the edit.
|
|
158
149
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
150
|
+
Arrow keys, Page Up/Down, Home, and End navigate slides. `P` or `F5` starts
|
|
151
|
+
presentation; `F11` toggles fullscreen; `Escape` exits either mode.
|
|
152
|
+
`Ctrl/Cmd-K` opens the command palette, and `Ctrl/Cmd-S` saves an opened deck.
|
|
162
153
|
|
|
163
|
-
|
|
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
|
-
```
|
|
154
|
+
### Notes and block editors
|
|
169
155
|
|
|
170
156
|
Speaker notes can be written as a one-line or multiline HTML comment. Their
|
|
171
157
|
Markdown remains in the source unchanged and does not appear in slide slots or
|
|
@@ -206,7 +192,7 @@ For a deck created with `Deck.parse`, pass a new path to `app.save(path)`.
|
|
|
206
192
|
Replacing an existing unrelated path requires `overwrite: true`. Opened decks
|
|
207
193
|
can use `app.save` or `Ctrl/Cmd-S`; the same external-change check applies.
|
|
208
194
|
|
|
209
|
-
|
|
195
|
+
### Layouts and themes
|
|
210
196
|
|
|
211
197
|
`title`, `title+body`, `two-column`, `image+text`, `full-bleed-image`,
|
|
212
198
|
`quote`, `code`, and `blank` are available. Explicit layout directives take
|
|
@@ -222,20 +208,76 @@ the referenced assets are not copied. If an absolute selected asset lives
|
|
|
222
208
|
outside the deck directory, its saved relative reference points outside that
|
|
223
209
|
directory rather than copying the file.
|
|
224
210
|
|
|
211
|
+
Built-in themes are `minimal`, `dark`, and `warm`. Custom JSONC themes can be
|
|
212
|
+
loaded with `Hadar::Theme.load(path)` and passed to `Deck.parse` or
|
|
213
|
+
`Deck.open`. A deck can also set a built-in theme in YAML front matter with
|
|
214
|
+
`theme: dark`.
|
|
215
|
+
|
|
216
|
+
Freeform placement is an explicit per-slide opt-in. Add `<!-- layout: freeform -->`
|
|
217
|
+
and one `<!-- place: x,y,width,height -->` immediately before each Markdown
|
|
218
|
+
block. Coordinates are percentages of the slide's inner canvas (after theme
|
|
219
|
+
margins); every rectangle must fit inside 0–100%. An image-only paragraph is
|
|
220
|
+
placed as an image. For example:
|
|
221
|
+
|
|
222
|
+
```markdown
|
|
223
|
+
<!-- layout: freeform -->
|
|
224
|
+
|
|
225
|
+
<!-- place: 5,8,90,20 -->
|
|
226
|
+
# Quarterly report
|
|
227
|
+
|
|
228
|
+
<!-- place: 10,35,80,50 -->
|
|
229
|
+
Revenue increased **18%**.
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The content stays readable in a plain Markdown viewer, but its placement does
|
|
233
|
+
not. Hadar marks freeform slides in the editor with a compatibility warning.
|
|
234
|
+
Missing, malformed, or overflowing positions are errors; Hadar never silently
|
|
235
|
+
drops a block. Edit the directives in the Markdown source to reposition items.
|
|
236
|
+
After an external edit reloads a freeform slide, select its block again before
|
|
237
|
+
editing; source-order item numbers may have changed.
|
|
238
|
+
|
|
225
239
|
`SlideList` creates thumbnail rows only for the visible viewport, using
|
|
226
|
-
`Zaniah::UniformList
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
`Zaniah::UniformList`. `build(width:, height:)` returns a Zaniah element;
|
|
241
|
+
`select(index)` updates the selection and invokes `on_select` when supplied:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
thumbnails = Hadar::SlideList.new(deck, selected: 0,
|
|
245
|
+
on_select: ->(slide, index) { puts "Selected slide #{index + 1}: #{slide.title}" })
|
|
246
|
+
element = thumbnails.build(width: 280, height: 640)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Export
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
Hadar::Export::PDF.write(deck, "slides.pdf", font: "/path/to/font.ttf")
|
|
253
|
+
Hadar::Export::PNGSequence.write(deck, "slides-png", width: 1280, height: 720)
|
|
254
|
+
Hadar::Export::APNG.write(deck, "slides.apng", width: 1280, height: 720,
|
|
255
|
+
duration_ms: 2500)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
PDF creates one searchable 16:9 page per slide from the same element tree used
|
|
259
|
+
by PNG export, including freeform placement.
|
|
260
|
+
It needs a TrueType-outline font containing every visible character; without
|
|
261
|
+
`font:`, Hadar uses Zaniah's local font database. PDF embeds local PNG and JPEG
|
|
262
|
+
images, but rejects remote and other image formats. PNG-sequence export refuses
|
|
263
|
+
to replace existing frames. APNG defaults to three seconds per slide and
|
|
264
|
+
infinite looping; an existing target requires `overwrite: true`.
|
|
265
|
+
`Application#export_png_sequence` and
|
|
266
|
+
`#export_apng` wrap the corresponding exporters.
|
|
230
267
|
|
|
231
268
|
## Development
|
|
232
269
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
270
|
+
```sh
|
|
271
|
+
bundle install
|
|
272
|
+
bundle exec rake
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Check the 100-slide thumbnail layout/scene-build budget with
|
|
276
|
+
`BUDGET=1 bundle exec ruby bench/slide_list.rb`. See the
|
|
277
|
+
[template-layout](docs/adr/001-template-layouts.md) and
|
|
278
|
+
[freeform-placement](docs/adr/002-opt-in-freeform-layout.md) decisions for the
|
|
279
|
+
source model.
|
|
238
280
|
|
|
239
281
|
## License
|
|
240
282
|
|
|
241
|
-
|
|
283
|
+
Hadar is released under the [MIT License](LICENSE.txt).
|
|
@@ -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
|
|
@@ -166,7 +169,7 @@ module Hadar
|
|
|
166
169
|
|
|
167
170
|
def insert_or_replace_selected_image(path)
|
|
168
171
|
slot = selected_slot || raise(Error, "there is no selected slot")
|
|
169
|
-
raise Error, "select the image slot first" unless slot
|
|
172
|
+
raise Error, "select the image slot first" unless image_slot?(slot)
|
|
170
173
|
|
|
171
174
|
updated = slot.empty? ? slot.insert_image(path) : slot.replace_image(path)
|
|
172
175
|
finish_slot_edit
|
|
@@ -427,12 +430,13 @@ module Hadar
|
|
|
427
430
|
def slot_editor_for_selected_slide
|
|
428
431
|
return [nil, nil] unless selected_index
|
|
429
432
|
unless selected_slot_name
|
|
430
|
-
@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"
|
|
431
435
|
return [nil, @body_editor_error]
|
|
432
436
|
end
|
|
433
437
|
|
|
434
438
|
slot = selected_slot
|
|
435
|
-
if slot
|
|
439
|
+
if image_slot?(slot)
|
|
436
440
|
pending_body_editor_focus(:clear) if @pending_body_editor_reload && @pending_body_editor_reload[:slot] == slot.name
|
|
437
441
|
[image_slot_editor(slot), @body_editor_error]
|
|
438
442
|
elsif slot.nodes.any? { |node| %i[table code_block].include?(node.type) }
|
|
@@ -480,7 +484,7 @@ module Hadar
|
|
|
480
484
|
end
|
|
481
485
|
|
|
482
486
|
def image_slot_editor(slot)
|
|
483
|
-
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)
|
|
484
488
|
if !slot.empty? && !(slot.nodes.one? && slot.nodes.first.type == :image)
|
|
485
489
|
return Zaniah::UI::Label.new("This image slot contains multiple images and cannot be edited safely.", tone: :muted)
|
|
486
490
|
end
|
|
@@ -606,6 +610,10 @@ module Hadar
|
|
|
606
610
|
slide.slots.key?(:body) ? :body : slide.slots.keys.first
|
|
607
611
|
end
|
|
608
612
|
|
|
613
|
+
def image_slot?(slot)
|
|
614
|
+
slot.name == :image || (slot.nodes.one? && slot.nodes.first.type == :image)
|
|
615
|
+
end
|
|
616
|
+
|
|
609
617
|
def key_context(window)
|
|
610
618
|
(window.dispatcher.focused&.ancestors || []).reverse.each_with_object({}) do |handle, context|
|
|
611
619
|
context.merge!(handle.context)
|
|
@@ -681,8 +689,13 @@ module Hadar
|
|
|
681
689
|
[[selected_index || 0, 0].max, deck.length - 1].min
|
|
682
690
|
end
|
|
683
691
|
@pending_body_editor_reload = nil if @selected_index != previous_index
|
|
684
|
-
@selected_slot_name = @selected_index && deck.slide(@selected_index).
|
|
685
|
-
|
|
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
|
|
686
699
|
reset_slot_editor_state_after_reload
|
|
687
700
|
presenter.reconcile!
|
|
688
701
|
rebuild_slide_list
|
data/lib/hadar/export/pdf.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "okab/zaniah_vector"
|
|
4
|
+
require "zaniah/vector"
|
|
5
|
+
|
|
3
6
|
module Hadar
|
|
4
7
|
module Export
|
|
5
8
|
class PDF
|
|
@@ -23,10 +26,12 @@ module Hadar
|
|
|
23
26
|
|
|
24
27
|
def render
|
|
25
28
|
document = Okab::Document.new(title: @deck.slides.first&.title, creator: "Hadar")
|
|
29
|
+
renderer = Renderer.new(font: @font.face)
|
|
26
30
|
@deck.slides.each do |slide|
|
|
31
|
+
ensure_glyphs!(renderer.describe(slide))
|
|
32
|
+
vector = Zaniah::Vector.record(width: WIDTH, height: HEIGHT) { renderer.build(slide) }
|
|
27
33
|
page = document.page(width: WIDTH, height: HEIGHT)
|
|
28
|
-
|
|
29
|
-
paint_slide(page, slide)
|
|
34
|
+
Okab::ZaniahVector.draw(page, vector)
|
|
30
35
|
document.outline(slide.title, page: page) unless slide.title.empty?
|
|
31
36
|
end
|
|
32
37
|
document.render
|
|
@@ -52,94 +57,16 @@ module Hadar
|
|
|
52
57
|
raise Error, "cannot load PDF font: #{error.message}"
|
|
53
58
|
end
|
|
54
59
|
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def paint_slide(page, slide)
|
|
60
|
-
margin = slide.theme.spacing.fetch("margin")
|
|
61
|
-
gap = slide.theme.spacing.fetch("gap")
|
|
62
|
-
title_size = slide.theme.font.fetch("title_size")
|
|
63
|
-
body_size = slide.theme.font.fetch("body_size")
|
|
64
|
-
text_color = rgb(slide.theme.colors.fetch("text"))
|
|
65
|
-
muted = rgb(slide.theme.colors.fetch("muted"))
|
|
66
|
-
width = WIDTH - margin * 2
|
|
60
|
+
def ensure_glyphs!(node)
|
|
61
|
+
if %i[text code_token].include?(node.type)
|
|
62
|
+
missing = node.props.fetch(:text).codepoints.find do |codepoint|
|
|
63
|
+
next false if codepoint == 0x0a || variation_selector?(codepoint)
|
|
67
64
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
text(page, slide.slot(:subtitle).text, margin, HEIGHT * 0.40, width, body_size, muted, :center)
|
|
72
|
-
when :two_column
|
|
73
|
-
paint_title(page, slide, margin, width, title_size, text_color)
|
|
74
|
-
column_width = (width - gap) / 2
|
|
75
|
-
y = HEIGHT - margin - title_size - gap - body_size
|
|
76
|
-
text(page, slide.slot(:left).text, margin, y, column_width, body_size, text_color)
|
|
77
|
-
text(page, slide.slot(:right).text, margin + column_width + gap, y, column_width, body_size, text_color)
|
|
78
|
-
when :image_text
|
|
79
|
-
paint_title(page, slide, margin, width, title_size, text_color)
|
|
80
|
-
y = HEIGHT - margin - title_size - gap - body_size
|
|
81
|
-
text(page, slide.slot(:text).text, margin, y, width * 0.48, body_size, text_color)
|
|
82
|
-
paint_image(page, slide.slot(:image), margin + width * 0.52, margin,
|
|
83
|
-
width * 0.48, HEIGHT - margin * 2 - title_size - gap)
|
|
84
|
-
when :full_bleed_image
|
|
85
|
-
paint_image(page, slide.slot(:image), 0, 0, WIDTH, HEIGHT, cover: true)
|
|
86
|
-
when :quote
|
|
87
|
-
text(page, slide.slot(:quote).text, margin + width * 0.08, HEIGHT * 0.62,
|
|
88
|
-
width * 0.84, title_size, text_color, :center)
|
|
89
|
-
text(page, slide.slot(:attribution).text, margin, HEIGHT * 0.28, width, body_size, muted, :center)
|
|
90
|
-
when :code
|
|
91
|
-
paint_title(page, slide, margin, width, title_size, text_color)
|
|
92
|
-
y = HEIGHT - margin - title_size - gap - body_size
|
|
93
|
-
text(page, slide.slot(:code).text, margin, y, width, body_size, text_color)
|
|
94
|
-
when :blank
|
|
95
|
-
nil
|
|
96
|
-
else
|
|
97
|
-
paint_title(page, slide, margin, width, title_size, text_color)
|
|
98
|
-
y = HEIGHT - margin - title_size - gap - body_size
|
|
99
|
-
text(page, slide.slot(:body).text, margin, y, width, body_size, text_color)
|
|
65
|
+
@font.face.glyph_id(codepoint).zero?
|
|
66
|
+
end
|
|
67
|
+
raise Error, "PDF font does not contain U+#{missing.to_s(16).upcase}; provide a font that covers the deck text" if missing
|
|
100
68
|
end
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def paint_title(page, slide, margin, width, size, color)
|
|
104
|
-
text(page, slide.slot(:title).text, margin, HEIGHT - margin - size, width, size, color)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def text(page, value, x, y, width, size, color, align = :left)
|
|
108
|
-
return if value.nil? || value.empty?
|
|
109
|
-
|
|
110
|
-
value = value.gsub(/\r\n?/, "\n")
|
|
111
|
-
ensure_glyphs!(value)
|
|
112
|
-
page.text_block(value, x: x, y: y, width: width, font: @font, size: size,
|
|
113
|
-
line_height: size * 1.3, align: align, color: color)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def paint_image(page, slot, x, y, width, height, cover: false)
|
|
117
|
-
return if slot.empty?
|
|
118
|
-
|
|
119
|
-
image = Okab::Image.decode(File.binread(slot.resolved_image_path))
|
|
120
|
-
scales = [width / image.width, height / image.height]
|
|
121
|
-
scale = cover ? scales.max : scales.min
|
|
122
|
-
draw_width, draw_height = image.width * scale, image.height * scale
|
|
123
|
-
page.clip { |path| path.rect(x, y, width, height) } if cover
|
|
124
|
-
page.image(image, x: x + (width - draw_width) / 2, y: y + (height - draw_height) / 2,
|
|
125
|
-
width: draw_width, height: draw_height)
|
|
126
|
-
rescue Okab::Error, SystemCallError => error
|
|
127
|
-
raise Error, "cannot export image: #{error.message}"
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def ensure_glyphs!(value)
|
|
131
|
-
missing = value.codepoints.find do |codepoint|
|
|
132
|
-
next false if codepoint == 0x0a || variation_selector?(codepoint)
|
|
133
|
-
|
|
134
|
-
@font.face.glyph_id(codepoint).zero?
|
|
135
|
-
end
|
|
136
|
-
return unless missing
|
|
137
|
-
|
|
138
|
-
raise Error, "PDF font does not contain U+#{missing.to_s(16).upcase}; provide a font that covers the deck text"
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def rgb(color)
|
|
142
|
-
color.delete_prefix("#").scan(/../).first(3).map { |component| component.to_i(16) / 255.0 }
|
|
69
|
+
node.children.each { |child| ensure_glyphs!(child) }
|
|
143
70
|
end
|
|
144
71
|
|
|
145
72
|
def variation_selector?(codepoint)
|
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
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Hadar
|
|
4
4
|
class Renderer
|
|
5
|
-
def initialize
|
|
5
|
+
def initialize(font: nil)
|
|
6
6
|
@font_db = nil
|
|
7
7
|
@fonts = {}
|
|
8
|
+
@font_override = font
|
|
8
9
|
@vocabulary = build_vocabulary
|
|
9
10
|
end
|
|
10
11
|
|
|
@@ -41,6 +42,12 @@ module Hadar
|
|
|
41
42
|
node :stack, props: {gap: :number} do |props, children|
|
|
42
43
|
Zaniah::Element.new.flex_col.style(gap: props.fetch(:gap)).children(children)
|
|
43
44
|
end
|
|
45
|
+
node :placed, props: {x: :number, y: :number, width: :number, height: :number} do |props, children|
|
|
46
|
+
Zaniah::Element.new.flex_col.style(position: :absolute,
|
|
47
|
+
left: Zaniah.percent(props.fetch(:x)), top: Zaniah.percent(props.fetch(:y)),
|
|
48
|
+
width: Zaniah.percent(props.fetch(:width)), height: Zaniah.percent(props.fetch(:height)))
|
|
49
|
+
.children(children)
|
|
50
|
+
end
|
|
44
51
|
node :columns, props: {gap: :number} do |props, children|
|
|
45
52
|
Zaniah::Element.new.flex_row.style(gap: props.fetch(:gap)).children(children)
|
|
46
53
|
end
|
|
@@ -86,6 +93,8 @@ module Hadar
|
|
|
86
93
|
end
|
|
87
94
|
|
|
88
95
|
def font(family)
|
|
96
|
+
return @font_override if @font_override
|
|
97
|
+
|
|
89
98
|
@fonts[family] ||= begin
|
|
90
99
|
@font_db ||= Zaniah::TextSystem::FontDB.new
|
|
91
100
|
@font_db.find(family: family == "sans-serif" ? nil : family)
|
|
@@ -96,6 +105,21 @@ module Hadar
|
|
|
96
105
|
theme = slide_theme(slide)
|
|
97
106
|
gap = theme.spacing.fetch("gap")
|
|
98
107
|
case slide.layout
|
|
108
|
+
when :freeform
|
|
109
|
+
slide.placements.map do |name, (x, y, width, height)|
|
|
110
|
+
slot = slide.slot(name)
|
|
111
|
+
entry = slot.nodes.first
|
|
112
|
+
children = if entry.type == :image
|
|
113
|
+
[image_node(slot)]
|
|
114
|
+
elsif entry.type == :heading
|
|
115
|
+
[text_node(slot.text, theme.font.fetch("title_size"), theme),
|
|
116
|
+
*images_in(entry).map { |image| image_node(slot, image: image) }]
|
|
117
|
+
else
|
|
118
|
+
[*render_blocks(slot, theme), *images_in(entry).map { |image| image_node(slot, image: image) }]
|
|
119
|
+
end
|
|
120
|
+
node(:placed, {x: x, y: y, width: width, height: height}, children.compact,
|
|
121
|
+
"slide-#{slide.index}-#{name}")
|
|
122
|
+
end
|
|
99
123
|
when :title
|
|
100
124
|
stack([
|
|
101
125
|
text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
|
|
@@ -226,10 +250,14 @@ module Hadar
|
|
|
226
250
|
Zaniah::Describe::Node.new(type, props, children, key)
|
|
227
251
|
end
|
|
228
252
|
|
|
229
|
-
def
|
|
253
|
+
def images_in(entry)
|
|
254
|
+
[entry, *entry.children.flat_map { |child| images_in(child) }].select { |node| node.type == :image }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def image_node(slot, image: nil)
|
|
230
258
|
return if slot.empty?
|
|
231
259
|
|
|
232
|
-
path = slot.resolved_image_path
|
|
260
|
+
path = image ? slot.resolved_image_path_for(image) : slot.resolved_image_path
|
|
233
261
|
node(:image, {path: path}, [], "slide-#{slot.slide_index}-#{slot.name}-image")
|
|
234
262
|
end
|
|
235
263
|
|
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/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
|
|
@@ -182,7 +184,7 @@ module Hadar
|
|
|
182
184
|
end
|
|
183
185
|
|
|
184
186
|
class Renderer
|
|
185
|
-
def initialize: () -> void
|
|
187
|
+
def initialize: (?font: Alhena::Font?) -> void
|
|
186
188
|
def describe: (Slide slide) -> Zaniah::Describe::Node
|
|
187
189
|
def build: (Slide slide) -> Zaniah::Element
|
|
188
190
|
def surface: (Slide slide) -> Zaniah::Describe::Surface
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: 0.
|
|
60
|
+
version: 0.2.0
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: 0.
|
|
67
|
+
version: 0.2.0
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: spica
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -113,14 +113,14 @@ dependencies:
|
|
|
113
113
|
requirements:
|
|
114
114
|
- - "~>"
|
|
115
115
|
- !ruby/object:Gem::Version
|
|
116
|
-
version: 0.
|
|
116
|
+
version: 0.9.0
|
|
117
117
|
type: :runtime
|
|
118
118
|
prerelease: false
|
|
119
119
|
version_requirements: !ruby/object:Gem::Requirement
|
|
120
120
|
requirements:
|
|
121
121
|
- - "~>"
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
|
-
version: 0.
|
|
123
|
+
version: 0.9.0
|
|
124
124
|
description: Projects Beid Markdown ASTs into template-based slide decks, edits supported
|
|
125
125
|
source-backed text, and exports PDF or PNG.
|
|
126
126
|
email:
|
|
@@ -136,6 +136,7 @@ files:
|
|
|
136
136
|
- assets/themes/minimal.jsonc
|
|
137
137
|
- assets/themes/warm.jsonc
|
|
138
138
|
- docs/adr/001-template-layouts.md
|
|
139
|
+
- docs/adr/002-opt-in-freeform-layout.md
|
|
139
140
|
- lib/hadar.rb
|
|
140
141
|
- lib/hadar/application.rb
|
|
141
142
|
- lib/hadar/command_palette.rb
|