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,689 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fiddle'
|
|
4
|
+
require_relative 'objc'
|
|
5
|
+
require_relative 'svg_sniffer'
|
|
6
|
+
require_relative 'svg_walker'
|
|
7
|
+
require_relative 'svg_color'
|
|
8
|
+
require_relative 'svg_transform'
|
|
9
|
+
require_relative 'svg_path_parser'
|
|
10
|
+
|
|
11
|
+
module Echoes
|
|
12
|
+
# Native CoreGraphics SVG rasterizer. Tried first by SvgRenderer
|
|
13
|
+
# before falling back to WKWebView. Covers "path-only" SVGs —
|
|
14
|
+
# <path>, basic shapes, <g> with attribute inheritance, transforms
|
|
15
|
+
# — and returns nil for anything outside that subset so the caller
|
|
16
|
+
# bails to WebKit.
|
|
17
|
+
#
|
|
18
|
+
# Wire format: {rgba:, width:, height:} or nil.
|
|
19
|
+
module SvgCgRenderer
|
|
20
|
+
# Tags whose presence forces a bail. Everything else we either
|
|
21
|
+
# render or silently ignore (e.g. <title>, <desc>, <metadata>).
|
|
22
|
+
# Using a Hash for O(1) lookup without requiring `set`.
|
|
23
|
+
BAIL_TAGS = %w[
|
|
24
|
+
text textPath tspan tref
|
|
25
|
+
image foreignObject
|
|
26
|
+
filter mask clipPath
|
|
27
|
+
linearGradient radialGradient pattern
|
|
28
|
+
use symbol defs
|
|
29
|
+
animate animateTransform animateMotion set
|
|
30
|
+
script style
|
|
31
|
+
].each_with_object({}) { |t, h| h[t] = true }.freeze
|
|
32
|
+
|
|
33
|
+
# Tags we know how to draw.
|
|
34
|
+
SHAPE_TAGS = %w[path rect circle ellipse line polygon polyline]
|
|
35
|
+
.each_with_object({}) { |t, h| h[t] = true }.freeze
|
|
36
|
+
|
|
37
|
+
# Initial graphics state inherited by the root <svg> element.
|
|
38
|
+
DEFAULT_ATTRS = {
|
|
39
|
+
'fill' => 'black',
|
|
40
|
+
'stroke' => 'none',
|
|
41
|
+
'stroke-width' => '1',
|
|
42
|
+
'fill-rule' => 'nonzero',
|
|
43
|
+
'stroke-linecap' => 'butt',
|
|
44
|
+
'stroke-linejoin' => 'miter',
|
|
45
|
+
'stroke-miterlimit' => '4',
|
|
46
|
+
'opacity' => '1',
|
|
47
|
+
'fill-opacity' => '1',
|
|
48
|
+
'stroke-opacity' => '1',
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Entry point. Returns {rgba:, width:, height:} or nil.
|
|
54
|
+
def rasterize(svg_bytes, width:, height:)
|
|
55
|
+
return nil if svg_bytes.nil? || svg_bytes.empty?
|
|
56
|
+
return nil if width <= 0 || height <= 0
|
|
57
|
+
|
|
58
|
+
events = collect_events(svg_bytes)
|
|
59
|
+
return nil unless events
|
|
60
|
+
|
|
61
|
+
root_attrs = find_root_attrs(events)
|
|
62
|
+
return nil unless root_attrs
|
|
63
|
+
|
|
64
|
+
buf = Fiddle::Pointer.malloc(width * height * 4, Fiddle::RUBY_FREE)
|
|
65
|
+
cs = ObjC::CGColorSpaceCreateDeviceRGB.call
|
|
66
|
+
begin
|
|
67
|
+
ctx = ObjC::CGBitmapContextCreate.call(
|
|
68
|
+
buf, width, height, 8, width * 4, cs,
|
|
69
|
+
ObjC::KCGImageAlphaPremultipliedLast,
|
|
70
|
+
)
|
|
71
|
+
return nil if ctx.null?
|
|
72
|
+
begin
|
|
73
|
+
ObjC::CGContextClearRect.call(ctx, 0.0, 0.0, width.to_f, height.to_f)
|
|
74
|
+
setup_root_ctm(ctx, width, height, root_attrs)
|
|
75
|
+
return nil unless render_events(ctx, events)
|
|
76
|
+
rgba = buf.to_str(width * height * 4)
|
|
77
|
+
{rgba: rgba, width: width, height: height}
|
|
78
|
+
ensure
|
|
79
|
+
ObjC::CGContextRelease.call(ctx)
|
|
80
|
+
end
|
|
81
|
+
ensure
|
|
82
|
+
ObjC::CGColorSpaceRelease.call(cs)
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
warn "echoes SvgCgRenderer: #{e.class}: #{e.message}"
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Walk the SVG bytes once into a flat event list. Bails (returns
|
|
90
|
+
# nil) on any blacklisted tag.
|
|
91
|
+
def collect_events(svg_bytes)
|
|
92
|
+
events = []
|
|
93
|
+
SvgWalker.events(svg_bytes) do |kind, tag, attrs|
|
|
94
|
+
if (kind == :open || kind == :self_close) && BAIL_TAGS.include?(tag)
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
events << [kind, tag, attrs]
|
|
98
|
+
end
|
|
99
|
+
events
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def find_root_attrs(events)
|
|
103
|
+
events.each do |kind, tag, attrs|
|
|
104
|
+
return attrs if (kind == :open || kind == :self_close) && tag == 'svg'
|
|
105
|
+
end
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Translate y-flip + viewBox onto the bitmap-context CTM. After
|
|
110
|
+
# this, drawing at SVG coord (x, y) lands at the right pixel —
|
|
111
|
+
# origin top-left, y down, scaled to fit.
|
|
112
|
+
def setup_root_ctm(ctx, width, height, root_attrs)
|
|
113
|
+
# CG is y-up; SVG is y-down. Flip the destination canvas first.
|
|
114
|
+
ObjC::CGContextTranslateCTM.call(ctx, 0.0, height.to_f)
|
|
115
|
+
ObjC::CGContextScaleCTM.call(ctx, 1.0, -1.0)
|
|
116
|
+
|
|
117
|
+
vb = parse_viewbox(root_attrs['viewBox'])
|
|
118
|
+
if vb
|
|
119
|
+
vbx, vby, vbw, vbh = vb
|
|
120
|
+
return if vbw <= 0 || vbh <= 0
|
|
121
|
+
ObjC::CGContextScaleCTM.call(ctx, width.to_f / vbw, height.to_f / vbh)
|
|
122
|
+
ObjC::CGContextTranslateCTM.call(ctx, -vbx, -vby)
|
|
123
|
+
else
|
|
124
|
+
# No viewBox — use intrinsic width/height attrs if they parse,
|
|
125
|
+
# else 1:1 (SVG with no sizing was authored at target-pixel units).
|
|
126
|
+
iw = parse_length(root_attrs['width'])
|
|
127
|
+
ih = parse_length(root_attrs['height'])
|
|
128
|
+
if iw && ih && iw.positive? && ih.positive?
|
|
129
|
+
ObjC::CGContextScaleCTM.call(ctx, width.to_f / iw, height.to_f / ih)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Numeric SVG length: a number with an optional CSS unit. px / pt /
|
|
135
|
+
# unitless treated as user units; em / % return nil (need layout
|
|
136
|
+
# context we don't have).
|
|
137
|
+
def parse_length(str)
|
|
138
|
+
return nil if str.nil? || str.empty?
|
|
139
|
+
m = /\A\s*([0-9]*\.?[0-9]+)\s*([a-z%]*)\s*\z/i.match(str)
|
|
140
|
+
return nil unless m
|
|
141
|
+
unit = m[2].downcase
|
|
142
|
+
return nil if unit == 'em' || unit == '%'
|
|
143
|
+
n = m[1].to_f
|
|
144
|
+
n.positive? ? n : nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_viewbox(str)
|
|
148
|
+
return nil if str.nil? || str.empty?
|
|
149
|
+
parts = str.strip.split(/[\s,]+/).map { |p| Float(p) rescue nil }
|
|
150
|
+
return nil unless parts.size == 4 && parts.none?(&:nil?)
|
|
151
|
+
parts
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Walk the event list, maintaining a stack of inherited paint
|
|
155
|
+
# attribute hashes. Group elements (`<svg>` / `<g>`) also push a
|
|
156
|
+
# CGState frame so their `transform=` applies to children. Each
|
|
157
|
+
# shape uses its OWN transform locally via SaveGState/Restore.
|
|
158
|
+
#
|
|
159
|
+
# Returns true on full success, false if any shape signaled an
|
|
160
|
+
# unrenderable element (e.g. unparseable path data) — the caller
|
|
161
|
+
# treats that as a bail and falls through to WKWebView.
|
|
162
|
+
def render_events(ctx, events)
|
|
163
|
+
attr_stack = [DEFAULT_ATTRS]
|
|
164
|
+
gstate_stack = [] # parallel to opens; entries are :saved or nil
|
|
165
|
+
ok = true
|
|
166
|
+
events.each do |kind, tag, attrs|
|
|
167
|
+
case kind
|
|
168
|
+
when :open
|
|
169
|
+
if tag == 'svg' || tag == 'g'
|
|
170
|
+
ObjC::CGContextSaveGState.call(ctx)
|
|
171
|
+
apply_transform(ctx, attrs['transform'])
|
|
172
|
+
gstate_stack.push(:saved)
|
|
173
|
+
attr_stack.push(merge_attrs(attr_stack.last, attrs))
|
|
174
|
+
else
|
|
175
|
+
gstate_stack.push(nil)
|
|
176
|
+
attr_stack.push(attr_stack.last)
|
|
177
|
+
if SHAPE_TAGS[tag]
|
|
178
|
+
ok &&= draw_shape(ctx, tag, attrs, attr_stack.last)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
when :close
|
|
182
|
+
ObjC::CGContextRestoreGState.call(ctx) if gstate_stack.last == :saved
|
|
183
|
+
gstate_stack.pop
|
|
184
|
+
attr_stack.pop if attr_stack.size > 1
|
|
185
|
+
when :self_close
|
|
186
|
+
if SHAPE_TAGS[tag]
|
|
187
|
+
ok &&= draw_shape(ctx, tag, attrs, attr_stack.last)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
break unless ok
|
|
191
|
+
end
|
|
192
|
+
# Defensive: if input was unbalanced (close missing), restore
|
|
193
|
+
# any GStates we still hold so we don't leak state.
|
|
194
|
+
gstate_stack.count(:saved).times { ObjC::CGContextRestoreGState.call(ctx) }
|
|
195
|
+
ok
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Effective attrs = inherited + element attrs + style= overrides.
|
|
199
|
+
# Style overrides per CSS specificity ranking.
|
|
200
|
+
def merge_attrs(parent, attrs)
|
|
201
|
+
style = parse_style(attrs['style'])
|
|
202
|
+
parent.merge(attrs).merge(style)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_style(str)
|
|
206
|
+
return {} if str.nil? || str.empty?
|
|
207
|
+
out = {}
|
|
208
|
+
str.split(';').each do |decl|
|
|
209
|
+
k, v = decl.split(':', 2)
|
|
210
|
+
next unless k && v
|
|
211
|
+
out[k.strip] = v.strip
|
|
212
|
+
end
|
|
213
|
+
out
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# --- shape dispatch ---
|
|
217
|
+
|
|
218
|
+
# `inherited` is the merged paint attrs from enclosing <svg>/<g>
|
|
219
|
+
# frames. `attrs` is the element's own attributes (geometry and
|
|
220
|
+
# any element-local paint overrides). The shape's own `transform=`
|
|
221
|
+
# is applied locally so the rest of the path's coordinates aren't
|
|
222
|
+
# affected.
|
|
223
|
+
#
|
|
224
|
+
# Returns true on success, false to bail the whole render (used
|
|
225
|
+
# only when a `<path>` has unparseable `d=` data). Missing /
|
|
226
|
+
# malformed geometry on other shapes silently skips the draw.
|
|
227
|
+
def draw_shape(ctx, tag, attrs, inherited)
|
|
228
|
+
effective = merge_attrs(inherited, attrs)
|
|
229
|
+
ObjC::CGContextSaveGState.call(ctx)
|
|
230
|
+
apply_transform(ctx, attrs['transform'])
|
|
231
|
+
ObjC::CGContextBeginPath.call(ctx)
|
|
232
|
+
built = case tag
|
|
233
|
+
when 'path' then build_path(ctx, attrs['d'])
|
|
234
|
+
when 'rect' then build_rect(ctx, attrs)
|
|
235
|
+
when 'circle' then build_circle(ctx, attrs)
|
|
236
|
+
when 'ellipse' then build_ellipse(ctx, attrs)
|
|
237
|
+
when 'line' then build_line(ctx, attrs)
|
|
238
|
+
when 'polygon' then build_polygon(ctx, attrs, close: true)
|
|
239
|
+
when 'polyline' then build_polygon(ctx, attrs, close: false)
|
|
240
|
+
end
|
|
241
|
+
return false if built.nil? # bail signal from build_*
|
|
242
|
+
paint(ctx, effective) if built
|
|
243
|
+
true
|
|
244
|
+
ensure
|
|
245
|
+
ObjC::CGContextRestoreGState.call(ctx)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def apply_transform(ctx, str)
|
|
249
|
+
return if str.nil? || str.empty?
|
|
250
|
+
ops = SvgTransform.parse(str)
|
|
251
|
+
return unless ops
|
|
252
|
+
ops.each do |op, args|
|
|
253
|
+
case op
|
|
254
|
+
when :translate
|
|
255
|
+
ObjC::CGContextTranslateCTM.call(ctx, args[0], args[1])
|
|
256
|
+
when :scale
|
|
257
|
+
ObjC::CGContextScaleCTM.call(ctx, args[0], args[1])
|
|
258
|
+
when :rotate
|
|
259
|
+
# SVG rotates around (cx, cy): translate to cx,cy, rotate,
|
|
260
|
+
# translate back.
|
|
261
|
+
deg, cx, cy = args
|
|
262
|
+
rad = deg * Math::PI / 180.0
|
|
263
|
+
if cx != 0.0 || cy != 0.0
|
|
264
|
+
ObjC::CGContextTranslateCTM.call(ctx, cx, cy)
|
|
265
|
+
ObjC::CGContextRotateCTM.call(ctx, rad)
|
|
266
|
+
ObjC::CGContextTranslateCTM.call(ctx, -cx, -cy)
|
|
267
|
+
else
|
|
268
|
+
ObjC::CGContextRotateCTM.call(ctx, rad)
|
|
269
|
+
end
|
|
270
|
+
when :matrix
|
|
271
|
+
a, b, c, d, e, f = args
|
|
272
|
+
ObjC::CGContextConcatCTM.call(ctx, a, b, c, d, e, f)
|
|
273
|
+
when :skewx
|
|
274
|
+
# skewX(angle) = matrix(1, 0, tan(angle), 1, 0, 0)
|
|
275
|
+
t = Math.tan(args[0] * Math::PI / 180.0)
|
|
276
|
+
ObjC::CGContextConcatCTM.call(ctx, 1.0, 0.0, t, 1.0, 0.0, 0.0)
|
|
277
|
+
when :skewy
|
|
278
|
+
t = Math.tan(args[0] * Math::PI / 180.0)
|
|
279
|
+
ObjC::CGContextConcatCTM.call(ctx, 1.0, t, 0.0, 1.0, 0.0, 0.0)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# --- basic-shape builders ---
|
|
285
|
+
|
|
286
|
+
def build_rect(ctx, attrs)
|
|
287
|
+
x = attr_num(attrs['x'], 0.0)
|
|
288
|
+
y = attr_num(attrs['y'], 0.0)
|
|
289
|
+
w = attr_num(attrs['width'])
|
|
290
|
+
h = attr_num(attrs['height'])
|
|
291
|
+
return false if w.nil? || h.nil? || w <= 0 || h <= 0
|
|
292
|
+
rx = attr_num(attrs['rx'])
|
|
293
|
+
ry = attr_num(attrs['ry'])
|
|
294
|
+
# SVG: missing rx/ry mirror each other; both missing = sharp.
|
|
295
|
+
rx ||= ry
|
|
296
|
+
ry ||= rx
|
|
297
|
+
if rx.nil? || rx <= 0 || ry <= 0
|
|
298
|
+
# Sharp rect.
|
|
299
|
+
ObjC::CGContextMoveToPoint.call(ctx, x, y)
|
|
300
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x + w, y)
|
|
301
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x + w, y + h)
|
|
302
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x, y + h)
|
|
303
|
+
ObjC::CGContextClosePath.call(ctx)
|
|
304
|
+
else
|
|
305
|
+
# Rounded rect via four arcs. Clip radii to half the side.
|
|
306
|
+
rx = [rx, w / 2.0].min
|
|
307
|
+
ry = [ry, h / 2.0].min
|
|
308
|
+
build_rounded_rect(ctx, x, y, w, h, rx, ry)
|
|
309
|
+
end
|
|
310
|
+
true
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def build_rounded_rect(ctx, x, y, w, h, rx, ry)
|
|
314
|
+
# Trace: start at (x+rx, y), go around clockwise (in SVG's
|
|
315
|
+
# y-down sense) emitting straight edges and corner arcs.
|
|
316
|
+
# Each corner arc is approximated as a cubic Bezier with the
|
|
317
|
+
# standard kappa = (4/3)(sqrt(2)-1) ≈ 0.5523 multiplier.
|
|
318
|
+
k = 4.0 / 3.0 * (Math.sqrt(2.0) - 1.0)
|
|
319
|
+
kx = rx * k
|
|
320
|
+
ky = ry * k
|
|
321
|
+
|
|
322
|
+
ObjC::CGContextMoveToPoint.call(ctx, x + rx, y)
|
|
323
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x + w - rx, y)
|
|
324
|
+
ObjC::CGContextAddCurveToPoint.call(ctx,
|
|
325
|
+
x + w - rx + kx, y, x + w, y + ry - ky, x + w, y + ry)
|
|
326
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x + w, y + h - ry)
|
|
327
|
+
ObjC::CGContextAddCurveToPoint.call(ctx,
|
|
328
|
+
x + w, y + h - ry + ky, x + w - rx + kx, y + h, x + w - rx, y + h)
|
|
329
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x + rx, y + h)
|
|
330
|
+
ObjC::CGContextAddCurveToPoint.call(ctx,
|
|
331
|
+
x + rx - kx, y + h, x, y + h - ry + ky, x, y + h - ry)
|
|
332
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x, y + ry)
|
|
333
|
+
ObjC::CGContextAddCurveToPoint.call(ctx,
|
|
334
|
+
x, y + ry - ky, x + rx - kx, y, x + rx, y)
|
|
335
|
+
ObjC::CGContextClosePath.call(ctx)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def build_circle(ctx, attrs)
|
|
339
|
+
cx = attr_num(attrs['cx'], 0.0)
|
|
340
|
+
cy = attr_num(attrs['cy'], 0.0)
|
|
341
|
+
r = attr_num(attrs['r'])
|
|
342
|
+
return false if r.nil? || r <= 0
|
|
343
|
+
ObjC::CGContextAddArc.call(ctx, cx, cy, r, 0.0, 2.0 * Math::PI, 0)
|
|
344
|
+
ObjC::CGContextClosePath.call(ctx)
|
|
345
|
+
true
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def build_ellipse(ctx, attrs)
|
|
349
|
+
cx = attr_num(attrs['cx'], 0.0)
|
|
350
|
+
cy = attr_num(attrs['cy'], 0.0)
|
|
351
|
+
rx = attr_num(attrs['rx'])
|
|
352
|
+
ry = attr_num(attrs['ry'])
|
|
353
|
+
return false if rx.nil? || ry.nil? || rx <= 0 || ry <= 0
|
|
354
|
+
# Build a unit circle scaled into an ellipse via cubic Beziers
|
|
355
|
+
# (avoids CTM games that would distort other path elements).
|
|
356
|
+
ellipse_path(ctx, cx, cy, rx, ry)
|
|
357
|
+
true
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def ellipse_path(ctx, cx, cy, rx, ry)
|
|
361
|
+
k = 4.0 / 3.0 * (Math.sqrt(2.0) - 1.0)
|
|
362
|
+
kx = rx * k
|
|
363
|
+
ky = ry * k
|
|
364
|
+
ObjC::CGContextMoveToPoint.call(ctx, cx + rx, cy)
|
|
365
|
+
ObjC::CGContextAddCurveToPoint.call(ctx,
|
|
366
|
+
cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry)
|
|
367
|
+
ObjC::CGContextAddCurveToPoint.call(ctx,
|
|
368
|
+
cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy)
|
|
369
|
+
ObjC::CGContextAddCurveToPoint.call(ctx,
|
|
370
|
+
cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry)
|
|
371
|
+
ObjC::CGContextAddCurveToPoint.call(ctx,
|
|
372
|
+
cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy)
|
|
373
|
+
ObjC::CGContextClosePath.call(ctx)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def build_line(ctx, attrs)
|
|
377
|
+
x1 = attr_num(attrs['x1'], 0.0)
|
|
378
|
+
y1 = attr_num(attrs['y1'], 0.0)
|
|
379
|
+
x2 = attr_num(attrs['x2'], 0.0)
|
|
380
|
+
y2 = attr_num(attrs['y2'], 0.0)
|
|
381
|
+
ObjC::CGContextMoveToPoint.call(ctx, x1, y1)
|
|
382
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x2, y2)
|
|
383
|
+
true
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def build_polygon(ctx, attrs, close:)
|
|
387
|
+
pts = parse_points(attrs['points'])
|
|
388
|
+
return false unless pts && pts.size >= 2
|
|
389
|
+
x, y = pts.shift
|
|
390
|
+
ObjC::CGContextMoveToPoint.call(ctx, x, y)
|
|
391
|
+
pts.each { |px, py| ObjC::CGContextAddLineToPoint.call(ctx, px, py) }
|
|
392
|
+
ObjC::CGContextClosePath.call(ctx) if close
|
|
393
|
+
true
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def parse_points(str)
|
|
397
|
+
return nil if str.nil? || str.strip.empty?
|
|
398
|
+
flat = str.scan(/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/).map(&:to_f)
|
|
399
|
+
return nil if flat.empty? || flat.size.odd?
|
|
400
|
+
flat.each_slice(2).to_a
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# --- path data ---
|
|
404
|
+
|
|
405
|
+
def build_path(ctx, d)
|
|
406
|
+
return true if d.nil? || d.empty? # empty path is valid, no-op
|
|
407
|
+
ops = SvgPathParser.parse(d)
|
|
408
|
+
return nil unless ops # nil = bail signal
|
|
409
|
+
|
|
410
|
+
cur_x = 0.0
|
|
411
|
+
cur_y = 0.0
|
|
412
|
+
start_x = 0.0
|
|
413
|
+
start_y = 0.0
|
|
414
|
+
last_cubic = nil # [cp2x, cp2y] for S reflection
|
|
415
|
+
last_quad = nil # [cpx, cpy] for T reflection
|
|
416
|
+
|
|
417
|
+
ops.each do |cmd, args|
|
|
418
|
+
abs = cmd.to_s.match?(/[A-Z]/)
|
|
419
|
+
case cmd
|
|
420
|
+
when :M, :m
|
|
421
|
+
x, y = args
|
|
422
|
+
if abs
|
|
423
|
+
cur_x, cur_y = x, y
|
|
424
|
+
else
|
|
425
|
+
cur_x += x; cur_y += y
|
|
426
|
+
end
|
|
427
|
+
start_x, start_y = cur_x, cur_y
|
|
428
|
+
ObjC::CGContextMoveToPoint.call(ctx, cur_x, cur_y)
|
|
429
|
+
last_cubic = nil; last_quad = nil
|
|
430
|
+
when :L, :l
|
|
431
|
+
x, y = args
|
|
432
|
+
if abs
|
|
433
|
+
cur_x, cur_y = x, y
|
|
434
|
+
else
|
|
435
|
+
cur_x += x; cur_y += y
|
|
436
|
+
end
|
|
437
|
+
ObjC::CGContextAddLineToPoint.call(ctx, cur_x, cur_y)
|
|
438
|
+
last_cubic = nil; last_quad = nil
|
|
439
|
+
when :H, :h
|
|
440
|
+
x = args[0]
|
|
441
|
+
cur_x = abs ? x : cur_x + x
|
|
442
|
+
ObjC::CGContextAddLineToPoint.call(ctx, cur_x, cur_y)
|
|
443
|
+
last_cubic = nil; last_quad = nil
|
|
444
|
+
when :V, :v
|
|
445
|
+
y = args[0]
|
|
446
|
+
cur_y = abs ? y : cur_y + y
|
|
447
|
+
ObjC::CGContextAddLineToPoint.call(ctx, cur_x, cur_y)
|
|
448
|
+
last_cubic = nil; last_quad = nil
|
|
449
|
+
when :C, :c
|
|
450
|
+
c1x, c1y, c2x, c2y, x, y = args
|
|
451
|
+
unless abs
|
|
452
|
+
c1x += cur_x; c1y += cur_y
|
|
453
|
+
c2x += cur_x; c2y += cur_y
|
|
454
|
+
x += cur_x; y += cur_y
|
|
455
|
+
end
|
|
456
|
+
ObjC::CGContextAddCurveToPoint.call(ctx, c1x, c1y, c2x, c2y, x, y)
|
|
457
|
+
cur_x, cur_y = x, y
|
|
458
|
+
last_cubic = [c2x, c2y]; last_quad = nil
|
|
459
|
+
when :S, :s
|
|
460
|
+
c2x, c2y, x, y = args
|
|
461
|
+
unless abs
|
|
462
|
+
c2x += cur_x; c2y += cur_y
|
|
463
|
+
x += cur_x; y += cur_y
|
|
464
|
+
end
|
|
465
|
+
c1x, c1y = if last_cubic
|
|
466
|
+
[2 * cur_x - last_cubic[0], 2 * cur_y - last_cubic[1]]
|
|
467
|
+
else
|
|
468
|
+
[cur_x, cur_y]
|
|
469
|
+
end
|
|
470
|
+
ObjC::CGContextAddCurveToPoint.call(ctx, c1x, c1y, c2x, c2y, x, y)
|
|
471
|
+
cur_x, cur_y = x, y
|
|
472
|
+
last_cubic = [c2x, c2y]; last_quad = nil
|
|
473
|
+
when :Q, :q
|
|
474
|
+
cpx, cpy, x, y = args
|
|
475
|
+
unless abs
|
|
476
|
+
cpx += cur_x; cpy += cur_y
|
|
477
|
+
x += cur_x; y += cur_y
|
|
478
|
+
end
|
|
479
|
+
ObjC::CGContextAddQuadCurveToPoint.call(ctx, cpx, cpy, x, y)
|
|
480
|
+
cur_x, cur_y = x, y
|
|
481
|
+
last_quad = [cpx, cpy]; last_cubic = nil
|
|
482
|
+
when :T, :t
|
|
483
|
+
x, y = args
|
|
484
|
+
unless abs
|
|
485
|
+
x += cur_x; y += cur_y
|
|
486
|
+
end
|
|
487
|
+
cpx, cpy = if last_quad
|
|
488
|
+
[2 * cur_x - last_quad[0], 2 * cur_y - last_quad[1]]
|
|
489
|
+
else
|
|
490
|
+
[cur_x, cur_y]
|
|
491
|
+
end
|
|
492
|
+
ObjC::CGContextAddQuadCurveToPoint.call(ctx, cpx, cpy, x, y)
|
|
493
|
+
cur_x, cur_y = x, y
|
|
494
|
+
last_quad = [cpx, cpy]; last_cubic = nil
|
|
495
|
+
when :A, :a
|
|
496
|
+
rx, ry, phi_deg, fa, fs, x, y = args
|
|
497
|
+
unless abs
|
|
498
|
+
x += cur_x; y += cur_y
|
|
499
|
+
end
|
|
500
|
+
arc_to_beziers(ctx, cur_x, cur_y, rx, ry, phi_deg, fa.to_i, fs.to_i, x, y)
|
|
501
|
+
cur_x, cur_y = x, y
|
|
502
|
+
last_cubic = nil; last_quad = nil
|
|
503
|
+
when :Z, :z
|
|
504
|
+
ObjC::CGContextClosePath.call(ctx)
|
|
505
|
+
cur_x, cur_y = start_x, start_y
|
|
506
|
+
last_cubic = nil; last_quad = nil
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
true
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# SVG endpoint-arc → cubic-Bezier segments in user space (no CTM
|
|
513
|
+
# tricks, so we don't disturb path coordinates emitted by other
|
|
514
|
+
# commands). Per SVG 1.1 appendix F.6.5 + the standard Bezier
|
|
515
|
+
# approximation for circular arcs.
|
|
516
|
+
def arc_to_beziers(ctx, x1, y1, rx, ry, phi_deg, fa, fs, x2, y2)
|
|
517
|
+
# Degenerate cases: zero radius or coincident endpoints → line.
|
|
518
|
+
if rx.abs < 1e-9 || ry.abs < 1e-9 || (x1 == x2 && y1 == y2)
|
|
519
|
+
ObjC::CGContextAddLineToPoint.call(ctx, x2, y2)
|
|
520
|
+
return
|
|
521
|
+
end
|
|
522
|
+
rx = rx.abs
|
|
523
|
+
ry = ry.abs
|
|
524
|
+
phi = phi_deg * Math::PI / 180.0
|
|
525
|
+
cos_phi = Math.cos(phi)
|
|
526
|
+
sin_phi = Math.sin(phi)
|
|
527
|
+
|
|
528
|
+
# Step 1: midpoint transform.
|
|
529
|
+
dx = (x1 - x2) / 2.0
|
|
530
|
+
dy = (y1 - y2) / 2.0
|
|
531
|
+
x1p = cos_phi * dx + sin_phi * dy
|
|
532
|
+
y1p = -sin_phi * dx + cos_phi * dy
|
|
533
|
+
|
|
534
|
+
# Step 2: ensure radii are big enough; otherwise scale them up.
|
|
535
|
+
lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
|
|
536
|
+
if lambda > 1
|
|
537
|
+
s = Math.sqrt(lambda)
|
|
538
|
+
rx *= s
|
|
539
|
+
ry *= s
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Step 3: compute center in the rotated frame.
|
|
543
|
+
sign = (fa == fs ? -1 : 1)
|
|
544
|
+
num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p
|
|
545
|
+
den = rx * rx * y1p * y1p + ry * ry * x1p * x1p
|
|
546
|
+
coef = sign * Math.sqrt([num / den, 0.0].max)
|
|
547
|
+
cxp = coef * (rx * y1p) / ry
|
|
548
|
+
cyp = -coef * (ry * x1p) / rx
|
|
549
|
+
|
|
550
|
+
# Step 4: rotate back + translate to actual center.
|
|
551
|
+
cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0
|
|
552
|
+
cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0
|
|
553
|
+
|
|
554
|
+
# Step 5: angles.
|
|
555
|
+
theta1 = angle_between(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry)
|
|
556
|
+
delta_raw = angle_between((x1p - cxp) / rx, (y1p - cyp) / ry,
|
|
557
|
+
(-x1p - cxp) / rx, (-y1p - cyp) / ry)
|
|
558
|
+
delta = delta_raw
|
|
559
|
+
if fs == 0 && delta > 0
|
|
560
|
+
delta -= 2 * Math::PI
|
|
561
|
+
elsif fs == 1 && delta < 0
|
|
562
|
+
delta += 2 * Math::PI
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Step 6: split the sweep into ≤ 90° pieces and emit cubics.
|
|
566
|
+
segments = (delta.abs / (Math::PI / 2.0)).ceil
|
|
567
|
+
segments = 1 if segments < 1
|
|
568
|
+
seg_delta = delta / segments
|
|
569
|
+
t = 4.0 / 3.0 * Math.tan(seg_delta / 4.0)
|
|
570
|
+
|
|
571
|
+
cur_theta = theta1
|
|
572
|
+
segments.times do
|
|
573
|
+
a1 = cur_theta
|
|
574
|
+
a2 = cur_theta + seg_delta
|
|
575
|
+
cos_a1 = Math.cos(a1); sin_a1 = Math.sin(a1)
|
|
576
|
+
cos_a2 = Math.cos(a2); sin_a2 = Math.sin(a2)
|
|
577
|
+
|
|
578
|
+
# Control points on the unit ellipse.
|
|
579
|
+
p1x = cos_a1 - t * sin_a1
|
|
580
|
+
p1y = sin_a1 + t * cos_a1
|
|
581
|
+
p2x = cos_a2 + t * sin_a2
|
|
582
|
+
p2y = sin_a2 - t * cos_a2
|
|
583
|
+
p3x = cos_a2
|
|
584
|
+
p3y = sin_a2
|
|
585
|
+
|
|
586
|
+
# Transform: scale by (rx, ry), rotate by phi, translate by (cx, cy).
|
|
587
|
+
c1x, c1y = ellipse_xform(p1x, p1y, rx, ry, cos_phi, sin_phi, cx, cy)
|
|
588
|
+
c2x, c2y = ellipse_xform(p2x, p2y, rx, ry, cos_phi, sin_phi, cx, cy)
|
|
589
|
+
ex, ey = ellipse_xform(p3x, p3y, rx, ry, cos_phi, sin_phi, cx, cy)
|
|
590
|
+
|
|
591
|
+
ObjC::CGContextAddCurveToPoint.call(ctx, c1x, c1y, c2x, c2y, ex, ey)
|
|
592
|
+
cur_theta = a2
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def ellipse_xform(px, py, rx, ry, cos_phi, sin_phi, cx, cy)
|
|
597
|
+
sx = px * rx
|
|
598
|
+
sy = py * ry
|
|
599
|
+
[cos_phi * sx - sin_phi * sy + cx,
|
|
600
|
+
sin_phi * sx + cos_phi * sy + cy]
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Signed angle from vector (ux, uy) to (vx, vy), in radians.
|
|
604
|
+
def angle_between(ux, uy, vx, vy)
|
|
605
|
+
dot = ux * vx + uy * vy
|
|
606
|
+
mag = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy))
|
|
607
|
+
c = (dot / mag).clamp(-1.0, 1.0)
|
|
608
|
+
a = Math.acos(c)
|
|
609
|
+
(ux * vy - uy * vx) < 0 ? -a : a
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# --- paint ---
|
|
613
|
+
|
|
614
|
+
def paint(ctx, attrs)
|
|
615
|
+
cur_color = SvgColor.parse(attrs['color']) || [0.0, 0.0, 0.0, 1.0]
|
|
616
|
+
cur_color = [0.0, 0.0, 0.0, 1.0] if cur_color == :none
|
|
617
|
+
|
|
618
|
+
fill = SvgColor.parse(attrs['fill'], current_color: cur_color)
|
|
619
|
+
stroke = SvgColor.parse(attrs['stroke'], current_color: cur_color)
|
|
620
|
+
sw = parse_float(attrs['stroke-width'], 1.0)
|
|
621
|
+
sw = 0.0 if sw < 0.0
|
|
622
|
+
sw = nil if stroke == :none
|
|
623
|
+
f_op = parse_opacity(attrs['fill-opacity'])
|
|
624
|
+
s_op = parse_opacity(attrs['stroke-opacity'])
|
|
625
|
+
o = parse_opacity(attrs['opacity'])
|
|
626
|
+
fill_rule = (attrs['fill-rule'] == 'evenodd') ? :evenodd : :nonzero
|
|
627
|
+
|
|
628
|
+
apply_line_attrs(ctx, attrs, sw)
|
|
629
|
+
|
|
630
|
+
if !fill.nil? && fill != :none
|
|
631
|
+
r, g, b, a = fill
|
|
632
|
+
ObjC::CGContextSetRGBFillColor.call(ctx, r, g, b, a * f_op * o)
|
|
633
|
+
end
|
|
634
|
+
if !stroke.nil? && stroke != :none && sw && sw > 0
|
|
635
|
+
r, g, b, a = stroke
|
|
636
|
+
ObjC::CGContextSetRGBStrokeColor.call(ctx, r, g, b, a * s_op * o)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
do_fill = !fill.nil? && fill != :none
|
|
640
|
+
do_stroke = !stroke.nil? && stroke != :none && sw && sw > 0
|
|
641
|
+
|
|
642
|
+
mode = if do_fill && do_stroke
|
|
643
|
+
fill_rule == :evenodd ? ObjC::KCG_PATH_EO_FILL_STROKE : ObjC::KCG_PATH_FILL_STROKE
|
|
644
|
+
elsif do_fill
|
|
645
|
+
fill_rule == :evenodd ? ObjC::KCG_PATH_EO_FILL : ObjC::KCG_PATH_FILL
|
|
646
|
+
elsif do_stroke
|
|
647
|
+
ObjC::KCG_PATH_STROKE
|
|
648
|
+
else
|
|
649
|
+
return
|
|
650
|
+
end
|
|
651
|
+
ObjC::CGContextDrawPath.call(ctx, mode)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def apply_line_attrs(ctx, attrs, sw)
|
|
655
|
+
ObjC::CGContextSetLineWidth.call(ctx, sw) if sw && sw > 0
|
|
656
|
+
case attrs['stroke-linecap']
|
|
657
|
+
when 'round' then ObjC::CGContextSetLineCap.call(ctx, ObjC::KCG_LINE_CAP_ROUND)
|
|
658
|
+
when 'square' then ObjC::CGContextSetLineCap.call(ctx, ObjC::KCG_LINE_CAP_SQUARE)
|
|
659
|
+
when 'butt' then ObjC::CGContextSetLineCap.call(ctx, ObjC::KCG_LINE_CAP_BUTT)
|
|
660
|
+
end
|
|
661
|
+
case attrs['stroke-linejoin']
|
|
662
|
+
when 'round' then ObjC::CGContextSetLineJoin.call(ctx, ObjC::KCG_LINE_JOIN_ROUND)
|
|
663
|
+
when 'bevel' then ObjC::CGContextSetLineJoin.call(ctx, ObjC::KCG_LINE_JOIN_BEVEL)
|
|
664
|
+
when 'miter' then ObjC::CGContextSetLineJoin.call(ctx, ObjC::KCG_LINE_JOIN_MITER)
|
|
665
|
+
end
|
|
666
|
+
if (ml = parse_float(attrs['stroke-miterlimit'], nil))
|
|
667
|
+
ObjC::CGContextSetMiterLimit.call(ctx, ml) if ml > 0
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def parse_opacity(str)
|
|
672
|
+
return 1.0 if str.nil? || str.empty?
|
|
673
|
+
f = Float(str) rescue nil
|
|
674
|
+
f.nil? ? 1.0 : f.clamp(0.0, 1.0)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def parse_float(str, default)
|
|
678
|
+
return default if str.nil? || str.empty?
|
|
679
|
+
f = Float(str) rescue nil
|
|
680
|
+
f.nil? ? default : f
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def attr_num(str, default = nil)
|
|
684
|
+
return default if str.nil? || str.empty?
|
|
685
|
+
f = Float(str) rescue nil
|
|
686
|
+
f.nil? ? default : f
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|