zaniah 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 +70 -0
- data/assets/fonts/Abel-Regular.ttf +0 -0
- data/assets/fonts/OFL.txt +93 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-native-gpu-backends.md +36 -0
- data/docs/adr/002-objective-c-aggregates.md +34 -0
- data/docs/adr/003-fiddle-call-boundary.md +33 -0
- data/docs/adr/README.md +10 -0
- data/docs/native.md +66 -0
- data/docs/process_pool.md +67 -0
- data/docs/text.md +90 -0
- data/docs/vector_and_list.md +52 -0
- data/examples/ffi_bench.rb +16 -0
- data/examples/linux_smoke.rb +53 -0
- data/examples/mac_event_check.rb +61 -0
- data/examples/mac_poll_bench.rb +24 -0
- data/examples/metal_state_check.rb +72 -0
- data/examples/native_bench.rb +18 -0
- data/examples/native_drop.rb +82 -0
- data/examples/native_font.rb +15 -0
- data/examples/native_ime.rb +50 -0
- data/examples/native_smoke.rb +75 -0
- data/examples/native_text_bench.rb +83 -0
- data/examples/native_watch.rb +25 -0
- data/examples/vulkan_triangle.rb +20 -0
- data/examples/word_count_worker.rb +8 -0
- data/lib/zaniah/anchored.rb +10 -0
- data/lib/zaniah/app.rb +130 -0
- data/lib/zaniah/bounds.rb +22 -0
- data/lib/zaniah/canvas.rb +15 -0
- data/lib/zaniah/color.rb +35 -0
- data/lib/zaniah/configuration.rb +5 -0
- data/lib/zaniah/corners.rb +7 -0
- data/lib/zaniah/data_compat.rb +25 -0
- data/lib/zaniah/device_pixels.rb +7 -0
- data/lib/zaniah/div.rb +5 -0
- data/lib/zaniah/edges.rb +7 -0
- data/lib/zaniah/element.rb +106 -0
- data/lib/zaniah/entity.rb +9 -0
- data/lib/zaniah/entity_context.rb +17 -0
- data/lib/zaniah/error.rb +5 -0
- data/lib/zaniah/ffi/com.rb +24 -0
- data/lib/zaniah/ffi/library.rb +29 -0
- data/lib/zaniah/ffi/objc.rb +86 -0
- data/lib/zaniah/ffi/struct/signature.rb +46 -0
- data/lib/zaniah/ffi/struct.rb +61 -0
- data/lib/zaniah/ffi/wayland.rb +140 -0
- data/lib/zaniah/frame_context.rb +14 -0
- data/lib/zaniah/geometry.rb +16 -0
- data/lib/zaniah/gpu/buffer.rb +23 -0
- data/lib/zaniah/gpu/frame_encoder.rb +43 -0
- data/lib/zaniah/gpu/instance_packing.rb +53 -0
- data/lib/zaniah/gpu/metal.rb +209 -0
- data/lib/zaniah/gpu/open_gl.rb +210 -0
- data/lib/zaniah/gpu/pipeline.rb +7 -0
- data/lib/zaniah/gpu/software.rb +126 -0
- data/lib/zaniah/gpu/texture.rb +33 -0
- data/lib/zaniah/gpu/vulkan.rb +233 -0
- data/lib/zaniah/gpu.rb +18 -0
- data/lib/zaniah/hsla.rb +14 -0
- data/lib/zaniah/image.rb +19 -0
- data/lib/zaniah/input/action_registry.rb +12 -0
- data/lib/zaniah/input/composition.rb +7 -0
- data/lib/zaniah/input/context_predicate.rb +87 -0
- data/lib/zaniah/input/dispatcher.rb +59 -0
- data/lib/zaniah/input/file_drop.rb +7 -0
- data/lib/zaniah/input/focus_handle.rb +24 -0
- data/lib/zaniah/input/key_down.rb +7 -0
- data/lib/zaniah/input/key_up.rb +7 -0
- data/lib/zaniah/input/keymap/binding.rb +9 -0
- data/lib/zaniah/input/keymap.rb +55 -0
- data/lib/zaniah/input/keystroke.rb +20 -0
- data/lib/zaniah/input/mouse_down.rb +7 -0
- data/lib/zaniah/input/mouse_move.rb +7 -0
- data/lib/zaniah/input/mouse_up.rb +7 -0
- data/lib/zaniah/input/scroll_wheel.rb +7 -0
- data/lib/zaniah/input/text_input.rb +7 -0
- data/lib/zaniah/input.rb +17 -0
- data/lib/zaniah/layout/available_space.rb +11 -0
- data/lib/zaniah/layout/engine.rb +214 -0
- data/lib/zaniah/layout/node.rb +36 -0
- data/lib/zaniah/layout/style.rb +19 -0
- data/lib/zaniah/layout/tree.rb +16 -0
- data/lib/zaniah/layout.rb +11 -0
- data/lib/zaniah/length.rb +14 -0
- data/lib/zaniah/length_units.rb +9 -0
- data/lib/zaniah/list/height_index.rb +68 -0
- data/lib/zaniah/list.rb +101 -0
- data/lib/zaniah/overlay.rb +10 -0
- data/lib/zaniah/platform/display.rb +7 -0
- data/lib/zaniah/platform/file_event.rb +7 -0
- data/lib/zaniah/platform/headless/window.rb +185 -0
- data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
- data/lib/zaniah/platform/linux/displays.rb +55 -0
- data/lib/zaniah/platform/linux/free_type.rb +80 -0
- data/lib/zaniah/platform/linux/watcher.rb +70 -0
- data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
- data/lib/zaniah/platform/linux/window.rb +418 -0
- data/lib/zaniah/platform/linux.rb +3 -0
- data/lib/zaniah/platform/mac/app.rb +67 -0
- data/lib/zaniah/platform/mac/core_text.rb +94 -0
- data/lib/zaniah/platform/mac/displays.rb +23 -0
- data/lib/zaniah/platform/mac/watcher.rb +63 -0
- data/lib/zaniah/platform/mac/window.rb +298 -0
- data/lib/zaniah/platform/mac.rb +19 -0
- data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
- data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
- data/lib/zaniah/platform/tui/window.rb +91 -0
- data/lib/zaniah/platform/windows/terminal.rb +127 -0
- data/lib/zaniah/platform/windows/watcher.rb +91 -0
- data/lib/zaniah/platform/windows/window.rb +407 -0
- data/lib/zaniah/platform/windows.rb +3 -0
- data/lib/zaniah/platform.rb +34 -0
- data/lib/zaniah/png.rb +118 -0
- data/lib/zaniah/point.rb +8 -0
- data/lib/zaniah/process_pool/worker.rb +7 -0
- data/lib/zaniah/process_pool.rb +239 -0
- data/lib/zaniah/process_wire.rb +127 -0
- data/lib/zaniah/quad_packer.rb +15 -0
- data/lib/zaniah/scene/sprite_batch.rb +7 -0
- data/lib/zaniah/scene.rb +167 -0
- data/lib/zaniah/scene_renderer.rb +8 -0
- data/lib/zaniah/size.rb +5 -0
- data/lib/zaniah/subscription.rb +14 -0
- data/lib/zaniah/svg/path.rb +134 -0
- data/lib/zaniah/svg/rasterizable.rb +186 -0
- data/lib/zaniah/svg.rb +295 -0
- data/lib/zaniah/task/cancelled.rb +7 -0
- data/lib/zaniah/task/timeout.rb +7 -0
- data/lib/zaniah/task.rb +51 -0
- data/lib/zaniah/task_executor.rb +105 -0
- data/lib/zaniah/text.rb +24 -0
- data/lib/zaniah/text_system/atlas/entry.rb +9 -0
- data/lib/zaniah/text_system/atlas.rb +102 -0
- data/lib/zaniah/text_system/atlas_cache.rb +146 -0
- data/lib/zaniah/text_system/font_db/face.rb +9 -0
- data/lib/zaniah/text_system/font_db.rb +193 -0
- data/lib/zaniah/text_system/glyph.rb +7 -0
- data/lib/zaniah/text_system/line_layout.rb +20 -0
- data/lib/zaniah/text_system/renderer.rb +196 -0
- data/lib/zaniah/text_system/shaper.rb +430 -0
- data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
- data/lib/zaniah/text_system/typesetter.rb +168 -0
- data/lib/zaniah/text_system.rb +9 -0
- data/lib/zaniah/unicode.rb +13 -0
- data/lib/zaniah/uniform_list.rb +30 -0
- data/lib/zaniah/version.rb +5 -0
- data/lib/zaniah/view.rb +13 -0
- data/lib/zaniah.rb +29 -0
- data/sig/app.rbs +80 -0
- data/sig/elements.rbs +129 -0
- data/sig/gpu.rbs +108 -0
- data/sig/input.rbs +114 -0
- data/sig/layout.rbs +53 -0
- data/sig/native.rbs +88 -0
- data/sig/platform.rbs +94 -0
- data/sig/scene.rbs +52 -0
- data/sig/shaper.rbs +8 -0
- data/sig/text.rbs +137 -0
- data/sig/zaniah.rbs +120 -0
- data/tools/check_dependencies.rb +52 -0
- metadata +249 -0
data/lib/zaniah/task.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
require_relative "subscription"
|
|
5
|
+
|
|
6
|
+
module Zaniah
|
|
7
|
+
class Task
|
|
8
|
+
def initialize
|
|
9
|
+
@mutex, @condition = Mutex.new, ConditionVariable.new
|
|
10
|
+
@done = false
|
|
11
|
+
@callbacks = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(value = nil, error: nil)
|
|
15
|
+
callbacks = @mutex.synchronize do
|
|
16
|
+
return if @done
|
|
17
|
+
@value, @error, @done = value, error, true
|
|
18
|
+
@condition.broadcast
|
|
19
|
+
saved, @callbacks = @callbacks, []
|
|
20
|
+
saved
|
|
21
|
+
end
|
|
22
|
+
callbacks.each(&:call)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def await(timeout: nil)
|
|
26
|
+
return TaskExecutor.current.await(self, timeout: timeout) if TaskExecutor.current && !done?
|
|
27
|
+
deadline = timeout && Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
until @done
|
|
30
|
+
remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
31
|
+
raise Timeout, "task timed out" if remaining && remaining <= 0
|
|
32
|
+
@condition.wait(@mutex, remaining)
|
|
33
|
+
end
|
|
34
|
+
raise @error if @error
|
|
35
|
+
@value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def cancel = resolve(error: Cancelled.new("task cancelled"))
|
|
40
|
+
def done? = @mutex.synchronize { @done }
|
|
41
|
+
|
|
42
|
+
def on_complete(&callback)
|
|
43
|
+
complete = @mutex.synchronize { @done || (@callbacks << callback; false) }
|
|
44
|
+
callback.call if complete
|
|
45
|
+
Subscription.new { @mutex.synchronize { @callbacks.delete(callback) } }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
require_relative "task/cancelled"
|
|
51
|
+
require_relative "task/timeout"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
require_relative "task"
|
|
5
|
+
|
|
6
|
+
module Zaniah
|
|
7
|
+
class TaskExecutor
|
|
8
|
+
def self.current = Thread.current[:zaniah_executor]
|
|
9
|
+
|
|
10
|
+
def initialize(workers: 2)
|
|
11
|
+
@jobs, @foreground = Queue.new, Queue.new
|
|
12
|
+
@wake, @condition = Mutex.new, ConditionVariable.new
|
|
13
|
+
@timers = []
|
|
14
|
+
@threads = Array.new(workers) do
|
|
15
|
+
Thread.new do
|
|
16
|
+
while (job = @jobs.pop)
|
|
17
|
+
task, work = job
|
|
18
|
+
next if task.done?
|
|
19
|
+
begin
|
|
20
|
+
task.resolve(work.call)
|
|
21
|
+
rescue StandardError => error
|
|
22
|
+
task.resolve(error: error)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def background(&block)
|
|
30
|
+
raise Error, "executor is shut down" if @closed
|
|
31
|
+
task = Task.new
|
|
32
|
+
@jobs << [task, block]
|
|
33
|
+
task
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def spawn(&block)
|
|
37
|
+
raise Error, "executor is shut down" if @closed
|
|
38
|
+
task = Task.new
|
|
39
|
+
fiber = Fiber.new do
|
|
40
|
+
Thread.current[:zaniah_executor] = self
|
|
41
|
+
Thread.current[:zaniah_task] = task
|
|
42
|
+
begin
|
|
43
|
+
task.resolve(block.call)
|
|
44
|
+
rescue StandardError => error
|
|
45
|
+
task.resolve(error: error)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
post { fiber.resume unless task.done? }
|
|
49
|
+
task
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def await(task, timeout: nil)
|
|
53
|
+
raise Error, "await must run in an executor fiber" unless self.class.current.equal?(self)
|
|
54
|
+
return task.await(timeout: timeout) if task.done?
|
|
55
|
+
fiber, owner = Fiber.current, Thread.current[:zaniah_task]
|
|
56
|
+
finished, error, deadline = false, nil, nil
|
|
57
|
+
finish = lambda do |failure = nil|
|
|
58
|
+
next if finished
|
|
59
|
+
finished, error = true, failure
|
|
60
|
+
fiber.resume if fiber.alive? && !owner.done?
|
|
61
|
+
end
|
|
62
|
+
subscription = task.on_complete { post { finish.call } }
|
|
63
|
+
cancellation = owner.on_complete { post { subscription.detach; @timers.delete(deadline) if deadline } }
|
|
64
|
+
if timeout
|
|
65
|
+
raise ArgumentError, "timeout must be nonnegative" unless timeout.is_a?(Numeric) && timeout >= 0
|
|
66
|
+
deadline = [Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout, -> { finish.call(Task::Timeout.new("task timed out")) }]
|
|
67
|
+
@timers << deadline
|
|
68
|
+
end
|
|
69
|
+
Fiber.yield
|
|
70
|
+
raise error if error
|
|
71
|
+
task.await(timeout: 0)
|
|
72
|
+
ensure
|
|
73
|
+
subscription&.detach
|
|
74
|
+
cancellation&.detach
|
|
75
|
+
@timers.delete(deadline) if deadline
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def post(&block)
|
|
79
|
+
@foreground << block
|
|
80
|
+
@wake.synchronize { @condition.signal }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def drain
|
|
84
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
85
|
+
due, @timers = @timers.partition { |deadline, _| deadline <= now }
|
|
86
|
+
due.each { |_, callback| callback.call }
|
|
87
|
+
loop { @foreground.pop(true).call }
|
|
88
|
+
rescue ThreadError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def wait(seconds)
|
|
93
|
+
deadline = @timers.map(&:first).min
|
|
94
|
+
seconds = [seconds, [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max].min if deadline
|
|
95
|
+
@wake.synchronize { @condition.wait(@wake, seconds) if @foreground.empty? }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def shutdown
|
|
99
|
+
return if @closed
|
|
100
|
+
@closed = true
|
|
101
|
+
@threads.length.times { @jobs << nil }
|
|
102
|
+
@threads.each(&:join)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/zaniah/text.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
class Text < Element
|
|
5
|
+
def initialize(text, size: 14, color: "#ddd", font: nil)
|
|
6
|
+
super()
|
|
7
|
+
@text, @font_size, @color, @font = text, size, color, font
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def measured(&block) = (@measure = block; self)
|
|
11
|
+
|
|
12
|
+
def request_layout(cx)
|
|
13
|
+
@line = cx.text_system&.layout_line(@text, font: @font, size: @font_size)
|
|
14
|
+
measurement = @measure || ->(_width, _height) { [@line ? @line.width : @text.length * @font_size * 0.6, @font_size * 1.4, @font_size] }
|
|
15
|
+
@layout_node = Layout::Node.new(style: @style, measure: measurement)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def paint(bounds, state, prepaint, cx)
|
|
19
|
+
super
|
|
20
|
+
cx.text_system&.paint_line(cx.scene, @line, x: bounds.x, y: bounds.y + @line.ascent, color: @color) if @line
|
|
21
|
+
cx.window.text_runs << [bounds.x, bounds.y, @text, @color] if cx.window.respond_to?(:text_runs)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module TextSystem
|
|
5
|
+
class Atlas
|
|
6
|
+
attr_reader :generation, :texture
|
|
7
|
+
|
|
8
|
+
def initialize(width: 2048, height: 2048, format: :r8)
|
|
9
|
+
@width, @height, @format = width, height, format
|
|
10
|
+
@generation, @cache, @retired = 0, {}, []
|
|
11
|
+
reset
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def fetch(key)
|
|
15
|
+
return @cache[key] if @cache.key?(key)
|
|
16
|
+
bitmap = yield
|
|
17
|
+
raise Error, "glyph exceeds atlas dimensions" if bitmap.width + 2 > @width || bitmap.height + 2 > @height
|
|
18
|
+
position = allocate(bitmap.width + 2, bitmap.height + 2)
|
|
19
|
+
unless position
|
|
20
|
+
@retired << @texture
|
|
21
|
+
reset
|
|
22
|
+
position = allocate(bitmap.width + 2, bitmap.height + 2)
|
|
23
|
+
end
|
|
24
|
+
x, y = position.map { |value| value + 1 }
|
|
25
|
+
@texture.upload(x, y, bitmap.width, bitmap.height, @format == :rgba8 ? bitmap.rgba : bitmap.coverage)
|
|
26
|
+
@cache[key] = Entry.new(@texture, x, y, bitmap.width, bitmap.height, bitmap.left, bitmap.top)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def lookup(key) = @cache[key]
|
|
30
|
+
def end_frame = @retired.clear
|
|
31
|
+
|
|
32
|
+
def save_cache(path, key:)
|
|
33
|
+
require_relative "atlas_cache"
|
|
34
|
+
AtlasCache.save(path, key: key, texture: @texture, skyline: @skyline, entries: @cache)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_cache(path, key:, preserve: false)
|
|
38
|
+
require_relative "atlas_cache"
|
|
39
|
+
loaded = AtlasCache.load(path, key: key, width: @width, height: @height, format: @format)
|
|
40
|
+
return false unless loaded
|
|
41
|
+
return false if preserve && @cache.any? { |id, _| !loaded.last.key?(id) }
|
|
42
|
+
@retired << @texture
|
|
43
|
+
@texture, @skyline, @cache = loaded
|
|
44
|
+
@generation += 1
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def reset
|
|
51
|
+
@texture = GPU::Texture.new(@width, @height, format: @format)
|
|
52
|
+
@skyline = [[0, 0, @width]]
|
|
53
|
+
@cache.clear
|
|
54
|
+
@generation += 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def allocate(width, height)
|
|
58
|
+
choice = nil
|
|
59
|
+
@skyline.each_index do |index|
|
|
60
|
+
x, y, = @skyline[index]
|
|
61
|
+
next if x + width > @width
|
|
62
|
+
remaining, cursor = width, index
|
|
63
|
+
while remaining.positive? && cursor < @skyline.length
|
|
64
|
+
y = [y, @skyline[cursor][1]].max
|
|
65
|
+
break if y + height > @height
|
|
66
|
+
remaining -= @skyline[cursor][2]
|
|
67
|
+
cursor += 1
|
|
68
|
+
end
|
|
69
|
+
next if remaining.positive? || y + height > @height
|
|
70
|
+
choice = [x, y, index] if choice.nil? || y < choice[1] || (y == choice[1] && x < choice[0])
|
|
71
|
+
end
|
|
72
|
+
return unless choice
|
|
73
|
+
x, y, index = choice
|
|
74
|
+
@skyline.insert(index, [x, y + height, width])
|
|
75
|
+
cursor = index + 1
|
|
76
|
+
while cursor < @skyline.length
|
|
77
|
+
previous, current = @skyline[cursor - 1], @skyline[cursor]
|
|
78
|
+
overlap = previous[0] + previous[2] - current[0]
|
|
79
|
+
break if overlap <= 0
|
|
80
|
+
if current[2] <= overlap
|
|
81
|
+
@skyline.delete_at(cursor)
|
|
82
|
+
else
|
|
83
|
+
current[0] += overlap
|
|
84
|
+
current[2] -= overlap
|
|
85
|
+
break
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
cursor = 0
|
|
89
|
+
while cursor + 1 < @skyline.length
|
|
90
|
+
if @skyline[cursor][1] == @skyline[cursor + 1][1]
|
|
91
|
+
@skyline[cursor][2] += @skyline.delete_at(cursor + 1)[2]
|
|
92
|
+
else
|
|
93
|
+
cursor += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
[x, y]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
require_relative "atlas/entry"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Zaniah
|
|
9
|
+
module TextSystem
|
|
10
|
+
# A bounded JSON manifest plus raw occupied texture rows. No Ruby objects are
|
|
11
|
+
# deserialized, and no texture allocation happens until every bound is checked.
|
|
12
|
+
module AtlasCache
|
|
13
|
+
MAGIC = "TSATLAS\0".b.freeze
|
|
14
|
+
VERSION = 1
|
|
15
|
+
HEADER_BYTES = 52
|
|
16
|
+
MAX_METADATA = 2 << 20
|
|
17
|
+
MAX_PIXELS = 32 << 20
|
|
18
|
+
MAX_ENTRIES = 16_384
|
|
19
|
+
WINDOWS = RUBY_PLATFORM.match?(/mingw|mswin/)
|
|
20
|
+
|
|
21
|
+
def self.save(path, key:, texture:, skyline:, entries:)
|
|
22
|
+
path = safe_path(path, create: true)
|
|
23
|
+
return false unless path
|
|
24
|
+
rows = skyline.map { |_, y, _| y }.max || 0
|
|
25
|
+
records = entries.map { |id, entry| [id, entry.x, entry.y, entry.width, entry.height, entry.left, entry.top] }
|
|
26
|
+
metadata = {"key" => key, "width" => texture.width, "height" => texture.height,
|
|
27
|
+
"format" => texture.format.to_s, "rows" => rows, "skyline" => skyline, "entries" => records}
|
|
28
|
+
return false unless valid_metadata?(metadata, key: key, width: texture.width, height: texture.height, format: texture.format)
|
|
29
|
+
json = JSON.generate(metadata)
|
|
30
|
+
return false if json.bytesize > MAX_METADATA
|
|
31
|
+
pixels = texture.data.byteslice(0, texture.width * rows * (texture.format == :r8 ? 1 : 4))
|
|
32
|
+
digest = Digest::SHA256.new.update(json).update(pixels).digest
|
|
33
|
+
header = MAGIC + [VERSION, json.bytesize, pixels.bytesize].pack("N3") + digest
|
|
34
|
+
Tempfile.create([".zaniah-atlas-", ".tmp"], File.dirname(path)) do |file|
|
|
35
|
+
file.binmode
|
|
36
|
+
file.write(header)
|
|
37
|
+
file.write(json)
|
|
38
|
+
file.write(pixels)
|
|
39
|
+
file.flush
|
|
40
|
+
file.fsync
|
|
41
|
+
file.close
|
|
42
|
+
attempts = 0
|
|
43
|
+
begin
|
|
44
|
+
File.rename(file.path, path)
|
|
45
|
+
rescue Errno::EACCES, Errno::EEXIST
|
|
46
|
+
raise unless WINDOWS && (attempts += 1) <= 10
|
|
47
|
+
sleep(0.001 * attempts)
|
|
48
|
+
retry
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
true
|
|
52
|
+
rescue SystemCallError, IOError, JSON::GeneratorError
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.load(path, key:, width:, height:, format:)
|
|
57
|
+
path = safe_path(path, create: false)
|
|
58
|
+
return unless path
|
|
59
|
+
flags = File::RDONLY
|
|
60
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW) && !WINDOWS
|
|
61
|
+
File.open(path, flags) do |file|
|
|
62
|
+
file.binmode
|
|
63
|
+
stat, location = file.stat, File.lstat(path)
|
|
64
|
+
return unless stat.file? && location.file?
|
|
65
|
+
return unless WINDOWS || (stat.ino == location.ino && stat.dev == location.dev)
|
|
66
|
+
return unless stat.size.between?(HEADER_BYTES, HEADER_BYTES + MAX_METADATA + MAX_PIXELS)
|
|
67
|
+
header = file.read(HEADER_BYTES)
|
|
68
|
+
return unless header&.bytesize == HEADER_BYTES && header.byteslice(0, 8) == MAGIC
|
|
69
|
+
version, json_size, pixel_size = header.byteslice(8, 12).unpack("N3")
|
|
70
|
+
return unless version == VERSION && json_size.between?(2, MAX_METADATA) && pixel_size <= MAX_PIXELS
|
|
71
|
+
return unless stat.size == HEADER_BYTES + json_size + pixel_size
|
|
72
|
+
json, pixels = file.read(json_size), file.read(pixel_size)
|
|
73
|
+
return unless json&.bytesize == json_size && pixels&.bytesize == pixel_size
|
|
74
|
+
return unless Digest::SHA256.new.update(json).update(pixels).digest == header.byteslice(20, 32)
|
|
75
|
+
metadata = JSON.parse(json, max_nesting: 8, create_additions: false)
|
|
76
|
+
return unless valid_metadata?(metadata, key: key, width: width, height: height, format: format)
|
|
77
|
+
return unless pixels.bytesize == width * metadata["rows"] * (format == :r8 ? 1 : 4)
|
|
78
|
+
texture = GPU::Texture.new(width, height, format: format)
|
|
79
|
+
texture.upload(0, 0, width, metadata["rows"], pixels)
|
|
80
|
+
entries = metadata["entries"].to_h do |id, x, y, w, h, left, top|
|
|
81
|
+
[id.freeze, Atlas::Entry.new(texture, x, y, w, h, left, top)]
|
|
82
|
+
end
|
|
83
|
+
[texture, metadata["skyline"], entries]
|
|
84
|
+
end
|
|
85
|
+
rescue SystemCallError, IOError, JSON::ParserError, EncodingError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.safe_path(path, create:)
|
|
90
|
+
raise ArgumentError, "atlas cache path must be a nonempty string" unless path.is_a?(String) && !path.empty? && !path.include?("\0")
|
|
91
|
+
path = File.expand_path(path)
|
|
92
|
+
directory = File.dirname(path)
|
|
93
|
+
return if File.symlink?(directory) || File.symlink?(path)
|
|
94
|
+
FileUtils.mkdir_p(directory, mode: 0o700) if create && !File.exist?(directory)
|
|
95
|
+
return unless File.directory?(directory)
|
|
96
|
+
File.join(File.realpath(directory), File.basename(path))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.valid_metadata?(data, key:, width:, height:, format:)
|
|
100
|
+
return false unless data.is_a?(Hash) && data["key"] == key && key.is_a?(String) && key.bytesize.between?(1, 4096)
|
|
101
|
+
return false unless data["width"] == width && data["height"] == height && data["format"] == format.to_s
|
|
102
|
+
return false unless width.is_a?(Integer) && height.is_a?(Integer) && width.between?(1, 8192) && height.between?(1, 8192)
|
|
103
|
+
return false unless [:r8, :rgba8].include?(format) && width * height * (format == :r8 ? 1 : 4) <= MAX_PIXELS
|
|
104
|
+
rows, skyline, entries = data.values_at("rows", "skyline", "entries")
|
|
105
|
+
return false unless rows.is_a?(Integer) && rows.between?(0, height) && skyline.is_a?(Array) && skyline.length.between?(1, width)
|
|
106
|
+
return false unless entries.is_a?(Array) && entries.length <= MAX_ENTRIES
|
|
107
|
+
cursor, highest = 0, 0
|
|
108
|
+
skyline.each do |segment|
|
|
109
|
+
return false unless segment.is_a?(Array) && segment.length == 3 && segment.all? { |value| value.is_a?(Integer) }
|
|
110
|
+
x, y, span = segment
|
|
111
|
+
return false unless x == cursor && y.between?(0, rows) && span.positive? && x + span <= width
|
|
112
|
+
cursor += span
|
|
113
|
+
highest = y if y > highest
|
|
114
|
+
end
|
|
115
|
+
return false unless cursor == width && highest == rows
|
|
116
|
+
seen, area = {}, 0
|
|
117
|
+
entries.each do |record|
|
|
118
|
+
return false unless record.is_a?(Array) && record.length == 7
|
|
119
|
+
id, x, y, w, h, left, top = record
|
|
120
|
+
return false unless valid_id?(id) && !seen.key?(id)
|
|
121
|
+
seen[id] = true
|
|
122
|
+
return false unless [x, y, w, h, left, top].all? { |value| value.is_a?(Integer) }
|
|
123
|
+
return false unless x >= 1 && y >= 1 && w >= 0 && h >= 0 && x + w + 1 <= width && y + h + 1 <= rows
|
|
124
|
+
return false unless left.abs <= 1_000_000 && top.abs <= 1_000_000
|
|
125
|
+
area += (w + 2) * (h + 2)
|
|
126
|
+
return false if area > width * rows
|
|
127
|
+
# Restored skyline allocation must not overwrite an existing glyph.
|
|
128
|
+
cursor = skyline.bsearch_index { |sx, _, sw| sx + sw > x - 1 }
|
|
129
|
+
while cursor && cursor < skyline.length && skyline[cursor][0] < x + w + 1
|
|
130
|
+
return false if skyline[cursor][1] < y + h + 1
|
|
131
|
+
cursor += 1
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.valid_id?(id)
|
|
138
|
+
return false unless id.is_a?(Array) && id.length == 4
|
|
139
|
+
font, glyph, size, bucket = id
|
|
140
|
+
font.is_a?(String) && font.match?(/\A[0-9a-f]{64}\z/) && glyph.is_a?(Integer) && glyph.between?(0, 0xffffffff) &&
|
|
141
|
+
size.is_a?(Numeric) && size.finite? && size.positive? && size <= 16_384 && bucket.is_a?(Integer) && bucket.between?(0, 3)
|
|
142
|
+
end
|
|
143
|
+
private_class_method :safe_path, :valid_metadata?, :valid_id?
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "alhena"
|
|
4
|
+
|
|
5
|
+
module Zaniah
|
|
6
|
+
module TextSystem
|
|
7
|
+
# Reads directories and small metadata tables, never outlines during discovery.
|
|
8
|
+
class FontDB
|
|
9
|
+
PREFERRED = %w[Menlo DejaVu\ Sans\ Mono Consolas Liberation\ Mono Noto\ Sans\ Mono].freeze
|
|
10
|
+
MAX_TABLE = 8 << 20
|
|
11
|
+
attr_reader :paths
|
|
12
|
+
|
|
13
|
+
def initialize(paths: nil)
|
|
14
|
+
roots = if RUBY_PLATFORM.include?("darwin")
|
|
15
|
+
["/System/Library/Fonts", "/Library/Fonts", File.expand_path("~/Library/Fonts")]
|
|
16
|
+
elsif RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
17
|
+
[File.join(ENV.fetch("WINDIR", "C:/Windows"), "Fonts")]
|
|
18
|
+
else
|
|
19
|
+
["/usr/share/fonts", "/usr/local/share/fonts", File.expand_path("~/.local/share/fonts"), File.expand_path("~/.fonts")]
|
|
20
|
+
end
|
|
21
|
+
paths ||= roots.flat_map { |root| Dir[File.join(root, "**", "*.{ttf,otf,ttc,otc,TTF,OTF,TTC,OTC}")] }
|
|
22
|
+
raise ArgumentError, "font paths must be an array" unless paths.is_a?(Array) && paths.all? { |path| path.is_a?(String) && !path.include?("\0") }
|
|
23
|
+
@paths = paths.map { |path| File.expand_path(path) }.uniq.freeze
|
|
24
|
+
refresh
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def refresh
|
|
28
|
+
@faces, @fonts, @data, @fallback, @probes, @matches, @normalized = nil, {}, {}, {}, {}, {}, {}
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def faces
|
|
33
|
+
@faces ||= @paths.flat_map do |path|
|
|
34
|
+
File.open(path, "rb") do |io|
|
|
35
|
+
header = read(io, 0, 12)
|
|
36
|
+
offsets = if header.bytes(0, 4) == "ttcf"
|
|
37
|
+
raise Error, "unsupported font collection version" unless [0x10000, 0x20000].include?(header.u32(4))
|
|
38
|
+
count = header.u32(8)
|
|
39
|
+
raise Error, "font collection exceeds face limit" unless count.between?(1, 4096)
|
|
40
|
+
read(io, 12, count * 4).data.unpack("N*")
|
|
41
|
+
else
|
|
42
|
+
[0]
|
|
43
|
+
end
|
|
44
|
+
offsets.each_with_index.filter_map do |offset, index|
|
|
45
|
+
metadata(io, path, offset, index)
|
|
46
|
+
rescue Error, Alhena::Error, EncodingError, ArgumentError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
rescue SystemCallError, IOError, Error, Alhena::Error
|
|
51
|
+
[]
|
|
52
|
+
end.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def open(path, index: 0)
|
|
56
|
+
path = File.expand_path(path)
|
|
57
|
+
@fonts[[path, index]] ||= Alhena::Font.new(@data[path] ||= File.binread(path).freeze, index: index)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def find(family: nil, weight: 400, width: 5, style: :normal)
|
|
61
|
+
raise ArgumentError, "family must be a string" unless family.nil? || family.is_a?(String)
|
|
62
|
+
raise ArgumentError, "weight must be in 1..1000" unless weight.is_a?(Integer) && weight.between?(1, 1000)
|
|
63
|
+
raise ArgumentError, "width must be an OS/2 class in 1..9" unless width.is_a?(Integer) && width.between?(1, 9)
|
|
64
|
+
raise ArgumentError, "style must be normal, italic or oblique" unless %i[normal italic oblique].include?(style)
|
|
65
|
+
key = [family && normalize(family), weight, width, style]
|
|
66
|
+
@matches[key] ||= begin
|
|
67
|
+
requested = key[0]
|
|
68
|
+
candidates = requested ? faces.select { |face| face.families.any? { |name| normalize(name) == requested } } : []
|
|
69
|
+
candidates = faces if candidates.empty?
|
|
70
|
+
candidates.sort_by do |face|
|
|
71
|
+
family_rank = if requested && face.families.any? { |name| normalize(name) == requested }
|
|
72
|
+
0
|
|
73
|
+
else
|
|
74
|
+
PREFERRED.index { |name| face.families.any? { |alias_name| normalize(alias_name) == normalize(name) } } || (face.fixed_pitch ? PREFERRED.length : PREFERRED.length + 1)
|
|
75
|
+
end
|
|
76
|
+
[family_rank, width_rank(face.width, width), style_rank(face.style, style), weight_rank(face.weight, weight), face.path, face.index]
|
|
77
|
+
end.each do |face|
|
|
78
|
+
begin
|
|
79
|
+
font = open(face.path, index: face.index)
|
|
80
|
+
# Reject incomplete/corrupt sfnts here, before handing one to layout.
|
|
81
|
+
font.units_per_em
|
|
82
|
+
font.ascent
|
|
83
|
+
font.glyph_id(32)
|
|
84
|
+
break font
|
|
85
|
+
rescue Alhena::Error, SystemCallError, IOError
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
end.then do |found|
|
|
89
|
+
found.is_a?(Alhena::Font) ? found : bundled_font
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def fallback(codepoint, primary)
|
|
95
|
+
return primary unless primary.glyph_id(codepoint).zero?
|
|
96
|
+
@fallback[[primary, codepoint]] ||= begin
|
|
97
|
+
found = faces.find do |face|
|
|
98
|
+
probe = @probes[[face.path, face.index]] ||= probe_font(face)
|
|
99
|
+
!probe.glyph_id(codepoint).zero?
|
|
100
|
+
rescue Error, Alhena::Error, SystemCallError, IOError
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
found ? open(found.path, index: found.index) : primary
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def normalize(name) = @normalized[name] ||= name.unicode_normalize(:nfkc).downcase(:fold).gsub(/\s+/, " ").strip.freeze
|
|
110
|
+
def width_rank(actual, wanted)
|
|
111
|
+
wanted <= 5 ? [actual > wanted ? 1 : 0, (actual - wanted).abs] : [actual < wanted ? 1 : 0, (actual - wanted).abs]
|
|
112
|
+
end
|
|
113
|
+
def style_rank(actual, wanted)
|
|
114
|
+
{normal: %i[normal oblique italic], italic: %i[italic oblique normal], oblique: %i[oblique italic normal]}.fetch(wanted).index(actual)
|
|
115
|
+
end
|
|
116
|
+
def weight_rank(actual, wanted)
|
|
117
|
+
if wanted.between?(400, 500)
|
|
118
|
+
actual.between?(wanted, 500) ? [0, actual - wanted] : actual < wanted ? [1, wanted - actual] : [2, actual - 500]
|
|
119
|
+
else
|
|
120
|
+
preferred = wanted < 400 ? actual <= wanted : actual >= wanted
|
|
121
|
+
[preferred ? 0 : 1, (actual - wanted).abs]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
def bundled_font
|
|
125
|
+
path = File.expand_path("../../../assets/fonts/Abel-Regular.ttf", __dir__)
|
|
126
|
+
raise Error, "no readable TrueType/OpenType font found" unless File.file?(path)
|
|
127
|
+
open(path)
|
|
128
|
+
end
|
|
129
|
+
def read(io, offset, length)
|
|
130
|
+
raise Error, "font metadata outside size limit" unless offset >= 0 && length >= 0 && length <= MAX_TABLE
|
|
131
|
+
io.seek(offset)
|
|
132
|
+
bytes = io.read(length)
|
|
133
|
+
raise Error, "truncated font metadata" unless bytes && bytes.bytesize == length
|
|
134
|
+
Alhena::Binary.new(bytes)
|
|
135
|
+
end
|
|
136
|
+
def metadata(io, path, offset, index)
|
|
137
|
+
header = read(io, offset, 12)
|
|
138
|
+
raise Error, "not an sfnt font" unless ["\x00\x01\x00\x00".b, "OTTO", "true"].include?(header.bytes(0, 4))
|
|
139
|
+
count = header.u16(4)
|
|
140
|
+
raise Error, "invalid sfnt table count" unless count.between?(1, 4096)
|
|
141
|
+
directory = read(io, offset + 12, count * 16)
|
|
142
|
+
size = io.stat.size
|
|
143
|
+
tables = count.times.to_h do |i|
|
|
144
|
+
at = i * 16
|
|
145
|
+
tag, start, length = directory.bytes(at, 4), directory.u32(at + 8), directory.u32(at + 12)
|
|
146
|
+
raise Error, "sfnt table outside file" if start > size - length
|
|
147
|
+
[tag, [start, length].freeze]
|
|
148
|
+
end
|
|
149
|
+
raise Error, "duplicate sfnt tables" unless tables.length == count
|
|
150
|
+
names = family_names(read(io, *tables.fetch("name")))
|
|
151
|
+
raise Error, "font has no family name" if names.empty?
|
|
152
|
+
os2 = tables["OS/2"] && read(io, tables["OS/2"][0], [tables["OS/2"][1], 64].min)
|
|
153
|
+
weight, width = os2 ? [os2.u16(4), os2.u16(6)] : [400, 5]
|
|
154
|
+
weight, width = 400, 5 unless weight.between?(1, 1000) && width.between?(1, 9)
|
|
155
|
+
post = tables["post"] && read(io, tables["post"][0], [tables["post"][1], 16].min)
|
|
156
|
+
flags = os2 && os2.size >= 64 ? os2.u16(62) : 0
|
|
157
|
+
style = flags & 512 != 0 ? :oblique : flags & 1 != 0 || (post && post.i32(4) != 0) ? :italic : :normal
|
|
158
|
+
Face.new(path, index, names.first, names.freeze, weight, width, style, !!(post && post.u32(12) != 0), tables.freeze)
|
|
159
|
+
rescue KeyError
|
|
160
|
+
raise Error, "missing font metadata table"
|
|
161
|
+
end
|
|
162
|
+
def family_names(data)
|
|
163
|
+
count, storage = data.u16(2), data.u16(4)
|
|
164
|
+
data.check(6, count * 12)
|
|
165
|
+
count.times.filter_map do |i|
|
|
166
|
+
platform, encoding, language, id, length, offset = data.bytes(6 + i * 12, 12).unpack("n6")
|
|
167
|
+
next unless [1, 16, 21].include?(id) && ([0, 3].include?(platform) || (platform == 1 && encoding.zero?))
|
|
168
|
+
text = data.bytes(storage + offset, length).force_encoding(platform == 1 ? "macRoman" : "UTF-16BE").encode("UTF-8")
|
|
169
|
+
next if text.empty?
|
|
170
|
+
[id == 16 ? 0 : id == 1 ? 1 : 2, platform == 3 && language == 0x409 ? 0 : 1, text.freeze]
|
|
171
|
+
end.sort_by { |priority, language, _| [priority, language] }.map(&:last).uniq
|
|
172
|
+
end
|
|
173
|
+
def probe_font(face)
|
|
174
|
+
tables = File.open(face.path, "rb") do |io|
|
|
175
|
+
%w[cmap maxp].to_h { |tag| [tag, read(io, *face.tables.fetch(tag)).data] }
|
|
176
|
+
end
|
|
177
|
+
# Reuse Alhena's checked cmap 0/4/6/12/13 parser on a tiny sfnt;
|
|
178
|
+
# no glyph outlines or full collection blobs are loaded for misses.
|
|
179
|
+
offset, directory, payload = 12 + tables.length * 16, "".b, "".b
|
|
180
|
+
tables.each do |tag, bytes|
|
|
181
|
+
directory << [tag, 0, offset, bytes.bytesize].pack("a4N3")
|
|
182
|
+
payload << bytes
|
|
183
|
+
offset += bytes.bytesize
|
|
184
|
+
end
|
|
185
|
+
Alhena::Font.new([0x10000, tables.length, 0, 0, 0].pack("Nn4") + directory + payload)
|
|
186
|
+
rescue KeyError
|
|
187
|
+
raise Error, "font has no Unicode cmap"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
require_relative "font_db/face"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module TextSystem
|
|
5
|
+
LineLayout = Data.define(:text, :glyphs, :width, :ascent, :descent, :size, :carets) do
|
|
6
|
+
def index_for_x(x)
|
|
7
|
+
index = carets.bsearch_index { |_, position| position >= x }
|
|
8
|
+
return text.bytesize unless index
|
|
9
|
+
return carets.first.first if index.zero?
|
|
10
|
+
previous, following = carets[index - 1], carets[index]
|
|
11
|
+
x - previous.last <= following.last - x ? previous.first : following.first
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def x_for_index(index)
|
|
15
|
+
caret = carets.bsearch { |byte, _| byte >= index }
|
|
16
|
+
caret ? caret.last : width
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|