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,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Hadar
|
|
7
|
+
module Export
|
|
8
|
+
class PNGSequence
|
|
9
|
+
MAX_PIXELS = 32_000_000
|
|
10
|
+
|
|
11
|
+
def self.write(deck, directory, renderer: Renderer.new, width: 1280, height: 720)
|
|
12
|
+
raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
|
|
13
|
+
raise Error, "cannot export an empty deck" if deck.empty?
|
|
14
|
+
raise TypeError, "renderer must respond to build" unless renderer.respond_to?(:build)
|
|
15
|
+
raise ArgumentError, "directory must be a nonempty path" unless directory.is_a?(String) && !directory.empty?
|
|
16
|
+
unless [width, height].all? { |dimension| dimension.is_a?(Integer) && dimension.positive? } && width * height <= MAX_PIXELS
|
|
17
|
+
raise ArgumentError, "PNG dimensions must be positive integers totaling at most #{MAX_PIXELS} pixels"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
output = File.expand_path(directory)
|
|
21
|
+
FileUtils.mkdir_p(output)
|
|
22
|
+
digits = [3, deck.length.to_s.length].max
|
|
23
|
+
names = deck.length.times.map { |index| format("slide-%0#{digits}d.png", index + 1) }
|
|
24
|
+
if (existing = names.find { |name| File.exist?(File.join(output, name)) || File.symlink?(File.join(output, name)) })
|
|
25
|
+
raise Error, "PNG sequence output already exists: #{existing}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Dir.mktmpdir(".hadar-png-", output) do |staging|
|
|
29
|
+
write_frames(deck, renderer, width, height, staging, names)
|
|
30
|
+
commit_frames(output, staging, names)
|
|
31
|
+
end
|
|
32
|
+
names.map { |name| File.join(output, name) }.freeze
|
|
33
|
+
rescue SystemCallError => error
|
|
34
|
+
raise Error, "cannot write PNG sequence: #{error.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.write_frames(deck, renderer, width, height, staging, names)
|
|
38
|
+
window = Zaniah::Platform.open_window(backend: :headless, width: width, height: height)
|
|
39
|
+
window.text_system = Zaniah::TextSystem::Renderer.new
|
|
40
|
+
deck.slides.each_with_index do |slide, index|
|
|
41
|
+
window.render(renderer.build(slide), clear: slide.theme.colors.fetch("background"))
|
|
42
|
+
window.write_png(File.join(staging, names[index]))
|
|
43
|
+
end
|
|
44
|
+
ensure
|
|
45
|
+
window&.close
|
|
46
|
+
end
|
|
47
|
+
private_class_method :write_frames
|
|
48
|
+
|
|
49
|
+
def self.commit_frames(output, staging, names)
|
|
50
|
+
created = []
|
|
51
|
+
names.each do |name|
|
|
52
|
+
target = File.join(output, name)
|
|
53
|
+
File.link(File.join(staging, name), target)
|
|
54
|
+
created << target
|
|
55
|
+
end
|
|
56
|
+
rescue SystemCallError
|
|
57
|
+
created.each { |path| File.unlink(path) if File.file?(path) }
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
private_class_method :commit_frames
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/hadar/layout.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
module Layout
|
|
5
|
+
Definition = Data.define(:name, :slots)
|
|
6
|
+
|
|
7
|
+
DEFINITIONS = [
|
|
8
|
+
Definition.new(:title, %i[title subtitle]),
|
|
9
|
+
Definition.new(:title_body, %i[title body]),
|
|
10
|
+
Definition.new(:two_column, %i[title left right]),
|
|
11
|
+
Definition.new(:image_text, %i[title text image]),
|
|
12
|
+
Definition.new(:full_bleed_image, %i[image]),
|
|
13
|
+
Definition.new(:quote, %i[quote attribution]),
|
|
14
|
+
Definition.new(:code, %i[title code]),
|
|
15
|
+
Definition.new(:blank, [])
|
|
16
|
+
].freeze
|
|
17
|
+
private_constant :Definition
|
|
18
|
+
|
|
19
|
+
NAMES = DEFINITIONS.map(&:name).freeze
|
|
20
|
+
ALIASES = {
|
|
21
|
+
"title+body" => :title_body,
|
|
22
|
+
"title-body" => :title_body,
|
|
23
|
+
"two-column" => :two_column,
|
|
24
|
+
"image+text" => :image_text,
|
|
25
|
+
"image-text" => :image_text,
|
|
26
|
+
"full-bleed-image" => :full_bleed_image
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def self.fetch(name)
|
|
30
|
+
normalized = normalize(name)
|
|
31
|
+
DEFINITIONS.find { |definition| definition.name == normalized } ||
|
|
32
|
+
raise(ArgumentError, "unknown slide layout: #{name.inspect}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.select(nodes, requested: nil)
|
|
36
|
+
return fetch(requested).name if requested && !requested.to_s.empty?
|
|
37
|
+
|
|
38
|
+
types = nodes.map(&:type)
|
|
39
|
+
div_names = nodes.select { |node| node.type == :directive && node.attributes[:kind] == :div }
|
|
40
|
+
.map { |node| node.attributes[:name] }
|
|
41
|
+
return :two_column if div_names.include?("left") || div_names.include?("right")
|
|
42
|
+
return :quote if types.include?(:block_quote)
|
|
43
|
+
return :code if types.include?(:code_block) && (types - %i[heading code_block]).empty?
|
|
44
|
+
|
|
45
|
+
images = nodes.any? { |node| contains_type?(node, :image) }
|
|
46
|
+
text = nodes.any? { |node| text?(node) }
|
|
47
|
+
return :image_text if images && text
|
|
48
|
+
return :full_bleed_image if images
|
|
49
|
+
return :blank if nodes.empty?
|
|
50
|
+
|
|
51
|
+
headings = nodes.count { |node| node.type == :heading }
|
|
52
|
+
return :title if headings.between?(1, 2) && nodes.all? { |node| node.type == :heading }
|
|
53
|
+
|
|
54
|
+
:title_body
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.normalize(name)
|
|
58
|
+
key = name.to_s.strip.downcase
|
|
59
|
+
ALIASES.fetch(key) { key.tr("-", "_").to_sym }
|
|
60
|
+
end
|
|
61
|
+
private_class_method :normalize
|
|
62
|
+
|
|
63
|
+
def self.contains_type?(node, type)
|
|
64
|
+
node.type == type || node.children.any? { |child| contains_type?(child, type) }
|
|
65
|
+
end
|
|
66
|
+
private_class_method :contains_type?
|
|
67
|
+
|
|
68
|
+
def self.text?(node)
|
|
69
|
+
return false if node.type == :image
|
|
70
|
+
if node.type == :text
|
|
71
|
+
text = node.attributes.fetch(:text, "")
|
|
72
|
+
return !text.strip.empty?
|
|
73
|
+
end
|
|
74
|
+
return true if %i[code_block html_block].include?(node.type)
|
|
75
|
+
|
|
76
|
+
node.children.any? { |child| text?(child) }
|
|
77
|
+
end
|
|
78
|
+
private_class_method :text?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
class Presenter
|
|
5
|
+
attr_reader :deck, :current_index, :started_at
|
|
6
|
+
|
|
7
|
+
def initialize(deck, renderer: Renderer.new, clock: Zaniah::MONOTONIC_CLOCK)
|
|
8
|
+
raise TypeError, "deck must be a Hadar::Deck" unless deck.is_a?(Deck)
|
|
9
|
+
raise TypeError, "renderer must respond to build" unless renderer.respond_to?(:build)
|
|
10
|
+
raise TypeError, "clock must be callable" unless clock.respond_to?(:call)
|
|
11
|
+
|
|
12
|
+
@deck, @renderer, @clock = deck, renderer, clock
|
|
13
|
+
@current_index = deck.empty? ? nil : 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def start(index: current_index)
|
|
17
|
+
validate_index!(index)
|
|
18
|
+
@current_index = index
|
|
19
|
+
@started_at = @clock.call
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def go_to(index)
|
|
24
|
+
validate_index!(index)
|
|
25
|
+
@current_index = index
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stop
|
|
30
|
+
@started_at = nil
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def started? = !started_at.nil?
|
|
35
|
+
|
|
36
|
+
def current_slide
|
|
37
|
+
current_index && deck.slide(current_index)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def next_slide
|
|
41
|
+
return if current_index.nil? || current_index + 1 >= deck.length
|
|
42
|
+
|
|
43
|
+
deck.slide(current_index + 1)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def notes = current_slide&.notes
|
|
47
|
+
|
|
48
|
+
def elapsed_seconds
|
|
49
|
+
return 0.0 unless started?
|
|
50
|
+
|
|
51
|
+
[@clock.call - started_at, 0.0].max
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def elapsed_text
|
|
55
|
+
seconds = elapsed_seconds.floor
|
|
56
|
+
format("%d:%02d", seconds / 60, seconds % 60)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def slide_view
|
|
60
|
+
return Zaniah::UI::Label.new("No slides") unless current_slide
|
|
61
|
+
|
|
62
|
+
@renderer.build(current_slide)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def presenter_view
|
|
66
|
+
following = next_slide
|
|
67
|
+
Zaniah::Div.new.flex_col.gap(12)
|
|
68
|
+
.child(Zaniah::UI::Label.new("Next slide", size: :lg))
|
|
69
|
+
.child(following ? @renderer.build(following) : Zaniah::UI::Label.new("End of deck", tone: :muted))
|
|
70
|
+
.child(Zaniah::UI::Label.new("Speaker notes", size: :lg))
|
|
71
|
+
.child(Zaniah::UI::Label.new(notes || "No speaker notes", wrap: :word))
|
|
72
|
+
.child(Zaniah::UI::Label.new("Elapsed: #{elapsed_text}", tone: :muted))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def reconcile!
|
|
76
|
+
if deck.empty?
|
|
77
|
+
@current_index = nil
|
|
78
|
+
@started_at = nil
|
|
79
|
+
else
|
|
80
|
+
@current_index = [[current_index || 0, 0].max, deck.length - 1].min
|
|
81
|
+
end
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def validate_index!(index)
|
|
88
|
+
raise IndexError, "cannot start a presentation without slides" if deck.empty?
|
|
89
|
+
raise IndexError, "slide index is outside the deck" unless index.is_a?(Integer) && (0...deck.length).cover?(index)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
class Renderer
|
|
5
|
+
def initialize
|
|
6
|
+
@font_db = nil
|
|
7
|
+
@fonts = {}
|
|
8
|
+
@vocabulary = build_vocabulary
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def describe(slide)
|
|
12
|
+
raise TypeError, "slide must be a Hadar::Slide" unless slide.is_a?(Slide)
|
|
13
|
+
|
|
14
|
+
children = Array(content_for(slide)).compact
|
|
15
|
+
Zaniah::Describe::Node.new(:slide, {
|
|
16
|
+
background: slide_theme(slide).colors.fetch("background"),
|
|
17
|
+
margin: slide_theme(slide).spacing.fetch("margin"),
|
|
18
|
+
gap: slide_theme(slide).spacing.fetch("gap")
|
|
19
|
+
}, children, "slide-#{slide.index}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def build(slide)
|
|
23
|
+
Zaniah::Describe.build(describe(slide), vocabulary: @vocabulary,
|
|
24
|
+
on_event: ->(_id, _payload) {})
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def surface(slide)
|
|
28
|
+
Zaniah::Describe::Surface.new(vocabulary: @vocabulary,
|
|
29
|
+
on_event: ->(_id, _payload) {}).replace(describe(slide))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build_vocabulary
|
|
35
|
+
db = self
|
|
36
|
+
Zaniah::Describe::Vocabulary.build do
|
|
37
|
+
node :slide, props: {background: :string, margin: :number, gap: :number} do |props, children|
|
|
38
|
+
Zaniah::Element.new.flex_col.w_full.h_full.bg(props.fetch(:background))
|
|
39
|
+
.p(props.fetch(:margin)).style(gap: props.fetch(:gap)).children(children)
|
|
40
|
+
end
|
|
41
|
+
node :stack, props: {gap: :number} do |props, children|
|
|
42
|
+
Zaniah::Element.new.flex_col.style(gap: props.fetch(:gap)).children(children)
|
|
43
|
+
end
|
|
44
|
+
node :columns, props: {gap: :number} do |props, children|
|
|
45
|
+
Zaniah::Element.new.flex_row.style(gap: props.fetch(:gap)).children(children)
|
|
46
|
+
end
|
|
47
|
+
node :text, props: {text: :string, size: :number, color: :string,
|
|
48
|
+
family: :string}, children: :none do |props, _children|
|
|
49
|
+
font = db.send(:font, props.fetch(:family))
|
|
50
|
+
Zaniah::Text.new(props.fetch(:text), size: props.fetch(:size),
|
|
51
|
+
color: props.fetch(:color), font: font, wrap: :word)
|
|
52
|
+
end
|
|
53
|
+
node :image, props: {path: :string}, children: :none do |props, _children|
|
|
54
|
+
path = props.fetch(:path)
|
|
55
|
+
begin
|
|
56
|
+
Zaniah::Image.new(path).w_full
|
|
57
|
+
rescue StandardError => error
|
|
58
|
+
raise Error, "cannot render image #{path.inspect}: #{error.message}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
node :table, props: {rows: :array, alignments: :array, size: :number,
|
|
62
|
+
family: :string, text_color: :string, header_color: :string, border_color: :string}, children: :none do |props, _children|
|
|
63
|
+
rows = props.fetch(:rows).each_with_index.map do |row, row_index|
|
|
64
|
+
cells = row.each_with_index.map do |value, column|
|
|
65
|
+
alignment = {left: :start, center: :center, right: :end}[props.fetch(:alignments)[column]] || :start
|
|
66
|
+
color = row_index.zero? ? props.fetch(:header_color) : props.fetch(:text_color)
|
|
67
|
+
Zaniah::Text.new(value, size: props.fetch(:size), color: color,
|
|
68
|
+
font: db.send(:font, props.fetch(:family)), wrap: :word, align: alignment)
|
|
69
|
+
.flex_1.p(6).border(1).border_color(props.fetch(:border_color))
|
|
70
|
+
end
|
|
71
|
+
Zaniah::Element.new.flex_row.w_full.children(cells)
|
|
72
|
+
end
|
|
73
|
+
Zaniah::Element.new.flex_col.w_full.children(rows)
|
|
74
|
+
end
|
|
75
|
+
node :code_block, props: {language: :string} do |_props, children|
|
|
76
|
+
Zaniah::Element.new.flex_col.w_full.children(children)
|
|
77
|
+
end
|
|
78
|
+
node :code_line, props: {} do |_props, children|
|
|
79
|
+
Zaniah::Element.new.flex_row.children(children)
|
|
80
|
+
end
|
|
81
|
+
node :code_token, props: {text: :string, color: :string, size: :number}, children: :none do |props, _children|
|
|
82
|
+
Zaniah::Text.new(props.fetch(:text), size: props.fetch(:size), color: props.fetch(:color),
|
|
83
|
+
font: db.send(:font, "monospace"), wrap: :none)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def font(family)
|
|
89
|
+
@fonts[family] ||= begin
|
|
90
|
+
@font_db ||= Zaniah::TextSystem::FontDB.new
|
|
91
|
+
@font_db.find(family: family == "sans-serif" ? nil : family)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def content_for(slide)
|
|
96
|
+
theme = slide_theme(slide)
|
|
97
|
+
gap = theme.spacing.fetch("gap")
|
|
98
|
+
case slide.layout
|
|
99
|
+
when :title
|
|
100
|
+
stack([
|
|
101
|
+
text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
|
|
102
|
+
text_node(slide.slot(:subtitle).text, theme.font.fetch("body_size"), theme, color: theme.colors.fetch("muted"))
|
|
103
|
+
].reject(&:nil?), gap)
|
|
104
|
+
when :two_column
|
|
105
|
+
heading = text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme)
|
|
106
|
+
columns = node(:columns, {gap: gap}, [
|
|
107
|
+
stack(render_blocks(slide.slot(:left), theme), gap),
|
|
108
|
+
stack(render_blocks(slide.slot(:right), theme), gap)
|
|
109
|
+
])
|
|
110
|
+
[heading, columns].compact
|
|
111
|
+
when :image_text
|
|
112
|
+
[text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
|
|
113
|
+
*render_blocks(slide.slot(:text), theme),
|
|
114
|
+
image_node(slide.slot(:image))].compact
|
|
115
|
+
when :full_bleed_image
|
|
116
|
+
[image_node(slide.slot(:image))].compact
|
|
117
|
+
when :quote
|
|
118
|
+
[*text_nodes(slide.slot(:quote).text, theme, size: theme.font.fetch("title_size")),
|
|
119
|
+
*text_nodes(slide.slot(:attribution).text, theme, size: theme.font.fetch("body_size"), color: theme.colors.fetch("muted"))]
|
|
120
|
+
when :code
|
|
121
|
+
[text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
|
|
122
|
+
*render_blocks(slide.slot(:code), theme)].compact
|
|
123
|
+
when :blank
|
|
124
|
+
[]
|
|
125
|
+
else
|
|
126
|
+
[text_node(slide.slot(:title).text, theme.font.fetch("title_size"), theme),
|
|
127
|
+
*render_blocks(slide.slot(:body), theme)].compact
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def render_blocks(slot, theme)
|
|
132
|
+
table_index = code_index = 0
|
|
133
|
+
slot.nodes.flat_map do |source_node|
|
|
134
|
+
case source_node.type
|
|
135
|
+
when :table
|
|
136
|
+
result = table_node(slot, source_node, table_index, theme)
|
|
137
|
+
table_index += 1
|
|
138
|
+
[result]
|
|
139
|
+
when :code_block
|
|
140
|
+
result = code_node(slot, source_node, code_index, theme)
|
|
141
|
+
code_index += 1
|
|
142
|
+
[result]
|
|
143
|
+
else
|
|
144
|
+
text_nodes(slot.text_for(source_node), theme)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def table_node(slot, source_node, index, theme)
|
|
150
|
+
node(:table, {
|
|
151
|
+
rows: slot.table_rows(table: index),
|
|
152
|
+
alignments: source_node.attributes.fetch(:alignments, []),
|
|
153
|
+
size: theme.font.fetch("body_size"),
|
|
154
|
+
family: theme.font.fetch("family"),
|
|
155
|
+
text_color: theme.colors.fetch("text"),
|
|
156
|
+
header_color: theme.colors.fetch("accent"),
|
|
157
|
+
border_color: theme.colors.fetch("muted")
|
|
158
|
+
}, [], "slide-#{slot.slide_index}-table-#{index}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def code_node(slot, source_node, index, theme)
|
|
162
|
+
source = slot.text_for(source_node)
|
|
163
|
+
language = source_node.attributes.fetch(:info, "").split.first.to_s
|
|
164
|
+
lines = source.scan(/.*?(?:\r\n|\r|\n|\z)/m).reject(&:empty?)
|
|
165
|
+
lines = [""] if lines.empty?
|
|
166
|
+
lexer = language.empty? ? nil : Rouge::Lexer.find(language)
|
|
167
|
+
rows = code_tokens(lines, lexer)
|
|
168
|
+
line_nodes = rows.each_with_index.map do |tokens, line_index|
|
|
169
|
+
line = lines.fetch(line_index)
|
|
170
|
+
body = line.sub(/\r\n\z|\r\z|\n\z/, "")
|
|
171
|
+
visible_bytes = body.bytesize
|
|
172
|
+
offset = 0
|
|
173
|
+
token_nodes = tokens.filter_map do |kind, text|
|
|
174
|
+
size = [text.bytesize, visible_bytes - offset].min
|
|
175
|
+
offset += text.bytesize
|
|
176
|
+
next if size <= 0
|
|
177
|
+
|
|
178
|
+
node(:code_token, {text: text.byteslice(0, size), color: token_color(kind, theme),
|
|
179
|
+
size: theme.font.fetch("body_size")}, [])
|
|
180
|
+
end
|
|
181
|
+
token_nodes << node(:code_token, {text: " ", color: theme.colors.fetch("text"),
|
|
182
|
+
size: theme.font.fetch("body_size")}, []) if token_nodes.empty?
|
|
183
|
+
node(:code_line, {}, token_nodes, "line-#{line_index}")
|
|
184
|
+
end
|
|
185
|
+
node(:code_block, {language: language}, line_nodes, "slide-#{slot.slide_index}-code-#{index}")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def code_tokens(lines, lexer)
|
|
189
|
+
return lines.map { |line| [[nil, line]] } unless lexer
|
|
190
|
+
|
|
191
|
+
lexer_lines = lines.map do |line|
|
|
192
|
+
line.match?(/(?:\r\n|\r|\n)\z/) ? line.sub(/\r\n\z|\r\z|\n\z/, "\n") : line
|
|
193
|
+
end
|
|
194
|
+
highlighter = Antares::Highlighter.new(lexer: lexer.new,
|
|
195
|
+
lines: ->(index) { lexer_lines.fetch(index) }, line_count: -> { lexer_lines.length })
|
|
196
|
+
lexer_lines.each_index.map { |index| highlighter.tokens_for(index) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def token_color(kind, theme)
|
|
200
|
+
name = kind&.qualname.to_s
|
|
201
|
+
return theme.colors.fetch("muted") if name.start_with?("Comment")
|
|
202
|
+
return theme.colors.fetch("accent") if name.start_with?("Keyword", "Literal.Number", "Literal.String", "Name.Builtin")
|
|
203
|
+
|
|
204
|
+
theme.colors.fetch("text")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def text_nodes(text, theme, size: theme.font.fetch("body_size"), color: theme.colors.fetch("text"), family: theme.font.fetch("family"))
|
|
208
|
+
return [] if text.nil? || text.empty?
|
|
209
|
+
|
|
210
|
+
text.split(/\n+/).reject(&:empty?).map do |part|
|
|
211
|
+
text_node(part, size, theme, color: color, family: family)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def text_node(text, size, theme, color: theme.colors.fetch("text"), family: theme.font.fetch("family"))
|
|
216
|
+
return if text.nil? || text.empty?
|
|
217
|
+
|
|
218
|
+
node(:text, {text: text, size: size, color: color, family: family}, [])
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def stack(children, gap)
|
|
222
|
+
node(:stack, {gap: gap}, children)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def node(type, props, children, key = nil)
|
|
226
|
+
Zaniah::Describe::Node.new(type, props, children, key)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def image_node(slot)
|
|
230
|
+
return if slot.empty?
|
|
231
|
+
|
|
232
|
+
path = slot.resolved_image_path
|
|
233
|
+
node(:image, {path: path}, [], "slide-#{slot.slide_index}-#{slot.name}-image")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def slide_theme(slide) = slide.theme
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hadar
|
|
4
|
+
class RichTextProjection
|
|
5
|
+
Segment = Data.define(:start, :finish, :text, :node, :style, :wrappers, :list_item, :list_level)
|
|
6
|
+
attr_reader :document, :text, :segments, :list_context, :paragraph_styles
|
|
7
|
+
|
|
8
|
+
def initialize(slot)
|
|
9
|
+
@document = slot.document
|
|
10
|
+
@list_context = {}
|
|
11
|
+
@parts = join(slot.nodes.map { |node| project(node, {}, [], -1, nil) }, "\n")
|
|
12
|
+
@text = @parts.map(&:first).join.freeze
|
|
13
|
+
offset = 0
|
|
14
|
+
@segments = @parts.map do |part, node, style, wrappers, list_item, list_level|
|
|
15
|
+
segment = Segment.new(offset, offset + part.bytesize, part, node, style.freeze, wrappers.freeze, list_item, list_level)
|
|
16
|
+
offset += part.bytesize
|
|
17
|
+
segment
|
|
18
|
+
end.freeze
|
|
19
|
+
@paragraph_styles = Array.new(@text.count("\n") + 1) { {} }
|
|
20
|
+
@segments.each do |segment|
|
|
21
|
+
next unless segment.list_level
|
|
22
|
+
|
|
23
|
+
first = @text.byteslice(0...segment.start).to_s.count("\n")
|
|
24
|
+
last = @text.byteslice(0...segment.finish).to_s.count("\n")
|
|
25
|
+
(first..last).each { |line| @paragraph_styles[line] = {level: segment.list_level} }
|
|
26
|
+
end
|
|
27
|
+
raise Error, "slot content is not representable as rich text" unless @text == slot.text
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build(editable: true)
|
|
31
|
+
runs = @parts.map { |part, _node, style, _wrappers, _item, _level| {text: part, **style} }
|
|
32
|
+
value = Zaniah::UI::RichText.new(runs, selectable: true, editable: editable)
|
|
33
|
+
line_start = 0
|
|
34
|
+
@paragraph_styles.each_with_index do |style, index|
|
|
35
|
+
line_end = @text.b.index("\n".b, line_start) || @text.bytesize
|
|
36
|
+
value.paragraph_style(line_start...line_end, level: style[:level]) if style.key?(:level)
|
|
37
|
+
line_start = line_end + 1
|
|
38
|
+
end
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def replace(value)
|
|
43
|
+
RichTextWriteback.new(self, value).replace
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def project(node, style, wrappers, list_level, parent_item)
|
|
49
|
+
case node.type
|
|
50
|
+
when :text
|
|
51
|
+
leaf(node.attributes.fetch(:text, ""), node, style, wrappers, parent_item, list_level)
|
|
52
|
+
when :code_span
|
|
53
|
+
leaf(node.attributes.fetch(:text, ""), node, style.merge(font: "monospace"), wrappers, parent_item, list_level)
|
|
54
|
+
when :image
|
|
55
|
+
leaf(node.attributes.fetch(:label, ""), node, style, wrappers, parent_item, list_level)
|
|
56
|
+
when :break, :softbreak
|
|
57
|
+
leaf("\n", node, style, wrappers, parent_item, list_level)
|
|
58
|
+
when :strong
|
|
59
|
+
children(node.children, style.merge(bold: true), wrappers + [[:bold, node]], list_level, parent_item)
|
|
60
|
+
when :emphasis
|
|
61
|
+
children(node.children, style.merge(italic: true), wrappers + [[:italic, node]], list_level, parent_item)
|
|
62
|
+
when :link
|
|
63
|
+
children(node.children, style.merge(link: node.attributes.fetch(:destination)), wrappers, list_level, parent_item)
|
|
64
|
+
when :paragraph, :heading
|
|
65
|
+
children(node.children, style, wrappers, list_level, parent_item)
|
|
66
|
+
when :block_quote
|
|
67
|
+
join(node.children.map { |child| project(child, style, wrappers, list_level, parent_item) }, "\n")
|
|
68
|
+
when :list, :ordered_list
|
|
69
|
+
level = list_level + 1
|
|
70
|
+
items = node.children.each_with_index.map do |item, index|
|
|
71
|
+
@list_context[item.object_id] = {level: level, list: node, previous: node.children[index - 1],
|
|
72
|
+
parent: parent_item}
|
|
73
|
+
marker = if item.attributes[:task]
|
|
74
|
+
item.attributes[:checked] ? "☑ " : "☐ "
|
|
75
|
+
elsif item.attributes[:ordered]
|
|
76
|
+
"#{item.attributes[:start] || index + 1}. "
|
|
77
|
+
else
|
|
78
|
+
"• "
|
|
79
|
+
end
|
|
80
|
+
[[marker, item, {}, [], item, level], *project(item, style, wrappers, level, item)]
|
|
81
|
+
end
|
|
82
|
+
join(items, "\n")
|
|
83
|
+
when :list_item
|
|
84
|
+
join(node.children.map { |child| project(child, style, wrappers, list_level, node) }, "\n")
|
|
85
|
+
when :footnote_reference, :task_checkbox, :front_matter, :directive, :thematic_break
|
|
86
|
+
[]
|
|
87
|
+
when :strikethrough
|
|
88
|
+
raise Error, "strikethrough has no Zaniah rich-text style"
|
|
89
|
+
else
|
|
90
|
+
raise Error, "#{node.type} slots are not supported by rich-text editing"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def children(nodes, style, wrappers, list_level, parent_item)
|
|
95
|
+
nodes.flat_map { |node| project(node, style, wrappers, list_level, parent_item) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def leaf(text, node, style, wrappers, list_item, list_level)
|
|
99
|
+
text.empty? ? [] : [[text, node, style, wrappers, list_item, list_item ? list_level : nil]]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def join(groups, separator)
|
|
103
|
+
groups.reject { |parts| parts.empty? || parts.all? { |part, _node, _style, _wrappers, _item, _level| part.empty? } }
|
|
104
|
+
.each_with_object([]) do |parts, result|
|
|
105
|
+
result << [separator, nil, {}, [], nil, nil] unless result.empty?
|
|
106
|
+
result.concat(parts)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|