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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d7b4c769b40f5836617885858a50bc58e93ef8072b97430520ec2315ed81edbb
4
- data.tar.gz: e8379cf1cc2f8039429d0fba6a650d8d7f059801e30c0df69864bf5ed94be8b0
3
+ metadata.gz: ca92c68fe912ebaddf03792345b9747c02f994a1134f47c4481313f8312d0fe2
4
+ data.tar.gz: 5e1e1e5dd08e6516af6a540dd86c262a6da4ba175e532301a4e3c7fd5d9ebc7a
5
5
  SHA512:
6
- metadata.gz: b44313439e6fcc8a411c8b7fbc379a2050c843b8758dfa42352c4f007f5334141b45ced44d8d139a14dc030332d826744feaab621678e7e671760710f2ee1edd
7
- data.tar.gz: 46f5d21160a6b804ba95067906abcc9f0277bf263a5aa51281ebe93ae0c1712982d8ebf0f328a3ca0dc2670a3f0a7573d67509b75f1787f6f477a64de1714d61
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
- # Hadar
2
-
3
- Hadar (β Centauri) is a Markdown-backed presentation app. Its deck model is a
4
- projection of Beid's source-positioned AST: slide content and slots keep their
5
- source nodes instead of becoming a second mutable copy. Markdown `---`
6
- thematic breaks separate slides, and `<!-- layout: ... -->` selects one of
7
- eight template layouts.
8
-
9
- Hadar provides deck/slide/slot parsing, three JSONC themes, layout selection, a
10
- declarative preview tree, and virtualized thumbnail rows built with Zaniah's
11
- existing `Describe` and `UniformList` APIs. Its selected-slide editor can switch
12
- among every declared layout slot. Text slots use Zaniah `RichText` and Beid;
13
- image slots provide insert/replace actions, while tables and fenced code expose
14
- source-preserving cell/body editors. Unsupported syntax remains visible but
15
- read-only. Beid-backed
16
- `<!-- notes: ... -->` comments provide speaker notes on each slide and are
17
- excluded from the visible preview. Image slots can insert and replace
18
- source-backed Markdown references; absolute asset paths are stored relative to
19
- the opened deck. Local image slots render through Zaniah's image decoder (PNG,
20
- GIF, and baseline JPEG); relative references resolve from the deck's directory.
21
- Missing, unreadable, and unsupported images fail preview construction with a
22
- `Hadar::Error`. Remote URLs are not fetched. Hadar does not copy image files;
23
- saving continues to write only the Markdown source. Its window host polls for
24
- external Markdown edits and can show a next-slide, notes, and elapsed-time
25
- presenter view. The host supports slide navigation, fullscreen, a fuzzy command
26
- palette, PNG-sequence, and APNG export. When a secondary display is available,
27
- the owned presenter window opens there fullscreen.
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
- ```ruby
32
- gem "hadar"
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
- ## Usage
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 # => :two_column
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
- Built-in themes are `minimal`, `dark`, and `warm`. Custom JSONC themes can be
75
- loaded with `Hadar::Theme.load(path)` and passed to `Deck.parse` or
76
- `Deck.open`.
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. A watcher is polled by the host's UI loop; successful
105
- reloads update the same deck object and invoke `on_reload`. Reload refuses to
106
- discard local unsaved Markdown edits, leaving both the in-memory and on-disk
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 platform watcher.
116
- `Hadar::Application` wires this polling into attached windows and retains the
117
- selected slide by index when a reload changes the deck. Its presenter view shows
118
- the next slide, current slide's notes, and elapsed time. While presenting, each
119
- window tick requests a fresh frame so elapsed time stays current:
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 the deck and ticks all attached windows in one loop.
129
- When at least two displays are available, Hadar creates its own presenter
130
- window and places it fullscreen on a secondary display (Wayland compositors may
131
- choose whether to honor the output request). With one display, it does not
132
- create a presenter automatically. A presenter window passed by the host is
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
- ```ruby
160
- app.export_png_sequence("slides-png", width: 1280, height: 720)
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
- 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
- ```
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
- ## Layouts
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`; `build(width:, height:)` returns the Zaniah element for
227
- embedding in an application layout. Its `select(index)` method updates the
228
- selection and invokes the optional `on_select` callback. Wezen is not involved
229
- in live thumbnails; it encodes rendered slide frames as APNG.
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
- Run the specs with `bundle exec rake`. Check the 100-slide virtual-list
234
- layout/scene-build budget with `BUDGET=1 bundle exec ruby bench/slide_list.rb`;
235
- the headless benchmark skips software pixel rasterization. Hadar depends on
236
- Beid, Antares, Kochab, Okab, Spica, Wezen, Xamidimura, and Zaniah; its
237
- declarative `Describe`, `UniformList`, and input keymap APIs are reused directly.
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
- MIT. See [LICENSE.txt](LICENSE.txt).
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.
@@ -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.name == :image
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 ||= "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"
431
435
  return [nil, @body_editor_error]
432
436
  end
433
437
 
434
438
  slot = selected_slot
435
- if slot.name == :image
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.name == :image
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).slots.key?(previous_slot) ? previous_slot :
685
- (@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
686
699
  reset_slot_editor_state_after_reload
687
700
  presenter.reconcile!
688
701
  rebuild_slide_list
@@ -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
- paint_background(page, slide.theme)
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 paint_background(page, theme)
56
- page.rect(0, 0, WIDTH, HEIGHT).fill(rgb(theme.colors.fetch("background")))
57
- end
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
- case slide.layout
69
- when :title
70
- text(page, slide.slot(:title).text, margin, HEIGHT * 0.57, width, title_size, text_color, :center)
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
- end
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
 
@@ -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 image_node(slot)
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 = 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.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.2.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.1.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.1.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.6.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.6.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