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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class SceneRenderer
5
+ def initialize(device) = @device = device
6
+ def render(scene, clear: "#0000") = @device.render(scene, clear: clear)
7
+ end
8
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ Size = Data.define(:width, :height)
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class Subscription
5
+ def initialize(&detach) = @detach = detach
6
+
7
+ def detach
8
+ callback, @detach = @detach, nil
9
+ callback&.call
10
+ end
11
+
12
+ def attached? = !@detach.nil?
13
+ end
14
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Zaniah
6
+ class SVG
7
+ class Path
8
+ # SVG path grammar, including compact arc flags and smooth relative curves.
9
+ NUMBER = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/
10
+ def self.parse(source) = new(source).parse
11
+ def initialize(source)
12
+ @scanner, @outline = StringScanner.new(source), Alhena::Outline.new
13
+ @x = @y = @start_x = @start_y = 0.0
14
+ @previous, @control = nil, nil
15
+ end
16
+
17
+ def parse
18
+ command, operations = nil, 0
19
+ loop do
20
+ separator
21
+ break if @scanner.eos?
22
+ explicit = @scanner.scan(/[a-zA-Z]/)
23
+ command = explicit if explicit
24
+ raise ArgumentError, "SVG path must start with moveto" if @previous.nil? && !%w[M m].include?(command)
25
+ raise ArgumentError, "missing SVG path command" unless command
26
+ operations += 1
27
+ raise ArgumentError, "SVG path is too complex" if operations > 100_000
28
+ relative, type = command == command.downcase, command.upcase
29
+ case type
30
+ when "M", "L", "T"
31
+ x, y = point(relative)
32
+ if type == "M"
33
+ @outline.move_to(x, y)
34
+ @start_x, @start_y = x, y
35
+ elsif type == "T"
36
+ control = %w[Q T].include?(@previous) ? reflected : [@x, @y]
37
+ @outline.quad_to(*control, x, y)
38
+ @control = control
39
+ else
40
+ @outline.line_to(x, y)
41
+ end
42
+ @x, @y = x, y
43
+ when "H"
44
+ @x = number + (relative ? @x : 0)
45
+ @outline.line_to(@x, @y)
46
+ when "V"
47
+ @y = number + (relative ? @y : 0)
48
+ @outline.line_to(@x, @y)
49
+ when "C"
50
+ a, b, endpoint = point(relative), point(relative), point(relative)
51
+ @outline.cubic_to(*a, *b, *endpoint)
52
+ @control, (@x, @y) = b, endpoint
53
+ when "S"
54
+ a = %w[C S].include?(@previous) ? reflected : [@x, @y]
55
+ b, endpoint = point(relative), point(relative)
56
+ @outline.cubic_to(*a, *b, *endpoint)
57
+ @control, (@x, @y) = b, endpoint
58
+ when "Q"
59
+ a, endpoint = point(relative), point(relative)
60
+ @outline.quad_to(*a, *endpoint)
61
+ @control, (@x, @y) = a, endpoint
62
+ when "A"
63
+ rx, ry, rotation, large, sweep = number, number, number, flag, flag
64
+ endpoint = point(relative)
65
+ arc(rx, ry, rotation, large, sweep, *endpoint)
66
+ @x, @y = endpoint
67
+ when "Z"
68
+ raise ArgumentError, "closepath cannot repeat without a command" unless explicit
69
+ @outline.close
70
+ @x, @y = @start_x, @start_y
71
+ command = nil
72
+ else raise ArgumentError, "unsupported SVG path command #{command}"
73
+ end
74
+ @previous = type
75
+ command = relative ? "l" : "L" if type == "M"
76
+ end
77
+ @outline
78
+ end
79
+
80
+ private
81
+
82
+ def separator = @scanner.skip(/[\s,]*/)
83
+ def number
84
+ separator
85
+ token = @scanner.scan(NUMBER)
86
+ raise ArgumentError, "invalid SVG path number near #{@scanner.peek(20).inspect}" unless token
87
+ value = Float(token)
88
+ raise ArgumentError, "SVG coordinate is out of range" unless value.finite? && value.abs <= 1e9
89
+ value
90
+ end
91
+ def flag
92
+ separator
93
+ token = @scanner.scan(/[01]/)
94
+ raise ArgumentError, "invalid SVG arc flag" unless token
95
+ token == "1"
96
+ end
97
+ def point(relative) = [number + (relative ? @x : 0), number + (relative ? @y : 0)]
98
+ def reflected = [2 * @x - @control[0], 2 * @y - @control[1]]
99
+
100
+ def arc(rx, ry, rotation, large, sweep, x, y)
101
+ return if x == @x && y == @y
102
+ rx, ry = rx.abs, ry.abs
103
+ return @outline.line_to(x, y) if rx.zero? || ry.zero?
104
+ phi = rotation * Math::PI / 180
105
+ cos, sin = Math.cos(phi), Math.sin(phi)
106
+ dx, dy = (@x - x) / 2, (@y - y) / 2
107
+ px, py = cos * dx + sin * dy, -sin * dx + cos * dy
108
+ scale = px * px / (rx * rx) + py * py / (ry * ry)
109
+ rx, ry = rx * Math.sqrt(scale), ry * Math.sqrt(scale) if scale > 1
110
+ rx2, ry2 = rx * rx, ry * ry
111
+ numerator = [rx2 * ry2 - rx2 * py * py - ry2 * px * px, 0].max
112
+ coefficient = Math.sqrt(numerator / (rx2 * py * py + ry2 * px * px)) * (large == sweep ? -1 : 1)
113
+ cxp, cyp = coefficient * rx * py / ry, -coefficient * ry * px / rx
114
+ cx, cy = cos * cxp - sin * cyp + (@x + x) / 2, sin * cxp + cos * cyp + (@y + y) / 2
115
+ ux, uy, vx, vy = (px - cxp) / rx, (py - cyp) / ry, (-px - cxp) / rx, (-py - cyp) / ry
116
+ theta = Math.atan2(uy, ux)
117
+ delta = Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
118
+ delta -= 2 * Math::PI if !sweep && delta.positive?
119
+ delta += 2 * Math::PI if sweep && delta.negative?
120
+ segments = (delta.abs / (Math::PI / 2)).ceil
121
+ step = delta / segments
122
+ mapped = ->(a, b) { [cx + cos * rx * a - sin * ry * b, cy + sin * rx * a + cos * ry * b] }
123
+ segments.times do
124
+ ending, factor = theta + step, 4.0 / 3 * Math.tan(step / 4)
125
+ a = mapped.call(Math.cos(theta) - factor * Math.sin(theta), Math.sin(theta) + factor * Math.cos(theta))
126
+ b = mapped.call(Math.cos(ending) + factor * Math.sin(ending), Math.sin(ending) - factor * Math.cos(ending))
127
+ endpoint = mapped.call(Math.cos(ending), Math.sin(ending))
128
+ @outline.cubic_to(*a, *b, *endpoint)
129
+ theta = ending
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class SVG
5
+ module Rasterizable
6
+ private
7
+
8
+ def contours(outline, tolerance: 0.15)
9
+ result, points, x, y = [], nil, 0.0, 0.0
10
+ outline.to_quadratic(tolerance: tolerance).each do |command, *values|
11
+ case command
12
+ when :move_to
13
+ result << [points, false] if points
14
+ x, y = values
15
+ points = [[x, y]]
16
+ when :line_to
17
+ points ||= [[x, y]]
18
+ x, y = values
19
+ points << [x, y]
20
+ when :quad_to
21
+ points ||= [[x, y]]
22
+ cx, cy, endpoint_x, endpoint_y = values
23
+ flatten_quad(points, x, y, cx, cy, endpoint_x, endpoint_y, tolerance, 0)
24
+ x, y = endpoint_x, endpoint_y
25
+ when :close
26
+ next unless points
27
+ result << [points, true]
28
+ x, y = points.first
29
+ points = nil
30
+ end
31
+ end
32
+ result << [points, false] if points
33
+ result
34
+ end
35
+
36
+ def flatten_quad(points, x, y, cx, cy, ex, ey, tolerance, depth)
37
+ raise ArgumentError, "SVG curve is too complex" if points.length > 100_000
38
+ if depth >= 16 || Math.hypot(x - 2 * cx + ex, y - 2 * cy + ey) <= tolerance * 4
39
+ points << [ex, ey]
40
+ else
41
+ ax, ay, bx, by = (x + cx) / 2, (y + cy) / 2, (cx + ex) / 2, (cy + ey) / 2
42
+ mx, my = (ax + bx) / 2, (ay + by) / 2
43
+ flatten_quad(points, x, y, ax, ay, mx, my, tolerance, depth + 1)
44
+ flatten_quad(points, mx, my, bx, by, ex, ey, tolerance, depth + 1)
45
+ end
46
+ end
47
+
48
+ def mask(outline, width, height, rule)
49
+ return Alhena::Rasterizer.new(width: width, height: height).fill(outline).coverage if rule == "nonzero"
50
+ raise ArgumentError, "unknown SVG fill rule #{rule}" unless rule == "evenodd"
51
+ # The font rasterizer deliberately uses nonzero winding. Even-odd icons
52
+ # need parity instead: subpixel scanlines pair sorted edge intersections.
53
+ edges = contours(outline).flat_map do |points, _closed|
54
+ next [] if points.length < 2
55
+ (points + [points.first]).each_cons(2).reject { |a, b| a[1] == b[1] }.to_a
56
+ end
57
+ coverage = "\0".b * (width * height)
58
+ height.times do |row|
59
+ sums = Array.new(width, 0.0)
60
+ 8.times do |sample|
61
+ y = row + (sample + 0.5) / 8
62
+ intersections = edges.filter_map do |a, b|
63
+ low, high = [a[1], b[1]].minmax
64
+ a[0] + (y - a[1]) * (b[0] - a[0]) / (b[1] - a[1]) if low <= y && y < high
65
+ end.sort
66
+ intersections.each_slice(2) do |left, right|
67
+ next unless right
68
+ left, right = left.clamp(0, width), right.clamp(0, width)
69
+ next if right <= left
70
+ (left.floor...[right.ceil, width].min).each do |column|
71
+ sums[column] += [right, column + 1].min - [left, column].max
72
+ end
73
+ end
74
+ end
75
+ sums.each_with_index { |amount, column| coverage.setbyte(row * width + column, (amount * 255 / 8).round.clamp(0, 255)) }
76
+ end
77
+ coverage
78
+ end
79
+
80
+ def clip_mask(element, matrix, width, height, references, inherited = {"clip-rule" => "nonzero"})
81
+ raise ArgumentError, "SVG clip is too complex" if references.length > 32
82
+ raise ArgumentError, "objectBoundingBox clips are not supported" if element.attributes["clipPathUnits"] == "objectBoundingBox"
83
+ style = properties(element, inherited)
84
+ output = "\0".b * (width * height)
85
+ return output if style["display"] == "none" || style["visibility"] == "hidden"
86
+ matrix = multiply(matrix, transform(element.attributes["transform"]))
87
+ if (outline = @paths[element])
88
+ return mask(outline.transform(matrix), width, height, style.fetch("clip-rule", "nonzero"))
89
+ end
90
+ children = element.elements.to_a
91
+ if element.name == "use"
92
+ href = element.attributes["href"] || element.attributes["xlink:href"]
93
+ raise ArgumentError, "invalid or cyclic clip use" unless href&.start_with?("#") && !references.include?(href)
94
+ children = [@ids.fetch(href.delete_prefix("#")) { raise ArgumentError, "unknown clip use #{href}" }]
95
+ matrix = multiply(matrix, [1, 0, 0, 1, length(element.attributes["x"]), length(element.attributes["y"])])
96
+ references += [href]
97
+ end
98
+ children.each do |child|
99
+ next if %w[title desc metadata defs].include?(child.name)
100
+ layer = clip_mask(child, matrix, width, height, references, style)
101
+ output.bytesize.times do |i|
102
+ a, b = output.getbyte(i), layer.getbyte(i)
103
+ output.setbyte(i, a + b - (a * b / 255.0).round)
104
+ end
105
+ end
106
+ output
107
+ end
108
+
109
+ def polygon(outline, points)
110
+ return if points.length < 3
111
+ # All stroke pieces have matching winding so their overlap is a union.
112
+ area = (points + [points.first]).each_cons(2).sum { |a, b| a[0] * b[1] - b[0] * a[1] }
113
+ points = points.reverse if area.negative?
114
+ outline.move_to(*points.first)
115
+ points.drop(1).each { |point| outline.line_to(*point) }
116
+ outline.close
117
+ end
118
+
119
+ def disk(outline, center, radius)
120
+ segments = [[(Math::PI * Math.sqrt(radius / 0.075)).ceil, 12].max, 512].min
121
+ polygon(outline, Array.new(segments) do |i|
122
+ angle = i * 2 * Math::PI / segments
123
+ [center[0] + Math.cos(angle) * radius, center[1] + Math.sin(angle) * radius]
124
+ end)
125
+ end
126
+
127
+ def stroke(outline, width, style)
128
+ cap, join = style["stroke-linecap"], style["stroke-linejoin"]
129
+ raise ArgumentError, "unknown SVG line cap" unless %w[butt round square].include?(cap)
130
+ raise ArgumentError, "unknown SVG line join" unless %w[miter round bevel].include?(join)
131
+ limit = number(style["stroke-miterlimit"])
132
+ raise ArgumentError, "SVG miter limit must be at least one" if limit < 1
133
+ radius, result = width / 2.0, Alhena::Outline.new
134
+ contours(outline).each do |points, closed|
135
+ points = points.each_with_object([]) { |point, unique| unique << point if unique.last != point }
136
+ points.pop if closed && points.length > 1 && points.first == points.last
137
+ if points.length == 1
138
+ disk(result, points.first, radius) if cap == "round"
139
+ if cap == "square"
140
+ x, y = points.first
141
+ polygon(result, [[x - radius, y - radius], [x + radius, y - radius], [x + radius, y + radius], [x - radius, y + radius]])
142
+ end
143
+ next
144
+ end
145
+ segments = (closed ? points + [points.first] : points).each_cons(2).map do |a, b|
146
+ dx, dy = b[0] - a[0], b[1] - a[1]
147
+ length = Math.hypot(dx, dy)
148
+ [a, b, dx / length, dy / length]
149
+ end
150
+ segments.each_with_index do |(a, b, dx, dy), index|
151
+ nx, ny = -dy * radius, dx * radius
152
+ start_cap = !closed && index.zero? && cap == "square" ? radius : 0
153
+ end_cap = !closed && index == segments.length - 1 && cap == "square" ? radius : 0
154
+ ax, ay, bx, by = a[0] - dx * start_cap, a[1] - dy * start_cap, b[0] + dx * end_cap, b[1] + dy * end_cap
155
+ polygon(result, [[ax + nx, ay + ny], [bx + nx, by + ny], [bx - nx, by - ny], [ax - nx, ay - ny]])
156
+ end
157
+ pairs = (closed ? segments + [segments.first] : segments).each_cons(2)
158
+ pairs.each do |left, right|
159
+ vertex, dx, dy, ex, ey = left[1], left[2], left[3], right[2], right[3]
160
+ cross = dx * ey - dy * ex
161
+ next if cross.abs < 1e-10
162
+ if join == "round"
163
+ disk(result, vertex, radius)
164
+ next
165
+ end
166
+ sign = cross.positive? ? -1 : 1
167
+ a = [vertex[0] - dy * radius * sign, vertex[1] + dx * radius * sign]
168
+ b = [vertex[0] - ey * radius * sign, vertex[1] + ex * radius * sign]
169
+ distance = ((b[0] - a[0]) * ey - (b[1] - a[1]) * ex) / cross
170
+ miter = [a[0] + distance * dx, a[1] + distance * dy]
171
+ if join == "miter" && Math.hypot(miter[0] - vertex[0], miter[1] - vertex[1]) <= radius * limit
172
+ polygon(result, [vertex, a, miter, b])
173
+ else
174
+ polygon(result, [vertex, a, b])
175
+ end
176
+ end
177
+ if !closed && cap == "round"
178
+ disk(result, points.first, radius)
179
+ disk(result, points.last, radius)
180
+ end
181
+ end
182
+ result
183
+ end
184
+ end
185
+ end
186
+ end
data/lib/zaniah/svg.rb ADDED
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Zaniah
6
+ # Static vector icons. No scripting, external resources, or browser engine.
7
+ class SVG < Element
8
+ IDENTITY = [1, 0, 0, 1, 0, 0].freeze
9
+ DEFAULTS = {"fill" => "black", "stroke" => "none", "stroke-width" => "1",
10
+ "fill-rule" => "nonzero", "fill-opacity" => "1", "stroke-opacity" => "1",
11
+ "stroke-linecap" => "butt", "stroke-linejoin" => "miter", "stroke-miterlimit" => "4"}.freeze
12
+ PRESENTATION = (DEFAULTS.keys + %w[color opacity display visibility clip-path clip-rule]).freeze
13
+ ELEMENTS = %w[svg g path rect circle ellipse line polyline polygon defs use clipPath title desc metadata].freeze
14
+ NAMES = {"black" => "#000", "white" => "#fff", "red" => "#f00", "green" => "#008000",
15
+ "blue" => "#00f", "yellow" => "#ff0", "gray" => "#808080", "grey" => "#808080",
16
+ "silver" => "#c0c0c0", "maroon" => "#800000", "purple" => "#800080", "fuchsia" => "#f0f",
17
+ "lime" => "#0f0", "olive" => "#808000", "navy" => "#000080", "teal" => "#008080",
18
+ "aqua" => "#0ff", "orange" => "#ffa500", "transparent" => "#0000", "rebeccapurple" => "#639"}.freeze
19
+ attr_reader :width, :height, :view_box
20
+
21
+ def self.parse(source, **options) = new(source, **options)
22
+ def self.open(path, **options) = new(File.binread(path), **options)
23
+
24
+ def initialize(source, color: "#000")
25
+ super()
26
+ require "alhena"
27
+ raise ArgumentError, "SVG exceeds 2 MiB" if source.bytesize > 2 * 1024 * 1024
28
+ raise ArgumentError, "SVG document types and entities are forbidden" if source.match?(/<!\s*(?:DOCTYPE|ENTITY)/i)
29
+ @root = REXML::Document.new(source).root
30
+ raise ArgumentError, "expected an SVG root" unless @root&.name == "svg"
31
+ @ids, @paths, @textures, @color = {}, {}, {}, color
32
+ validate(@root, 0, [0])
33
+ @view_box = numbers(@root.attributes["viewBox"]) if @root.attributes["viewBox"]
34
+ if @view_box && (@view_box.length != 4 || !@view_box[2].positive? || !@view_box[3].positive?)
35
+ raise ArgumentError, "viewBox must contain x y width height with positive dimensions"
36
+ end
37
+ @width = length(@root.attributes["width"], @view_box ? @view_box[2] : 300, percentage: true)
38
+ @height = length(@root.attributes["height"], @view_box ? @view_box[3] : 150, percentage: true)
39
+ raise ArgumentError, "SVG dimensions must be positive" unless @width.positive? && @height.positive?
40
+ @view_box ||= [0, 0, @width, @height]
41
+ rescue REXML::ParseException => error
42
+ raise ArgumentError, "invalid SVG: #{error.message.lines.first}"
43
+ end
44
+
45
+ def request_layout(_cx)
46
+ @layout_node = Layout::Node.new(style: @style, measure: ->(_w, _h) { [@width, @height] })
47
+ end
48
+
49
+ def paint(bounds, _state, _pre, cx)
50
+ return if bounds.width <= 0 || bounds.height <= 0
51
+ scale = cx.window.respond_to?(:scale_factor) ? cx.window.scale_factor : 1
52
+ raster = texture(width: (bounds.width * scale).ceil, height: (bounds.height * scale).ceil)
53
+ cx.scene.sprite(bounds.x, bounds.y, bounds.width, bounds.height, texture: raster)
54
+ end
55
+
56
+ def texture(width: @width.ceil, height: @height.ceil, color: @color)
57
+ unless width.is_a?(Integer) && height.is_a?(Integer) && width.positive? && height.positive? && width * height <= 1_048_576
58
+ raise ArgumentError, "SVG raster dimensions must be positive and at most 1 megapixel"
59
+ end
60
+ key = [width, height, color]
61
+ return @textures[key] if @textures.key?(key)
62
+ pixels = "\0".b * (width * height * 4)
63
+ render(@root, pixels, viewport(width, height), DEFAULTS.merge("color" => color), width, height, [])
64
+ @textures.shift if @textures.length >= 8
65
+ @textures[key] = GPU::Texture.new(width, height, data: pixels)
66
+ end
67
+
68
+ private
69
+
70
+ def validate(element, depth, count)
71
+ count[0] += 1
72
+ raise ArgumentError, "SVG tree is too complex" if depth > 64 || count[0] > 10_000
73
+ raise ArgumentError, "unsupported SVG element #{element.name}" unless ELEMENTS.include?(element.name)
74
+ raise ArgumentError, "nested SVG viewports are not supported" if depth.positive? && element.name == "svg"
75
+ %w[filter mask stroke-dasharray marker-start marker-mid marker-end].each do |attribute|
76
+ raise ArgumentError, "unsupported SVG attribute #{attribute}" if element.attributes[attribute]
77
+ end
78
+ @ids[element.attributes["id"]] = element if element.attributes["id"]
79
+ @paths[element] = geometry(element) if %w[path rect circle ellipse line polyline polygon].include?(element.name)
80
+ element.elements.each { |child| validate(child, depth + 1, count) }
81
+ end
82
+
83
+ def number(value)
84
+ text = value.to_s.strip
85
+ raise ArgumentError, "invalid SVG number #{value.inspect}" unless text.match?(/\A#{Path::NUMBER}\z/)
86
+ result = Float(text)
87
+ raise ArgumentError, "SVG number out of range" unless result.finite? && result.abs <= 1e9
88
+ result
89
+ end
90
+
91
+ def numbers(value)
92
+ scanner, result = StringScanner.new(value.to_s), []
93
+ until scanner.eos?
94
+ scanner.skip(/[\s,]*/)
95
+ break if scanner.eos?
96
+ token = scanner.scan(Path::NUMBER)
97
+ raise ArgumentError, "invalid SVG number list" unless token
98
+ result << number(token)
99
+ end
100
+ result
101
+ end
102
+
103
+ def length(value, fallback = 0, percentage: false)
104
+ return fallback unless value
105
+ text = value.to_s.strip
106
+ if text.end_with?("%")
107
+ raise ArgumentError, "percentage SVG geometry is not supported" unless percentage
108
+ return number(text.delete_suffix("%")) * fallback / 100
109
+ end
110
+ number(text.delete_suffix("px"))
111
+ end
112
+
113
+ def geometry(element)
114
+ attr = element.attributes
115
+ get = ->(key, fallback = 0) { length(attr[key], fallback) }
116
+ case element.name
117
+ when "path" then Path.parse(attr["d"].to_s)
118
+ when "line" then Alhena::Outline.new.move_to(get.call("x1"), get.call("y1")).line_to(get.call("x2"), get.call("y2"))
119
+ when "polyline", "polygon"
120
+ points = numbers(attr["points"])
121
+ raise ArgumentError, "polygon needs coordinate pairs" if points.length.odd?
122
+ outline = Alhena::Outline.new
123
+ points.each_slice(2).with_index { |point, i| outline.public_send(i.zero? ? :move_to : :line_to, *point) }
124
+ outline.close if element.name == "polygon" && !outline.empty?
125
+ outline
126
+ when "circle", "ellipse"
127
+ cx, cy = get.call("cx"), get.call("cy")
128
+ rx, ry = element.name == "circle" ? [get.call("r")] * 2 : [get.call("rx"), get.call("ry")]
129
+ raise ArgumentError, "negative ellipse radius" if rx.negative? || ry.negative?
130
+ return Alhena::Outline.new if rx.zero? || ry.zero?
131
+ Path.parse("M#{cx + rx} #{cy} A#{rx} #{ry} 0 1 1 #{cx - rx} #{cy} A#{rx} #{ry} 0 1 1 #{cx + rx} #{cy}Z")
132
+ when "rect"
133
+ x, y, w, h = get.call("x"), get.call("y"), get.call("width"), get.call("height")
134
+ raise ArgumentError, "negative rectangle size" if w.negative? || h.negative?
135
+ return Alhena::Outline.new if w.zero? || h.zero?
136
+ rx, ry = get.call("rx", get.call("ry")), get.call("ry", get.call("rx"))
137
+ raise ArgumentError, "negative rectangle radius" if rx.negative? || ry.negative?
138
+ rx, ry = [rx, w / 2].min, [ry, h / 2].min
139
+ if rx.zero? || ry.zero?
140
+ Alhena::Outline.new.move_to(x, y).line_to(x + w, y).line_to(x + w, y + h).line_to(x, y + h).close
141
+ else
142
+ Path.parse("M#{x + rx} #{y}H#{x + w - rx}A#{rx} #{ry} 0 0 1 #{x + w} #{y + ry}V#{y + h - ry}A#{rx} #{ry} 0 0 1 #{x + w - rx} #{y + h}H#{x + rx}A#{rx} #{ry} 0 0 1 #{x} #{y + h - ry}V#{y + ry}A#{rx} #{ry} 0 0 1 #{x + rx} #{y}Z")
143
+ end
144
+ end
145
+ end
146
+
147
+ def multiply(left, right)
148
+ a, b, c, d, e, f = left
149
+ g, h, i, j, k, l = right
150
+ [a * g + c * h, b * g + d * h, a * i + c * j, b * i + d * j, a * k + c * l + e, b * k + d * l + f]
151
+ end
152
+
153
+ def transform(source)
154
+ scanner, matrix = StringScanner.new(source.to_s), IDENTITY
155
+ until scanner.eos?
156
+ scanner.skip(/[\s,]*/)
157
+ break if scanner.eos?
158
+ name = scanner.scan(/[a-zA-Z]+/)
159
+ scanner.skip(/\s*/)
160
+ raise ArgumentError, "invalid SVG transform" unless name && scanner.scan(/\(/)
161
+ values = scanner.scan(/[^)]*/)
162
+ raise ArgumentError, "unclosed SVG transform" unless scanner.scan(/\)/)
163
+ values = numbers(values)
164
+ operation = case name
165
+ when "matrix" then values if values.length == 6
166
+ when "translate" then [1, 0, 0, 1, values[0], values.fetch(1, 0)] if (1..2).cover?(values.length)
167
+ when "scale" then [values[0], 0, 0, values.fetch(1, values[0]), 0, 0] if (1..2).cover?(values.length)
168
+ when "rotate"
169
+ if [1, 3].include?(values.length)
170
+ angle = values[0] * Math::PI / 180
171
+ rotation = [Math.cos(angle), Math.sin(angle), -Math.sin(angle), Math.cos(angle), 0, 0]
172
+ values.length == 1 ? rotation : multiply(multiply([1, 0, 0, 1, values[1], values[2]], rotation), [1, 0, 0, 1, -values[1], -values[2]])
173
+ end
174
+ when "skewX", "skewY"
175
+ if values.length == 1
176
+ tangent = Math.tan(values[0] * Math::PI / 180)
177
+ name == "skewX" ? [1, 0, tangent, 1, 0, 0] : [1, tangent, 0, 1, 0, 0]
178
+ end
179
+ end
180
+ raise ArgumentError, "invalid SVG transform #{name}" unless operation && operation.all?(&:finite?)
181
+ matrix = multiply(matrix, operation)
182
+ end
183
+ matrix
184
+ end
185
+
186
+ def viewport(width, height)
187
+ x, y, w, h = @view_box
188
+ sx, sy = width.to_f / w, height.to_f / h
189
+ alignment, mode = (@root.attributes["preserveAspectRatio"] || "xMidYMid meet").split
190
+ return [sx, 0, 0, sy, -x * sx, -y * sy] if alignment == "none"
191
+ raise ArgumentError, "invalid preserveAspectRatio" unless alignment.match?(/\Ax(Min|Mid|Max)Y(Min|Mid|Max)\z/) && [nil, "meet", "slice"].include?(mode)
192
+ scale = mode == "slice" ? [sx, sy].max : [sx, sy].min
193
+ ax = alignment.include?("xMin") ? 0 : (alignment.include?("xMax") ? 1 : 0.5)
194
+ ay = alignment.include?("YMin") ? 0 : (alignment.include?("YMax") ? 1 : 0.5)
195
+ [scale, 0, 0, scale, (width - w * scale) * ax - x * scale, (height - h * scale) * ay - y * scale]
196
+ end
197
+
198
+ def properties(element, inherited)
199
+ style = inherited.reject { |key, _| %w[opacity clip-path display].include?(key) }
200
+ PRESENTATION.each { |key| style[key] = element.attributes[key] if element.attributes[key] }
201
+ element.attributes["style"].to_s.split(";").each do |declaration|
202
+ key, value = declaration.split(":", 2).map(&:strip)
203
+ next unless key && value
204
+ raise ArgumentError, "unsupported SVG style #{key}" unless PRESENTATION.include?(key)
205
+ style[key] = value
206
+ end
207
+ style.each { |key, value| style[key] = inherited.fetch(key, DEFAULTS[key]) if value == "inherit" }
208
+ style
209
+ end
210
+
211
+ def render(element, pixels, matrix, inherited, width, height, references, definition: false)
212
+ return if %w[title desc metadata].include?(element.name)
213
+ return if %w[defs clipPath].include?(element.name) && !definition
214
+ style = properties(element, inherited)
215
+ return if style["display"] == "none"
216
+ matrix = multiply(matrix, transform(element.attributes["transform"]))
217
+ opacity = number(style.fetch("opacity", "1")).clamp(0, 1)
218
+ isolated = opacity < 1 || style["clip-path"]
219
+ target = isolated ? "\0".b * pixels.bytesize : pixels
220
+ if element.name == "use"
221
+ href = element.attributes["href"] || element.attributes["xlink:href"]
222
+ raise ArgumentError, "SVG use must reference a local id" unless href&.start_with?("#")
223
+ raise ArgumentError, "cyclic SVG use" if references.include?(href) || references.length >= 32
224
+ referenced = @ids.fetch(href.delete_prefix("#")) { raise ArgumentError, "unknown SVG reference #{href}" }
225
+ shifted = multiply(matrix, [1, 0, 0, 1, length(element.attributes["x"]), length(element.attributes["y"])])
226
+ render(referenced, target, shifted, style, width, height, references + [href], definition: true)
227
+ elsif (outline = @paths[element]) && style["visibility"] != "hidden"
228
+ unless style["fill"] == "none" || outline.empty?
229
+ paint_mask(target, mask(outline.transform(matrix), width, height, style["fill-rule"]), color(style["fill"], style["color"]), number(style["fill-opacity"]).clamp(0, 1))
230
+ end
231
+ unless style["stroke"] == "none" || outline.empty?
232
+ thickness = length(style["stroke-width"])
233
+ raise ArgumentError, "negative SVG stroke width" if thickness.negative?
234
+ if thickness.positive?
235
+ stroked = stroke(outline, thickness, style).transform(matrix)
236
+ paint_mask(target, mask(stroked, width, height, "nonzero"), color(style["stroke"], style["color"]), number(style["stroke-opacity"]).clamp(0, 1))
237
+ end
238
+ end
239
+ else
240
+ element.elements.each { |child| render(child, target, matrix, style, width, height, references) }
241
+ end
242
+ if isolated
243
+ if style["clip-path"] && style["clip-path"] != "none"
244
+ id = style["clip-path"][/\Aurl\(\s*#([^\s)]+)\s*\)\z/, 1]
245
+ clipping = @ids[id]
246
+ raise ArgumentError, "invalid SVG clipping reference" unless clipping&.name == "clipPath"
247
+ raise ArgumentError, "cyclic SVG clip" if references.include?(id) || references.length >= 32
248
+ clip = clip_mask(clipping, matrix, width, height, references + [id])
249
+ (width * height).times { |i| target.setbyte(i * 4 + 3, (target.getbyte(i * 4 + 3) * clip.getbyte(i) / 255.0).round) }
250
+ end
251
+ composite(pixels, target, opacity)
252
+ end
253
+ end
254
+
255
+ def color(value, current)
256
+ value = current if value == "currentColor"
257
+ value = NAMES.fetch(value.downcase, value)
258
+ if (match = value.match(/\Argba?\(([^)]+)\)\z/i))
259
+ channels = match[1].split(/[\s,\/]+/)
260
+ raise ArgumentError, "invalid SVG rgb color" unless (3..4).cover?(channels.length)
261
+ rgba = channels.each_with_index.map { |channel, i| channel.end_with?("%") ? number(channel.delete_suffix("%")) / 100 : number(channel) / (i == 3 ? 1 : 255.0) }
262
+ rgba << 1 if rgba.length == 3
263
+ return rgba.map { |channel| (channel.clamp(0, 1) * 255).round }
264
+ end
265
+ Color.parse(value).to_a.map { |channel| (channel * 255).round }
266
+ end
267
+
268
+ def paint_mask(pixels, coverage, rgba, opacity)
269
+ coverage.bytes.each_with_index do |alpha, index|
270
+ next if alpha.zero?
271
+ blend(pixels, index * 4, rgba[0], rgba[1], rgba[2], (alpha * rgba[3] * opacity / 255.0).round)
272
+ end
273
+ end
274
+
275
+ def composite(pixels, layer, opacity)
276
+ (pixels.bytesize / 4).times do |index|
277
+ offset = index * 4
278
+ blend(pixels, offset, layer.getbyte(offset), layer.getbyte(offset + 1), layer.getbyte(offset + 2), (layer.getbyte(offset + 3) * opacity).round)
279
+ end
280
+ end
281
+
282
+ def blend(pixels, offset, r, g, b, alpha)
283
+ return if alpha.zero?
284
+ previous = pixels.getbyte(offset + 3) * (255 - alpha) / 255.0
285
+ result = alpha + previous
286
+ [r, g, b].each_with_index { |channel, i| pixels.setbyte(offset + i, ((channel * alpha + pixels.getbyte(offset + i) * previous) / result).round) }
287
+ pixels.setbyte(offset + 3, result.round)
288
+ end
289
+ end
290
+
291
+ end
292
+
293
+ require_relative "svg/path"
294
+ require_relative "svg/rasterizable"
295
+ Zaniah::SVG.include(Zaniah::SVG::Rasterizable)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class Task
5
+ class Cancelled < Error; end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class Task
5
+ class Timeout < Error; end
6
+ end
7
+ end