From 725de17513273b2a84579f8ee22c908ef61dbdd9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:52:31 -0400 Subject: [PATCH] fix(shared-core): restore the KikCode figure API to the SPM sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CGPath figure wrappers — `KikCode.figure`, the badge, and the SVG path parser they read artwork through — only ever existed on `feat/shared-badge-cgpath`, which 0.3.1 was published from. 0.4.0 was published from `code/cash`, and the publish workflow stages the package by `rm -rf spm-repo/Sources spm-repo/Tests` and re-copying from the checkout, so cutting it deleted all three files. iOS has not built since: `type 'KikCode' has no member 'figure'`. Cherry-picked from 22bcafb60 unchanged. Kotlin needs nothing — the 0.4.0 framework header still exports `KikCodeGeometry`, `KikCodeBadge`, `KikCodeSpec` and the mark types the wrappers read, so only the Swift side was lost. `KikCodeFigureTests` comes back with them, which is also why the loss went unnoticed: `shared-core-tests.yml` runs the package's own suite, and the suite was deleted along with the sources it covers. --- .../Sources/SharedCoreKit/KikCode+Badge.swift | 52 ++++ .../SharedCoreKit/KikCode+Figure.swift | 154 ++++++++++++ .../spm/Sources/SharedCoreKit/SVGPath.swift | 224 ++++++++++++++++++ .../KikCodeFigureTests.swift | 128 ++++++++++ 4 files changed, 558 insertions(+) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift new file mode 100644 index 000000000..58183850c --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift @@ -0,0 +1,52 @@ +import CoreGraphics +import Foundation +import SharedCore + +public extension KikCode { + + /// The Flipcash badge that sits in a code's middle well. + /// + /// The artwork is shared: Kotlin carries it as one SVG path — the same one [KikCodeSvg] + /// embeds in an export and Android's `ic_logo_round_white` draws — and this hands iOS a + /// `CGPath` of it. Drawing this rather than a bundled image is what keeps the code on + /// screen, the exported PNG, the exported SVG, and Android all showing one figure. + enum Badge { + + /// Side of the square viewport ``path`` is authored in. + public static var viewport: CGFloat { CGFloat(KikCodeBadge.shared.VIEWPORT) } + + /// The shared artwork, as the SVG path data ``path`` is parsed from. + static var artwork: String { KikCodeBadge.shared.PATH_DATA } + + /// The badge in viewport coordinates: a disc with the glyph knocked out of it. + /// + /// - Important: fill this with the even-odd rule. Filled non-zero, the glyph fills in + /// solid and the badge is a plain white disc. + public static let path: CGPath = { + // The artwork is a compile-time constant on the Kotlin side, so a parse failure + // is a change to that constant, not bad input — `badgeArtworkParses` guards it. + (try? SVGPath.parse(artwork)) ?? CGMutablePath() + }() + + /// The badge sized and placed for a code laid out in a `dimension`-sided square, in + /// that code's coordinates. + /// + /// Independent of the payload: every code reserves the same middle well. + public static func path(forCodeOfDimension dimension: CGFloat) -> CGPath { + path(radius: KikCode.badgeRadius(forCodeOfDimension: dimension), center: dimension / 2) + } + + /// The badge drawn to `radius` about `center`, on both axes. + static func path(radius: CGFloat, center: CGFloat) -> CGPath { + let scale = (radius * 2) / viewport + var transform = CGAffineTransform(translationX: center - radius, y: center - radius) + .scaledBy(x: scale, y: scale) + return path.copy(using: &transform) ?? path + } + } + + /// Radius of the middle well in a code laid out in a `dimension`-sided square. + static func badgeRadius(forCodeOfDimension dimension: CGFloat) -> CGFloat { + dimension * CGFloat(KikCodeSpec.shared.OUTER_RATIO) * CGFloat(KikCodeSpec.shared.INNER_RING_RATIO) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift new file mode 100644 index 000000000..52c89b862 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift @@ -0,0 +1,154 @@ +import CoreGraphics +import Foundation +import SharedCore + +public extension KikCode { + + /// A code resolved into paths ready to fill — the iOS counterpart to Android's + /// `KikCodePainter` and to [KikCodeSvg], all three fed by the same shared geometry. + struct Figure { + + /// Side of the square the paths are laid out in. + public let dimension: CGFloat + + /// Center of the figure, on both axes. + public let center: CGPoint + + /// Radius of the badge well at the middle. + public let badgeRadius: CGFloat + + /// Diameter of a dot, and equivalently the width a run is stroked at. + public let dotDiameter: CGFloat + + /// Every data mark as one path. Fill it; runs arrive already outlined, so there is + /// nothing left to stroke. + public let marks: CGPath + + /// The badge in the middle well, or `nil` if it was left out. + /// + /// - Important: fill this with the even-odd rule; see ``Badge/path``. + public let badge: CGPath? + } + + /// Largest payload a code can carry. + static var maxPayloadBytes: Int { Int(KikCodeSpec.shared.MAX_PAYLOAD_BYTES) } + + /// Lays `payload` out in a `dimension`-sided square and resolves it to drawable paths. + /// + /// - Throws: ``FigureFailure`` if `dimension` isn't positive, or `payload` is empty or + /// longer than ``maxPayloadBytes``. Checked here rather than left to Kotlin, whose own + /// `require` would raise an exception Swift can't catch. + static func figure( + payload: Data, + dimension: CGFloat, + includeBadge: Bool = true + ) throws -> Figure { + guard dimension > 0 else { throw FigureFailure.invalidDimension(dimension) } + guard !payload.isEmpty else { throw FigureFailure.emptyPayload } + guard payload.count <= maxPayloadBytes else { + throw FigureFailure.payloadTooLong(payload.count, maximum: maxPayloadBytes) + } + + let description = KikCodeGeometry.shared.describe( + payload: payload.kotlinByteArray, + dimension: Double(dimension) + ) + + let center = CGFloat(description.center) + let dotDiameter = CGFloat(description.dotDiameter) + let badgeRadius = CGFloat(description.badgeRadius) + + return Figure( + dimension: CGFloat(description.dimension), + center: CGPoint(x: center, y: center), + badgeRadius: badgeRadius, + dotDiameter: dotDiameter, + marks: marksPath(description, center: center, dotDiameter: dotDiameter), + badge: includeBadge ? Badge.path(radius: badgeRadius, center: center) : nil + ) + } + + /// Why a payload couldn't be laid out. + enum FigureFailure: Error { + case invalidDimension(CGFloat) + case emptyPayload + case payloadTooLong(Int, maximum: Int) + } +} + +// MARK: - Marks - + +private extension KikCode { + + /// Collapses the shared marks into a single fillable path. + /// + /// Runs are centerlines widened to `dotDiameter` with round caps, so a run's ends land + /// exactly where its first and last dots would — the same construction [KikCodeSvg] emits + /// as a stroked group. Outlining them here lets the whole figure be one fill. + static func marksPath( + _ description: KikCodeDescription, + center: CGFloat, + dotDiameter: CGFloat + ) -> CGPath { + let dotRadius = dotDiameter / 2 + let middle = CGPoint(x: center, y: center) + + let dots = CGMutablePath() + let centerlines = CGMutablePath() + + for mark in description.marks { + switch mark { + case let dot as KikCodeMarkDot: + dots.addEllipse(in: CGRect( + x: CGFloat(dot.x) - dotRadius, + y: CGFloat(dot.y) - dotRadius, + width: dotDiameter, + height: dotDiameter + )) + + case let ring as KikCodeMarkRing: + centerlines.addEllipse(in: CGRect( + x: center - CGFloat(ring.radius), + y: center - CGFloat(ring.radius), + width: CGFloat(ring.radius) * 2, + height: CGFloat(ring.radius) * 2 + )) + + case let arc as KikCodeMarkArc: + let radius = CGFloat(arc.radius) + let start = CGFloat(arc.startRadians) + let end = start + CGFloat(arc.sweepRadians) + // Move first: `addArc` would otherwise join this arc to the previous one. + centerlines.move(to: CGPoint( + x: center + radius * cos(start), + y: center + radius * sin(start) + )) + // `clockwise: false` sweeps in the direction of increasing angle, which is + // the direction the shared geometry measures its sweeps in. + centerlines.addArc( + center: middle, + radius: radius, + startAngle: start, + endAngle: end, + clockwise: false + ) + + default: + // The mark set is closed on the Kotlin side; a new case is a version skew. + continue + } + } + + let path = CGMutablePath() + path.addPath(dots) + if !centerlines.isEmpty { + path.addPath(centerlines.copy( + strokingWithWidth: dotDiameter, + lineCap: .round, + lineJoin: .round, + miterLimit: 0 + )) + } + return path.copy() ?? path + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift new file mode 100644 index 000000000..b4ad9c121 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift @@ -0,0 +1,224 @@ +import CoreGraphics +import Foundation + +/// Turns the SVG path syntax the shared artwork is authored in into a `CGPath`. +/// +/// Deliberately not a general SVG implementation. It covers the commands an Android vector +/// drawable emits — `M`, `L`, `H`, `V`, `C`, `S`, `Z`, absolute and relative — and reports +/// anything else rather than guessing, so artwork that outgrows this parser fails a test +/// instead of drawing wrong. +enum SVGPath { + + /// Parses `d`, the value of an SVG `path` element's `d` attribute. + static func parse(_ d: String) throws -> CGPath { + let path = CGMutablePath() + var tokens = Tokenizer(d) + + var command: Character? + var point: CGPoint = .zero + var subpathStart: CGPoint = .zero + // Tracked for `S`, whose first control point mirrors the previous curve's second. + var lastControl: CGPoint? + + func coordinate(_ command: Character) throws -> Double { + guard let value = tokens.nextNumber() else { throw Failure.truncatedCommand(command) } + return value + } + + /// Reads a point, folding in the current point when `command` is relative (lowercase). + func nextPoint(_ command: Character) throws -> CGPoint { + let x = try coordinate(command) + let y = try coordinate(command) + guard command.isLowercase else { return CGPoint(x: x, y: y) } + return CGPoint(x: point.x + x, y: point.y + y) + } + + while let token = tokens.next() { + switch token { + case .command(let character): + command = character + + case .number(let value): + // An omitted command repeats the previous one, except that a repeated + // `moveto` draws lines. Push the number back for the handler below. + guard let previous = command else { throw Failure.leadingNumber } + command = (previous == "M") ? "L" : (previous == "m") ? "l" : previous + tokens.pushBack(value) + } + + guard let current = command else { throw Failure.leadingNumber } + + switch current { + case "M", "m": + point = try nextPoint(current) + path.move(to: point) + subpathStart = point + lastControl = nil + + case "L", "l": + point = try nextPoint(current) + path.addLine(to: point) + lastControl = nil + + case "H", "h": + let x = try coordinate(current) + point = CGPoint(x: current.isLowercase ? point.x + x : x, y: point.y) + path.addLine(to: point) + lastControl = nil + + case "V", "v": + let y = try coordinate(current) + point = CGPoint(x: point.x, y: current.isLowercase ? point.y + y : y) + path.addLine(to: point) + lastControl = nil + + case "C", "c": + let control1 = try nextPoint(current) + let control2 = try nextPoint(current) + point = try nextPoint(current) + path.addCurve(to: point, control1: control1, control2: control2) + lastControl = control2 + + case "S", "s": + // With no preceding curve the first control point coincides with the current + // point, which is what the spec asks for. + let control1 = lastControl.map { + CGPoint(x: 2 * point.x - $0.x, y: 2 * point.y - $0.y) + } ?? point + let control2 = try nextPoint(current) + point = try nextPoint(current) + path.addCurve(to: point, control1: control1, control2: control2) + lastControl = control2 + + case "Z", "z": + path.closeSubpath() + point = subpathStart + lastControl = nil + + default: + throw Failure.unsupportedCommand(current) + } + } + + return path.copy() ?? path + } + + enum Failure: Error { + /// A command this parser doesn't implement — most likely `A`, an elliptical arc. + case unsupportedCommand(Character) + /// A command ran out of coordinates before it had all of them. + case truncatedCommand(Character) + /// Coordinates appeared before any command told us what to do with them. + case leadingNumber + } +} + +// MARK: - Tokenizer - + +private extension SVGPath { + + enum Token { + case command(Character) + case number(Double) + } + + /// Splits path data into commands and numbers. + /// + /// SVG lets separators be dropped wherever the split is unambiguous — `20,-0` and + /// `1.5.5` are each two numbers — so numbers end at the first character that can't + /// continue them rather than at whitespace. + struct Tokenizer { + + private let characters: [Character] + private var index: Int = 0 + private var pushedBack: Double? + + init(_ string: String) { + characters = Array(string) + } + + /// Returns a number read ahead of its turn, so a repeated command can re-read it. + mutating func pushBack(_ value: Double) { + pushedBack = value + } + + mutating func next() -> Token? { + if let value = pushedBack { + pushedBack = nil + return .number(value) + } + while true { + skipSeparators() + guard index < characters.count else { return nil } + + let character = characters[index] + if character.isLetter { + index += 1 + return .command(character) + } + // `scanNumber` steps over anything that starts neither a number nor a + // command, so this retries rather than ending the stream early. + if let value = scanNumber() { return .number(value) } + } + } + + /// Reads the next number, refusing to step over a command to find one. + mutating func nextNumber() -> Double? { + if let value = pushedBack { + pushedBack = nil + return value + } + skipSeparators() + guard index < characters.count, !characters[index].isLetter else { return nil } + return scanNumber() + } + + private mutating func skipSeparators() { + while index < characters.count, + characters[index] == "," || characters[index].isWhitespace { + index += 1 + } + } + + private mutating func scanNumber() -> Double? { + let start = index + + if index < characters.count, characters[index] == "-" || characters[index] == "+" { + index += 1 + } + var sawDot = false + while index < characters.count { + let character = characters[index] + if character.isNumber { + index += 1 + } else if character == ".", !sawDot { + sawDot = true + index += 1 + } else { + break + } + } + // An exponent's own sign belongs to the exponent, not to a following number. + if index < characters.count, characters[index] == "e" || characters[index] == "E" { + var lookahead = index + 1 + if lookahead < characters.count, + characters[lookahead] == "-" || characters[lookahead] == "+" { + lookahead += 1 + } + if lookahead < characters.count, characters[lookahead].isNumber { + index = lookahead + while index < characters.count, characters[index].isNumber { + index += 1 + } + } + } + + guard index > start else { + // Not a number and not a letter: skip it so a stray character can't spin. + index += 1 + return nil + } + return Double(String(characters[start..= 0) + #expect(bounds.minY >= 0) + #expect(bounds.maxX <= dimension) + #expect(bounds.maxY <= dimension) + #expect(bounds.width > dimension * 0.9) + } + + @Test func figureLeavesTheWellClearForTheBadge() throws { + let figure = try KikCode.figure(payload: payload, dimension: 512) + + // Nothing is drawn where the badge goes, which is what lets the badge be opaque. + #expect(!figure.marks.contains(figure.center)) + #expect(figure.badge?.contains(figure.center, using: .evenOdd) == false) // the glyph + #expect(figure.badge?.contains( + CGPoint(x: figure.center.x - figure.badgeRadius * 0.9, y: figure.center.y), + using: .evenOdd + ) == true) + } + + @Test func figureCanLeaveTheBadgeOut() throws { + let figure = try KikCode.figure(payload: payload, dimension: 512, includeBadge: false) + + #expect(figure.badge == nil) + } + + /// The figure and the SVG are two renderings of one description; if they disagree about + /// where the badge goes, an exported code and the code on screen show different logos. + @Test func figurePlacesTheBadgeWhereTheSvgDoes() throws { + let dimension: CGFloat = 1024 + let figure = try KikCode.figure(payload: payload, dimension: dimension) + let svg = KikCode.svg(payload: payload, dimension: Double(dimension)) + + let transform = try #require( + svg.split(separator: "\n").first { $0.contains("fill-rule=\"evenodd\"") } + ) + let numbers = transform + .split(whereSeparator: { "() ".contains($0) }) + .compactMap { Double($0) } + let translate = try #require(numbers.first) + let scale = try #require(numbers.last) + + let bounds = figure.badge?.boundingBox + #expect(abs((bounds?.minX ?? 0) - CGFloat(translate)) < 0.01) + #expect(abs((bounds?.width ?? 0) - CGFloat(scale) * 61.665) < 0.02) + } +}