echoes 0.3.0 → 0.4.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 +4 -4
- data/README.md +178 -11
- data/lib/echoes/gui.rb +24 -9
- data/lib/echoes/iterm2_images.rb +56 -1
- data/lib/echoes/kitty_graphics.rb +47 -2
- data/lib/echoes/kitty_graphics_appkit.rb +14 -5
- data/lib/echoes/objc.rb +47 -0
- data/lib/echoes/screen.rb +2 -1
- data/lib/echoes/svg_cg_renderer.rb +689 -0
- data/lib/echoes/svg_color.rb +120 -0
- data/lib/echoes/svg_path_parser.rb +120 -0
- data/lib/echoes/svg_renderer.rb +272 -0
- data/lib/echoes/svg_sniffer.rb +81 -0
- data/lib/echoes/svg_transform.rb +54 -0
- data/lib/echoes/svg_walker.rb +107 -0
- data/lib/echoes/version.rb +1 -1
- metadata +8 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Echoes
|
|
4
|
+
# SVG color parser for the CG fast-path renderer. Returns
|
|
5
|
+
# [r, g, b, a] (each 0..1), :none ("don't paint this stroke/fill"),
|
|
6
|
+
# or nil ("unsupported — bail to WKWebView").
|
|
7
|
+
module SvgColor
|
|
8
|
+
# 20 CSS / SVG named colors covering the common cases. Anything
|
|
9
|
+
# outside this set returns nil so the caller falls through to
|
|
10
|
+
# the WKWebView renderer (which has the full CSS palette).
|
|
11
|
+
NAMED = {
|
|
12
|
+
'black' => [ 0, 0, 0],
|
|
13
|
+
'white' => [255, 255, 255],
|
|
14
|
+
'red' => [255, 0, 0],
|
|
15
|
+
'green' => [ 0, 128, 0],
|
|
16
|
+
'blue' => [ 0, 0, 255],
|
|
17
|
+
'yellow' => [255, 255, 0],
|
|
18
|
+
'cyan' => [ 0, 255, 255],
|
|
19
|
+
'aqua' => [ 0, 255, 255],
|
|
20
|
+
'magenta' => [255, 0, 255],
|
|
21
|
+
'fuchsia' => [255, 0, 255],
|
|
22
|
+
'gray' => [128, 128, 128],
|
|
23
|
+
'grey' => [128, 128, 128],
|
|
24
|
+
'orange' => [255, 165, 0],
|
|
25
|
+
'purple' => [128, 0, 128],
|
|
26
|
+
'pink' => [255, 192, 203],
|
|
27
|
+
'brown' => [165, 42, 42],
|
|
28
|
+
'lime' => [ 0, 255, 0],
|
|
29
|
+
'navy' => [ 0, 0, 128],
|
|
30
|
+
'teal' => [ 0, 128, 128],
|
|
31
|
+
'silver' => [192, 192, 192],
|
|
32
|
+
'maroon' => [128, 0, 0],
|
|
33
|
+
'olive' => [128, 128, 0],
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
HEX_RE = /\A#([0-9a-f]{3,8})\z/i
|
|
37
|
+
RGB_RE = /\Argba?\(\s*([^)]*)\)\z/i
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def parse(str, current_color: [0.0, 0.0, 0.0, 1.0])
|
|
42
|
+
return nil if str.nil?
|
|
43
|
+
s = str.strip
|
|
44
|
+
return nil if s.empty?
|
|
45
|
+
|
|
46
|
+
down = s.downcase
|
|
47
|
+
return :none if down == 'none'
|
|
48
|
+
return current_color.dup if down == 'currentcolor'
|
|
49
|
+
return [0.0, 0.0, 0.0, 0.0] if down == 'transparent'
|
|
50
|
+
|
|
51
|
+
if (m = NAMED[down])
|
|
52
|
+
return [m[0] / 255.0, m[1] / 255.0, m[2] / 255.0, 1.0]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if (m = HEX_RE.match(s))
|
|
56
|
+
return parse_hex(m[1])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if (m = RGB_RE.match(s))
|
|
60
|
+
return parse_rgb_func(m[1])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_hex(hex)
|
|
67
|
+
case hex.length
|
|
68
|
+
when 3
|
|
69
|
+
r, g, b = hex.chars.map { |c| (c + c).to_i(16) }
|
|
70
|
+
[r / 255.0, g / 255.0, b / 255.0, 1.0]
|
|
71
|
+
when 4
|
|
72
|
+
r, g, b, a = hex.chars.map { |c| (c + c).to_i(16) }
|
|
73
|
+
[r / 255.0, g / 255.0, b / 255.0, a / 255.0]
|
|
74
|
+
when 6
|
|
75
|
+
r = hex[0, 2].to_i(16)
|
|
76
|
+
g = hex[2, 2].to_i(16)
|
|
77
|
+
b = hex[4, 2].to_i(16)
|
|
78
|
+
[r / 255.0, g / 255.0, b / 255.0, 1.0]
|
|
79
|
+
when 8
|
|
80
|
+
r = hex[0, 2].to_i(16)
|
|
81
|
+
g = hex[2, 2].to_i(16)
|
|
82
|
+
b = hex[4, 2].to_i(16)
|
|
83
|
+
a = hex[6, 2].to_i(16)
|
|
84
|
+
[r / 255.0, g / 255.0, b / 255.0, a / 255.0]
|
|
85
|
+
else
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Parse the body of `rgb(...)` or `rgba(...)`. Components can be
|
|
91
|
+
# int 0..255 or `N%` (0..100). Alpha (if present) is a plain
|
|
92
|
+
# 0..1 float, not a percentage.
|
|
93
|
+
def parse_rgb_func(inner)
|
|
94
|
+
parts = inner.split(',').map(&:strip)
|
|
95
|
+
return nil unless parts.size == 3 || parts.size == 4
|
|
96
|
+
rgb = parts[0, 3].map { |p| parse_component(p) }
|
|
97
|
+
return nil if rgb.any?(&:nil?)
|
|
98
|
+
a = if parts.size == 4
|
|
99
|
+
f = Float(parts[3]) rescue nil
|
|
100
|
+
return nil if f.nil?
|
|
101
|
+
f.clamp(0.0, 1.0)
|
|
102
|
+
else
|
|
103
|
+
1.0
|
|
104
|
+
end
|
|
105
|
+
[*rgb, a]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_component(p)
|
|
109
|
+
if p.end_with?('%')
|
|
110
|
+
f = Float(p[0..-2]) rescue nil
|
|
111
|
+
return nil if f.nil?
|
|
112
|
+
(f / 100.0).clamp(0.0, 1.0)
|
|
113
|
+
else
|
|
114
|
+
i = Integer(p, 10) rescue nil
|
|
115
|
+
return nil if i.nil?
|
|
116
|
+
(i / 255.0).clamp(0.0, 1.0)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Echoes
|
|
4
|
+
# Tokenizer / parser for the `<path d="...">` mini-language.
|
|
5
|
+
# Returns an ordered list of `[cmd_symbol, [args…]]` tuples.
|
|
6
|
+
#
|
|
7
|
+
# Uppercase cmd_symbols (:M, :L, :C, …) are absolute; lowercase
|
|
8
|
+
# (:m, :l, :c, …) are relative — the renderer applies the
|
|
9
|
+
# absolute-vs-relative interpretation since both forms have the
|
|
10
|
+
# same operand counts.
|
|
11
|
+
#
|
|
12
|
+
# Returns nil on:
|
|
13
|
+
# - empty / nil input
|
|
14
|
+
# - first command isn't M or m (spec violation; no current point)
|
|
15
|
+
# - unknown command letter
|
|
16
|
+
# - operand-count mismatch (e.g. trailing partial group)
|
|
17
|
+
# - any tokenizer leftover (unrecognized character)
|
|
18
|
+
module SvgPathParser
|
|
19
|
+
# Operand count per command, per spec.
|
|
20
|
+
OPERAND_COUNT = {
|
|
21
|
+
M: 2, m: 2,
|
|
22
|
+
L: 2, l: 2,
|
|
23
|
+
H: 1, h: 1,
|
|
24
|
+
V: 1, v: 1,
|
|
25
|
+
C: 6, c: 6,
|
|
26
|
+
S: 4, s: 4,
|
|
27
|
+
Q: 4, q: 4,
|
|
28
|
+
T: 2, t: 2,
|
|
29
|
+
A: 7, a: 7,
|
|
30
|
+
Z: 0, z: 0,
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Implicit continuation: after consuming a command's operands, if
|
|
34
|
+
# there are more numbers without a new command letter, reuse the
|
|
35
|
+
# last command — except M/m, where the implicit continuation is
|
|
36
|
+
# L/l per spec ("Subsequent pairs are treated as implicit lineto
|
|
37
|
+
# commands").
|
|
38
|
+
IMPLICIT_CONTINUATION = {M: :L, m: :l}.freeze
|
|
39
|
+
|
|
40
|
+
# Token regex: matches a command letter (excluding E/e which are
|
|
41
|
+
# reserved for scientific-notation exponents) OR a number.
|
|
42
|
+
# Number form: optional sign, then digits-and-optional-decimal or
|
|
43
|
+
# leading-decimal, with optional scientific notation. Whitespace
|
|
44
|
+
# and commas between tokens are silently skipped by the scanner.
|
|
45
|
+
TOKEN_RE = /
|
|
46
|
+
([MmLlHhVvCcSsQqTtAaZz]) |
|
|
47
|
+
([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)
|
|
48
|
+
/x
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
def parse(d)
|
|
53
|
+
return nil if d.nil?
|
|
54
|
+
tokens = tokenize(d)
|
|
55
|
+
return nil if tokens.nil? || tokens.empty?
|
|
56
|
+
|
|
57
|
+
# First token must be M or m.
|
|
58
|
+
first = tokens.first
|
|
59
|
+
return nil unless first.is_a?(Symbol) && (first == :M || first == :m)
|
|
60
|
+
|
|
61
|
+
ops = []
|
|
62
|
+
i = 0
|
|
63
|
+
last_cmd = nil
|
|
64
|
+
while i < tokens.size
|
|
65
|
+
tok = tokens[i]
|
|
66
|
+
if tok.is_a?(Symbol)
|
|
67
|
+
cmd = tok
|
|
68
|
+
i += 1
|
|
69
|
+
else
|
|
70
|
+
return nil unless last_cmd
|
|
71
|
+
cmd = IMPLICIT_CONTINUATION[last_cmd] || last_cmd
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
arity = OPERAND_COUNT[cmd]
|
|
75
|
+
return nil unless arity
|
|
76
|
+
|
|
77
|
+
if arity.zero?
|
|
78
|
+
ops << [cmd, []]
|
|
79
|
+
last_cmd = cmd
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
args = tokens[i, arity]
|
|
84
|
+
return nil if args.size < arity
|
|
85
|
+
return nil if args.any? { |t| t.is_a?(Symbol) }
|
|
86
|
+
|
|
87
|
+
ops << [cmd, args]
|
|
88
|
+
i += arity
|
|
89
|
+
last_cmd = cmd
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
ops
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Scan the string for tokens, ensuring we account for every
|
|
96
|
+
# non-whitespace, non-comma character. If any junk is left,
|
|
97
|
+
# returns nil so the caller can bail.
|
|
98
|
+
def tokenize(d)
|
|
99
|
+
tokens = []
|
|
100
|
+
pos = 0
|
|
101
|
+
bytes = d.b
|
|
102
|
+
while pos < bytes.length
|
|
103
|
+
# Skip separators (whitespace, comma).
|
|
104
|
+
if (m = /\G[\s,]+/.match(bytes, pos))
|
|
105
|
+
pos = m.end(0)
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
m = TOKEN_RE.match(bytes, pos)
|
|
109
|
+
return nil unless m && m.begin(0) == pos
|
|
110
|
+
if m[1]
|
|
111
|
+
tokens << m[1].to_sym
|
|
112
|
+
else
|
|
113
|
+
tokens << m[2].to_f
|
|
114
|
+
end
|
|
115
|
+
pos = m.end(0)
|
|
116
|
+
end
|
|
117
|
+
tokens
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fiddle'
|
|
4
|
+
require_relative 'objc'
|
|
5
|
+
require_relative 'kitty_graphics_appkit'
|
|
6
|
+
|
|
7
|
+
module Echoes
|
|
8
|
+
# SVG → RGBA8 rasterizer backed by WKWebView. The Kitty graphics
|
|
9
|
+
# and iTerm2 inline-image protocols both detect SVG payloads via
|
|
10
|
+
# SvgSniffer and route here instead of NSBitmapImageRep (which
|
|
11
|
+
# can't decode vector formats).
|
|
12
|
+
#
|
|
13
|
+
# WebKit's snapshot API is async, but the decoders are synchronous.
|
|
14
|
+
# We bridge by pumping a nested NSRunLoop until the completion
|
|
15
|
+
# handler fires, with a hard per-call timeout so a runaway SVG
|
|
16
|
+
# can't hang the GUI. Re-entrant calls during the pump are
|
|
17
|
+
# short-circuited by a recursion guard.
|
|
18
|
+
#
|
|
19
|
+
# JavaScript is disabled and baseURL is nil so the SVG sandbox
|
|
20
|
+
# can't execute scripts or fetch external resources.
|
|
21
|
+
module SvgRenderer
|
|
22
|
+
WEBKIT = Fiddle.dlopen('/System/Library/Frameworks/WebKit.framework/WebKit')
|
|
23
|
+
LIBSYSTEM = Fiddle.dlopen('/usr/lib/libSystem.B.dylib')
|
|
24
|
+
COREGRAPHICS = Fiddle.dlopen('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')
|
|
25
|
+
|
|
26
|
+
P = ObjC::P
|
|
27
|
+
D = ObjC::D
|
|
28
|
+
L = ObjC::L
|
|
29
|
+
I = ObjC::I
|
|
30
|
+
V = ObjC::V
|
|
31
|
+
|
|
32
|
+
# `initWithFrame:configuration:` — CGRect (4 doubles) + id.
|
|
33
|
+
MSG_PTR_RECT_1 = ObjC.new_msg([P, P, D, D, D, D, P], P)
|
|
34
|
+
|
|
35
|
+
# CG functions for snapshot pixel-dim lookup.
|
|
36
|
+
CGImageGetWidth = Fiddle::Function.new(COREGRAPHICS['CGImageGetWidth'], [P], L)
|
|
37
|
+
CGImageGetHeight = Fiddle::Function.new(COREGRAPHICS['CGImageGetHeight'], [P], L)
|
|
38
|
+
|
|
39
|
+
# Foundation exports NSDefaultRunLoopMode as an NSString* symbol.
|
|
40
|
+
# Same dereference-the-symbol trick as ObjC.appkit_const, just
|
|
41
|
+
# against Foundation instead of AppKit.
|
|
42
|
+
NS_DEFAULT_RUN_LOOP_MODE = begin
|
|
43
|
+
sym_ptr = Fiddle::Pointer.new(ObjC::FOUNDATION['NSDefaultRunLoopMode'])
|
|
44
|
+
Fiddle::Pointer.new(sym_ptr[0, Fiddle::SIZEOF_VOIDP].unpack1('J'))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Objective-C global block plumbing. Snapshot's completion handler
|
|
48
|
+
# is `void (^)(NSImage *, NSError *)`, which we fabricate via Fiddle
|
|
49
|
+
# by building a Block_layout struct that points at a Fiddle closure.
|
|
50
|
+
# Global blocks live forever and never copy/dispose, so the layout
|
|
51
|
+
# is minimal.
|
|
52
|
+
NS_CONCRETE_GLOBAL_BLOCK = Fiddle::Pointer.new(LIBSYSTEM['_NSConcreteGlobalBlock'])
|
|
53
|
+
BLOCK_HAS_STRET = 1 << 29 # not set
|
|
54
|
+
BLOCK_HAS_SIGNATURE = 1 << 30 # not set; we omit the type signature
|
|
55
|
+
BLOCK_IS_GLOBAL = 1 << 28
|
|
56
|
+
|
|
57
|
+
@rendering = false
|
|
58
|
+
@web_view = nil
|
|
59
|
+
@configuration = nil
|
|
60
|
+
@delegate_class = nil
|
|
61
|
+
@delegate_instance = nil
|
|
62
|
+
|
|
63
|
+
# Snapshot completion handler state, captured by the global block's
|
|
64
|
+
# invoke closure. Per-call values are stashed in instance variables
|
|
65
|
+
# because the block can only carry a stable function pointer.
|
|
66
|
+
@snap_image = nil
|
|
67
|
+
@snap_error = false
|
|
68
|
+
@snap_done = false
|
|
69
|
+
@nav_done = false
|
|
70
|
+
@nav_error = false
|
|
71
|
+
|
|
72
|
+
# Build the global block once at module load. The same block is
|
|
73
|
+
# passed to every takeSnapshotWithConfiguration: call; the per-call
|
|
74
|
+
# result is read back through module state.
|
|
75
|
+
@snap_invoke_closure = Fiddle::Closure::BlockCaller.new(
|
|
76
|
+
V, [P, P, P]
|
|
77
|
+
) do |_block, image_ptr, error_ptr|
|
|
78
|
+
@snap_image = image_ptr.null? ? nil : image_ptr
|
|
79
|
+
@snap_error = !error_ptr.null?
|
|
80
|
+
@snap_done = true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@snap_block_descriptor = Fiddle::Pointer.malloc(16, Fiddle::RUBY_FREE)
|
|
84
|
+
@snap_block_descriptor[0, 8] = [0].pack('Q') # reserved
|
|
85
|
+
@snap_block_descriptor[8, 8] = [32].pack('Q') # sizeof(Block_layout)
|
|
86
|
+
|
|
87
|
+
@snap_block = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
|
|
88
|
+
@snap_block[0, 8] = [NS_CONCRETE_GLOBAL_BLOCK.to_i].pack('Q')
|
|
89
|
+
@snap_block[8, 4] = [BLOCK_IS_GLOBAL].pack('l')
|
|
90
|
+
@snap_block[12, 4] = [0].pack('l')
|
|
91
|
+
@snap_block[16, 8] = [@snap_invoke_closure.to_i].pack('Q')
|
|
92
|
+
@snap_block[24, 8] = [@snap_block_descriptor.to_i].pack('Q')
|
|
93
|
+
|
|
94
|
+
class << self
|
|
95
|
+
# Returns {rgba:, width:, height:} or nil. `width` and `height`
|
|
96
|
+
# are the target pixel dimensions for the rasterized output;
|
|
97
|
+
# the renderer respects them via the WKWebView frame, but the
|
|
98
|
+
# final pixel count may exceed them on Retina displays (we use
|
|
99
|
+
# whatever the snapshot reports back).
|
|
100
|
+
#
|
|
101
|
+
# Tries the native CoreGraphics renderer first (fast, synchronous,
|
|
102
|
+
# no WebContent XPC process); falls back to WKWebView when the
|
|
103
|
+
# SVG uses anything outside that subset (text, filters, gradients,
|
|
104
|
+
# etc.) — see SvgCgRenderer for the precise contract.
|
|
105
|
+
def rasterize(svg_bytes, width:, height:)
|
|
106
|
+
return nil if svg_bytes.nil? || svg_bytes.empty?
|
|
107
|
+
return nil if width <= 0 || height <= 0
|
|
108
|
+
return nil if @rendering # recursion guard — nested runloop can re-enter
|
|
109
|
+
@rendering = true
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
require_relative 'svg_cg_renderer'
|
|
113
|
+
fast = SvgCgRenderer.rasterize(svg_bytes, width: width, height: height)
|
|
114
|
+
return fast if fast
|
|
115
|
+
|
|
116
|
+
ensure_setup
|
|
117
|
+
html = build_html(svg_bytes)
|
|
118
|
+
|
|
119
|
+
ObjC::MSG_VOID_2D.call(@web_view, ObjC.sel('setFrameSize:'),
|
|
120
|
+
width.to_f, height.to_f)
|
|
121
|
+
|
|
122
|
+
@nav_done = false
|
|
123
|
+
@nav_error = false
|
|
124
|
+
@snap_done = false
|
|
125
|
+
@snap_error = false
|
|
126
|
+
@snap_image = nil
|
|
127
|
+
|
|
128
|
+
ObjC::MSG_VOID_2.call(@web_view, ObjC.sel('loadHTMLString:baseURL:'),
|
|
129
|
+
ObjC.nsstring(html), Fiddle::Pointer.new(0))
|
|
130
|
+
|
|
131
|
+
return nil unless pump_until(2.0) { @nav_done }
|
|
132
|
+
return nil if @nav_error
|
|
133
|
+
|
|
134
|
+
snap_config = build_snapshot_config(width, height)
|
|
135
|
+
ObjC::MSG_VOID_2.call(@web_view,
|
|
136
|
+
ObjC.sel('takeSnapshotWithConfiguration:completionHandler:'),
|
|
137
|
+
snap_config, @snap_block)
|
|
138
|
+
|
|
139
|
+
return nil unless pump_until(2.0) { @snap_done }
|
|
140
|
+
return nil if @snap_error || @snap_image.nil?
|
|
141
|
+
|
|
142
|
+
cgimage = ns_image_to_cgimage(@snap_image, width, height)
|
|
143
|
+
return nil if cgimage.nil? || cgimage.null?
|
|
144
|
+
|
|
145
|
+
w_px = CGImageGetWidth.call(cgimage)
|
|
146
|
+
h_px = CGImageGetHeight.call(cgimage)
|
|
147
|
+
return nil if w_px <= 0 || h_px <= 0
|
|
148
|
+
|
|
149
|
+
KittyGraphics::AppKitPng.cgimage_to_rgba(cgimage, w_px, h_px)
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
warn "echoes SvgRenderer: #{e.class}: #{e.message}"
|
|
152
|
+
nil
|
|
153
|
+
ensure
|
|
154
|
+
@rendering = false
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def ensure_setup
|
|
161
|
+
return if @web_view
|
|
162
|
+
@configuration = ObjC::MSG_PTR.call(ObjC.cls('WKWebViewConfiguration'),
|
|
163
|
+
ObjC.sel('alloc'))
|
|
164
|
+
@configuration = ObjC::MSG_PTR.call(@configuration, ObjC.sel('init'))
|
|
165
|
+
ObjC.retain(@configuration)
|
|
166
|
+
|
|
167
|
+
prefs = ObjC::MSG_PTR.call(@configuration, ObjC.sel('preferences'))
|
|
168
|
+
ObjC::MSG_VOID_I.call(prefs, ObjC.sel('setJavaScriptEnabled:'), 0)
|
|
169
|
+
|
|
170
|
+
# macOS 11+ moved JS control to defaultWebpagePreferences; set
|
|
171
|
+
# both for forward/backward compatibility. The selector is a
|
|
172
|
+
# no-op on older systems (NSObject swallows it via msgSend),
|
|
173
|
+
# so the old setJavaScriptEnabled: is the actual guarantee.
|
|
174
|
+
wpp_sel = ObjC.sel('defaultWebpagePreferences')
|
|
175
|
+
if ObjC::MSG_RET_L.call(@configuration, ObjC.sel('respondsToSelector:'),
|
|
176
|
+
wpp_sel) != 0
|
|
177
|
+
wpp = ObjC::MSG_PTR.call(@configuration, wpp_sel)
|
|
178
|
+
unless wpp.null?
|
|
179
|
+
ObjC::MSG_VOID_I.call(wpp, ObjC.sel('setAllowsContentJavaScript:'), 0)
|
|
180
|
+
ObjC::MSG_VOID_1.call(@configuration,
|
|
181
|
+
ObjC.sel('setDefaultWebpagePreferences:'), wpp)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@delegate_class = build_delegate_class
|
|
186
|
+
@delegate_instance = ObjC::MSG_PTR.call(@delegate_class, ObjC.sel('alloc'))
|
|
187
|
+
@delegate_instance = ObjC::MSG_PTR.call(@delegate_instance, ObjC.sel('init'))
|
|
188
|
+
ObjC.retain(@delegate_instance)
|
|
189
|
+
|
|
190
|
+
@web_view = ObjC::MSG_PTR.call(ObjC.cls('WKWebView'), ObjC.sel('alloc'))
|
|
191
|
+
@web_view = MSG_PTR_RECT_1.call(@web_view,
|
|
192
|
+
ObjC.sel('initWithFrame:configuration:'),
|
|
193
|
+
0.0, 0.0, 1.0, 1.0, @configuration)
|
|
194
|
+
ObjC.retain(@web_view)
|
|
195
|
+
ObjC::MSG_VOID_1.call(@web_view, ObjC.sel('setNavigationDelegate:'),
|
|
196
|
+
@delegate_instance)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build_delegate_class
|
|
200
|
+
finish = Fiddle::Closure::BlockCaller.new(V, [P, P, P, P]) do |_s, _c, _wv, _nav|
|
|
201
|
+
@nav_done = true
|
|
202
|
+
end
|
|
203
|
+
fail_nav = Fiddle::Closure::BlockCaller.new(V, [P, P, P, P, P]) do |_s, _c, _wv, _nav, _err|
|
|
204
|
+
@nav_done = true
|
|
205
|
+
@nav_error = true
|
|
206
|
+
end
|
|
207
|
+
fail_prov = Fiddle::Closure::BlockCaller.new(V, [P, P, P, P, P]) do |_s, _c, _wv, _nav, _err|
|
|
208
|
+
@nav_done = true
|
|
209
|
+
@nav_error = true
|
|
210
|
+
end
|
|
211
|
+
# Hold strong refs so Fiddle doesn't GC the closure thunks.
|
|
212
|
+
@delegate_closures = [finish, fail_nav, fail_prov]
|
|
213
|
+
|
|
214
|
+
ObjC.define_class('EchoesSvgNavDelegate', 'NSObject', {
|
|
215
|
+
'webView:didFinishNavigation:' => ['v@:@@', finish],
|
|
216
|
+
'webView:didFailNavigation:withError:' => ['v@:@@@', fail_nav],
|
|
217
|
+
'webView:didFailProvisionalNavigation:withError:' => ['v@:@@@', fail_prov],
|
|
218
|
+
})
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def build_snapshot_config(width, height)
|
|
222
|
+
cfg = ObjC::MSG_PTR.call(ObjC.cls('WKSnapshotConfiguration'), ObjC.sel('alloc'))
|
|
223
|
+
cfg = ObjC::MSG_PTR.call(cfg, ObjC.sel('init'))
|
|
224
|
+
# Default rect = view bounds; default snapshotWidth = view width.
|
|
225
|
+
# We just leave both at defaults so the snapshot matches the
|
|
226
|
+
# frame we set on the web view.
|
|
227
|
+
cfg
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def build_html(svg_bytes)
|
|
231
|
+
svg = svg_bytes.dup.force_encoding(Encoding::UTF_8)
|
|
232
|
+
# Replace invalid byte sequences so a malformed SVG doesn't
|
|
233
|
+
# crash NSString creation.
|
|
234
|
+
svg = svg.scrub('')
|
|
235
|
+
<<~HTML
|
|
236
|
+
<!doctype html>
|
|
237
|
+
<html><head><style>
|
|
238
|
+
html, body { margin: 0; padding: 0; background: transparent; width: 100%; height: 100%; overflow: hidden; }
|
|
239
|
+
svg { display: block; width: 100%; height: 100%; }
|
|
240
|
+
</style></head><body>#{svg}</body></html>
|
|
241
|
+
HTML
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def ns_image_to_cgimage(ns_image, width, height)
|
|
245
|
+
rect_buf = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
|
|
246
|
+
rect_buf[0, 32] = [0.0, 0.0, width.to_f, height.to_f].pack('d4')
|
|
247
|
+
ObjC::MSG_PTR_3.call(ns_image,
|
|
248
|
+
ObjC.sel('CGImageForProposedRect:context:hints:'),
|
|
249
|
+
rect_buf, Fiddle::Pointer.new(0), Fiddle::Pointer.new(0))
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Pump the main run loop briefly until the block returns true or
|
|
253
|
+
# the deadline expires. Returns true if the condition was met,
|
|
254
|
+
# false on timeout. Uses 50 ms slices so the runloop stays
|
|
255
|
+
# responsive to other events (timer ticks, IO callbacks) while
|
|
256
|
+
# we wait.
|
|
257
|
+
def pump_until(timeout_s)
|
|
258
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
|
|
259
|
+
rl = ObjC::MSG_PTR.call(ObjC.cls('NSRunLoop'), ObjC.sel('currentRunLoop'))
|
|
260
|
+
until yield
|
|
261
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
262
|
+
date = ObjC::MSG_PTR_D.call(ObjC.cls('NSDate'),
|
|
263
|
+
ObjC.sel('dateWithTimeIntervalSinceNow:'),
|
|
264
|
+
0.05)
|
|
265
|
+
ObjC::MSG_VOID_2.call(rl, ObjC.sel('runMode:beforeDate:'),
|
|
266
|
+
NS_DEFAULT_RUN_LOOP_MODE, date)
|
|
267
|
+
end
|
|
268
|
+
true
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Echoes
|
|
4
|
+
# Detect SVG payloads in arbitrary image bytes. Used by the Kitty
|
|
5
|
+
# graphics and iTerm2 inline-image decoders to fork off to the
|
|
6
|
+
# SvgRenderer instead of NSBitmapImageRep (which is raster-only).
|
|
7
|
+
module SvgSniffer
|
|
8
|
+
# Skip optional UTF-8 BOM, whitespace, an XML prologue, and a
|
|
9
|
+
# DOCTYPE before the `<svg` element. Case-insensitive so `<SVG`
|
|
10
|
+
# and `<Svg` both match. `n` flag forces ASCII-8BIT so we can
|
|
11
|
+
# match against the binary payloads the decoders hand us.
|
|
12
|
+
SVG_RE = /\A(?:\xEF\xBB\xBF)?\s*(?:<\?xml[^>]*\?>\s*)?(?:<!DOCTYPE\s+svg[^>]*>\s*)?<svg\b/imn
|
|
13
|
+
|
|
14
|
+
# Capture the leading `<svg ...>` opening tag so intrinsic_size can
|
|
15
|
+
# work on just the attribute soup, ignoring the rest of the document.
|
|
16
|
+
OPEN_SVG_RE = /<svg\b([^>]*)>/imn
|
|
17
|
+
|
|
18
|
+
# Numeric attribute on the SVG root, optionally suffixed with a CSS
|
|
19
|
+
# length unit. We extract the number; unit conversion is best-effort
|
|
20
|
+
# (px / pt / unitless treated as pixels; em / % yield nil because
|
|
21
|
+
# they need layout context we don't have).
|
|
22
|
+
DIM_RE = /\A\s*([0-9]*\.?[0-9]+)\s*([a-z%]*)\s*\z/i
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def svg?(bytes)
|
|
27
|
+
return false if bytes.nil? || bytes.empty?
|
|
28
|
+
head = bytes.byteslice(0, 4096).b
|
|
29
|
+
SVG_RE.match?(head)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns [width_px, height_px] or nil. Parses the <svg> tag's
|
|
33
|
+
# width/height attributes first; falls back to viewBox dimensions
|
|
34
|
+
# if either is missing or non-pixel.
|
|
35
|
+
def intrinsic_size(bytes)
|
|
36
|
+
return nil if bytes.nil? || bytes.empty?
|
|
37
|
+
head = bytes.byteslice(0, 4096).b
|
|
38
|
+
m = OPEN_SVG_RE.match(head)
|
|
39
|
+
return nil unless m
|
|
40
|
+
attrs = m[1].to_s
|
|
41
|
+
|
|
42
|
+
w = parse_dim(extract_attr(attrs, 'width'))
|
|
43
|
+
h = parse_dim(extract_attr(attrs, 'height'))
|
|
44
|
+
|
|
45
|
+
if w.nil? || h.nil?
|
|
46
|
+
vb = extract_attr(attrs, 'viewBox')
|
|
47
|
+
if vb
|
|
48
|
+
parts = vb.strip.split(/[\s,]+/)
|
|
49
|
+
if parts.size == 4
|
|
50
|
+
vw = parts[2].to_f
|
|
51
|
+
vh = parts[3].to_f
|
|
52
|
+
w ||= vw if vw.positive?
|
|
53
|
+
h ||= vh if vh.positive?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return nil unless w && h && w.positive? && h.positive?
|
|
59
|
+
[w.round, h.round]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def extract_attr(attrs, name)
|
|
63
|
+
m = attrs.match(/\s#{name}\s*=\s*["']([^"']*)["']/i)
|
|
64
|
+
m && m[1]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_dim(str)
|
|
68
|
+
return nil if str.nil? || str.empty?
|
|
69
|
+
m = DIM_RE.match(str)
|
|
70
|
+
return nil unless m
|
|
71
|
+
unit = m[2].downcase
|
|
72
|
+
# Unitless / px / pt are treated as pixels (pt is technically
|
|
73
|
+
# 1.333px but the difference is invisible at typical SVG sizes
|
|
74
|
+
# and we'd be guessing the renderer's DPI anyway). em / % need
|
|
75
|
+
# layout context, so we punt.
|
|
76
|
+
return nil if unit == 'em' || unit == '%'
|
|
77
|
+
n = m[1].to_f
|
|
78
|
+
n.positive? ? n : nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Echoes
|
|
4
|
+
# SVG `transform=` attribute parser. Returns an ordered list of
|
|
5
|
+
# `[op_symbol, [args…]]` tuples, or nil for unknown functions /
|
|
6
|
+
# malformed input. Caller applies them left-to-right via CG's CTM
|
|
7
|
+
# setters (CGContextTranslateCTM, ScaleCTM, RotateCTM, ConcatCTM).
|
|
8
|
+
module SvgTransform
|
|
9
|
+
FN_RE = /([a-zA-Z]+)\s*\(([^)]*)\)/
|
|
10
|
+
NUM_RE = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def parse(str)
|
|
15
|
+
return nil if str.nil? || str.strip.empty?
|
|
16
|
+
ops = []
|
|
17
|
+
str.scan(FN_RE) do |name, body|
|
|
18
|
+
nums = body.scan(NUM_RE).map(&:to_f)
|
|
19
|
+
op = build(name.downcase, nums)
|
|
20
|
+
return nil if op.nil?
|
|
21
|
+
ops << op
|
|
22
|
+
end
|
|
23
|
+
return nil if ops.empty?
|
|
24
|
+
ops
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build(name, nums)
|
|
28
|
+
case name
|
|
29
|
+
when 'translate'
|
|
30
|
+
return nil unless (1..2).cover?(nums.size)
|
|
31
|
+
[:translate, [nums[0], nums[1] || 0.0]]
|
|
32
|
+
when 'scale'
|
|
33
|
+
return nil unless (1..2).cover?(nums.size)
|
|
34
|
+
[:scale, [nums[0], nums[1] || nums[0]]]
|
|
35
|
+
when 'rotate'
|
|
36
|
+
return nil unless [1, 3].include?(nums.size)
|
|
37
|
+
deg = nums[0]
|
|
38
|
+
cx, cy = nums.size == 3 ? [nums[1], nums[2]] : [0.0, 0.0]
|
|
39
|
+
[:rotate, [deg, cx, cy]]
|
|
40
|
+
when 'matrix'
|
|
41
|
+
return nil unless nums.size == 6
|
|
42
|
+
[:matrix, nums]
|
|
43
|
+
when 'skewx'
|
|
44
|
+
return nil unless nums.size == 1
|
|
45
|
+
[:skewx, [nums[0]]]
|
|
46
|
+
when 'skewy'
|
|
47
|
+
return nil unless nums.size == 1
|
|
48
|
+
[:skewy, [nums[0]]]
|
|
49
|
+
else
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|