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.
@@ -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