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,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