hadar 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +234 -0
- data/assets/themes/dark.jsonc +11 -0
- data/assets/themes/minimal.jsonc +12 -0
- data/assets/themes/warm.jsonc +11 -0
- data/docs/adr/001-template-layouts.md +22 -0
- data/lib/hadar/application.rb +749 -0
- data/lib/hadar/command_palette.rb +57 -0
- data/lib/hadar/deck.rb +440 -0
- data/lib/hadar/deck_watcher.rb +33 -0
- data/lib/hadar/export/pdf.rb +150 -0
- data/lib/hadar/export/png_sequence.rb +63 -0
- data/lib/hadar/layout.rb +80 -0
- data/lib/hadar/presenter.rb +92 -0
- data/lib/hadar/renderer.rb +238 -0
- data/lib/hadar/rich_text_projection.rb +110 -0
- data/lib/hadar/rich_text_writeback.rb +377 -0
- data/lib/hadar/slide.rb +130 -0
- data/lib/hadar/slide_list.rb +74 -0
- data/lib/hadar/slot.rb +149 -0
- data/lib/hadar/theme.rb +85 -0
- data/lib/hadar/version.rb +5 -0
- data/lib/hadar.rb +31 -0
- data/sig/hadar.rbs +221 -0
- metadata +168 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spica"
|
|
4
|
+
|
|
5
|
+
module Hadar
|
|
6
|
+
class CommandPalette < Zaniah::UI::Modal
|
|
7
|
+
attr_reader :query
|
|
8
|
+
|
|
9
|
+
def initialize(commands)
|
|
10
|
+
@commands = commands.to_h.freeze
|
|
11
|
+
@index = Spica::Index.new(@commands.keys)
|
|
12
|
+
@session = @index.session
|
|
13
|
+
@query = ""
|
|
14
|
+
super(Zaniah::Div.new, title: "Command palette", open: false, width: 520)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def query=(value)
|
|
18
|
+
@session.query = value
|
|
19
|
+
@query = @session.query
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def matches(limit = 20) = @session.matches(limit)
|
|
23
|
+
|
|
24
|
+
def choose(label, event = nil, context = nil)
|
|
25
|
+
command = @commands.fetch(label) { raise ArgumentError, "unknown command: #{label}" }
|
|
26
|
+
command&.call(event, context)
|
|
27
|
+
dismiss(event, context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build(cx)
|
|
31
|
+
field = Zaniah::UI::SearchInput.new(query, placeholder: "Search commands…")
|
|
32
|
+
.on_change do |value, context|
|
|
33
|
+
self.query = value
|
|
34
|
+
context.window.request_frame
|
|
35
|
+
end
|
|
36
|
+
results = Zaniah::Div.new.gap(cx.theme.spacing[1]).children(matches.map do |match|
|
|
37
|
+
Zaniah::UI::Button.new(match.candidate, variant: :ghost).w_full.on_click do |event, context|
|
|
38
|
+
choose(match.candidate, event, context)
|
|
39
|
+
end
|
|
40
|
+
end)
|
|
41
|
+
@content = Zaniah::Div.new.gap(cx.theme.spacing[2]).child(field).child(results)
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tui_cells(*) = "> #{query}\n" + matches.map(&:candidate).join("\n")
|
|
46
|
+
|
|
47
|
+
def accessibility_node(cx)
|
|
48
|
+
return unless open?
|
|
49
|
+
|
|
50
|
+
node(:dialog, label: "Command palette", states: {modal: true}, children: [
|
|
51
|
+
Zaniah::Accessibility.node(role: :searchbox, label: "Search commands", value: query),
|
|
52
|
+
Zaniah::Accessibility.node(role: :list,
|
|
53
|
+
children: matches.map { |match| Zaniah::Accessibility.node(role: :listitem, label: match.candidate) })
|
|
54
|
+
], actions: [:dismiss])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/hadar/deck.rb
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Hadar
|
|
8
|
+
class Deck
|
|
9
|
+
attr_reader :document, :slides, :theme, :source_path
|
|
10
|
+
|
|
11
|
+
def self.parse(markdown, theme: nil)
|
|
12
|
+
document = Beid::Document.parse(markdown)
|
|
13
|
+
from_document(document, theme: theme)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.open(path, theme: nil)
|
|
17
|
+
source_path = File.realpath(path)
|
|
18
|
+
raise Error, "deck path must be a regular file" unless File.file?(source_path)
|
|
19
|
+
|
|
20
|
+
source = File.binread(source_path).force_encoding(Encoding::UTF_8)
|
|
21
|
+
raise Error, "deck source is not valid UTF-8" unless source.valid_encoding?
|
|
22
|
+
|
|
23
|
+
document = Beid::Document.parse(source)
|
|
24
|
+
new(document, theme: theme, source_path: source_path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.from_document(document, theme: nil)
|
|
28
|
+
raise TypeError, "document must be a Beid::Document" unless document.is_a?(Beid::Document)
|
|
29
|
+
|
|
30
|
+
new(document, theme: theme)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(document, theme: nil, source_path: nil)
|
|
34
|
+
@document = document
|
|
35
|
+
@source_path = source_path
|
|
36
|
+
@saved_source = source_path && document.source.dup.freeze
|
|
37
|
+
@theme_override = theme
|
|
38
|
+
@theme = theme_for(document)
|
|
39
|
+
@saved_signature = source_signature(source_path) if source_path
|
|
40
|
+
@slides = build_slides(document, deck_theme: @theme).freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def slide(index) = slides.fetch(index)
|
|
44
|
+
def length = slides.length
|
|
45
|
+
alias size length
|
|
46
|
+
def empty? = slides.empty?
|
|
47
|
+
|
|
48
|
+
def external_change?
|
|
49
|
+
return false unless source_path
|
|
50
|
+
|
|
51
|
+
source_signature(source_path) != @saved_signature
|
|
52
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
53
|
+
true
|
|
54
|
+
rescue Error
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def reload_if_changed
|
|
59
|
+
return false unless external_change?
|
|
60
|
+
|
|
61
|
+
reload
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reload
|
|
65
|
+
raise Error, "cannot reload a deck that was not opened from a file" unless source_path
|
|
66
|
+
|
|
67
|
+
source, read_signature = read_source_snapshot
|
|
68
|
+
if source == @saved_source.b
|
|
69
|
+
@saved_signature = read_signature
|
|
70
|
+
return false
|
|
71
|
+
end
|
|
72
|
+
if document.source.b != @saved_source.b
|
|
73
|
+
raise Error, "deck has local edits and external changes; refusing to discard either version"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
source = source.dup.force_encoding(Encoding::UTF_8)
|
|
77
|
+
raise Error, "external deck is not valid UTF-8; keeping the current document" unless source.valid_encoding?
|
|
78
|
+
|
|
79
|
+
updated_document = Beid::Document.parse(source)
|
|
80
|
+
updated_theme = theme_for(updated_document)
|
|
81
|
+
updated_slides = build_slides(updated_document, deck_theme: updated_theme).freeze
|
|
82
|
+
@document, @slides, @theme = updated_document, updated_slides, updated_theme
|
|
83
|
+
@saved_source = updated_document.source.dup.freeze
|
|
84
|
+
@saved_signature = read_signature
|
|
85
|
+
true
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
raise Error, "deck file disappeared since it was opened"
|
|
88
|
+
rescue Errno::EACCES
|
|
89
|
+
raise Error, "deck file is not readable"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def watch(latency: 0.05, on_reload: nil)
|
|
93
|
+
DeckWatcher.new(self, latency: latency, on_reload: on_reload)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def replace_text(slot, text)
|
|
97
|
+
validate_slot!(slot)
|
|
98
|
+
return replace_code(slot, text) if slot.nodes.one? && slot.nodes.first.type == :code_block
|
|
99
|
+
raise ArgumentError, "replace_text requires a slot containing exactly one node" unless slot.nodes.one?
|
|
100
|
+
|
|
101
|
+
updated_document = Beid::Editing.replace_text(document, slot.nodes.first, text)
|
|
102
|
+
updated_slides = build_slides(updated_document).freeze
|
|
103
|
+
@document, @slides = updated_document, updated_slides
|
|
104
|
+
slide(slot.slide_index).slot(slot.name)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def replace_table_cell(slot, row:, column:, value:, table: 0)
|
|
108
|
+
validate_slot!(slot)
|
|
109
|
+
[table, row, column].each_with_index do |index, position|
|
|
110
|
+
name = %w[table row column][position]
|
|
111
|
+
raise TypeError, "#{name} index must be an Integer" unless index.is_a?(Integer)
|
|
112
|
+
raise IndexError, "#{name} index must not be negative" if index.negative?
|
|
113
|
+
end
|
|
114
|
+
unless value.is_a?(String) && value.valid_encoding? &&
|
|
115
|
+
(value.encoding == Encoding::UTF_8 || value.ascii_only?)
|
|
116
|
+
raise TypeError, "table cell value must be valid UTF-8 text"
|
|
117
|
+
end
|
|
118
|
+
if value.match?(/[\r\n\0|\\`*_~{}\[\]<>!]/)
|
|
119
|
+
raise ArgumentError, "table cell values must be plain text without Markdown delimiters, pipes, or newlines"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
tables = slot.nodes.select { |node| node.type == :table }
|
|
123
|
+
table_node = tables.fetch(table) { raise IndexError, "table index is outside the slot" }
|
|
124
|
+
rows = table_node.children.select { |node| node.type == :table_row }
|
|
125
|
+
row_node = rows.fetch(row) { raise IndexError, "row index is outside the table" }
|
|
126
|
+
cell = row_node.children.fetch(column) { raise IndexError, "column index is outside the row" }
|
|
127
|
+
expected = slot.table_rows(table: table).map(&:dup)
|
|
128
|
+
expected[row][column] = value
|
|
129
|
+
updated_document = Beid::Editing.replace(document, cell, value)
|
|
130
|
+
update_slot_document(slot, updated_document) do |updated_slot|
|
|
131
|
+
raise Error, "table cell edit changed neighboring cells or table structure" unless updated_slot.table_rows(table: table) == expected
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def replace_code(slot, text, block: 0)
|
|
136
|
+
validate_slot!(slot)
|
|
137
|
+
raise TypeError, "code must be a String" unless text.is_a?(String)
|
|
138
|
+
unless text.valid_encoding? && (text.encoding == Encoding::UTF_8 || text.ascii_only?)
|
|
139
|
+
raise ArgumentError, "code must be valid UTF-8"
|
|
140
|
+
end
|
|
141
|
+
raise ArgumentError, "code must not contain NUL" if text.include?("\0")
|
|
142
|
+
raise TypeError, "block index must be an Integer" unless block.is_a?(Integer)
|
|
143
|
+
raise IndexError, "block index must not be negative" if block.negative?
|
|
144
|
+
|
|
145
|
+
code_blocks = slot.nodes.select { |node| node.type == :code_block }
|
|
146
|
+
code = code_blocks.fetch(block) { raise IndexError, "code block index is outside the slot" }
|
|
147
|
+
fence = code.attributes[:fence]
|
|
148
|
+
unless fence && code.attributes[:closed]
|
|
149
|
+
raise Error, "only closed fenced code blocks can be edited safely"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
range = code_body_range(code)
|
|
153
|
+
current_body = document.source.byteslice(range)
|
|
154
|
+
replacement = text
|
|
155
|
+
if !replacement.empty? && !replacement.end_with?("\r", "\n")
|
|
156
|
+
ending = current_body[/\r\n\z|\r\z|\n\z/] || document.source[/\r\n|\r|\n/] || "\n"
|
|
157
|
+
replacement += ending
|
|
158
|
+
end
|
|
159
|
+
closer = /\A {0,3}#{Regexp.escape(fence[0])}{#{fence.length},}[ \t]*\z/
|
|
160
|
+
lines = replacement.scan(/.*?(?:\r\n|\r|\n|\z)/m).reject(&:empty?)
|
|
161
|
+
if lines.any? { |line| closer.match?(line.delete_suffix("\r\n").delete_suffix("\n").delete_suffix("\r")) }
|
|
162
|
+
raise ArgumentError, "code contains a line that would terminate its fenced block"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
updated_document = Beid::Editing.edit_range(document, code, range, replacement)
|
|
166
|
+
update_slot_document(slot, updated_document) do |updated_slot|
|
|
167
|
+
updated_code = updated_slot.nodes.select { |node| node.type == :code_block }.fetch(block)
|
|
168
|
+
unless updated_code.attributes[:info] == code.attributes[:info] && updated_slot.text_for(updated_code) == replacement
|
|
169
|
+
raise Error, "code edit could not be represented without changing adjacent Markdown"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def replace_rich_text(slot, value)
|
|
175
|
+
validate_slot!(slot)
|
|
176
|
+
raise TypeError, "value must be a Zaniah::UI::RichText" unless value.is_a?(Zaniah::UI::RichText)
|
|
177
|
+
raise Error, "rich text must be editable" unless value.editable?
|
|
178
|
+
|
|
179
|
+
updated_document = RichTextProjection.new(slot).replace(value)
|
|
180
|
+
return slot if updated_document.equal?(document)
|
|
181
|
+
|
|
182
|
+
updated_slides = build_slides(updated_document).freeze
|
|
183
|
+
updated_slot = updated_slides.fetch(slot.slide_index).slot(slot.name)
|
|
184
|
+
projected = RichTextProjection.new(updated_slot).build(editable: false)
|
|
185
|
+
unless updated_slot.text == value.text && projected.runs == value.runs
|
|
186
|
+
raise Error, "rich text edit cannot be represented without changing untouched Markdown"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
@document, @slides = updated_document, updated_slides
|
|
190
|
+
slide(slot.slide_index).slot(slot.name)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def replace_image(slot, path)
|
|
194
|
+
validate_slot!(slot)
|
|
195
|
+
unless slot.nodes.one? && slot.nodes.first.type == :image
|
|
196
|
+
raise ArgumentError, "replace_image requires a slot containing exactly one image"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
destination = image_destination(path)
|
|
200
|
+
updated_document = Beid::Editing.set_attribute(document, slot.nodes.first, :destination, destination)
|
|
201
|
+
update_image_document(slot, updated_document)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def insert_image(slot, path, alt: nil)
|
|
205
|
+
validate_slot!(slot)
|
|
206
|
+
raise ArgumentError, "insert_image requires an empty image slot" unless slot.empty?
|
|
207
|
+
raise TypeError, "alt must be a String or nil" unless alt.nil? || alt.is_a?(String)
|
|
208
|
+
raise ArgumentError, "alt must not contain newlines or NUL" if alt&.match?(/[\r\n\0]/)
|
|
209
|
+
|
|
210
|
+
slide = self.slide(slot.slide_index)
|
|
211
|
+
unless Layout.fetch(slide.layout).slots.include?(:image)
|
|
212
|
+
raise Error, "image insertion requires an image layout slot"
|
|
213
|
+
end
|
|
214
|
+
anchor = slide.nodes.last
|
|
215
|
+
raise Error, "image insertion requires a source node in the slide" unless anchor
|
|
216
|
+
|
|
217
|
+
destination = image_destination(path)
|
|
218
|
+
alt ||= File.basename(URI::DEFAULT_PARSER.unescape(destination))
|
|
219
|
+
updated_document = Beid::Editing.insert_after(document, anchor, image_markdown(destination, alt))
|
|
220
|
+
update_image_document(slot, updated_document)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def resolve_image_path(path)
|
|
224
|
+
raise TypeError, "image path must be a String" unless path.is_a?(String)
|
|
225
|
+
raise ArgumentError, "image path must not be empty or contain NUL" if path.empty? || path.include?("\0")
|
|
226
|
+
|
|
227
|
+
if path.match?(/\Ahttps?:\/\//i)
|
|
228
|
+
raise Error, "remote image URLs are not supported in slide preview: #{path}"
|
|
229
|
+
end
|
|
230
|
+
has_uri_scheme = path.match?(/\A[a-z][a-z0-9.+-]*:/i) && !path.match?(/\A[a-z]:[\\\/]/i)
|
|
231
|
+
if has_uri_scheme
|
|
232
|
+
raise Error, "image URI schemes are not supported in slide preview: #{path}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
directory = source_path ? File.dirname(source_path) : Dir.pwd
|
|
236
|
+
resolved = File.realpath(File.expand_path(path, directory))
|
|
237
|
+
raise Error, "image path is not a regular file: #{path}" unless File.file?(resolved)
|
|
238
|
+
raise Error, "image file is not readable: #{path}" unless File.readable?(resolved)
|
|
239
|
+
|
|
240
|
+
resolved
|
|
241
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
242
|
+
raise Error, "image file not found: #{path}"
|
|
243
|
+
rescue Errno::EACCES
|
|
244
|
+
raise Error, "image file is not readable: #{path}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def save(path = source_path, overwrite: false)
|
|
248
|
+
raise Error, "no save path; open a file or pass a path" unless path
|
|
249
|
+
raise TypeError, "save path must be a String" unless path.is_a?(String)
|
|
250
|
+
raise TypeError, "overwrite must be true or false" unless overwrite == true || overwrite == false
|
|
251
|
+
raise ArgumentError, "save path must not be empty or contain NUL" if path.empty? || path.include?("\0")
|
|
252
|
+
|
|
253
|
+
target = File.expand_path(path)
|
|
254
|
+
same_source = source_path && target == source_path
|
|
255
|
+
write_atomically(target, overwrite: overwrite, check_source: same_source && !overwrite)
|
|
256
|
+
@source_path = target
|
|
257
|
+
@saved_source = document.source.dup.freeze
|
|
258
|
+
@saved_signature = source_signature(target)
|
|
259
|
+
self
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
def validate_slot!(slot)
|
|
265
|
+
raise TypeError, "slot must be a Hadar::Slot" unless slot.is_a?(Slot)
|
|
266
|
+
raise Error, "slot belongs to another deck" unless slot.owned_by?(self)
|
|
267
|
+
raise Error, "slot is stale; retrieve it again from the current deck" unless slot.document.equal?(document)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def image_destination(path)
|
|
271
|
+
raise TypeError, "image path must be a String" unless path.is_a?(String)
|
|
272
|
+
raise ArgumentError, "image path must not be empty or contain NUL/newlines" if path.empty? || path.match?(/[\0\r\n]/)
|
|
273
|
+
|
|
274
|
+
path = relative_image_path(path) if Pathname.new(path).absolute?
|
|
275
|
+
return path if path.match?(/\Ahttps?:\/\//i)
|
|
276
|
+
|
|
277
|
+
URI::DEFAULT_PARSER.escape(path, /[^A-Za-z0-9\-._~\/]/)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def relative_image_path(path)
|
|
281
|
+
raise Error, "absolute image paths require an opened deck" unless source_path
|
|
282
|
+
|
|
283
|
+
absolute_path = Pathname.new(File.realpath(path))
|
|
284
|
+
raise Error, "image path must be a regular file" unless absolute_path.file?
|
|
285
|
+
|
|
286
|
+
absolute_path.relative_path_from(Pathname.new(File.dirname(source_path))).to_s
|
|
287
|
+
rescue Errno::ENOENT
|
|
288
|
+
raise Error, "image file does not exist"
|
|
289
|
+
rescue ArgumentError => error
|
|
290
|
+
raise Error, "image path cannot be made relative to the deck: #{error.message}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def image_markdown(destination, alt)
|
|
294
|
+
template = Beid::Document.parse("")
|
|
295
|
+
image = first_image(template.root.children)
|
|
296
|
+
template = Beid::Editing.set_attribute(template, image, :destination, destination)
|
|
297
|
+
image = first_image(template.root.children)
|
|
298
|
+
template = Beid::Editing.replace_text(template, image, alt)
|
|
299
|
+
image = first_image(template.root.children)
|
|
300
|
+
unless image && image.attributes.fetch(:destination) == destination
|
|
301
|
+
raise Error, "image markup cannot be represented by Beid"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
template.source
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def first_image(nodes)
|
|
308
|
+
nodes.each do |node|
|
|
309
|
+
return node if node.type == :image
|
|
310
|
+
|
|
311
|
+
nested = first_image(node.children)
|
|
312
|
+
return nested if nested
|
|
313
|
+
end
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def update_image_document(slot, updated_document)
|
|
318
|
+
update_slot_document(slot, updated_document) do |updated_slot|
|
|
319
|
+
raise Error, "image edit did not produce an image node" if updated_slot.empty?
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def update_slot_document(slot, updated_document)
|
|
324
|
+
updated_slides = build_slides(updated_document).freeze
|
|
325
|
+
updated_slot = updated_slides.fetch(slot.slide_index).slot(slot.name)
|
|
326
|
+
yield updated_slot if block_given?
|
|
327
|
+
|
|
328
|
+
@document, @slides = updated_document, updated_slides
|
|
329
|
+
updated_slot
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def read_source_snapshot
|
|
333
|
+
snapshot = Xamidimura::SourceRevision.read(source_path, reject_symlink: true)
|
|
334
|
+
[snapshot.bytes, snapshot.signature]
|
|
335
|
+
rescue Xamidimura::Error => error
|
|
336
|
+
raise Error, error.message
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def source_signature(path)
|
|
340
|
+
Xamidimura::SourceRevision.signature(path, reject_symlink: true)
|
|
341
|
+
rescue Xamidimura::Error => error
|
|
342
|
+
raise Error, error.message
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def theme_for(source_document)
|
|
346
|
+
value = @theme_override || self.class.send(:theme_from_front_matter, source_document,
|
|
347
|
+
source_path ? File.dirname(source_path) : Dir.pwd)
|
|
348
|
+
value ||= Theme.default
|
|
349
|
+
value = %w[minimal dark warm].include?(value.downcase) ? Theme.builtin(value) : Theme.load(value) if value.is_a?(String)
|
|
350
|
+
raise TypeError, "theme must be a Hadar::Theme" unless value.is_a?(Theme)
|
|
351
|
+
|
|
352
|
+
value
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def code_body_range(node)
|
|
356
|
+
source = document.source
|
|
357
|
+
lines = source.byteslice(node.range).scan(/.*?(?:\r\n|\r|\n|\z)/m).reject(&:empty?)
|
|
358
|
+
opener = lines.shift
|
|
359
|
+
raise Error, "code block opener is missing" unless opener
|
|
360
|
+
lines.pop if node.attributes[:closed]
|
|
361
|
+
start = node.range.begin + opener.bytesize
|
|
362
|
+
start...(start + lines.sum(&:bytesize))
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def self.theme_from_front_matter(document, directory)
|
|
366
|
+
return unless document.front_matter
|
|
367
|
+
|
|
368
|
+
match = document.front_matter.match(/^theme:\s*["']?([^\r\n"']+)["']?\s*$/)
|
|
369
|
+
return unless match
|
|
370
|
+
|
|
371
|
+
name = match[1].strip
|
|
372
|
+
return Theme.builtin(name) if %w[minimal dark warm].include?(name.downcase)
|
|
373
|
+
|
|
374
|
+
path = File.expand_path(name, directory)
|
|
375
|
+
return unless File.file?(path)
|
|
376
|
+
|
|
377
|
+
Theme.load(path)
|
|
378
|
+
end
|
|
379
|
+
private_class_method :theme_from_front_matter
|
|
380
|
+
|
|
381
|
+
def build_slides(source_document, deck_theme: theme)
|
|
382
|
+
groups = [[]]
|
|
383
|
+
source_document.root.children.each do |node|
|
|
384
|
+
if node.type == :thematic_break
|
|
385
|
+
groups << []
|
|
386
|
+
else
|
|
387
|
+
groups.last << node
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
groups.reject!(&:empty?)
|
|
391
|
+
groups = [[]] if groups.empty? && source_document.source.strip.empty?
|
|
392
|
+
groups.each_with_index.map do |nodes, index|
|
|
393
|
+
requested = nodes.filter_map do |node|
|
|
394
|
+
node.attributes.dig(:values, "layout") if node.type == :directive
|
|
395
|
+
end.first
|
|
396
|
+
Slide.new(index: index, document: source_document, nodes: nodes, layout: requested,
|
|
397
|
+
theme: deck_theme, deck: self)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def write_atomically(target, overwrite:, check_source:)
|
|
402
|
+
existing = lstat(target)
|
|
403
|
+
raise Error, "refusing to write through a symbolic link" if existing&.symlink?
|
|
404
|
+
raise Error, "save target must be a regular file" if existing && !existing.file?
|
|
405
|
+
if check_source
|
|
406
|
+
verify_unchanged!(target)
|
|
407
|
+
elsif existing && !overwrite
|
|
408
|
+
raise Error, "save target already exists; pass overwrite: true to replace it"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
mode = existing ? existing.mode & 0o777 : (0o666 & ~File.umask)
|
|
412
|
+
directory = File.dirname(target)
|
|
413
|
+
basename = File.basename(target)
|
|
414
|
+
Tempfile.create([".#{basename}.", ".tmp"], directory) do |file|
|
|
415
|
+
file.binmode
|
|
416
|
+
file.write(document.source.b)
|
|
417
|
+
file.flush
|
|
418
|
+
file.chmod(mode)
|
|
419
|
+
file.fsync
|
|
420
|
+
file.close
|
|
421
|
+
verify_unchanged!(target) if check_source
|
|
422
|
+
File.rename(file.path, target)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def verify_unchanged!(target)
|
|
427
|
+
existing = lstat(target)
|
|
428
|
+
raise Error, "deck file disappeared since it was opened" unless existing&.file? && !existing.symlink?
|
|
429
|
+
return if File.binread(target) == @saved_source.b
|
|
430
|
+
|
|
431
|
+
raise Error, "deck file changed since it was opened; refusing to overwrite external edits"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def lstat(path)
|
|
435
|
+
File.lstat(path)
|
|
436
|
+
rescue Errno::ENOENT
|
|
437
|
+
nil
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
class DeckWatcher
|
|
5
|
+
attr_reader :deck
|
|
6
|
+
|
|
7
|
+
def initialize(deck, latency: 0.05, on_reload: nil)
|
|
8
|
+
raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
|
|
9
|
+
raise Error, "file watching requires a deck opened from a path" unless deck.source_path
|
|
10
|
+
raise TypeError, "on_reload must be callable" if on_reload && !on_reload.respond_to?(:call)
|
|
11
|
+
|
|
12
|
+
@deck, @on_reload, @closed = deck, on_reload, false
|
|
13
|
+
@watcher = Zaniah::Platform.watch(deck.source_path, latency: latency)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def poll(timeout: 0)
|
|
17
|
+
return false if @closed
|
|
18
|
+
|
|
19
|
+
@watcher.poll(timeout: timeout)
|
|
20
|
+
return false unless deck.reload_if_changed
|
|
21
|
+
|
|
22
|
+
@on_reload&.call(deck)
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def close
|
|
27
|
+
return if @closed
|
|
28
|
+
|
|
29
|
+
@closed = true
|
|
30
|
+
@watcher.close
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
module Export
|
|
5
|
+
class PDF
|
|
6
|
+
WIDTH, HEIGHT = 960, 540
|
|
7
|
+
|
|
8
|
+
def self.render(deck, font: nil)
|
|
9
|
+
new(deck, font: font).render
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.write(deck, path, font: nil)
|
|
13
|
+
File.binwrite(path, render(deck, font: font))
|
|
14
|
+
path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(deck, font: nil)
|
|
18
|
+
raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
|
|
19
|
+
|
|
20
|
+
@deck = deck
|
|
21
|
+
@font = load_font(font)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render
|
|
25
|
+
document = Okab::Document.new(title: @deck.slides.first&.title, creator: "Hadar")
|
|
26
|
+
@deck.slides.each do |slide|
|
|
27
|
+
page = document.page(width: WIDTH, height: HEIGHT)
|
|
28
|
+
paint_background(page, slide.theme)
|
|
29
|
+
paint_slide(page, slide)
|
|
30
|
+
document.outline(slide.title, page: page) unless slide.title.empty?
|
|
31
|
+
end
|
|
32
|
+
document.render
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def load_font(font)
|
|
38
|
+
loaded = case font
|
|
39
|
+
when String then Okab::Font.load(font)
|
|
40
|
+
when Okab::Font then font
|
|
41
|
+
when Alhena::Font then Okab::Font.new(font)
|
|
42
|
+
when nil
|
|
43
|
+
family = @deck.theme.font.fetch("family")
|
|
44
|
+
Okab::Font.new(Zaniah::TextSystem::FontDB.new.find(family: family == "sans-serif" ? nil : family))
|
|
45
|
+
else
|
|
46
|
+
raise TypeError, "font must be a path, Okab::Font, or Alhena::Font"
|
|
47
|
+
end
|
|
48
|
+
raise Error, "Okab PDF export currently requires a TrueType-outline font" if loaded.face.cff?
|
|
49
|
+
|
|
50
|
+
loaded
|
|
51
|
+
rescue Okab::Error, Alhena::Error, Zaniah::Error => error
|
|
52
|
+
raise Error, "cannot load PDF font: #{error.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
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
|
|
67
|
+
|
|
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)
|
|
100
|
+
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 }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def variation_selector?(codepoint)
|
|
146
|
+
(0xfe00..0xfe0f).cover?(codepoint) || (0xe0100..0xe01ef).cover?(codepoint)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|