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.
@@ -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("![image](placeholder)")
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