zaniah 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +70 -0
- data/assets/fonts/Abel-Regular.ttf +0 -0
- data/assets/fonts/OFL.txt +93 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-native-gpu-backends.md +36 -0
- data/docs/adr/002-objective-c-aggregates.md +34 -0
- data/docs/adr/003-fiddle-call-boundary.md +33 -0
- data/docs/adr/README.md +10 -0
- data/docs/native.md +66 -0
- data/docs/process_pool.md +67 -0
- data/docs/text.md +90 -0
- data/docs/vector_and_list.md +52 -0
- data/examples/ffi_bench.rb +16 -0
- data/examples/linux_smoke.rb +53 -0
- data/examples/mac_event_check.rb +61 -0
- data/examples/mac_poll_bench.rb +24 -0
- data/examples/metal_state_check.rb +72 -0
- data/examples/native_bench.rb +18 -0
- data/examples/native_drop.rb +82 -0
- data/examples/native_font.rb +15 -0
- data/examples/native_ime.rb +50 -0
- data/examples/native_smoke.rb +75 -0
- data/examples/native_text_bench.rb +83 -0
- data/examples/native_watch.rb +25 -0
- data/examples/vulkan_triangle.rb +20 -0
- data/examples/word_count_worker.rb +8 -0
- data/lib/zaniah/anchored.rb +10 -0
- data/lib/zaniah/app.rb +130 -0
- data/lib/zaniah/bounds.rb +22 -0
- data/lib/zaniah/canvas.rb +15 -0
- data/lib/zaniah/color.rb +35 -0
- data/lib/zaniah/configuration.rb +5 -0
- data/lib/zaniah/corners.rb +7 -0
- data/lib/zaniah/data_compat.rb +25 -0
- data/lib/zaniah/device_pixels.rb +7 -0
- data/lib/zaniah/div.rb +5 -0
- data/lib/zaniah/edges.rb +7 -0
- data/lib/zaniah/element.rb +106 -0
- data/lib/zaniah/entity.rb +9 -0
- data/lib/zaniah/entity_context.rb +17 -0
- data/lib/zaniah/error.rb +5 -0
- data/lib/zaniah/ffi/com.rb +24 -0
- data/lib/zaniah/ffi/library.rb +29 -0
- data/lib/zaniah/ffi/objc.rb +86 -0
- data/lib/zaniah/ffi/struct/signature.rb +46 -0
- data/lib/zaniah/ffi/struct.rb +61 -0
- data/lib/zaniah/ffi/wayland.rb +140 -0
- data/lib/zaniah/frame_context.rb +14 -0
- data/lib/zaniah/geometry.rb +16 -0
- data/lib/zaniah/gpu/buffer.rb +23 -0
- data/lib/zaniah/gpu/frame_encoder.rb +43 -0
- data/lib/zaniah/gpu/instance_packing.rb +53 -0
- data/lib/zaniah/gpu/metal.rb +209 -0
- data/lib/zaniah/gpu/open_gl.rb +210 -0
- data/lib/zaniah/gpu/pipeline.rb +7 -0
- data/lib/zaniah/gpu/software.rb +126 -0
- data/lib/zaniah/gpu/texture.rb +33 -0
- data/lib/zaniah/gpu/vulkan.rb +233 -0
- data/lib/zaniah/gpu.rb +18 -0
- data/lib/zaniah/hsla.rb +14 -0
- data/lib/zaniah/image.rb +19 -0
- data/lib/zaniah/input/action_registry.rb +12 -0
- data/lib/zaniah/input/composition.rb +7 -0
- data/lib/zaniah/input/context_predicate.rb +87 -0
- data/lib/zaniah/input/dispatcher.rb +59 -0
- data/lib/zaniah/input/file_drop.rb +7 -0
- data/lib/zaniah/input/focus_handle.rb +24 -0
- data/lib/zaniah/input/key_down.rb +7 -0
- data/lib/zaniah/input/key_up.rb +7 -0
- data/lib/zaniah/input/keymap/binding.rb +9 -0
- data/lib/zaniah/input/keymap.rb +55 -0
- data/lib/zaniah/input/keystroke.rb +20 -0
- data/lib/zaniah/input/mouse_down.rb +7 -0
- data/lib/zaniah/input/mouse_move.rb +7 -0
- data/lib/zaniah/input/mouse_up.rb +7 -0
- data/lib/zaniah/input/scroll_wheel.rb +7 -0
- data/lib/zaniah/input/text_input.rb +7 -0
- data/lib/zaniah/input.rb +17 -0
- data/lib/zaniah/layout/available_space.rb +11 -0
- data/lib/zaniah/layout/engine.rb +214 -0
- data/lib/zaniah/layout/node.rb +36 -0
- data/lib/zaniah/layout/style.rb +19 -0
- data/lib/zaniah/layout/tree.rb +16 -0
- data/lib/zaniah/layout.rb +11 -0
- data/lib/zaniah/length.rb +14 -0
- data/lib/zaniah/length_units.rb +9 -0
- data/lib/zaniah/list/height_index.rb +68 -0
- data/lib/zaniah/list.rb +101 -0
- data/lib/zaniah/overlay.rb +10 -0
- data/lib/zaniah/platform/display.rb +7 -0
- data/lib/zaniah/platform/file_event.rb +7 -0
- data/lib/zaniah/platform/headless/window.rb +185 -0
- data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
- data/lib/zaniah/platform/linux/displays.rb +55 -0
- data/lib/zaniah/platform/linux/free_type.rb +80 -0
- data/lib/zaniah/platform/linux/watcher.rb +70 -0
- data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
- data/lib/zaniah/platform/linux/window.rb +418 -0
- data/lib/zaniah/platform/linux.rb +3 -0
- data/lib/zaniah/platform/mac/app.rb +67 -0
- data/lib/zaniah/platform/mac/core_text.rb +94 -0
- data/lib/zaniah/platform/mac/displays.rb +23 -0
- data/lib/zaniah/platform/mac/watcher.rb +63 -0
- data/lib/zaniah/platform/mac/window.rb +298 -0
- data/lib/zaniah/platform/mac.rb +19 -0
- data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
- data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
- data/lib/zaniah/platform/tui/window.rb +91 -0
- data/lib/zaniah/platform/windows/terminal.rb +127 -0
- data/lib/zaniah/platform/windows/watcher.rb +91 -0
- data/lib/zaniah/platform/windows/window.rb +407 -0
- data/lib/zaniah/platform/windows.rb +3 -0
- data/lib/zaniah/platform.rb +34 -0
- data/lib/zaniah/png.rb +118 -0
- data/lib/zaniah/point.rb +8 -0
- data/lib/zaniah/process_pool/worker.rb +7 -0
- data/lib/zaniah/process_pool.rb +239 -0
- data/lib/zaniah/process_wire.rb +127 -0
- data/lib/zaniah/quad_packer.rb +15 -0
- data/lib/zaniah/scene/sprite_batch.rb +7 -0
- data/lib/zaniah/scene.rb +167 -0
- data/lib/zaniah/scene_renderer.rb +8 -0
- data/lib/zaniah/size.rb +5 -0
- data/lib/zaniah/subscription.rb +14 -0
- data/lib/zaniah/svg/path.rb +134 -0
- data/lib/zaniah/svg/rasterizable.rb +186 -0
- data/lib/zaniah/svg.rb +295 -0
- data/lib/zaniah/task/cancelled.rb +7 -0
- data/lib/zaniah/task/timeout.rb +7 -0
- data/lib/zaniah/task.rb +51 -0
- data/lib/zaniah/task_executor.rb +105 -0
- data/lib/zaniah/text.rb +24 -0
- data/lib/zaniah/text_system/atlas/entry.rb +9 -0
- data/lib/zaniah/text_system/atlas.rb +102 -0
- data/lib/zaniah/text_system/atlas_cache.rb +146 -0
- data/lib/zaniah/text_system/font_db/face.rb +9 -0
- data/lib/zaniah/text_system/font_db.rb +193 -0
- data/lib/zaniah/text_system/glyph.rb +7 -0
- data/lib/zaniah/text_system/line_layout.rb +20 -0
- data/lib/zaniah/text_system/renderer.rb +196 -0
- data/lib/zaniah/text_system/shaper.rb +430 -0
- data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
- data/lib/zaniah/text_system/typesetter.rb +168 -0
- data/lib/zaniah/text_system.rb +9 -0
- data/lib/zaniah/unicode.rb +13 -0
- data/lib/zaniah/uniform_list.rb +30 -0
- data/lib/zaniah/version.rb +5 -0
- data/lib/zaniah/view.rb +13 -0
- data/lib/zaniah.rb +29 -0
- data/sig/app.rbs +80 -0
- data/sig/elements.rbs +129 -0
- data/sig/gpu.rbs +108 -0
- data/sig/input.rbs +114 -0
- data/sig/layout.rbs +53 -0
- data/sig/native.rbs +88 -0
- data/sig/platform.rbs +94 -0
- data/sig/scene.rbs +52 -0
- data/sig/shaper.rbs +8 -0
- data/sig/text.rbs +137 -0
- data/sig/zaniah.rbs +120 -0
- data/tools/check_dependencies.rb +52 -0
- metadata +249 -0
data/lib/zaniah/size.rb
ADDED
|
@@ -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)
|