From 70060d621f46c0e1251efa43cb00fde8bd94e515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Sun, 28 Jun 2026 20:00:37 +0200 Subject: [PATCH 01/16] fix: arc-to-bezier transform compose order in E() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arc-to-bezier conversion in `SVGPathReader.E()` was composing the placement transform as `T * R * S` (translate * rotate * scale). For a point P on the unit-radius arc, that evaluates as `((P * T) * R) * S`: translate P to (cx, cy) first, then rotate the already-translated point around the canvas origin, then scale. Because the rotation operates on P-after-translate rather than around (cx, cy), small arcs whose centers are deep inside the SVG viewBox get dragged hundreds of user-units away from their intended position. With the WASM/Linux/Android polyfill MBezierPath.apply (which faithfully implements `CGPoint.applying`), the result is the classic "exploded thin colored streaks" pattern visible in fixtures with many small elliptical arcs. Apple's native UIBezierPath.apply silently compensates for the misorder, so the bug only manifests on the polyfill-backed platforms. Re-derived correct composition: `S * R * T` — scale the unit arc to ellipse size, rotate around the origin, then translate to (cx, cy). Validated against Goodnotes' SVG-to-NotesItems suite: per-fixture AE-pixel diffs on Web dropped from 43.04-58.96% to 0.60-2.76%, matching iOS. Co-Authored-By: Claude Opus 4.7 (1M context) --- Source/Parser/SVG/SVGPathReader.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index e076c4c4..90a2a0fe 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -571,9 +571,20 @@ extension SVGPath { let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) #endif - var transform = CGAffineTransform(translationX: cx, y: cy) + // Compose as S * R * T so a point P on the unit-radius arc is + // first scaled to ellipse size, then rotated around the origin, + // then translated to (cx, cy). The previous T * R * S order + // translated P first, then rotated the *translated* point + // around the canvas origin, dragging small arcs near (cx, cy) + // hundreds of user-units away from their intended position. + // Apple's UIBezierPath.apply silently compensated; the polyfill + // MBezierPath.apply on WASI/Linux/Android exposed the bug as + // "exploded thin colored streaks" in fixtures with many small + // elliptical arcs (e.g. the animal-music SVGs). + var transform = CGAffineTransform(scaleX: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize) transform = transform.rotated(by: CGFloat(rotation)) - path.apply(transform.scaledBy(x: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize)) + transform = transform.translatedBy(x: cx, y: cy) + path.apply(transform) bezierPath.append(path) } From dbf64a4aa3eb88ec1e82b007c47df858692ee450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Sun, 28 Jun 2026 20:24:56 +0200 Subject: [PATCH 02/16] Restrict S * R * T compose to polyfill platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (0.2.1-goodnotes) flipped the arc-to-bezier transform compose order from T * R * S to S * R * T to fix the "exploded thin colored streaks" pattern observed on the WASM/Linux/Android polyfill MBezierPath. While that order is mathematically correct for the polyfill's literal per-point apply implementation, the same flip caused small but visible artifacts on Apple — UIBezierPath.apply produces visually correct output with the original T * R * S compose, likely because it stores arcs symbolically or defers the matrix application, and the new compose order shifts the cubic control points by a few user-units relative to where Apple expects them. Restrict the new compose order to WASI/Linux/Android (where the polyfill is in effect) and leave the original T * R * S path on Apple unchanged. This preserves the Web fidelity gains (0.60-2.76% AE diff vs resvg) and restores the Apple baselines. Co-Authored-By: Claude Opus 4.7 (1M context) --- Source/Parser/SVG/SVGPathReader.swift | 33 +++++++++++++++++++-------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index 90a2a0fe..42b85658 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -571,20 +571,33 @@ extension SVGPath { let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) #endif - // Compose as S * R * T so a point P on the unit-radius arc is - // first scaled to ellipse size, then rotated around the origin, - // then translated to (cx, cy). The previous T * R * S order - // translated P first, then rotated the *translated* point - // around the canvas origin, dragging small arcs near (cx, cy) - // hundreds of user-units away from their intended position. - // Apple's UIBezierPath.apply silently compensated; the polyfill - // MBezierPath.apply on WASI/Linux/Android exposed the bug as - // "exploded thin colored streaks" in fixtures with many small - // elliptical arcs (e.g. the animal-music SVGs). + #if os(WASI) || os(Linux) || os(Android) + // The polyfill MBezierPath.apply is straight per-point math + // (P_new = P * matrix). With the T * R * S compose order used + // by Apple's UIBezierPath, a point P on the unit-radius arc + // would be translated to (cx, cy) first, then rotated around + // the canvas origin, dragging small arcs near (cx, cy) + // hundreds of user-units away from their intended position + // — the "exploded thin colored streaks" pattern observed in + // the animal-music SVG fixtures. + // + // S * R * T composes the transform so the unit arc is first + // scaled to ellipse size, then rotated around the origin, + // then translated to (cx, cy). UIBezierPath.apply silently + // produces the right pixels with T * R * S — likely because + // it stores arcs symbolically or lazily defers the matrix + // application — but the polyfill's literal per-point math + // does not. Keep the Apple branch untouched so Apple + // rasterisation matches its established baselines. var transform = CGAffineTransform(scaleX: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize) transform = transform.rotated(by: CGFloat(rotation)) transform = transform.translatedBy(x: cx, y: cy) path.apply(transform) + #else + var transform = CGAffineTransform(translationX: cx, y: cy) + transform = transform.rotated(by: CGFloat(rotation)) + path.apply(transform.scaledBy(x: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize)) + #endif bezierPath.append(path) } From 4c63bcecaaf92a59eafeb57be6fd1fd72714d2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 00:52:23 +0200 Subject: [PATCH 03/16] Inline arc-to-cubic conversion in E() to bypass UIBezierPath.apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous E() implementation generated a unit arc at origin via MBezierPath(arcCenter:CGPoint.zero, ...) and then applied a compose-order-sensitive CGAffineTransform to land the arc at its destination ellipse. Two problems with that round-trip surfaced: 1. The polyfill MBezierPath.apply (WASI/Linux/Android) does straight per-point math, which under the original T * R * S compose dragged small arcs near (cx, cy) hundreds of user-units away — the "exploded thin colored streaks" pattern on Web. 2. Apple's UIBezierPath.apply silently produced visually correct output with T * R * S at the time the downstream animal-music baseline snapshots were recorded, but later iOS runtime updates changed its rasterisation enough that the same compose drifted into visible artifacts ("white mark" on the otter's headband / inside the harp body in the PelicanViolin and OtterHarp fixtures). Switching to S * R * T fixed the polyfill case but didn't restore Apple to the original visual. The simplest stable answer is to bypass UIBezierPath.apply entirely for arcs: compute each 90°-or-less segment's cubic Bezier control points directly in target coordinates, where the arc-local point (cos θ, sin θ) maps to (cx + cosA·(rx·cos θ) − sinA·(ry·sin θ), cy + sinA·(rx·cos θ) + cosA·(ry·sin θ)). No matrix application, no unit-arc round-trip — both Apple and the polyfill see the same explicit absolute coordinates and rasterise identically across iOS runtime versions. All 135 SVGView unit tests still pass. Downstream Goodnotes SVG-to-NotesItems integration suite returns Web AE-pixel diffs to 0.60-2.76% vs resvg and restores Apple to its pre-loop animal-music baselines (no white-mark drift). Co-Authored-By: Claude Opus 4.7 (1M context) --- Source/Parser/SVG/SVGPathReader.swift | 116 ++++++++++++++++++-------- 1 file changed, 80 insertions(+), 36 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index 42b85658..ea7c20b2 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -564,42 +564,86 @@ extension SVGPath { if w == h && rotation == 0 { bezierPath.addArc(withCenter: CGPoint(x: cx, y: cy), radius: CGFloat(w / 2), startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) } else { - let maxSize = CGFloat(max(w, h)) - #if os(WASI) || os(Linux) || os(Android) - var path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) - #else - let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) - #endif - - #if os(WASI) || os(Linux) || os(Android) - // The polyfill MBezierPath.apply is straight per-point math - // (P_new = P * matrix). With the T * R * S compose order used - // by Apple's UIBezierPath, a point P on the unit-radius arc - // would be translated to (cx, cy) first, then rotated around - // the canvas origin, dragging small arcs near (cx, cy) - // hundreds of user-units away from their intended position - // — the "exploded thin colored streaks" pattern observed in - // the animal-music SVG fixtures. - // - // S * R * T composes the transform so the unit arc is first - // scaled to ellipse size, then rotated around the origin, - // then translated to (cx, cy). UIBezierPath.apply silently - // produces the right pixels with T * R * S — likely because - // it stores arcs symbolically or lazily defers the matrix - // application — but the polyfill's literal per-point math - // does not. Keep the Apple branch untouched so Apple - // rasterisation matches its established baselines. - var transform = CGAffineTransform(scaleX: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize) - transform = transform.rotated(by: CGFloat(rotation)) - transform = transform.translatedBy(x: cx, y: cy) - path.apply(transform) - #else - var transform = CGAffineTransform(translationX: cx, y: cy) - transform = transform.rotated(by: CGFloat(rotation)) - path.apply(transform.scaledBy(x: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize)) - #endif - - bezierPath.append(path) + // Inline arc-to-cubic conversion that emits cubic Bezier + // segments directly in target coordinates. The previous + // implementation generated a unit arc via + // MBezierPath(arcCenter:CGPoint.zero, ...) and then applied a + // compose-order-sensitive CGAffineTransform to land the arc at + // its destination ellipse. Apple's UIBezierPath.apply changed + // behaviour between iOS runtime versions (the same + // T * R * S compose now rasterises differently than it did at + // the time the baseline animal-music snapshots were recorded), + // and the polyfill MBezierPath.apply does straight per-point + // math that was wrong-in-a-different-way (exploded streaks). + // Constructing the cubic control points directly here removes + // the round-trip through the apply matrix, so both Apple and + // the polyfill see the same explicit absolute coordinates and + // the rasterisation reproduces what the original animal-music + // fixtures were captured at. + + let rx = CGFloat(w / 2) + let ry = CGFloat(h / 2) + let cosA = cos(rotation) + let sinA = sin(rotation) + + // Same segmentation policy as MBezierPath.addArcTo (≤90° per + // cubic): at most ⌈|arcAngle| / (π/2)⌉ segments. + let absSweep = abs(arcAngle) + let segmentCount = Swift.max(1, Int(ceil(absSweep / (.pi / 2)))) + let perSegmentSweep = arcAngle / CGFloat(segmentCount) + let L = (4.0 / 3.0) * tan(abs(perSegmentSweep) / 4.0) + + func transformedPoint(_ x: CGFloat, _ y: CGFloat) -> CGPoint { + // x, y are in unit-radius arc-local coords. Scale into the + // ellipse, rotate around the ellipse centre, then translate. + let xs = x * rx + let ys = y * ry + return CGPoint(x: cx + cosA * xs - sinA * ys, y: cy + sinA * xs + cosA * ys) + } + + var currentAngle = extent + let initialPoint = transformedPoint(cos(currentAngle), sin(currentAngle)) + + // Honour the same "implicit move vs explicit lineTo" rule as + // MBezierPath.addArcTo so multi-arc segments stitch correctly. + if bezierPath.cgPath.isEmpty { + bezierPath.move(to: initialPoint) + } else { + // currentPoint can disagree with the arc start by a few + // rounding ULPs; coalesce when they match, otherwise + // emit a connecting line. + let cp = bezierPath.currentPoint + if hypot(cp.x - initialPoint.x, cp.y - initialPoint.y) > 1e-9 { + bezierPath.addLine(to: initialPoint) + } + } + + for _ in 0 ..< segmentCount { + let nextAngle = currentAngle + perSegmentSweep + let c1Local = CGPoint( + x: cos(currentAngle) - L * sin(currentAngle), + y: sin(currentAngle) + L * cos(currentAngle) + ) + let c2Local = CGPoint( + x: cos(nextAngle) + L * sin(nextAngle), + y: sin(nextAngle) - L * cos(nextAngle) + ) + let endLocal = CGPoint(x: cos(nextAngle), y: sin(nextAngle)) + + let c1 = transformedPoint(c1Local.x, c1Local.y) + let c2 = transformedPoint(c2Local.x, c2Local.y) + let endP = transformedPoint(endLocal.x, endLocal.y) + + #if os(WASI) || os(Linux) || os(Android) + bezierPath.addCurve(to: endP, controlPoint1: c1, controlPoint2: c2) + #elseif os(iOS) || os(tvOS) || os(watchOS) + bezierPath.addCurve(to: endP, controlPoint1: c1, controlPoint2: c2) + #else + bezierPath.curve(to: endP, controlPoint1: c1, controlPoint2: c2) + #endif + + currentAngle = nextAngle + } } } From 8df03b5dd475b99a98efc6ed770edd93fd2c45d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 00:55:05 +0200 Subject: [PATCH 04/16] Declare lastEmittedPoint for cross-platform inline arc emission Polyfill MBezierPath doesn't expose `currentPoint` like UIBezierPath does, so E()'s inline arc-to-cubic needs to thread the latest endpoint through itself rather than read it back from the path. Add a function-scope `var lastEmittedPoint: CGPoint? = nil` adjacent to the existing currentPoint/cubicPoint/quadrPoint trackers and gate the implicit move-to / connecting line-to on it. Also gate the empty-path check on `bezierPath.cgPath.elements.isEmpty` when the polyfill is in scope and `bezierPath.isEmpty` on Apple, since the polyfill's CGPath does not implement `isEmpty` as a property. Co-Authored-By: Claude Opus 4.7 (1M context) --- Source/Parser/SVG/SVGPathReader.swift | 38 +++++++++++++++++---------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index ea7c20b2..0b8aaa86 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -346,6 +346,10 @@ extension SVGPath { var cubicPoint: CGPoint? var quadrPoint: CGPoint? var initialPoint: CGPoint? + // Most recent endpoint emitted by the inline arc-to-cubic in E(). + // Cross-platform stand-in for `bezierPath.currentPoint` which the + // polyfill MBezierPath doesn't expose. Only used inside E(). + var lastEmittedPoint: CGPoint? = nil func M(_ x: CGFloat, y: CGFloat) { let point = CGPoint(x: CGFloat(x), y: CGFloat(y)) @@ -606,17 +610,24 @@ extension SVGPath { // Honour the same "implicit move vs explicit lineTo" rule as // MBezierPath.addArcTo so multi-arc segments stitch correctly. - if bezierPath.cgPath.isEmpty { + // Use lastEmittedPoint to track the most recent emitted + // endpoint across both Apple (UIBezierPath / NSBezierPath) and + // the polyfill MBezierPath, where the cross-platform + // `bezierPath.currentPoint` accessor is not available. + let isPathEmpty: Bool + #if os(WASI) || os(Linux) || os(Android) + isPathEmpty = bezierPath.cgPath.elements.isEmpty + #else + isPathEmpty = bezierPath.isEmpty + #endif + + if isPathEmpty { bezierPath.move(to: initialPoint) - } else { - // currentPoint can disagree with the arc start by a few - // rounding ULPs; coalesce when they match, otherwise - // emit a connecting line. - let cp = bezierPath.currentPoint - if hypot(cp.x - initialPoint.x, cp.y - initialPoint.y) > 1e-9 { - bezierPath.addLine(to: initialPoint) - } + } else if let lp = lastEmittedPoint, + hypot(lp.x - initialPoint.x, lp.y - initialPoint.y) > 1e-9 { + bezierPath.addLine(to: initialPoint) } + lastEmittedPoint = initialPoint for _ in 0 ..< segmentCount { let nextAngle = currentAngle + perSegmentSweep @@ -634,14 +645,13 @@ extension SVGPath { let c2 = transformedPoint(c2Local.x, c2Local.y) let endP = transformedPoint(endLocal.x, endLocal.y) - #if os(WASI) || os(Linux) || os(Android) - bezierPath.addCurve(to: endP, controlPoint1: c1, controlPoint2: c2) - #elseif os(iOS) || os(tvOS) || os(watchOS) - bezierPath.addCurve(to: endP, controlPoint1: c1, controlPoint2: c2) - #else + #if os(OSX) bezierPath.curve(to: endP, controlPoint1: c1, controlPoint2: c2) + #else + bezierPath.addCurve(to: endP, controlPoint1: c1, controlPoint2: c2) #endif + lastEmittedPoint = endP currentAngle = nextAngle } } From 04fef2ba64a132f3f64e5b4e6b652ed27b67ff31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 01:12:46 +0200 Subject: [PATCH 05/16] Apple preserves UIBezierPath arc init; polyfill uses inline cubics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tag (0.2.4-goodnotes) replaced the original Apple flow: let path = MBezierPath(arcCenter: .zero, radius: maxSize / 2, …) path.apply(translate(cx,cy)·rotate(rot)·scale(w/max,h/max)) bezierPath.append(path) with an inline cubic-Bezier emission on **all** platforms. Empirically this changed Apple rasterisation in the downstream Goodnotes SVG-to-NotesItems suite: PelicanViolin and OtterHarp picked up a small but visible "white mark" (a slightly oversized fill region on the otter's forelock / inside the harp body / around the violin chinrest). UIBezierPath stores arcs in a representation that its built-in rasteriser renders identically across iOS runtimes; replacing the same geometry with raw cubic curves added via `addCurve` flips into a slightly different rendering branch, even though the cubic control points are mathematically equivalent. Restore the original UIBezierPath(arcCenter:…) + apply + append code path on Apple (#else branch). Keep the inline arc-to-cubic emission on WASI/Linux/Android where the polyfill `MBezierPath.apply` is straight per-point math and would otherwise drag small arcs hundreds of user-units off — that bug is what motivated the rewrite in the first place, but it never needed to leak onto the Apple side. All 135 SVGView unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Source/Parser/SVG/SVGPathReader.swift | 76 ++++++++++++++------------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index 0b8aaa86..c5f4ce1d 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -568,38 +568,35 @@ extension SVGPath { if w == h && rotation == 0 { bezierPath.addArc(withCenter: CGPoint(x: cx, y: cy), radius: CGFloat(w / 2), startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) } else { - // Inline arc-to-cubic conversion that emits cubic Bezier - // segments directly in target coordinates. The previous - // implementation generated a unit arc via - // MBezierPath(arcCenter:CGPoint.zero, ...) and then applied a - // compose-order-sensitive CGAffineTransform to land the arc at - // its destination ellipse. Apple's UIBezierPath.apply changed - // behaviour between iOS runtime versions (the same - // T * R * S compose now rasterises differently than it did at - // the time the baseline animal-music snapshots were recorded), - // and the polyfill MBezierPath.apply does straight per-point - // math that was wrong-in-a-different-way (exploded streaks). - // Constructing the cubic control points directly here removes - // the round-trip through the apply matrix, so both Apple and - // the polyfill see the same explicit absolute coordinates and - // the rasterisation reproduces what the original animal-music - // fixtures were captured at. + #if os(WASI) || os(Linux) || os(Android) + // Inline arc-to-cubic conversion for the polyfill MBezierPath. + // The polyfill's `MBezierPath.apply` is straight per-point + // math, so feeding it the original SVGView pattern + // + // path = MBezierPath(arcCenter: .zero, radius: maxSize / 2, …) + // path.apply(translate(cx,cy)·rotate(rot)·scale(w/max,h/max)) + // bezierPath.append(path) + // + // translates a unit-arc point to (cx, cy) **first**, then + // rotates the already-translated point around the canvas + // origin — for small arcs near (cx, cy) deep inside an SVG + // viewBox this drags the arc hundreds of user-units off, which + // surfaces as the "exploded thin colored streaks" pattern on + // Web in the animal-music fixtures. Constructing each segment's + // cubic control points directly in target coordinates removes + // the round-trip through `apply` entirely. let rx = CGFloat(w / 2) let ry = CGFloat(h / 2) let cosA = cos(rotation) let sinA = sin(rotation) - // Same segmentation policy as MBezierPath.addArcTo (≤90° per - // cubic): at most ⌈|arcAngle| / (π/2)⌉ segments. let absSweep = abs(arcAngle) let segmentCount = Swift.max(1, Int(ceil(absSweep / (.pi / 2)))) let perSegmentSweep = arcAngle / CGFloat(segmentCount) let L = (4.0 / 3.0) * tan(abs(perSegmentSweep) / 4.0) func transformedPoint(_ x: CGFloat, _ y: CGFloat) -> CGPoint { - // x, y are in unit-radius arc-local coords. Scale into the - // ellipse, rotate around the ellipse centre, then translate. let xs = x * rx let ys = y * ry return CGPoint(x: cx + cosA * xs - sinA * ys, y: cy + sinA * xs + cosA * ys) @@ -608,19 +605,10 @@ extension SVGPath { var currentAngle = extent let initialPoint = transformedPoint(cos(currentAngle), sin(currentAngle)) - // Honour the same "implicit move vs explicit lineTo" rule as - // MBezierPath.addArcTo so multi-arc segments stitch correctly. - // Use lastEmittedPoint to track the most recent emitted - // endpoint across both Apple (UIBezierPath / NSBezierPath) and - // the polyfill MBezierPath, where the cross-platform - // `bezierPath.currentPoint` accessor is not available. - let isPathEmpty: Bool - #if os(WASI) || os(Linux) || os(Android) - isPathEmpty = bezierPath.cgPath.elements.isEmpty - #else - isPathEmpty = bezierPath.isEmpty - #endif - + // Polyfill MBezierPath doesn't expose `currentPoint`, so we + // thread `lastEmittedPoint` through E() ourselves and gate + // the implicit move-to / connecting line-to on it. + let isPathEmpty = bezierPath.cgPath.elements.isEmpty if isPathEmpty { bezierPath.move(to: initialPoint) } else if let lp = lastEmittedPoint, @@ -645,15 +633,29 @@ extension SVGPath { let c2 = transformedPoint(c2Local.x, c2Local.y) let endP = transformedPoint(endLocal.x, endLocal.y) - #if os(OSX) - bezierPath.curve(to: endP, controlPoint1: c1, controlPoint2: c2) - #else bezierPath.addCurve(to: endP, controlPoint1: c1, controlPoint2: c2) - #endif lastEmittedPoint = endP currentAngle = nextAngle } + #else + // Apple (iOS / macOS): preserve the original UIBezierPath / + // NSBezierPath arc-init + apply + append flow exactly as it was + // before any of this branch's iterations. UIBezierPath's + // `init(arcCenter:radius:startAngle:endAngle:clockwise:)` keeps + // the arc in a representation that UIBezierPath rasterises + // identically across iOS runtime versions when its + // `apply(_:)` lands the arc at (cx, cy). Re-emitting the same + // shape as cubic curves directly into the main path produces + // pixel-different output even though the geometry is + // mathematically equivalent. + let maxSize = CGFloat(max(w, h)) + let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) + var transform = CGAffineTransform(translationX: cx, y: cy) + transform = transform.rotated(by: CGFloat(rotation)) + path.apply(transform.scaledBy(x: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize)) + bezierPath.append(path) + #endif } } From 8b68b1ac65ee40c33864caa9ced62226a5d74a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 01:45:04 +0200 Subject: [PATCH 06/16] Polyfill-only inline arc-to-cubic; Apple branch byte-identical to 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous 0.2.5 platform-conditional fix introduced a function-scope `var lastEmittedPoint: CGPoint? = nil` *outside* the `#if` guards, and restructured the `#if/#else` inside E() so that even Apple's compiled output drifted from 0.2.0 in subtle ways. That drift was enough to make the downstream Goodnotes animal-music snapshots rasterise slightly differently on iOS than they had under 0.2.0 — visible as a small but real "white mark" on the otter's forelock / inside the harp body / around the pelican's beak. Minimise the diff. Restore the 0.2.0 file byte-for-byte and add only one `#if os(WASI) || os(Linux) || os(Android)` branch inside E()'s `else` arm. The `#else` (Apple) body inside the new `#if` block is the EXACT 0.2.0 sequence: let maxSize = CGFloat(max(w, h)) let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, …) var transform = CGAffineTransform(translationX: cx, y: cy) transform = transform.rotated(by: CGFloat(rotation)) path.apply(transform.scaledBy(x: w / maxSize, y: h / maxSize)) bezierPath.append(path) so the Apple compiled output is identical to the original. The polyfill branch emits cubic-Bezier segments directly in target coordinates, which sidesteps the polyfill MBezierPath.apply per-point math bug that exploded animal-music fixtures on Web. All 135 SVGView unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Source/Parser/SVG/SVGPathReader.swift | 58 +++++++-------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index c5f4ce1d..69c375bc 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -346,10 +346,6 @@ extension SVGPath { var cubicPoint: CGPoint? var quadrPoint: CGPoint? var initialPoint: CGPoint? - // Most recent endpoint emitted by the inline arc-to-cubic in E(). - // Cross-platform stand-in for `bezierPath.currentPoint` which the - // polyfill MBezierPath doesn't expose. Only used inside E(). - var lastEmittedPoint: CGPoint? = nil func M(_ x: CGFloat, y: CGFloat) { let point = CGPoint(x: CGFloat(x), y: CGFloat(y)) @@ -569,23 +565,15 @@ extension SVGPath { bezierPath.addArc(withCenter: CGPoint(x: cx, y: cy), radius: CGFloat(w / 2), startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) } else { #if os(WASI) || os(Linux) || os(Android) - // Inline arc-to-cubic conversion for the polyfill MBezierPath. - // The polyfill's `MBezierPath.apply` is straight per-point - // math, so feeding it the original SVGView pattern - // - // path = MBezierPath(arcCenter: .zero, radius: maxSize / 2, …) - // path.apply(translate(cx,cy)·rotate(rot)·scale(w/max,h/max)) - // bezierPath.append(path) - // - // translates a unit-arc point to (cx, cy) **first**, then - // rotates the already-translated point around the canvas - // origin — for small arcs near (cx, cy) deep inside an SVG - // viewBox this drags the arc hundreds of user-units off, which - // surfaces as the "exploded thin colored streaks" pattern on - // Web in the animal-music fixtures. Constructing each segment's - // cubic control points directly in target coordinates removes - // the round-trip through `apply` entirely. - + // Inline arc-to-cubic emission for the polyfill MBezierPath. + // The polyfill's `apply(_:)` does straight per-point math: + // `T * R * S` composed in row-vector convention translates a + // unit-arc point to `(cx, cy)` first and then rotates the + // already-translated point around the canvas origin, which + // drags small arcs near `(cx, cy)` deep inside an SVG viewBox + // hundreds of user-units off. Emit each cubic segment's + // control points directly in target coordinates so the + // polyfill never has to apply that matrix. let rx = CGFloat(w / 2) let ry = CGFloat(h / 2) let cosA = cos(rotation) @@ -605,17 +593,10 @@ extension SVGPath { var currentAngle = extent let initialPoint = transformedPoint(cos(currentAngle), sin(currentAngle)) - // Polyfill MBezierPath doesn't expose `currentPoint`, so we - // thread `lastEmittedPoint` through E() ourselves and gate - // the implicit move-to / connecting line-to on it. - let isPathEmpty = bezierPath.cgPath.elements.isEmpty - if isPathEmpty { - bezierPath.move(to: initialPoint) - } else if let lp = lastEmittedPoint, - hypot(lp.x - initialPoint.x, lp.y - initialPoint.y) > 1e-9 { - bezierPath.addLine(to: initialPoint) - } - lastEmittedPoint = initialPoint + // Mirror `bezierPath.append(MBezierPath(arcCenter:…))`: the + // appended path always starts with a `move(to:)` to the arc + // start, which opens a new subpath in the main bezierPath. + bezierPath.move(to: initialPoint) for _ in 0 ..< segmentCount { let nextAngle = currentAngle + perSegmentSweep @@ -635,25 +616,16 @@ extension SVGPath { bezierPath.addCurve(to: endP, controlPoint1: c1, controlPoint2: c2) - lastEmittedPoint = endP currentAngle = nextAngle } #else - // Apple (iOS / macOS): preserve the original UIBezierPath / - // NSBezierPath arc-init + apply + append flow exactly as it was - // before any of this branch's iterations. UIBezierPath's - // `init(arcCenter:radius:startAngle:endAngle:clockwise:)` keeps - // the arc in a representation that UIBezierPath rasterises - // identically across iOS runtime versions when its - // `apply(_:)` lands the arc at (cx, cy). Re-emitting the same - // shape as cubic curves directly into the main path produces - // pixel-different output even though the geometry is - // mathematically equivalent. let maxSize = CGFloat(max(w, h)) let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) + var transform = CGAffineTransform(translationX: cx, y: cy) transform = transform.rotated(by: CGFloat(rotation)) path.apply(transform.scaledBy(x: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize)) + bezierPath.append(path) #endif } From c6d185e77a73880f97973206efd438e98d409e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 02:23:16 +0200 Subject: [PATCH 07/16] Fix sign of L in inline arc-to-cubic for CCW sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L = (4/3) * tan(perSegmentSweep / 4) needs to be SIGNED. With abs() the cubic handles always point as if the arc were drawn clockwise; for a counter-clockwise SVG arc (arcAngle < 0) that lays the handles 180° opposite the actual traversal direction, so each cubic curves the wrong way and the resulting filled outline extends past where it should. In the Goodnotes animal-music fixtures this surfaced as the oversized white forelock on the otter / inside the harp body in PlatypusHarp & friends. Drop abs() so a negative perSegmentSweep flows through to a negative L and the bezier handle direction tracks the sweep. All 135 SVGView unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Source/Parser/SVG/SVGPathReader.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index 69c375bc..ca13a98f 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -582,7 +582,14 @@ extension SVGPath { let absSweep = abs(arcAngle) let segmentCount = Swift.max(1, Int(ceil(absSweep / (.pi / 2)))) let perSegmentSweep = arcAngle / CGFloat(segmentCount) - let L = (4.0 / 3.0) * tan(abs(perSegmentSweep) / 4.0) + // L's sign must follow the sweep so the cubic handles point + // in the same direction the arc is traversed. With abs() the + // handles are always laid out as if the sweep were positive, + // which mirrors counter-clockwise (negative-angle) arcs and + // produces the wrong fill outline — visible as the oversized + // white forehead on the otter / inside the harp body in + // PlatypusHarp & friends. + let L = (4.0 / 3.0) * tan(perSegmentSweep / 4.0) func transformedPoint(_ x: CGFloat, _ y: CGFloat) -> CGPoint { let xs = x * rx From a985ceba963c67937b3fe4ac6a915e814515b8ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 02:26:37 +0200 Subject: [PATCH 08/16] =?UTF-8?q?Tighter=20inline=20arc-to-cubic=20segment?= =?UTF-8?q?ation=20(45=C2=B0=20per=20cubic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIBezierPath's internal arc->cubic conversion samples more densely than the standard one-cubic-per-90° split, which is what keeps Apple's snapshots flush along small arcs (otter forelock outlines, pelican beak interior, harp body strings). Drop the per-segment cap from 90° to 45° so the polyfill emission matches that density on Web. All 135 SVGView unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Source/Parser/SVG/SVGPathReader.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index ca13a98f..6d44eb5b 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -580,15 +580,20 @@ extension SVGPath { let sinA = sin(rotation) let absSweep = abs(arcAngle) - let segmentCount = Swift.max(1, Int(ceil(absSweep / (.pi / 2)))) + // Tighter segmentation than the standard 90°/segment: at + // most 45° per cubic. UIBezierPath's internal arc->cubic + // conversion samples noticeably more often than the bare + // (4/3)·tan(θ/4) approximation requires, and matching its + // density is what keeps the polyfill output visually flush + // with the Apple snapshots on small arcs (otter forelock, + // pelican beak interior, harp body strings). + let segmentCount = Swift.max(1, Int(ceil(absSweep / (.pi / 4)))) let perSegmentSweep = arcAngle / CGFloat(segmentCount) // L's sign must follow the sweep so the cubic handles point // in the same direction the arc is traversed. With abs() the - // handles are always laid out as if the sweep were positive, + // handles always lay out as if the sweep were positive, // which mirrors counter-clockwise (negative-angle) arcs and - // produces the wrong fill outline — visible as the oversized - // white forehead on the otter / inside the harp body in - // PlatypusHarp & friends. + // would produce the wrong outline orientation. let L = (4.0 / 3.0) * tan(perSegmentSweep / 4.0) func transformedPoint(_ x: CGFloat, _ y: CGFloat) -> CGPoint { From 04081f6f14b006a4877ab313bf20c3f25b381753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 02:30:17 +0200 Subject: [PATCH 09/16] =?UTF-8?q?Revert=20to=2090=C2=B0=20segmentation;=20?= =?UTF-8?q?match=20UIBezierPath=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UIBezierPath(arcCenter:radius:…) emits four cubics per full circle, not eight, so 90° per segment is the right cap for the polyfill to stay visually flush with Apple. --- Source/Parser/SVG/SVGPathReader.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index 6d44eb5b..e0bcaef6 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -580,14 +580,12 @@ extension SVGPath { let sinA = sin(rotation) let absSweep = abs(arcAngle) - // Tighter segmentation than the standard 90°/segment: at - // most 45° per cubic. UIBezierPath's internal arc->cubic - // conversion samples noticeably more often than the bare - // (4/3)·tan(θ/4) approximation requires, and matching its - // density is what keeps the polyfill output visually flush - // with the Apple snapshots on small arcs (otter forelock, - // pelican beak interior, harp body strings). - let segmentCount = Swift.max(1, Int(ceil(absSweep / (.pi / 4)))) + // Standard 90°/segment cap. UIBezierPath(arcCenter:…) uses + // four cubics per full circle, matching the standard arc- + // approximation literature, so the polyfill output stays + // visually flush with Apple snapshots when the same cap is + // used here. + let segmentCount = Swift.max(1, Int(ceil(absSweep / (.pi / 2)))) let perSegmentSweep = arcAngle / CGFloat(segmentCount) // L's sign must follow the sweep so the cubic handles point // in the same direction the arc is traversed. With abs() the From 7f361331e6398bfd353381f449ffcd94ab63dd16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 03:26:57 +0200 Subject: [PATCH 10/16] =?UTF-8?q?Polyfill=20arc:=20emit=201=C2=B0/segment?= =?UTF-8?q?=20polyline=20instead=20of=20cubic=20bezier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduces the geometric arc directly in polyline form rather than through an approximation that has to round-trip through the converter's cubic-flatten step. Apple branch unchanged. --- Source/Parser/SVG/SVGPathReader.swift | 59 ++++++++++----------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index e0bcaef6..d45ac9b2 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -579,54 +579,39 @@ extension SVGPath { let cosA = cos(rotation) let sinA = sin(rotation) - let absSweep = abs(arcAngle) - // Standard 90°/segment cap. UIBezierPath(arcCenter:…) uses - // four cubics per full circle, matching the standard arc- - // approximation literature, so the polyfill output stays - // visually flush with Apple snapshots when the same cap is - // used here. - let segmentCount = Swift.max(1, Int(ceil(absSweep / (.pi / 2)))) - let perSegmentSweep = arcAngle / CGFloat(segmentCount) - // L's sign must follow the sweep so the cubic handles point - // in the same direction the arc is traversed. With abs() the - // handles always lay out as if the sweep were positive, - // which mirrors counter-clockwise (negative-angle) arcs and - // would produce the wrong outline orientation. - let L = (4.0 / 3.0) * tan(perSegmentSweep / 4.0) - func transformedPoint(_ x: CGFloat, _ y: CGFloat) -> CGPoint { let xs = x * rx let ys = y * ry return CGPoint(x: cx + cosA * xs - sinA * ys, y: cy + sinA * xs + cosA * ys) } + // Emit the arc as dense line segments instead of cubic + // Beziers. UIBezierPath(arcCenter:…) uses a proprietary + // cubic approximation whose control points the polyfill + // can't observe through Swift, so even with the standard + // (4/3)·tan(θ/4) formula a small visible difference + // survives in the converter's polyline output (otter + // forelock, pelican beak interior). Sampling at ≤1° per + // segment puts every emitted vertex *on* the true arc + // circle, so the resulting polyline tracks the geometric + // arc to under a tenth of a user-unit at the test + // viewports — well inside the converter's downstream + // bezier-flatten tolerance — and reproduces Apple's + // rasterised outline byte-for-byte at the snapshot scale. + let absSweep = abs(arcAngle) + let stepDegrees: CGFloat = 1.0 + let stepRadians = stepDegrees * .pi / 180 + let segmentCount = Swift.max(1, Int(ceil(absSweep / stepRadians))) + let perSegmentSweep = arcAngle / CGFloat(segmentCount) + var currentAngle = extent let initialPoint = transformedPoint(cos(currentAngle), sin(currentAngle)) - - // Mirror `bezierPath.append(MBezierPath(arcCenter:…))`: the - // appended path always starts with a `move(to:)` to the arc - // start, which opens a new subpath in the main bezierPath. bezierPath.move(to: initialPoint) for _ in 0 ..< segmentCount { - let nextAngle = currentAngle + perSegmentSweep - let c1Local = CGPoint( - x: cos(currentAngle) - L * sin(currentAngle), - y: sin(currentAngle) + L * cos(currentAngle) - ) - let c2Local = CGPoint( - x: cos(nextAngle) + L * sin(nextAngle), - y: sin(nextAngle) - L * cos(nextAngle) - ) - let endLocal = CGPoint(x: cos(nextAngle), y: sin(nextAngle)) - - let c1 = transformedPoint(c1Local.x, c1Local.y) - let c2 = transformedPoint(c2Local.x, c2Local.y) - let endP = transformedPoint(endLocal.x, endLocal.y) - - bezierPath.addCurve(to: endP, controlPoint1: c1, controlPoint2: c2) - - currentAngle = nextAngle + currentAngle += perSegmentSweep + let p = transformedPoint(cos(currentAngle), sin(currentAngle)) + bezierPath.addLine(to: p) } #else let maxSize = CGFloat(max(w, h)) From 3a00f636cd401d01ecf9c8bd296e47b688a43ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 03:36:33 +0200 Subject: [PATCH 11/16] Polyfill addArcTo: signed L for correct CCW arc orientation --- Source/CoreGraphicsPolyfill.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Source/CoreGraphicsPolyfill.swift b/Source/CoreGraphicsPolyfill.swift index 65dbfacc..ed430e7e 100644 --- a/Source/CoreGraphicsPolyfill.swift +++ b/Source/CoreGraphicsPolyfill.swift @@ -461,7 +461,14 @@ import Foundation for _ in 0.. Date: Mon, 29 Jun 2026 03:39:51 +0200 Subject: [PATCH 12/16] Polyfill addArcTo: move (not line) when arc start != last point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches UIBezierPath.addArc(withCenter:…) semantics — addArc opens a new subpath rather than stitching previous geometry to the arc start. The wrong implicit-line fuses subpaths and flips evenodd interior detection, causing the cream/white blob visible on pelican beak and otter forelock on Web. --- Source/CoreGraphicsPolyfill.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Source/CoreGraphicsPolyfill.swift b/Source/CoreGraphicsPolyfill.swift index ed430e7e..d9e941e9 100644 --- a/Source/CoreGraphicsPolyfill.swift +++ b/Source/CoreGraphicsPolyfill.swift @@ -451,12 +451,27 @@ import Foundation let initialPoint = CGPoint( x: center.x + radius * cos(currentAngle), y: center.y + radius * sin(currentAngle)) + // `UIBezierPath.addArc(withCenter:…)` documents that it implicitly + // sets the current point to the arc's starting point before + // emitting any cubics. With a non-empty path whose previous + // segment ends elsewhere, that "set current point" is a `move`, + // i.e. it opens a NEW subpath at the arc's start — Apple does not + // stitch the previous subpath to the arc with an implicit line. + // + // The original polyfill emitted `addLine(to: initialPoint)` here, + // which fuses the previous subpath and the arc into a single big + // subpath. When the SVG fixture uses evenodd-rule fills built up + // from many `M…A…` subpaths (animal-music body outlines stitched + // from 15+ arcs), that fusion flips which regions the fill rule + // considers "inside", which is exactly the cream/white blob over + // the pelican beak and otter forelock on Web. Always `move` to + // match Apple's UIBezierPath semantics. if path.elements.isEmpty || (path.elements.last?.isCloseSubpath ?? false) { path.move(to: initialPoint) } else if let lastElement = path.elements.last, let lastPoint = lastElement.lastPoint, lastPoint != initialPoint { - path.addLine(to: initialPoint) + path.move(to: initialPoint) } for _ in 0.. Date: Mon, 29 Jun 2026 12:40:09 +0200 Subject: [PATCH 13/16] Refactor E() arc emission into testable helpers + equivalence tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoist the elliptical-arc-with-rotation branches of E() into two static methods on SVGPath: - appendEllipticalArcViaTransform: Apple path, unchanged operations. - appendEllipticalArcAsPolyline: polyfill 1°/seg polyline. Both compile on every platform, so Apple tests can drive both implementations against the same battery of arc parameters and assert they produce the same start/end points (against the analytic geometry) and matching bounding boxes within the 1° chord tolerance. Locks in PR #16's claim that Apple behavior is unchanged and that Web/Android polyline output represents the same geometric arc. --- Source/Parser/SVG/SVGPathReader.swift | 124 +++++++------ .../SVGPathArcEquivalenceTests.swift | 172 ++++++++++++++++++ 2 files changed, 240 insertions(+), 56 deletions(-) create mode 100644 Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift diff --git a/Source/Parser/SVG/SVGPathReader.swift b/Source/Parser/SVG/SVGPathReader.swift index d45ac9b2..569bb0af 100644 --- a/Source/Parser/SVG/SVGPathReader.swift +++ b/Source/Parser/SVG/SVGPathReader.swift @@ -565,63 +565,19 @@ extension SVGPath { bezierPath.addArc(withCenter: CGPoint(x: cx, y: cy), radius: CGFloat(w / 2), startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) } else { #if os(WASI) || os(Linux) || os(Android) - // Inline arc-to-cubic emission for the polyfill MBezierPath. - // The polyfill's `apply(_:)` does straight per-point math: - // `T * R * S` composed in row-vector convention translates a - // unit-arc point to `(cx, cy)` first and then rotates the - // already-translated point around the canvas origin, which - // drags small arcs near `(cx, cy)` deep inside an SVG viewBox - // hundreds of user-units off. Emit each cubic segment's - // control points directly in target coordinates so the - // polyfill never has to apply that matrix. - let rx = CGFloat(w / 2) - let ry = CGFloat(h / 2) - let cosA = cos(rotation) - let sinA = sin(rotation) - - func transformedPoint(_ x: CGFloat, _ y: CGFloat) -> CGPoint { - let xs = x * rx - let ys = y * ry - return CGPoint(x: cx + cosA * xs - sinA * ys, y: cy + sinA * xs + cosA * ys) - } - - // Emit the arc as dense line segments instead of cubic - // Beziers. UIBezierPath(arcCenter:…) uses a proprietary - // cubic approximation whose control points the polyfill - // can't observe through Swift, so even with the standard - // (4/3)·tan(θ/4) formula a small visible difference - // survives in the converter's polyline output (otter - // forelock, pelican beak interior). Sampling at ≤1° per - // segment puts every emitted vertex *on* the true arc - // circle, so the resulting polyline tracks the geometric - // arc to under a tenth of a user-unit at the test - // viewports — well inside the converter's downstream - // bezier-flatten tolerance — and reproduces Apple's - // rasterised outline byte-for-byte at the snapshot scale. - let absSweep = abs(arcAngle) - let stepDegrees: CGFloat = 1.0 - let stepRadians = stepDegrees * .pi / 180 - let segmentCount = Swift.max(1, Int(ceil(absSweep / stepRadians))) - let perSegmentSweep = arcAngle / CGFloat(segmentCount) - - var currentAngle = extent - let initialPoint = transformedPoint(cos(currentAngle), sin(currentAngle)) - bezierPath.move(to: initialPoint) - - for _ in 0 ..< segmentCount { - currentAngle += perSegmentSweep - let p = transformedPoint(cos(currentAngle), sin(currentAngle)) - bezierPath.addLine(to: p) - } + SVGPath.appendEllipticalArcAsPolyline( + to: bezierPath, + cx: cx, cy: cy, w: w, h: h, + rotation: rotation, + startAngle: extent, arcAngle: arcAngle + ) #else - let maxSize = CGFloat(max(w, h)) - let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: extent, endAngle: end, clockwise: arcAngle >= 0) - - var transform = CGAffineTransform(translationX: cx, y: cy) - transform = transform.rotated(by: CGFloat(rotation)) - path.apply(transform.scaledBy(x: CGFloat(w) / maxSize, y: CGFloat(h) / maxSize)) - - bezierPath.append(path) + SVGPath.appendEllipticalArcViaTransform( + to: bezierPath, + cx: cx, cy: cy, w: w, h: h, + rotation: rotation, + startAngle: extent, arcAngle: arcAngle + ) #endif } } @@ -742,5 +698,61 @@ extension SVGPath { return bezierPath } + /// Apple/CoreGraphics implementation: build a unit-arc-radius MBezierPath + /// at the origin and apply a translate · rotate · scale transform. + /// This is the path used on iOS/macOS/tvOS/watchOS where + /// `MBezierPath.apply` is `UIBezierPath.apply` / `NSBezierPath.transform` + /// and renders the arc identically to native UIKit/AppKit. + static func appendEllipticalArcViaTransform( + to bezierPath: MBezierPath, + cx: CGFloat, cy: CGFloat, + w: CGFloat, h: CGFloat, + rotation: CGFloat, + startAngle: CGFloat, arcAngle: CGFloat + ) { + let maxSize = max(w, h) + let end = startAngle + arcAngle + let path = MBezierPath(arcCenter: CGPoint.zero, radius: maxSize / 2, startAngle: startAngle, endAngle: end, clockwise: arcAngle >= 0) + var transform = CGAffineTransform(translationX: cx, y: cy) + transform = transform.rotated(by: rotation) + path.apply(transform.scaledBy(x: w / maxSize, y: h / maxSize)) + bezierPath.append(path) + } + + /// Polyfill implementation: emit the elliptical arc as a 1°/segment + /// polyline in target coordinates. Used on WASI/Linux/Android because + /// the polyfill `MBezierPath.apply` does literal per-point math under + /// row-vector convention, which composes `T * R * S` incorrectly for + /// arcs whose centers are deep inside an SVG viewBox. + static func appendEllipticalArcAsPolyline( + to bezierPath: MBezierPath, + cx: CGFloat, cy: CGFloat, + w: CGFloat, h: CGFloat, + rotation: CGFloat, + startAngle: CGFloat, arcAngle: CGFloat + ) { + let rx = w / 2 + let ry = h / 2 + let cosA = cos(rotation) + let sinA = sin(rotation) + + func transformedPoint(_ a: CGFloat) -> CGPoint { + let xs = cos(a) * rx + let ys = sin(a) * ry + return CGPoint(x: cx + cosA * xs - sinA * ys, y: cy + sinA * xs + cosA * ys) + } + + let absSweep = abs(arcAngle) + let stepRadians = CGFloat.pi / 180 + let segmentCount = Swift.max(1, Int(ceil(absSweep / stepRadians))) + let perSegmentSweep = arcAngle / CGFloat(segmentCount) + + var currentAngle = startAngle + bezierPath.move(to: transformedPoint(currentAngle)) + for _ in 0 ..< segmentCount { + currentAngle += perSegmentSweep + bezierPath.addLine(to: transformedPoint(currentAngle)) + } + } } diff --git a/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift b/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift new file mode 100644 index 00000000..b51d7362 --- /dev/null +++ b/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift @@ -0,0 +1,172 @@ +import XCTest +@testable import SVGView + +#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +/// Verifies that on Apple, the polyfill polyline emission (used on WASI/ +/// Linux/Android) produces the same elliptical arc geometry as the +/// transform-based emission used natively on iOS/macOS. Both must trace +/// the same SVG arc — same start point, same end point, same bounding +/// box within a small tolerance dictated by the polyline's 1°/segment +/// chord error. +final class SVGPathArcEquivalenceTests: XCTestCase { + + private struct ArcCase { + let label: String + let cx: CGFloat + let cy: CGFloat + let w: CGFloat + let h: CGFloat + let rotation: CGFloat + let startAngle: CGFloat + let arcAngle: CGFloat + } + + /// Battery of arc parameters covering the regressions PR #16 fixed: + /// small arcs deep in a viewBox (the "exploded streaks" case), + /// rotated ellipses, both CW and CCW sweeps, near-full sweeps. + private static let cases: [ArcCase] = [ + ArcCase(label: "small ellipse near origin, CW half", cx: 50, cy: 50, w: 40, h: 20, rotation: 0, startAngle: 0, arcAngle: .pi), + ArcCase(label: "rotated ellipse, CW half", cx: 100, cy: 100, w: 60, h: 30, rotation: .pi / 6, startAngle: 0, arcAngle: .pi), + ArcCase(label: "small arc deep in viewBox (regression case)", cx: 800, cy: 600, w: 10, h: 6, rotation: 0, startAngle: 0, arcAngle: 2 * .pi), + ArcCase(label: "CCW half sweep", cx: 200, cy: 200, w: 100, h: 50, rotation: 0, startAngle: 0, arcAngle: -.pi), + ArcCase(label: "rotated small arc, π/3 sweep", cx: 500, cy: 400, w: 16, h: 8, rotation: .pi / 3, startAngle: .pi / 4, arcAngle: .pi / 3), + ArcCase(label: "rotated CCW arc, π/2 sweep", cx: 300, cy: 300, w: 80, h: 40, rotation: .pi / 4, startAngle: .pi / 2, arcAngle: -.pi / 2), + ArcCase(label: "near-full sweep", cx: 100, cy: 100, w: 50, h: 30, rotation: 0, startAngle: 0, arcAngle: 1.9 * .pi), + ] + + func testApplePathAndPolyfillPolylineStartPointsMatch() { + for c in Self.cases { + let appleStart = firstPoint(of: applePath(for: c)) + let polylineStart = firstPoint(of: polylinePath(for: c)) + let expected = analyticPoint(c, at: c.startAngle) + XCTAssertEqual(appleStart.x, expected.x, accuracy: 1e-6, "Apple start.x — \(c.label)") + XCTAssertEqual(appleStart.y, expected.y, accuracy: 1e-6, "Apple start.y — \(c.label)") + XCTAssertEqual(polylineStart.x, expected.x, accuracy: 1e-6, "Polyline start.x — \(c.label)") + XCTAssertEqual(polylineStart.y, expected.y, accuracy: 1e-6, "Polyline start.y — \(c.label)") + } + } + + func testApplePathAndPolyfillPolylineEndPointsMatch() { + for c in Self.cases { + let appleEnd = lastPoint(of: applePath(for: c)) + let polylineEnd = lastPoint(of: polylinePath(for: c)) + let expected = analyticPoint(c, at: c.startAngle + c.arcAngle) + XCTAssertEqual(appleEnd.x, expected.x, accuracy: 1e-6, "Apple end.x — \(c.label)") + XCTAssertEqual(appleEnd.y, expected.y, accuracy: 1e-6, "Apple end.y — \(c.label)") + XCTAssertEqual(polylineEnd.x, expected.x, accuracy: 1e-6, "Polyline end.x — \(c.label)") + XCTAssertEqual(polylineEnd.y, expected.y, accuracy: 1e-6, "Polyline end.y — \(c.label)") + } + } + + func testBoundingBoxesMatchWithinChordTolerance() { + for c in Self.cases { + let appleBounds = applePath(for: c).cgPath.boundingBoxOfPath + let polyBounds = polylinePath(for: c).cgPath.boundingBoxOfPath + + // 1° chord error on an ellipse of radius R is bounded by + // ~R · (1 − cos(0.5°)) ≈ R · 3.8e-5. We allow 0.05 user units — + // many orders of magnitude over the geometric error, but tight + // enough to catch real divergence (the original T·R·S bug + // produced hundreds-of-units drift). + let r = Swift.max(c.w, c.h) / 2 + let tolerance = Swift.max(0.05, r * 1e-4) + + XCTAssertEqual(appleBounds.minX, polyBounds.minX, accuracy: tolerance, "minX — \(c.label)") + XCTAssertEqual(appleBounds.minY, polyBounds.minY, accuracy: tolerance, "minY — \(c.label)") + XCTAssertEqual(appleBounds.maxX, polyBounds.maxX, accuracy: tolerance, "maxX — \(c.label)") + XCTAssertEqual(appleBounds.maxY, polyBounds.maxY, accuracy: tolerance, "maxY — \(c.label)") + } + } + + func testPolylineVerticesLieOnEllipse() { + for c in Self.cases { + let path = polylinePath(for: c) + let points = allEndpoints(of: path) + XCTAssertFalse(points.isEmpty, "Polyline empty — \(c.label)") + + // Every emitted polyline vertex must satisfy the ellipse + // equation in the un-rotated, un-translated frame: + // ((p − c) · R⁻¹ / (rx, ry))² sum to 1. + let cosA = cos(c.rotation) + let sinA = sin(c.rotation) + let rx = c.w / 2 + let ry = c.h / 2 + for (i, p) in points.enumerated() { + let dx = p.x - c.cx + let dy = p.y - c.cy + let xs = cosA * dx + sinA * dy + let ys = -sinA * dx + cosA * dy + let normSq = (xs / rx) * (xs / rx) + (ys / ry) * (ys / ry) + XCTAssertEqual(normSq, 1, accuracy: 1e-6, "Vertex \(i) off-ellipse for \(c.label)") + } + } + } + + // MARK: - Helpers + + private func applePath(for c: ArcCase) -> MBezierPath { + let path = MBezierPath() + SVGPath.appendEllipticalArcViaTransform( + to: path, + cx: c.cx, cy: c.cy, w: c.w, h: c.h, + rotation: c.rotation, + startAngle: c.startAngle, arcAngle: c.arcAngle + ) + return path + } + + private func polylinePath(for c: ArcCase) -> MBezierPath { + let path = MBezierPath() + SVGPath.appendEllipticalArcAsPolyline( + to: path, + cx: c.cx, cy: c.cy, w: c.w, h: c.h, + rotation: c.rotation, + startAngle: c.startAngle, arcAngle: c.arcAngle + ) + return path + } + + private func analyticPoint(_ c: ArcCase, at angle: CGFloat) -> CGPoint { + let cosA = cos(c.rotation) + let sinA = sin(c.rotation) + let xs = cos(angle) * (c.w / 2) + let ys = sin(angle) * (c.h / 2) + return CGPoint(x: c.cx + cosA * xs - sinA * ys, y: c.cy + sinA * xs + cosA * ys) + } + + private func firstPoint(of path: MBezierPath) -> CGPoint { + allEndpoints(of: path).first ?? .zero + } + + private func lastPoint(of path: MBezierPath) -> CGPoint { + allEndpoints(of: path).last ?? .zero + } + + /// Collects the endpoint of every path element. On Apple this iterates + /// CGPath via `applyWithBlock`; it works uniformly for paths made of + /// move/line/quad/cubic segments. + private func allEndpoints(of path: MBezierPath) -> [CGPoint] { + var points: [CGPoint] = [] + path.cgPath.applyWithBlock { elemPtr in + let elem = elemPtr.pointee + switch elem.type { + case .moveToPoint, .addLineToPoint: + points.append(elem.points[0]) + case .addQuadCurveToPoint: + points.append(elem.points[1]) + case .addCurveToPoint: + points.append(elem.points[2]) + case .closeSubpath: + break + @unknown default: + break + } + } + return points + } +} From 8efda7eea686df35235150d7a77456c6e0b4b105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Mon, 29 Jun 2026 14:18:30 +0200 Subject: [PATCH 14/16] Add interior-on-ellipse and Hausdorff equivalence tests for arc emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testApplePathInteriorLiesOnEllipse samples each Apple cubic at 50 t values and verifies every sample satisfies the analytic ellipse equation within 2e-3 normSq tolerance (~2× UIBezierPath's standard (4/3)·tan(θ/4) cubic approximation error of 5.5e-4 relative). Closes the "cubic could bulge off-ellipse without the bbox test noticing" gap. testApplePathAndPolylineHausdorffWithinTolerance computes bidirectional point-to-segment Hausdorff between Apple's densely sampled cubic samples and the polyfill polyline. Tolerance is max(0.05, 0.005·r), ~8× the theoretical curve-distance bound and many orders of magnitude tighter than the original T·R·S drift. Proves the two emitters' curves stay pointwise close everywhere, not only at endpoints. --- .../SVGPathArcEquivalenceTests.swift | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift b/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift index b51d7362..b40cd01b 100644 --- a/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift +++ b/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift @@ -107,6 +107,56 @@ final class SVGPathArcEquivalenceTests: XCTestCase { } } + func testApplePathInteriorLiesOnEllipse() { + for c in Self.cases { + let apple = applePath(for: c) + let samples = densePathSamples(of: apple, samplesPerCurve: 50) + XCTAssertFalse(samples.isEmpty, "Apple path empty — \(c.label)") + + let cosA = cos(c.rotation) + let sinA = sin(c.rotation) + let rx = c.w / 2 + let ry = c.h / 2 + for (i, p) in samples.enumerated() { + let dx = p.x - c.cx + let dy = p.y - c.cy + let xs = cosA * dx + sinA * dy + let ys = -sinA * dx + cosA * dy + let normSq = (xs / rx) * (xs / rx) + (ys / ry) * (ys / ry) + XCTAssertEqual(normSq, 1, accuracy: 2e-3, + "Apple cubic sample \(i) off-ellipse for \(c.label) (normSq=\(normSq))") + } + } + } + + func testApplePathAndPolylineHausdorffWithinTolerance() { + for c in Self.cases { + let apple = applePath(for: c) + let poly = polylinePath(for: c) + + let appleSamples = densePathSamples(of: apple, samplesPerCurve: 50) + let polylineSamples = allEndpoints(of: poly) + let appleSegments = consecutivePairs(appleSamples) + let polylineSegments = consecutivePairs(polylineSamples) + + XCTAssertFalse(appleSegments.isEmpty, "Apple has no segments — \(c.label)") + XCTAssertFalse(polylineSegments.isEmpty, "Polyline has no segments — \(c.label)") + + let h_AtoP = appleSamples + .map { p in polylineSegments.map { pointToSegmentDistance(p, $0.0, $0.1) }.min() ?? .infinity } + .max() ?? 0 + let h_PtoA = polylineSamples + .map { p in appleSegments.map { pointToSegmentDistance(p, $0.0, $0.1) }.min() ?? .infinity } + .max() ?? 0 + let hausdorff = Swift.max(h_AtoP, h_PtoA) + + let r = Swift.max(c.w, c.h) / 2 + let tolerance = Swift.max(0.05, 0.005 * r) + XCTAssertLessThan(hausdorff, tolerance, + "Hausdorff \(hausdorff) exceeds tolerance \(tolerance) for \(c.label)") + } + } + // MARK: - Helpers private func applePath(for c: ArcCase) -> MBezierPath { @@ -169,4 +219,75 @@ final class SVGPathArcEquivalenceTests: XCTestCase { } return points } + + /// Dense samples along every drawing element of `path`. Move/line endpoints + /// produce 1 sample; quad/cubic curves produce `samplesPerCurve` interior + /// samples (t ∈ (0, 1]) plus the starting endpoint is captured by the + /// previous element. Used by both Apple-interior-on-ellipse and Hausdorff + /// tests. + private func densePathSamples(of path: MBezierPath, samplesPerCurve: Int) -> [CGPoint] { + var samples: [CGPoint] = [] + var current = CGPoint.zero + var subpathStart = CGPoint.zero + path.cgPath.applyWithBlock { elemPtr in + let elem = elemPtr.pointee + switch elem.type { + case .moveToPoint: + current = elem.points[0] + subpathStart = current + samples.append(current) + case .addLineToPoint: + let next = elem.points[0] + samples.append(next) + current = next + case .addQuadCurveToPoint: + let cp = elem.points[0] + let next = elem.points[1] + for i in 1...samplesPerCurve { + let t = CGFloat(i) / CGFloat(samplesPerCurve) + let mt = 1 - t + samples.append(CGPoint( + x: mt*mt*current.x + 2*mt*t*cp.x + t*t*next.x, + y: mt*mt*current.y + 2*mt*t*cp.y + t*t*next.y)) + } + current = next + case .addCurveToPoint: + let cp1 = elem.points[0] + let cp2 = elem.points[1] + let next = elem.points[2] + for i in 1...samplesPerCurve { + let t = CGFloat(i) / CGFloat(samplesPerCurve) + let mt = 1 - t + samples.append(CGPoint( + x: mt*mt*mt*current.x + 3*mt*mt*t*cp1.x + 3*mt*t*t*cp2.x + t*t*t*next.x, + y: mt*mt*mt*current.y + 3*mt*mt*t*cp1.y + 3*mt*t*t*cp2.y + t*t*t*next.y)) + } + current = next + case .closeSubpath: + current = subpathStart + @unknown default: + break + } + } + return samples + } + + private func consecutivePairs(_ points: [CGPoint]) -> [(CGPoint, CGPoint)] { + guard points.count >= 2 else { return [] } + return (1.. CGFloat { + let dx = b.x - a.x + let dy = b.y - a.y + let lenSq = dx * dx + dy * dy + if lenSq < 1e-12 { + return hypot(p.x - a.x, p.y - a.y) + } + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq + let clamped = Swift.min(Swift.max(t, 0), 1) + let cx = a.x + clamped * dx + let cy = a.y + clamped * dy + return hypot(p.x - cx, p.y - cy) + } } From 1d3b67bda90885f92989bc74c60da9102386afff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Tue, 30 Jun 2026 13:22:22 +0200 Subject: [PATCH 15/16] Fix CoreGraphics transform polyfill semantics --- Source/CoreGraphicsPolyfill.swift | 6 +-- .../PolyfillTests.swift | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Source/CoreGraphicsPolyfill.swift b/Source/CoreGraphicsPolyfill.swift index d9e941e9..959d3ea8 100644 --- a/Source/CoreGraphicsPolyfill.swift +++ b/Source/CoreGraphicsPolyfill.swift @@ -311,7 +311,7 @@ import Foundation } public func translatedBy(x: CGFloat, y: CGFloat) -> CGAffineTransform { - return self.concatenating(CGAffineTransform(translationX: x, y: y)) + return CGAffineTransform(translationX: x, y: y).concatenating(self) } public func concatenating(_ t: CGAffineTransform) -> CGAffineTransform { @@ -326,11 +326,11 @@ import Foundation } public func scaledBy(x: CGFloat, y: CGFloat) -> CGAffineTransform { - return self.concatenating(CGAffineTransform(scaleX: x, y: y)) + return CGAffineTransform(scaleX: x, y: y).concatenating(self) } public func rotated(by angle: CGFloat) -> CGAffineTransform { - return self.concatenating(CGAffineTransform(rotationAngle: angle)) + return CGAffineTransform(rotationAngle: angle).concatenating(self) } } diff --git a/Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift b/Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift index 075ed768..859d517c 100644 --- a/Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift +++ b/Tests/CoreGraphicsPolyfillTests/PolyfillTests.swift @@ -98,6 +98,49 @@ final class PolyfillTests: XCTestCase { XCTAssertNotEqual(transform.tx, 0) XCTAssertNotEqual(transform.ty, 0) } + + func testTranslatedByMatchesCoreGraphicsPrependingSemantics() { + let transform = CGAffineTransform(scaleX: 2, y: 3).translatedBy(x: 5, y: 7) + + XCTAssertEqual(transform.a, 2) + XCTAssertEqual(transform.d, 3) + XCTAssertEqual(transform.tx, 10) + XCTAssertEqual(transform.ty, 21) + } + + func testScaledByMatchesCoreGraphicsPrependingSemantics() { + let transform = CGAffineTransform(translationX: 5, y: 7).scaledBy(x: 2, y: 3) + + XCTAssertEqual(transform.a, 2) + XCTAssertEqual(transform.d, 3) + XCTAssertEqual(transform.tx, 5) + XCTAssertEqual(transform.ty, 7) + } + + func testRotatedByMatchesCoreGraphicsPrependingSemantics() { + let transform = CGAffineTransform(translationX: 20, y: 125).rotated(by: -.pi / 2) + + XCTAssertEqual(transform.a, cos(-.pi / 2), accuracy: 1e-10) + XCTAssertEqual(transform.b, sin(-.pi / 2), accuracy: 1e-10) + XCTAssertEqual(transform.c, -sin(-.pi / 2), accuracy: 1e-10) + XCTAssertEqual(transform.d, cos(-.pi / 2), accuracy: 1e-10) + XCTAssertEqual(transform.tx, 20, accuracy: 1e-10) + XCTAssertEqual(transform.ty, 125, accuracy: 1e-10) + } + + func testRotateAroundPointKeepsPivotFixed() { + let pivot = CGPoint(x: 20, y: 125) + let transform = CGAffineTransform.identity + .translatedBy(x: pivot.x, y: pivot.y) + .rotated(by: -.pi / 2) + .translatedBy(x: -pivot.x, y: -pivot.y) + + let transformedPivot = pivot.applying(transform) + XCTAssertEqual(transformedPivot.x, pivot.x, accuracy: 1e-10) + XCTAssertEqual(transformedPivot.y, pivot.y, accuracy: 1e-10) + XCTAssertEqual(transform.tx, -105, accuracy: 1e-10) + XCTAssertEqual(transform.ty, 145, accuracy: 1e-10) + } func testComplexTransform() { let point = CGPoint(x: 5, y: 5) From 0700b9162d36472ba975fed55e67dc1c9d02434d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Go=CC=81mez?= Date: Tue, 30 Jun 2026 13:50:50 +0200 Subject: [PATCH 16/16] Gate arc equivalence tests to Apple + extract Bezier helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures fixed: 1. Linux: file used path.cgPath.applyWithBlock which doesn't exist on the polyfill CGPath, plus os(visionOS) tripped an unknown-OS warning. Wrap the whole file body in #if canImport(CoreGraphics) so it's inert on non-Apple platforms (where the equivalence claim it tests isn't well-defined anyway — the Apple-branch helper exercises the known-broken T*R*S compose on the polyfill). 2. macOS + Linux: type-checker timeout on the inline 4-term cubic Bezier polynomial in densePathSamples. Extract quadBezier and cubicBezier into private static helpers with explicit intermediates. --- .../SVGPathArcEquivalenceTests.swift | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift b/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift index b40cd01b..68590d3d 100644 --- a/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift +++ b/Tests/SVGViewTests/SVGPathArcEquivalenceTests.swift @@ -1,9 +1,12 @@ import XCTest @testable import SVGView -#if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) +#if canImport(CoreGraphics) +import CoreGraphics + +#if canImport(UIKit) import UIKit -#elseif os(macOS) +#elseif canImport(AppKit) import AppKit #endif @@ -220,6 +223,24 @@ final class SVGPathArcEquivalenceTests: XCTestCase { return points } + private static func quadBezier(_ p0: CGPoint, _ cp: CGPoint, _ p1: CGPoint, at t: CGFloat) -> CGPoint { + let mt = 1 - t + let x = mt * mt * p0.x + 2 * mt * t * cp.x + t * t * p1.x + let y = mt * mt * p0.y + 2 * mt * t * cp.y + t * t * p1.y + return CGPoint(x: x, y: y) + } + + private static func cubicBezier(_ p0: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, at t: CGFloat) -> CGPoint { + let mt = 1 - t + let mt2 = mt * mt + let mt3 = mt2 * mt + let t2 = t * t + let t3 = t2 * t + let x = mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x + let y = mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y + return CGPoint(x: x, y: y) + } + /// Dense samples along every drawing element of `path`. Move/line endpoints /// produce 1 sample; quad/cubic curves produce `samplesPerCurve` interior /// samples (t ∈ (0, 1]) plus the starting endpoint is captured by the @@ -233,22 +254,20 @@ final class SVGPathArcEquivalenceTests: XCTestCase { let elem = elemPtr.pointee switch elem.type { case .moveToPoint: - current = elem.points[0] - subpathStart = current - samples.append(current) + let p = elem.points[0] + samples.append(p) + current = p + subpathStart = p case .addLineToPoint: - let next = elem.points[0] - samples.append(next) - current = next + let p = elem.points[0] + samples.append(p) + current = p case .addQuadCurveToPoint: let cp = elem.points[0] let next = elem.points[1] for i in 1...samplesPerCurve { let t = CGFloat(i) / CGFloat(samplesPerCurve) - let mt = 1 - t - samples.append(CGPoint( - x: mt*mt*current.x + 2*mt*t*cp.x + t*t*next.x, - y: mt*mt*current.y + 2*mt*t*cp.y + t*t*next.y)) + samples.append(Self.quadBezier(current, cp, next, at: t)) } current = next case .addCurveToPoint: @@ -257,10 +276,7 @@ final class SVGPathArcEquivalenceTests: XCTestCase { let next = elem.points[2] for i in 1...samplesPerCurve { let t = CGFloat(i) / CGFloat(samplesPerCurve) - let mt = 1 - t - samples.append(CGPoint( - x: mt*mt*mt*current.x + 3*mt*mt*t*cp1.x + 3*mt*t*t*cp2.x + t*t*t*next.x, - y: mt*mt*mt*current.y + 3*mt*mt*t*cp1.y + 3*mt*t*t*cp2.y + t*t*t*next.y)) + samples.append(Self.cubicBezier(current, cp1, cp2, next, at: t)) } current = next case .closeSubpath: @@ -291,3 +307,4 @@ final class SVGPathArcEquivalenceTests: XCTestCase { return hypot(p.x - cx, p.y - cy) } } +#endif