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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +70 -0
  5. data/assets/fonts/Abel-Regular.ttf +0 -0
  6. data/assets/fonts/OFL.txt +93 -0
  7. data/docs/adr/000-template.md +24 -0
  8. data/docs/adr/001-native-gpu-backends.md +36 -0
  9. data/docs/adr/002-objective-c-aggregates.md +34 -0
  10. data/docs/adr/003-fiddle-call-boundary.md +33 -0
  11. data/docs/adr/README.md +10 -0
  12. data/docs/native.md +66 -0
  13. data/docs/process_pool.md +67 -0
  14. data/docs/text.md +90 -0
  15. data/docs/vector_and_list.md +52 -0
  16. data/examples/ffi_bench.rb +16 -0
  17. data/examples/linux_smoke.rb +53 -0
  18. data/examples/mac_event_check.rb +61 -0
  19. data/examples/mac_poll_bench.rb +24 -0
  20. data/examples/metal_state_check.rb +72 -0
  21. data/examples/native_bench.rb +18 -0
  22. data/examples/native_drop.rb +82 -0
  23. data/examples/native_font.rb +15 -0
  24. data/examples/native_ime.rb +50 -0
  25. data/examples/native_smoke.rb +75 -0
  26. data/examples/native_text_bench.rb +83 -0
  27. data/examples/native_watch.rb +25 -0
  28. data/examples/vulkan_triangle.rb +20 -0
  29. data/examples/word_count_worker.rb +8 -0
  30. data/lib/zaniah/anchored.rb +10 -0
  31. data/lib/zaniah/app.rb +130 -0
  32. data/lib/zaniah/bounds.rb +22 -0
  33. data/lib/zaniah/canvas.rb +15 -0
  34. data/lib/zaniah/color.rb +35 -0
  35. data/lib/zaniah/configuration.rb +5 -0
  36. data/lib/zaniah/corners.rb +7 -0
  37. data/lib/zaniah/data_compat.rb +25 -0
  38. data/lib/zaniah/device_pixels.rb +7 -0
  39. data/lib/zaniah/div.rb +5 -0
  40. data/lib/zaniah/edges.rb +7 -0
  41. data/lib/zaniah/element.rb +106 -0
  42. data/lib/zaniah/entity.rb +9 -0
  43. data/lib/zaniah/entity_context.rb +17 -0
  44. data/lib/zaniah/error.rb +5 -0
  45. data/lib/zaniah/ffi/com.rb +24 -0
  46. data/lib/zaniah/ffi/library.rb +29 -0
  47. data/lib/zaniah/ffi/objc.rb +86 -0
  48. data/lib/zaniah/ffi/struct/signature.rb +46 -0
  49. data/lib/zaniah/ffi/struct.rb +61 -0
  50. data/lib/zaniah/ffi/wayland.rb +140 -0
  51. data/lib/zaniah/frame_context.rb +14 -0
  52. data/lib/zaniah/geometry.rb +16 -0
  53. data/lib/zaniah/gpu/buffer.rb +23 -0
  54. data/lib/zaniah/gpu/frame_encoder.rb +43 -0
  55. data/lib/zaniah/gpu/instance_packing.rb +53 -0
  56. data/lib/zaniah/gpu/metal.rb +209 -0
  57. data/lib/zaniah/gpu/open_gl.rb +210 -0
  58. data/lib/zaniah/gpu/pipeline.rb +7 -0
  59. data/lib/zaniah/gpu/software.rb +126 -0
  60. data/lib/zaniah/gpu/texture.rb +33 -0
  61. data/lib/zaniah/gpu/vulkan.rb +233 -0
  62. data/lib/zaniah/gpu.rb +18 -0
  63. data/lib/zaniah/hsla.rb +14 -0
  64. data/lib/zaniah/image.rb +19 -0
  65. data/lib/zaniah/input/action_registry.rb +12 -0
  66. data/lib/zaniah/input/composition.rb +7 -0
  67. data/lib/zaniah/input/context_predicate.rb +87 -0
  68. data/lib/zaniah/input/dispatcher.rb +59 -0
  69. data/lib/zaniah/input/file_drop.rb +7 -0
  70. data/lib/zaniah/input/focus_handle.rb +24 -0
  71. data/lib/zaniah/input/key_down.rb +7 -0
  72. data/lib/zaniah/input/key_up.rb +7 -0
  73. data/lib/zaniah/input/keymap/binding.rb +9 -0
  74. data/lib/zaniah/input/keymap.rb +55 -0
  75. data/lib/zaniah/input/keystroke.rb +20 -0
  76. data/lib/zaniah/input/mouse_down.rb +7 -0
  77. data/lib/zaniah/input/mouse_move.rb +7 -0
  78. data/lib/zaniah/input/mouse_up.rb +7 -0
  79. data/lib/zaniah/input/scroll_wheel.rb +7 -0
  80. data/lib/zaniah/input/text_input.rb +7 -0
  81. data/lib/zaniah/input.rb +17 -0
  82. data/lib/zaniah/layout/available_space.rb +11 -0
  83. data/lib/zaniah/layout/engine.rb +214 -0
  84. data/lib/zaniah/layout/node.rb +36 -0
  85. data/lib/zaniah/layout/style.rb +19 -0
  86. data/lib/zaniah/layout/tree.rb +16 -0
  87. data/lib/zaniah/layout.rb +11 -0
  88. data/lib/zaniah/length.rb +14 -0
  89. data/lib/zaniah/length_units.rb +9 -0
  90. data/lib/zaniah/list/height_index.rb +68 -0
  91. data/lib/zaniah/list.rb +101 -0
  92. data/lib/zaniah/overlay.rb +10 -0
  93. data/lib/zaniah/platform/display.rb +7 -0
  94. data/lib/zaniah/platform/file_event.rb +7 -0
  95. data/lib/zaniah/platform/headless/window.rb +185 -0
  96. data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
  97. data/lib/zaniah/platform/linux/displays.rb +55 -0
  98. data/lib/zaniah/platform/linux/free_type.rb +80 -0
  99. data/lib/zaniah/platform/linux/watcher.rb +70 -0
  100. data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
  101. data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
  102. data/lib/zaniah/platform/linux/window.rb +418 -0
  103. data/lib/zaniah/platform/linux.rb +3 -0
  104. data/lib/zaniah/platform/mac/app.rb +67 -0
  105. data/lib/zaniah/platform/mac/core_text.rb +94 -0
  106. data/lib/zaniah/platform/mac/displays.rb +23 -0
  107. data/lib/zaniah/platform/mac/watcher.rb +63 -0
  108. data/lib/zaniah/platform/mac/window.rb +298 -0
  109. data/lib/zaniah/platform/mac.rb +19 -0
  110. data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
  111. data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
  112. data/lib/zaniah/platform/tui/window.rb +91 -0
  113. data/lib/zaniah/platform/windows/terminal.rb +127 -0
  114. data/lib/zaniah/platform/windows/watcher.rb +91 -0
  115. data/lib/zaniah/platform/windows/window.rb +407 -0
  116. data/lib/zaniah/platform/windows.rb +3 -0
  117. data/lib/zaniah/platform.rb +34 -0
  118. data/lib/zaniah/png.rb +118 -0
  119. data/lib/zaniah/point.rb +8 -0
  120. data/lib/zaniah/process_pool/worker.rb +7 -0
  121. data/lib/zaniah/process_pool.rb +239 -0
  122. data/lib/zaniah/process_wire.rb +127 -0
  123. data/lib/zaniah/quad_packer.rb +15 -0
  124. data/lib/zaniah/scene/sprite_batch.rb +7 -0
  125. data/lib/zaniah/scene.rb +167 -0
  126. data/lib/zaniah/scene_renderer.rb +8 -0
  127. data/lib/zaniah/size.rb +5 -0
  128. data/lib/zaniah/subscription.rb +14 -0
  129. data/lib/zaniah/svg/path.rb +134 -0
  130. data/lib/zaniah/svg/rasterizable.rb +186 -0
  131. data/lib/zaniah/svg.rb +295 -0
  132. data/lib/zaniah/task/cancelled.rb +7 -0
  133. data/lib/zaniah/task/timeout.rb +7 -0
  134. data/lib/zaniah/task.rb +51 -0
  135. data/lib/zaniah/task_executor.rb +105 -0
  136. data/lib/zaniah/text.rb +24 -0
  137. data/lib/zaniah/text_system/atlas/entry.rb +9 -0
  138. data/lib/zaniah/text_system/atlas.rb +102 -0
  139. data/lib/zaniah/text_system/atlas_cache.rb +146 -0
  140. data/lib/zaniah/text_system/font_db/face.rb +9 -0
  141. data/lib/zaniah/text_system/font_db.rb +193 -0
  142. data/lib/zaniah/text_system/glyph.rb +7 -0
  143. data/lib/zaniah/text_system/line_layout.rb +20 -0
  144. data/lib/zaniah/text_system/renderer.rb +196 -0
  145. data/lib/zaniah/text_system/shaper.rb +430 -0
  146. data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
  147. data/lib/zaniah/text_system/typesetter.rb +168 -0
  148. data/lib/zaniah/text_system.rb +9 -0
  149. data/lib/zaniah/unicode.rb +13 -0
  150. data/lib/zaniah/uniform_list.rb +30 -0
  151. data/lib/zaniah/version.rb +5 -0
  152. data/lib/zaniah/view.rb +13 -0
  153. data/lib/zaniah.rb +29 -0
  154. data/sig/app.rbs +80 -0
  155. data/sig/elements.rbs +129 -0
  156. data/sig/gpu.rbs +108 -0
  157. data/sig/input.rbs +114 -0
  158. data/sig/layout.rbs +53 -0
  159. data/sig/native.rbs +88 -0
  160. data/sig/platform.rbs +94 -0
  161. data/sig/scene.rbs +52 -0
  162. data/sig/shaper.rbs +8 -0
  163. data/sig/text.rbs +137 -0
  164. data/sig/zaniah.rbs +120 -0
  165. data/tools/check_dependencies.rb +52 -0
  166. metadata +249 -0
@@ -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
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module TextSystem
5
+ class Atlas
6
+ Entry = Data.define(:texture, :x, :y, :width, :height, :left, :top)
7
+ end
8
+ end
9
+ 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module TextSystem
5
+ class FontDB
6
+ Face = Data.define(:path, :index, :family, :families, :weight, :width, :style, :fixed_pitch, :tables)
7
+ end
8
+ end
9
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module TextSystem
5
+ Glyph = Data.define(:font, :id, :start, :finish, :x, :advance)
6
+ end
7
+ end
@@ -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