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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "atlas"
4
+ require_relative "typesetter"
5
+
6
+ module Zaniah
7
+ module TextSystem
8
+ class Renderer < Typesetter
9
+ MAX_PAINT_BYTES = 32 << 20
10
+
11
+ attr_reader :atlas, :color_atlas, :cache_dir
12
+ attr_accessor :scale_factor
13
+
14
+ def initialize(font: nil, font_db: Zaniah.configuration.font_db, capacity: 2000, font_raster: Zaniah.configuration.font_raster,
15
+ shaper: Zaniah.configuration.shaper, segmenter: Zaniah.configuration.segmenter, cache_dir: nil)
16
+ super(font: font, font_db: font_db, capacity: capacity, shaper: shaper, segmenter: segmenter)
17
+ @rasters = Alhena::Cache.new(capacity: 4096, max_bytes: 32 << 20)
18
+ case font_raster
19
+ when :native, :alhena
20
+ # The default is the bundled pure-Ruby engine.
21
+ when :core_text, :coretext
22
+ require_relative "../platform/mac/core_text"
23
+ @rasters = Platform::Mac::CoreText.new
24
+ when :freetype
25
+ require_relative "../platform/linux/free_type"
26
+ @rasters = Platform::Linux::FreeType.new
27
+ else
28
+ raise ArgumentError, "unknown font rasterizer" unless font_raster.respond_to?(:rasterize)
29
+ @rasters = font_raster
30
+ end
31
+ @atlas = Atlas.new
32
+ @color_atlas, @color_rasters, @scale_factor = Atlas.new(format: :rgba8), {}, 1.0
33
+ @paint_cache, @paint_colors, @paint_span_cache, @paint_bytes = {}, {}, {}, 0
34
+ @paint_lookup = Array.new(6)
35
+ if cache_dir
36
+ raise ArgumentError, "cache_dir must be a nonempty path string" unless cache_dir.is_a?(String) && !cache_dir.empty? && !cache_dir.include?("\0")
37
+ require_relative "atlas_cache"
38
+ @cache_dir = File.expand_path(cache_dir).freeze
39
+ @font_identities = {}.compare_by_identity
40
+ @raster_identity = if [:native, :alhena].include?(font_raster)
41
+ "alhena/#{Alhena::VERSION}"
42
+ elsif @rasters.respond_to?(:cache_key) && (identity = @rasters.cache_key).is_a?(String) && !identity.empty?
43
+ identity
44
+ else
45
+ raise ArgumentError, "disk-cached rasterizers must supply a stable nonempty String cache_key"
46
+ end
47
+ end
48
+ end
49
+
50
+ def paint_line(scene, line, x:, y:, color: "#ddd", spans: nil)
51
+ scale = @scale_factor.to_f
52
+ raise ArgumentError, "scale factor must be positive" unless scale.positive? && scale.finite?
53
+ raise ArgumentError, "text origin must be finite" unless x.is_a?(Numeric) && x.finite? && y.is_a?(Numeric) && y.finite?
54
+ color = paint_color(color)
55
+ spans = paint_spans(spans)
56
+ refresh_paint_cache
57
+ @paint_lookup[0], @paint_lookup[1], @paint_lookup[2] = line.object_id, x, y
58
+ @paint_lookup[3], @paint_lookup[4], @paint_lookup[5] = color, spans, scale
59
+ cached = @paint_cache.delete(@paint_lookup)
60
+ if cached
61
+ @paint_cache[cached[3]] = cached
62
+ batches = cached[1]
63
+ else
64
+ batches = pack_line(line, x, y, color, spans, scale)
65
+ refresh_paint_cache
66
+ length = batches.sum { |batch| batch.bytes.bytesize }
67
+ if length <= MAX_PAINT_BYTES
68
+ while !@paint_cache.empty? && (@paint_cache.length >= @capacity || @paint_bytes + length > MAX_PAINT_BYTES)
69
+ @paint_bytes -= @paint_cache.shift.last[2]
70
+ end
71
+ # Retaining the line prevents object_id reuse while cached.
72
+ key = [line.object_id, x, y, color, spans, scale].freeze
73
+ @paint_cache[key] = [line, batches, length, key]
74
+ @paint_bytes += length
75
+ end
76
+ end
77
+ batches.each { |batch| scene.sprite_batch(batch) }
78
+ scene
79
+ end
80
+
81
+ def end_frame
82
+ @atlas.end_frame
83
+ @color_atlas.end_frame
84
+ end
85
+
86
+ def prewarm(text = (32..126).map(&:chr).join.force_encoding(Encoding::UTF_8), size: 14)
87
+ scale = @scale_factor.to_f
88
+ raise ArgumentError, "scale factor must be positive" unless scale.positive? && scale.finite?
89
+ line = layout_line(text, size: size)
90
+ if @cache_dir
91
+ identity = [Zaniah::VERSION, Alhena::VERSION, @raster_identity, RUBY_PLATFORM, scale, size.to_f, text,
92
+ line.glyphs.map { |glyph| [font_identity(glyph.font), glyph.id, glyph.x, glyph.advance] }]
93
+ key = Digest::SHA256.hexdigest(JSON.generate(identity))
94
+ mono, color = %w[mono color].map { |kind| File.join(@cache_dir, "#{key}-#{kind}.atlas") }
95
+ mono_loaded, color_loaded = @atlas.load_cache(mono, key: key, preserve: true), @color_atlas.load_cache(color, key: key, preserve: true)
96
+ if mono_loaded && color_loaded
97
+ refresh_paint_cache
98
+ return self
99
+ end
100
+ end
101
+ tint = paint_color("#fff")
102
+ 4.times { |bucket| pack_line(line, bucket / (4.0 * scale), 0, tint, nil, scale) }
103
+ if @cache_dir
104
+ @atlas.save_cache(mono, key: key)
105
+ @color_atlas.save_cache(color, key: key)
106
+ end
107
+ refresh_paint_cache
108
+ self
109
+ end
110
+
111
+ def close
112
+ return if @closed
113
+ @paint_cache.clear
114
+ @rasters.close if @rasters.respond_to?(:close) && ![@shaper, @segmenter, @font_db].include?(@rasters)
115
+ super
116
+ end
117
+
118
+ private
119
+
120
+ def font_identity(font)
121
+ return font.object_id unless @cache_dir
122
+ @font_identities[font] ||= begin
123
+ raise ArgumentError, "disk-cached fonts must expose immutable data, index and axis_values" unless font.respond_to?(:data) && font.respond_to?(:index) && font.respond_to?(:axis_values) && font.data.is_a?(String) && font.data.frozen?
124
+ digest = Digest::SHA256.new.update(font.data)
125
+ digest.update(JSON.generate([font.index, font.axis_values.sort])).hexdigest.freeze
126
+ end
127
+ end
128
+
129
+ def refresh_paint_cache
130
+ return if @paint_mono_generation == @atlas.generation && @paint_color_generation == @color_atlas.generation
131
+ @paint_cache.clear
132
+ @paint_bytes = 0
133
+ @paint_mono_generation, @paint_color_generation = @atlas.generation, @color_atlas.generation
134
+ end
135
+
136
+ def paint_color(value)
137
+ return value if value.is_a?(Color)
138
+ return Color.parse(value) if value.is_a?(Array)
139
+ @paint_colors.clear if @paint_colors.length >= 256
140
+ @paint_colors[value] ||= Color.parse(value)
141
+ end
142
+
143
+ def paint_spans(spans)
144
+ return unless spans
145
+ cached = @paint_span_cache[spans]
146
+ return cached if cached
147
+ @paint_span_cache.clear if @paint_span_cache.length >= 256
148
+ # Keys are snapshots: callers may update their range/color arrays later.
149
+ snapshot = spans.map do |first, last, tint|
150
+ tint = tint.dup.freeze if tint.is_a?(String) || tint.is_a?(Array)
151
+ [first, last, tint].freeze
152
+ end.freeze
153
+ @paint_span_cache[snapshot] = snapshot.map { |first, last, tint| [first, last, paint_color(tint)].freeze }.freeze
154
+ end
155
+
156
+ def pack_line(line, x, y, color, spans, scale)
157
+ span_index, batches, values, texture = 0, [], [], nil
158
+ line.glyphs.each do |glyph|
159
+ span_index += 1 while spans && span_index < spans.length && glyph.start >= spans[span_index][1]
160
+ tint = spans && spans[span_index] && glyph.start >= spans[span_index][0] ? spans[span_index][2] : color
161
+ position = (x + glyph.x) * scale
162
+ bucket = ((position - position.floor) * 4).round % 4
163
+ key = [font_identity(glyph.font), glyph.id, (line.size * scale).to_f, bucket]
164
+ colored = glyph.font.tables.key?("COLR") || glyph.font.tables.key?("sbix") || glyph.font.tables.key?("CBDT")
165
+ entry = colored ? @color_atlas.lookup(key) : nil
166
+ color_glyph = !entry.nil?
167
+ bitmap = if colored && !entry
168
+ unless @color_rasters.key?(key)
169
+ @color_rasters.shift if @color_rasters.length >= 1024
170
+ @color_rasters[key] = glyph.font.color_bitmap(glyph.id, size: line.size * scale, subpixel_x: bucket / 4.0)
171
+ end
172
+ @color_rasters[key]
173
+ end
174
+ if bitmap
175
+ entry = @color_atlas.fetch(key) { bitmap }
176
+ color_glyph = true
177
+ end
178
+ entry ||= @atlas.fetch(key) { @rasters.rasterize(glyph.font, glyph.id, size: line.size * scale, subpixel_x: bucket / 4.0) }
179
+ next if entry.width.zero? || entry.height.zero?
180
+ unless texture.equal?(entry.texture)
181
+ batches << Scene::SpriteBatch.new(values.pack("f*").freeze, texture) unless values.empty?
182
+ values.clear
183
+ texture = entry.texture
184
+ end
185
+ tint = paint_color("#fff") if color_glyph
186
+ values.push((position.floor + entry.left) / scale, ((y * scale).floor - entry.top) / scale, entry.width / scale, entry.height / scale,
187
+ tint.r, tint.g, tint.b, tint.a, 0, 0, 0, 0, 0, 0, 0, 0, 0, texture.format == :r8 ? 1 : 2, 0, 0,
188
+ entry.x.to_f / texture.width, entry.y.to_f / texture.height,
189
+ entry.width.to_f / texture.width, entry.height.to_f / texture.height)
190
+ end
191
+ batches << Scene::SpriteBatch.new(values.pack("f*").freeze, texture) unless values.empty?
192
+ batches.freeze
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module TextSystem
5
+ # Horizontal Latin/CJK/kana shaping, not complex-script reordering.
6
+ # OpenType GSUB 1/4/6/7; GPOS 2/9; GDEF lookup filtering; legacy kern.
7
+ class Shaper
8
+ def initialize = (@lookups, @filters = {}, {})
9
+
10
+ def shape(glyphs, size:, text: nil, script: nil, language: nil)
11
+ raise ArgumentError, "font size must be finite and positive" unless size.is_a?(Numeric) && size.finite? && size.positive?
12
+ runs, previous = [], nil
13
+ inferred = glyphs.map { |glyph| script || (text && script_for(text.byteslice(glyph.start...glyph.finish))) }
14
+ inherited = inferred.compact.first || "latn"
15
+ glyphs.each_with_index do |glyph, i|
16
+ inherited = inferred[i] || inherited
17
+ key = [glyph.font, inherited.to_s]
18
+ runs << [key, []] unless key == previous
19
+ runs.last[1] << glyph
20
+ previous = key
21
+ end
22
+ x = 0.0
23
+ runs.flat_map do |(font, tag), run|
24
+ if font.tables.key?("GSUB")
25
+ data = font.table("GSUB")
26
+ lookups(data, font, "GSUB", %w[liga calt], tag, language).each do |lookup|
27
+ index = 0
28
+ index = substitute(data, lookup, run, index, size, 0, []) || index + 1 while index < run.length
29
+ end
30
+ end
31
+ placements, advances = positioning(run, size, tag, language)
32
+ run.each_with_index.map do |glyph, i|
33
+ advance = glyph.advance + advances[i]
34
+ placed = Glyph.new(font, glyph.id, glyph.start, glyph.finish, x + placements[i], advance)
35
+ x += advance
36
+ placed
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def script_for(text)
44
+ return unless text
45
+ case text
46
+ when /\p{Latin}/ then "latn"
47
+ when /[\p{Hiragana}\p{Katakana}]/ then "kana"
48
+ when /\p{Han}/ then "hani"
49
+ when /\p{Hangul}/ then "hang"
50
+ when /\p{Greek}/ then "grek"
51
+ when /\p{Cyrillic}/ then "cyrl"
52
+ end
53
+ end
54
+
55
+ def words(data, offset, count)
56
+ data.check(offset, count * 2)
57
+ Array.new(count) { |i| data.u16(offset + i * 2) }
58
+ end
59
+
60
+ def records(data, offset, base = offset)
61
+ count = data.u16(offset)
62
+ data.check(offset + 2, count * 6)
63
+ Array.new(count) { |i| [data.bytes(offset + 2 + i * 6, 4), base + data.u16(offset + 6 + i * 6)] }.to_h
64
+ end
65
+
66
+ def lookups(data, font, tag, features, script, language)
67
+ @lookups[[font, tag, script, language, features]] ||= begin
68
+ raise Error, "unsupported OpenType layout version" unless data.u16(0) == 1 && data.u16(2) <= 1
69
+ scripts = records(data, data.u16(4))
70
+ selected = scripts[script] || scripts["DFLT"]
71
+ indices = []
72
+ if selected
73
+ languages = records(data, selected + 2, selected)
74
+ lang = (language && languages[language.to_s]) || (data.u16(selected).positive? && selected + data.u16(selected))
75
+ if lang
76
+ required = data.u16(lang + 2)
77
+ candidates = words(data, lang + 6, data.u16(lang + 4))
78
+ candidates.unshift(required) unless required == 0xffff
79
+ feature_list = data.u16(6)
80
+ count = data.u16(feature_list)
81
+ data.check(feature_list + 2, count * 6)
82
+ candidates.uniq.each do |index|
83
+ raise Error, "invalid OpenType feature index" if index >= count
84
+ at = feature_list + 2 + index * 6
85
+ next unless index == required || features.include?(data.bytes(at, 4))
86
+ feature = feature_list + data.u16(at + 4)
87
+ indices.concat(words(data, feature + 4, data.u16(feature + 2)))
88
+ end
89
+ end
90
+ end
91
+ list = data.u16(8)
92
+ offsets = words(data, list + 2, data.u16(list))
93
+ indices.uniq.sort.map do |index|
94
+ raise Error, "invalid OpenType lookup index" if index >= offsets.length
95
+ list + offsets[index]
96
+ end
97
+ end
98
+ end
99
+
100
+ def coverage(data, offset, glyph)
101
+ case data.u16(offset)
102
+ when 1
103
+ count = data.u16(offset + 2)
104
+ data.check(offset + 4, count * 2)
105
+ index = (0...count).bsearch { |i| data.u16(offset + 4 + i * 2) >= glyph }
106
+ index if index && data.u16(offset + 4 + index * 2) == glyph
107
+ when 2
108
+ count = data.u16(offset + 2)
109
+ data.check(offset + 4, count * 6)
110
+ index = (0...count).bsearch { |i| data.u16(offset + 6 + i * 6) >= glyph }
111
+ return unless index
112
+ at = offset + 4 + index * 6
113
+ first, last = data.u16(at), data.u16(at + 2)
114
+ raise Error, "reversed OpenType coverage range" if last < first
115
+ data.u16(at + 4) + glyph - first if glyph.between?(first, last)
116
+ else raise Error, "invalid OpenType coverage format"
117
+ end
118
+ end
119
+
120
+ def glyph_class(data, at, glyph)
121
+ return 0 if at.zero?
122
+ case data.u16(at)
123
+ when 1
124
+ first, count = data.u16(at + 2), data.u16(at + 4)
125
+ data.check(at + 6, count * 2)
126
+ glyph.between?(first, first + count - 1) ? data.u16(at + 6 + (glyph - first) * 2) : 0
127
+ when 2
128
+ count = data.u16(at + 2)
129
+ data.check(at + 4, count * 6)
130
+ index = (0...count).bsearch { |i| data.u16(at + 6 + i * 6) >= glyph }
131
+ return 0 unless index && data.u16(at + 4 + index * 6) <= glyph
132
+ data.u16(at + 8 + index * 6)
133
+ else raise Error, "invalid OpenType class definition"
134
+ end
135
+ end
136
+
137
+ def filter(data, lookup, font)
138
+ @filters[[data, lookup, font]] ||= begin
139
+ flags = data.u16(lookup + 2)
140
+ raise Error, "reserved OpenType lookup flags" unless (flags & 0xe0).zero?
141
+ gdef = font.tables.key?("GDEF") && font.table("GDEF")
142
+ if flags & 0xff1e != 0
143
+ raise Error, "lookup filtering requires GDEF glyph classes" unless gdef && gdef.u16(4).positive?
144
+ if flags >> 8 != 0 && flags & 0x18 == 0 && gdef.u16(10).zero?
145
+ raise Error, "lookup filtering requires GDEF mark attachment classes"
146
+ end
147
+ end
148
+ mark_set = nil
149
+ if flags & 0x10 != 0
150
+ raise Error, "missing GDEF mark filtering sets" unless gdef && gdef.u16(0) == 1 && gdef.u16(2) >= 2
151
+ sets = gdef.u16(12)
152
+ raise Error, "missing GDEF mark filtering sets" if sets.zero?
153
+ index = data.u16(lookup + 6 + data.u16(lookup + 4) * 2)
154
+ raise Error, "invalid GDEF mark filtering set" unless gdef.u16(sets) == 1 && index < gdef.u16(sets + 2)
155
+ mark_set = sets + gdef.u32(sets + 4 + index * 4)
156
+ end
157
+ lambda do |glyph|
158
+ next false unless gdef && flags & 0xff1e != 0
159
+ klass = glyph_class(gdef, gdef.u16(4), glyph.id)
160
+ next true if (klass == 1 && flags & 2 != 0) || (klass == 2 && flags & 4 != 0) || (klass == 3 && flags & 8 != 0)
161
+ next false unless klass == 3
162
+ next coverage(gdef, mark_set, glyph.id).nil? if mark_set
163
+ flags >> 8 != 0 && glyph_class(gdef, gdef.u16(10), glyph.id) != flags >> 8
164
+ end
165
+ end
166
+ end
167
+
168
+ def next_index(glyphs, index, direction, ignored)
169
+ index += direction
170
+ index += direction while index.between?(0, glyphs.length - 1) && ignored.call(glyphs[index])
171
+ index if index.between?(0, glyphs.length - 1)
172
+ end
173
+
174
+ def subtables(data, lookup, extension)
175
+ type = data.u16(lookup)
176
+ words(data, lookup + 6, data.u16(lookup + 4)).each do |offset|
177
+ raise Error, "null OpenType subtable" if offset.zero?
178
+ at, actual = lookup + offset, type
179
+ if type == extension
180
+ raise Error, "invalid OpenType extension" unless data.u16(at) == 1
181
+ actual = data.u16(at + 2)
182
+ raise Error, "recursive OpenType extension" if actual == extension
183
+ at += data.u32(at + 4)
184
+ end
185
+ yield actual, at
186
+ end
187
+ end
188
+
189
+ def substitute(data, lookup, glyphs, index, size, depth, contexts)
190
+ raise Error, "OpenType contextual lookup recursion limit" if depth > 8
191
+ return unless (glyph = glyphs[index])
192
+ ignored = filter(data, lookup, glyph.font)
193
+ subtables(data, lookup, 7) do |type, at|
194
+ next unless [1, 4, 6].include?(type)
195
+ format = data.u16(at)
196
+ cov = type == 6 && format == 3 ? 0 : coverage(data, at + data.u16(at + 2), glyph.id)
197
+ next unless cov
198
+ case type
199
+ when 1
200
+ id = case format
201
+ when 1 then (glyph.id + data.i16(at + 4)) & 0xffff
202
+ when 2
203
+ substitutes = words(data, at + 6, data.u16(at + 4))
204
+ raise Error, "OpenType substitute coverage exceeds array" if cov >= substitutes.length
205
+ substitutes[cov]
206
+ else raise Error, "invalid single substitution format"
207
+ end
208
+ glyphs[index] = Glyph.new(glyph.font, id, glyph.start, glyph.finish, glyph.x, glyph.font.advance(id, size: size))
209
+ return index + 1
210
+ when 4
211
+ raise Error, "invalid ligature substitution format" unless format == 1
212
+ sets = words(data, at + 6, data.u16(at + 4))
213
+ next if cov >= sets.length || sets[cov].zero?
214
+ set = at + sets[cov]
215
+ words(data, set + 2, data.u16(set)).each do |offset|
216
+ ligature = set + offset
217
+ id, count = data.u16(ligature), data.u16(ligature + 2)
218
+ raise Error, "empty OpenType ligature" if count.zero?
219
+ expected = words(data, ligature + 4, count - 1)
220
+ positions, cursor = [index], index
221
+ matched = expected.all? do |expected_id|
222
+ cursor = next_index(glyphs, cursor, 1, ignored)
223
+ positions << cursor if cursor
224
+ cursor && glyphs[cursor].id == expected_id
225
+ end
226
+ next unless matched
227
+ ending = glyphs[positions.last].finish
228
+ glyphs[index] = Glyph.new(glyph.font, id, glyph.start, ending, glyph.x, glyph.font.advance(id, size: size))
229
+ remove_positions(glyphs, positions.drop(1), contexts)
230
+ return index + 1
231
+ end
232
+ when 6
233
+ ending = contextual(data, at, format, cov, glyphs, index, size, depth, contexts, ignored)
234
+ return ending if ending
235
+ end
236
+ end
237
+ nil
238
+ end
239
+
240
+ def remove_positions(glyphs, removed, contexts)
241
+ contexts.each do |context|
242
+ context[:positions] = context[:positions].reject { |i| removed.include?(i) }.map { |i| i - removed.count { |j| j < i } }
243
+ context[:end] -= removed.count { |i| i < context[:end] }
244
+ end
245
+ removed.reverse_each { |i| glyphs.delete_at(i) }
246
+ end
247
+
248
+ def contextual(data, at, format, cov, glyphs, index, size, depth, contexts, ignored)
249
+ if format == 3
250
+ rules, definitions = [at + 2], nil
251
+ elsif [1, 2].include?(format)
252
+ definitions = format == 2 ? words(data, at + 4, 3).map { |offset| at + offset } : nil
253
+ set_index = definitions ? glyph_class(data, definitions[1], glyphs[index].id) : cov
254
+ offset = format == 1 ? 4 : 10
255
+ sets = words(data, at + offset + 2, data.u16(at + offset))
256
+ return if set_index >= sets.length || sets[set_index].zero?
257
+ set = at + sets[set_index]
258
+ rules = words(data, set + 2, data.u16(set)).map { |distance| set + distance }
259
+ else raise Error, "invalid chained context format"
260
+ end
261
+ rules.each do |cursor|
262
+ sequences = []
263
+ 3.times do |part|
264
+ count = data.u16(cursor)
265
+ cursor += 2
266
+ raise Error, "empty context input" if part == 1 && count.zero?
267
+ count -= 1 if part == 1 && format != 3
268
+ sequences << words(data, cursor, count)
269
+ cursor += count * 2
270
+ end
271
+ match = lambda do |part, value, position|
272
+ id = glyphs[position].id
273
+ if format == 3
274
+ !coverage(data, at + value, id).nil?
275
+ elsif definitions
276
+ glyph_class(data, definitions[part], id) == value
277
+ else
278
+ id == value
279
+ end
280
+ end
281
+ previous = index
282
+ next unless sequences[0].all? { |value| previous = next_index(glyphs, previous, -1, ignored); previous && match.call(0, value, previous) }
283
+ positions, current = [index], index
284
+ input = sequences[1].dup
285
+ next if format == 3 && !match.call(1, input.shift, index)
286
+ next unless input.all? do |value|
287
+ current = next_index(glyphs, current, 1, ignored)
288
+ positions << current if current
289
+ current && match.call(1, value, current)
290
+ end
291
+ following = current
292
+ next unless sequences[2].all? { |value| following = next_index(glyphs, following, 1, ignored); following && match.call(2, value, following) }
293
+ count = data.u16(cursor)
294
+ actions = words(data, cursor + 2, count * 2).each_slice(2).to_a
295
+ list = data.u16(8)
296
+ lookups = words(data, list + 2, data.u16(list))
297
+ context = {positions: positions, end: current + 1}
298
+ contexts << context
299
+ actions.each do |sequence, lookup_index|
300
+ raise Error, "invalid contextual lookup index" if lookup_index >= lookups.length
301
+ target = context[:positions][sequence]
302
+ substitute(data, list + lookups[lookup_index], glyphs, target, size, depth + 1, contexts) if target
303
+ end
304
+ contexts.pop
305
+ return context[:end]
306
+ end
307
+ nil
308
+ end
309
+
310
+ def positioning(glyphs, size, script, language)
311
+ placements, advances = Array.new(glyphs.length, 0.0), Array.new(glyphs.length, 0.0)
312
+ return [placements, advances] if glyphs.empty?
313
+ font, active = glyphs.first.font, []
314
+ factor = size.to_f / font.units_per_em
315
+ if font.tables.key?("GPOS")
316
+ data = font.table("GPOS")
317
+ active = lookups(data, font, "GPOS", ["kern"], script, language)
318
+ active.each do |lookup|
319
+ ignored = filter(data, lookup, font)
320
+ index = 0
321
+ while index < glyphs.length
322
+ following = next_index(glyphs, index, 1, ignored)
323
+ break unless following
324
+ next_cursor = index + 1
325
+ subtables(data, lookup, 9) do |type, at|
326
+ next unless type == 2
327
+ cov = coverage(data, at + data.u16(at + 2), glyphs[index].id)
328
+ next unless cov
329
+ pair = pair_adjustment(data, at, cov, glyphs[index].id, glyphs[following].id, size, factor)
330
+ next unless pair
331
+ first, second, second_format = pair
332
+ placements[index] += first[0]
333
+ advances[index] += first[1]
334
+ placements[following] += second[0]
335
+ advances[following] += second[1]
336
+ next_cursor = second_format.zero? ? following : following + 1
337
+ break
338
+ end
339
+ index = next_cursor
340
+ end
341
+ end
342
+ end
343
+ if active.empty?
344
+ glyphs.each_cons(2).with_index { |(left, right), i| advances[i] += legacy_kern(font, left.id, right.id) * factor }
345
+ end
346
+ [placements, advances]
347
+ end
348
+
349
+ def pair_adjustment(data, at, coverage_index, left, right, size, factor)
350
+ format, first_format, second_format = data.u16(at), data.u16(at + 4), data.u16(at + 6)
351
+ raise Error, "reserved OpenType value format" unless ((first_format | second_format) & 0xff00).zero?
352
+ first_size, second_size = first_format.digits(2).sum * 2, second_format.digits(2).sum * 2
353
+ stride = first_size + second_size
354
+ if format == 1
355
+ sets = words(data, at + 10, data.u16(at + 8))
356
+ return if coverage_index >= sets.length
357
+ parent = at + sets[coverage_index]
358
+ count = data.u16(parent)
359
+ data.check(parent + 2, count * (stride + 2))
360
+ index = (0...count).bsearch { |j| data.u16(parent + 2 + j * (stride + 2)) >= right }
361
+ return unless index && data.u16(parent + 2 + index * (stride + 2)) == right
362
+ record = parent + 4 + index * (stride + 2)
363
+ elsif format == 2
364
+ parent = at
365
+ class1 = glyph_class(data, at + data.u16(at + 8), left)
366
+ class2 = glyph_class(data, at + data.u16(at + 10), right)
367
+ count1, count2 = data.u16(at + 12), data.u16(at + 14)
368
+ data.check(at + 16, count1 * count2 * stride)
369
+ raise Error, "OpenType pair class exceeds matrix" unless class1 < count1 && class2 < count2
370
+ record = at + 16 + (class1 * count2 + class2) * stride
371
+ else raise Error, "invalid pair positioning format"
372
+ end
373
+ [value_record(data, record, first_format, parent, size, factor),
374
+ value_record(data, record + first_size, second_format, parent, size, factor), second_format]
375
+ end
376
+
377
+ def value_record(data, at, format, parent, size, factor)
378
+ values = Array.new(8, 0)
379
+ 8.times do |bit|
380
+ next if format & (1 << bit) == 0
381
+ values[bit] = bit < 4 ? data.i16(at) : data.u16(at)
382
+ at += 2
383
+ end
384
+ # yAdvance is irrelevant to horizontal text; vertical placement needs
385
+ # a native shaper with a two-dimensional glyph attachment model.
386
+ raise Error, "vertical pair placement requires a native shaper" unless values[1].zero? && values[5].zero?
387
+ placement, advance = values[0] * factor, values[2] * factor
388
+ placement += device_delta(data, parent + values[4], size.round) unless values[4].zero?
389
+ advance += device_delta(data, parent + values[6], size.round) unless values[6].zero?
390
+ [placement, advance]
391
+ end
392
+
393
+ def device_delta(data, at, ppem)
394
+ first, last, format = data.u16(at), data.u16(at + 2), data.u16(at + 4)
395
+ raise Error, "unsupported OpenType device delta format" unless (1..3).cover?(format)
396
+ raise Error, "reversed device ppem range" if last < first
397
+ bits = 1 << format
398
+ data.check(at + 6, ((last - first + 1) * bits + 15) / 16 * 2)
399
+ return 0 unless ppem.between?(first, last)
400
+ index, mask = ppem - first, (1 << bits) - 1
401
+ value = (data.u16(at + 6 + index * bits / 16 * 2) >> (16 - bits - index * bits % 16)) & mask
402
+ value >= 1 << (bits - 1) ? value - (1 << bits) : value
403
+ end
404
+
405
+ def legacy_kern(font, left, right)
406
+ return 0 unless font.tables.key?("kern")
407
+ data = font.table("kern")
408
+ return 0 unless data.u16(0).zero?
409
+ cursor, result = 4, 0
410
+ data.u16(2).times do
411
+ length, flags = data.u16(cursor + 2), data.u16(cursor + 4)
412
+ raise Error, "invalid kern table length" if length < 6
413
+ data.check(cursor, length)
414
+ if flags >> 8 == 0 && flags & 7 == 1
415
+ count = data.u16(cursor + 6)
416
+ raise Error, "kern pairs exceed subtable" if 14 + count * 6 > length
417
+ pair = (left << 16) | right
418
+ index = (0...count).bsearch { |j| data.u32(cursor + 14 + j * 6) >= pair }
419
+ if index && data.u32(cursor + 14 + index * 6) == pair
420
+ value = data.i16(cursor + 18 + index * 6)
421
+ result = flags & 8 == 0 ? result + value : value
422
+ end
423
+ end
424
+ cursor += length
425
+ end
426
+ result
427
+ end
428
+ end
429
+ end
430
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module TextSystem
5
+ class Typesetter
6
+ class CopyError < Error; end
7
+ end
8
+ end
9
+ end