# Conflicts:
#	CLAUDE.md
#	MODULE.bazel.lock
#	submodules/TgVoipWebrtc/tgcalls
#	third-party/td/build-td-bazel.sh
#	third-party/webrtc/webrtc
This commit is contained in:
Isaac 2026-04-30 22:20:45 +02:00
commit 8dc06f48ce
1570 changed files with 133567 additions and 34276 deletions

17
third-party/Swift2D/BUILD vendored Normal file
View file

@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "Swift2D",
module_name = "Swift2D",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-suppress-warnings",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View file

@ -0,0 +1,19 @@
#if canImport(CoreGraphics)
import CoreGraphics
#else
import Foundation
#endif
public extension Point {
/// Initialize a `Point` using the provided `CGPoint` values.
init(_ point: CGPoint) {
self.init(x: point.x, y: point.y)
}
}
public extension CGPoint {
/// Initialize a `CPPoint` using the provided `Point` values.
init(_ point: Point) {
self.init(x: point.x, y: point.y)
}
}

82
third-party/Swift2D/Sources/Point.swift vendored Normal file
View file

@ -0,0 +1,82 @@
/// The representation of a single point in a two-dimensional plane.
public struct Point: Hashable, Codable, Sendable, CustomStringConvertible {
public static let zero: Point = Point(x: 0, y: 0)
public static let nan: Point = Point(x: Double.nan, y: Double.nan)
public static let infinite: Point = Point(x: -Double.greatestFiniteMagnitude / 2, y: -Double.greatestFiniteMagnitude / 2)
public static let null: Point = Point(x: Double.infinity, y: Double.infinity)
public let x: Double
public let y: Double
public init(x: Double = 0.0, y: Double = 0.0) {
self.x = x
self.y = y
}
public init(x: Float, y: Float) {
self.x = Double(x)
self.y = Double(y)
}
public init(x: Int, y: Int) {
self.x = Double(x)
self.y = Double(y)
}
public var description: String { "Point(x: \(x), y: \(y))" }
public var isZero: Bool { self == .zero }
public var isNaN: Bool { self == .nan }
public var isInfinite: Bool { self == .infinite }
public var isNull: Bool { self == .null }
/// Create a new instance maintaining the `y` value while using the provided `x` value.
public func x(_ value: Double) -> Point {
Point(x: value, y: y)
}
/// Create a new instance maintaining the `x` value while using the provided `y` value.
public func y(_ value: Double) -> Point {
Point(x: x, y: value)
}
/// The _mirror_ of the instance, rotated 180° around the provided `point`
/// in a two-dimensional plane.
///
/// - parameters:
/// - point: The anchor to use in calculating the reflection.
public func reflecting(around point: Point) -> Point {
let x = (2 * point.x) - x
let y = (2 * point.y) - y
return Point(x: x, y: y)
}
public static func == (lhs: Point, rhs: Point) -> Bool {
if lhs.x.isNaN, rhs.x.isNaN, lhs.y.isNaN, rhs.y.isNaN {
return true
}
if lhs.x.isInfinite, rhs.x.isInfinite, lhs.y.isInfinite, rhs.y.isInfinite {
return true
}
return lhs.x == rhs.x && lhs.y == rhs.y
}
}
public extension Point {
@available(*, deprecated, renamed: "x(_:)")
func with(x value: Double) -> Point {
x(value)
}
@available(*, deprecated, renamed: "y(_:)")
func with(y value: Double) -> Point {
y(value)
}
@available(*, deprecated, renamed: "reflecting(around:)")
func reflection(using point: Point) -> Point {
reflecting(around: point)
}
}

View file

@ -0,0 +1,32 @@
#if canImport(CoreGraphics)
import CoreGraphics
#else
import Foundation
#endif
public extension Rect {
/// Initialize a `Rect` using the provided `CGRect` values.
init(_ rect: CGRect) {
self.init(
origin: Point(rect.origin),
size: Size(rect.size)
)
}
@available(*, deprecated, renamed: "CGRect(_:)", message: "Use CGRect initializer directly")
var cgRect: CGRect {
CGRect(self)
}
}
public extension CGRect {
/// Initialize a `CGRect` using the provided `Rect` values.
init(_ rect: Rect) {
self.init(
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
)
}
}

251
third-party/Swift2D/Sources/Rect.swift vendored Normal file
View file

@ -0,0 +1,251 @@
/// The location and dimensions of a rectangle.
public struct Rect: Hashable, Codable, Sendable, CustomStringConvertible {
public static let zero: Rect = Rect(origin: .zero, size: .zero)
public static let nan: Rect = Rect(origin: .nan, size: .nan)
public static let infinite: Rect = Rect(origin: .infinite, size: .infinite)
public static let null: Rect = Rect(origin: .null, size: .zero)
/// A point that specifies the coordinates of the rectangles origin.
public let origin: Point
/// A size that specifies the height and width of the rectangle.
public let size: Size
public init(origin: Point = .zero, size: Size = .zero) {
self.origin = origin
self.size = size
}
public init(x: Double, y: Double, width: Double, height: Double) {
origin = Point(x: x, y: y)
size = Size(width: width, height: height)
}
public init(x: Float, y: Float, width: Float, height: Float) {
origin = Point(x: x, y: y)
size = Size(width: width, height: height)
}
public init(x: Int, y: Int, width: Int, height: Int) {
origin = Point(x: x, y: y)
size = Size(width: width, height: height)
}
public var description: String { "Rect(origin: \(origin), size: \(size))" }
public var isZero: Bool { self == .zero }
public var isNaN: Bool { self == .nan }
public var isInfinite: Bool { self == .infinite }
public var isNull: Bool { origin == .infinite || origin == .null }
public var isEmpty: Bool { isNull || width == 0.0 || height == 0.0 }
/// The x-coordinate of the rectangle origin
public var x: Double { origin.x }
/// The y-coordinate of the rectangle origin
public var y: Double { origin.y }
/// The width of the rectangle.
public var width: Double { size.width }
/// The height of the rectangle.
public var height: Double { size.height }
/// The middle of the `Rect` both horizontally and vertically.
public var center: Point { Point(x: midX, y: midY) }
/// The smallest value for the x-coordinate of the rectangle.
public var minX: Double {
if size.width < 0.0 {
return origin.x - abs(size.width)
}
return origin.x
}
/// The x-coordinate that establishes the center of a rectangle.
public var midX: Double { minX + size.widthRadius }
/// The largest value of the x-coordinate for the rectangle.
public var maxX: Double {
if size.width < 0.0 {
return origin.x
}
return origin.x + size.width
}
/// The smallest value for the y-coordinate of the rectangle.
public var minY: Double {
if size.height < 0.0 {
return origin.y - abs(size.height)
}
return origin.y
}
/// The y-coordinate that establishes the center of the rectangle.
public var midY: Double { minY + size.heightRadius }
/// The largest value for the y-coordinate of the rectangle.
public var maxY: Double {
if size.height < 0.0 {
return origin.y
}
return origin.y + size.height
}
/// Returns a rectangle with a positive width and height.
public var standardized: Rect {
guard !isNull else {
return .null
}
return Rect(x: minX, y: minY, width: abs(width), height: abs(height))
}
public func origin(_ value: Point) -> Rect {
Rect(origin: value, size: size)
}
public func size(_ value: Size) -> Rect {
Rect(origin: origin, size: value)
}
public func contains(_ point: Point) -> Bool {
guard !isNull else {
return false
}
guard !isEmpty else {
return false
}
return (minX ..< maxX).contains(point.x) && (minY ..< maxY).contains(point.y)
}
public func contains(_ rect: Rect) -> Bool {
union(rect) == self
}
public func intersects(_ rect: Rect) -> Bool {
!intersection(rect).isNull
}
public func intersection(_ rect: Rect) -> Rect {
guard !isNull else {
return .null
}
guard !rect.isNull else {
return .null
}
let r1 = standardized
let r1spanH = r1.minX ... r1.maxX
let r1spanV = r1.minY ... r1.maxY
let r2 = rect.standardized
let r2spanH = r2.minX ... r2.maxX
let r2spanV = r2.minY ... r2.maxY
guard r1spanH.overlaps(r2spanH), r2spanV.overlaps(r2spanV) else {
return .null
}
let overlapH = r1spanH.clamped(to: r2spanH)
let width: Double = if overlapH == r1spanH {
r1.width
} else if overlapH == r2spanH {
r2.width
} else {
overlapH.upperBound - overlapH.lowerBound
}
let overlapV = r1spanV.clamped(to: r2spanV)
let height: Double = if overlapV == r1spanV {
r1.height
} else if overlapV == r2spanV {
r2.height
} else {
overlapV.upperBound - overlapV.lowerBound
}
return Rect(x: overlapH.lowerBound, y: overlapV.lowerBound, width: width, height: height)
}
public func union(_ rect: Rect) -> Rect {
guard !isNull else {
return rect
}
guard !rect.isNull else {
return self
}
let r1 = standardized
let r2 = rect.standardized
let minX = min(r1.minX, r2.minX)
let maxX = max(r1.maxX, r2.maxX)
let minY = min(r1.minY, r2.minY)
let maxY = max(r1.maxY, r2.maxY)
return Rect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
}
public func offsetBy(dx: Double, dy: Double) -> Rect {
guard !isNull else {
return self
}
let rect = standardized
return Rect(
origin: Point(
x: rect.origin.x + dx,
y: rect.origin.y + dy
),
size: rect.size
)
}
public func insetBy(dx: Double, dy: Double) -> Rect {
guard !isNull else {
return self
}
let normalized = standardized
let rect = Rect(
origin: Point(
x: normalized.x + dx,
y: normalized.y + dy
),
size: Size(
width: normalized.width - (dx * 2),
height: normalized.height - (dy * 2)
)
)
guard rect.size.width >= 0, rect.size.height >= 0 else {
return .null
}
return rect
}
public func expandedBy(dx: Double, dy: Double) -> Rect {
insetBy(dx: -dx, dy: -dy)
}
}
public extension Rect {
@available(*, deprecated, renamed: "origin(_:)")
func with(origin value: Point) -> Rect {
Rect(origin: value, size: size)
}
@available(*, deprecated, renamed: "size(_:)")
func with(size value: Size) -> Rect {
Rect(origin: origin, size: value)
}
}

View file

@ -0,0 +1,19 @@
#if canImport(CoreGraphics)
import CoreGraphics
#else
import Foundation
#endif
public extension Size {
/// Initialize a `Size` using the provided `CGSize` values.
init(_ size: CGSize) {
self.init(width: size.width, height: size.height)
}
}
public extension CGSize {
/// Initialize a `CGSize` using the provided `Size` values.
init(_ size: Size) {
self.init(width: size.width, height: size.height)
}
}

87
third-party/Swift2D/Sources/Size.swift vendored Normal file
View file

@ -0,0 +1,87 @@
/// A representation of two-dimensional width and height values.
public struct Size: Hashable, Codable, Sendable, CustomStringConvertible {
public static let zero: Size = Size(width: 0.0, height: 0.0)
public static let nan: Size = Size(width: Double.nan, height: Double.nan)
public static let infinite: Size = Size(width: Double.greatestFiniteMagnitude, height: Double.greatestFiniteMagnitude)
public let width: Double
public let height: Double
public init(width: Double = 0.0, height: Double = 0.0) {
self.width = width
self.height = height
}
public init(width: Float, height: Float) {
self.width = Double(width)
self.height = Double(height)
}
public init(width: Int, height: Int) {
self.width = Double(width)
self.height = Double(height)
}
public var description: String { "Size(width: \(width), height: \(height))" }
public var isZero: Bool { self == .zero }
public var isNaN: Bool { self == .nan }
public var isInfinite: Bool { self == .infinite }
/// The radius defined by the `width` dimension.
public var widthRadius: Double { abs(width) / 2.0 }
/// The radius defined by the `height` dimension.
public var heightRadius: Double { abs(height) / 2.0 }
/// The largest of the width and height radii.
public var maxRadius: Double { max(widthRadius, heightRadius) }
/// The smallest of the width and height radii.
public var minRadius: Double { min(widthRadius, heightRadius) }
/// The `Point` at which the `widthRadius` & `heightRadius` intersect.
public var center: Point { Point(x: widthRadius, y: heightRadius) }
/// Create a new instance maintaining the `height` value while using the provided `width` value.
public func width(_ value: Double) -> Size {
Size(width: value, height: height)
}
/// Create a new instance maintaining the `width` value while using the provided `height` value.
public func height(_ value: Double) -> Size {
Size(width: width, height: value)
}
public static func == (lhs: Size, rhs: Size) -> Bool {
if lhs.width.isNaN, rhs.width.isNaN, lhs.height.isNaN, rhs.height.isNaN {
return true
}
return lhs.width == rhs.width && lhs.height == rhs.height
}
}
public extension Size {
@available(*, deprecated, renamed: "widthRadius")
var xRadius: Double { abs(width) / 2.0 }
@available(*, deprecated, renamed: "heightRadius")
var yRadius: Double { abs(height) / 2.0 }
@available(*, deprecated, renamed: "widthRadius")
var horizontalRadius: Double { xRadius }
@available(*, deprecated, renamed: "heightRadius")
var verticalRadius: Double { yRadius }
@available(*, deprecated, renamed: "width(_:)")
func with(width value: Double) -> Size {
width(value)
}
@available(*, deprecated, renamed: "height(_:)")
func with(height value: Double) -> Size {
height(value)
}
}

17
third-party/SwiftColor/BUILD vendored Normal file
View file

@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SwiftColor",
module_name = "SwiftColor",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-suppress-warnings",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View file

@ -0,0 +1,20 @@
@propertyWrapper
public struct Clamping<Value: Comparable> {
var value: Value
let range: ClosedRange<Value>
public init(wrappedValue value: Value, _ range: ClosedRange<Value>) {
precondition(range.contains(value))
self.value = value
self.range = range
}
public var wrappedValue: Value {
get {
value
}
set(newValue) {
value = min(max(range.lowerBound, newValue), range.upperBound)
}
}
}

View file

@ -0,0 +1,3 @@
public enum ColorSpace: Equatable, Sendable {
case rgba
}

View file

@ -0,0 +1,24 @@
import Foundation
#if canImport(AppKit) && !targetEnvironment(macCatalyst)
import AppKit
public extension Pigment {
/// Initialize a `Pigment` using an `NSColor`.
init(_ color: NSColor) {
red = color.redComponent
green = color.greenComponent
blue = color.blueComponent
alpha = color.alphaComponent
}
var nsColor: NSColor {
NSColor(red: red, green: green, blue: blue, alpha: alpha)
}
}
public extension NSColor {
var pigment: Pigment {
Pigment(self)
}
}
#endif

View file

@ -0,0 +1,39 @@
import Foundation
#if canImport(CoreGraphics)
import CoreGraphics
public extension Pigment {
/// Initialize a `Pigment` using an `CGColor`.
init?(_ color: CGColor) {
let components = color.components ?? []
switch components.count {
case 2:
// Monochrome/Grayscale
// TODO: Express this in 'Color'.
red = 0.0
green = 0.0
blue = 0.0
alpha = components[1]
case 4:
// RGB
red = components[0]
green = components[1]
blue = components[2]
alpha = components[3]
default:
return nil
}
}
}
public extension CGColor {
static func make(_ color: Pigment) -> CGColor {
if color.red == 0.0, color.green == 0.0, color.blue == 0.0, color.alpha == 0.0 {
// Return the CG color equivalent of `UIColor.clear`.
return CGColor(genericGrayGamma2_2Gray: 0.0, alpha: 0.0)
}
return CGColor(srgbRed: color.red, green: color.green, blue: color.blue, alpha: color.alpha)
}
}
#endif

View file

@ -0,0 +1,95 @@
import Foundation
public extension Pigment {
/// Initialize a `Pigment` using `Float` values leaning towards the 'Red' spectrum.
///
/// - parameters
/// - red: A value in the range of 0.0 to 1.0 representing the **red** percent.
/// - green: A value in the range of 0.0 to 1.0 representing the **green** percent.
/// - blue: A value in the range of 0.0 to 1.0 representing the **blue** percent.
/// - alpha: A value in the range of 0.0 to 1.0 representing the **alpha/opacity/transparency** percent.
init(
red: Float,
green: Float = 0.0,
blue: Float = 0.0,
alpha: Float = 1.0
) {
self.red = Double(red)
self.green = Double(green)
self.blue = Double(blue)
self.alpha = Double(alpha)
}
/// Initialize a `Pigment` using `Float` values leaning towards the 'Green' spectrum.
///
/// - parameters:
/// - green: A value in the range of 0.0 to 1.0 representing the **green** percent.
/// - blue: A value in the range of 0.0 to 1.0 representing the **blue** percent.
/// - red: A value in the range of 0.0 to 1.0 representing the **red** percent.
/// - alpha: A value in the range of 0.0 to 1.0 representing the **alpha/opacity/transparency** percent.
init(
green: Float,
blue: Float = 0.0,
red: Float = 0.0,
alpha: Float = 1.0
) {
self.red = Double(red)
self.green = Double(green)
self.blue = Double(blue)
self.alpha = Double(alpha)
}
/// Initialize a `Pigment` using `Float` values leaning towards the 'Blue' spectrum.
///
/// - parameter:
/// - blue: A value in the range of 0.0 to 1.0 representing the **blue** percent.
/// - red: A value in the range of 0.0 to 1.0 representing the **red** percent.
/// - green: A value in the range of 0.0 to 1.0 representing the **green** percent.
/// - alpha: A value in the range of 0.0 to 1.0 representing the **alpha/opacity/transparency** percent.
init(
blue: Float,
red: Float = 0.0,
green: Float = 0.0,
alpha: Float = 1.0
) {
self.red = Double(red)
self.green = Double(green)
self.blue = Double(blue)
self.alpha = Double(alpha)
}
/// Initialize a `Pigment` using variadic `Float` values.
///
/// All _values_ should be expressed in the range of 0.0 to 1.0.
///
/// - parameters:
/// - values: A number of `Float` which are mapped to **red**, **green**, **blue** in that order.
/// - alpha: Amount of _opacity/transparency_ to apply.
init(
_ values: Float...,
alpha: Float
) {
if values.count > 0 {
red = Double(values[0].clamped(to: 0 ... 1))
} else {
red = 0.0
}
if values.count > 1 {
green = Double(values[1].clamped(to: 0 ... 1))
} else {
green = 0.0
}
if values.count > 2 {
blue = Double(values[2].clamped(to: 0 ... 1))
} else {
blue = 0.0
}
self.alpha = Double(alpha.clamped(to: 0 ... 1))
}
}
extension Float {
func clamped(to range: ClosedRange<Float>) -> Float {
min(max(range.lowerBound, self), range.upperBound)
}
}

View file

@ -0,0 +1,145 @@
import Foundation
public extension Pigment {
/// Initializes a `Pigment` with an `Int` in the expected format of **0x000**.
///
/// Used as a short-hand. If the hex 0x123 is provided, it is interpreted as 0x112233.
init(
hex3 hex: Int,
@Clamping(0 ... 1) alpha: Double = 1.0
) {
let values = Self.hex3(hex: hex, alpha: alpha)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
}
/// Shorthand **0x0000** initializer similar to `init(hex3:alpha:)` where
/// the last digit represent the alpha component.
init(
hex4 hex: Int
) {
let values = Self.hex4(hex: hex)
red = values.red
green = values.green
blue = values.blue
alpha = values.alpha
}
/// Initializes with a standard format hex representation of color in the form of **0x1E2C3D**.
init(
hex6 hex: Int,
@Clamping(0 ... 1) alpha: Double = 1.0
) {
let values = Self.hex6(hex: hex, alpha: alpha)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
}
#if !os(watchOS)
/// Extended form of `init(hex6:alpha:)` expecting **0x112233FF**, that uses the last
/// bits for the alpha component.
init(
hex8 hex: Int
) {
let values = Self.hex8(hex: hex)
red = values.red
green = values.green
blue = values.blue
alpha = values.alpha
}
#endif
/// Initializes a `Pigment` with an `Int` representation of an RGB(a) Hex Value
///
/// This initializer will do its best to interpret the intentions of what is provided.
/// **YOUR RESULTS WILL VARY**, and it's best to use one of the `init(hex?:)` initializers.
///
/// - Parameter hex: Hex value
/// - Parameter alpha: The opacity value of the color object
init(
_ hex: Int,
alpha: Double? = nil
) {
#if !os(watchOS)
if hex > 0xFFFFFF {
let values = Self.hex8(hex: hex)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
return
}
#endif
if hex > 0xFFFF {
let values = Self.hex6(hex: hex, alpha: alpha ?? 1.0)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
} else if hex > 0xFFF {
let values = Self.hex4(hex: hex)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
} else {
let values = Self.hex3(hex: hex, alpha: alpha ?? 1.0)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
}
}
}
extension Pigment {
static func hex3(
hex: Int,
@Clamping(0 ... 1) alpha: Double = 1.0
) -> (red: Double, green: Double, blue: Double, alpha: Double) {
let red = Double(duplicateBits((hex & 0xF00) >> 8)) / 255.0
let green = Double(duplicateBits((hex & 0x0F0) >> 4)) / 255.0
let blue = Double(duplicateBits((hex & 0x00F) >> 0)) / 255.0
return (red, green, blue, alpha)
}
static func hex4(
hex: Int
) -> (red: Double, green: Double, blue: Double, alpha: Double) {
let red = Double(duplicateBits((hex & 0xF000) >> 12)) / 255.0
let green = Double(duplicateBits((hex & 0x0F00) >> 8)) / 255.0
let blue = Double(duplicateBits((hex & 0x00F0) >> 4)) / 255.0
let alpha = Double(duplicateBits((hex & 0x000F) >> 0)) / 255.0
return (red, green, blue, alpha)
}
static func hex6(
hex: Int,
@Clamping(0 ... 1) alpha: Double = 1.0
) -> (red: Double, green: Double, blue: Double, alpha: Double) {
let red = Double((hex & 0xFF0000) >> 16) / 255.0
let green = Double((hex & 0x00FF00) >> 8) / 255.0
let blue = Double((hex & 0x0000FF) >> 0) / 255.0
return (red, green, blue, alpha)
}
#if !os(watchOS)
static func hex8(
hex: Int
) -> (red: Double, green: Double, blue: Double, alpha: Double) {
let red = Double((hex & 0xFF00_0000) >> 24) / 255.0
let green = Double((hex & 0x00FF_0000) >> 16) / 255.0
let blue = Double((hex & 0x0000_FF00) >> 8) / 255.0
let alpha = Double((hex & 0x0000_00FF) >> 0) / 255.0
return (red, green, blue, alpha)
}
#endif
static func duplicateBits(_ value: Int) -> Int {
(value << 4) + value
}
}

View file

@ -0,0 +1,89 @@
import Foundation
public extension Pigment {
/// Initialize a `Pigment` using `Int` values leaning towards the 'Red' spectrum.
///
/// - parameters:
/// - red: A value in the range of 0 to 255 representing the **red** bits
/// - green: A value in the range of 0 to 255 representing the **green** bits
/// - blue: A value in the range of 0 to 255 representing the **blue** bits
/// - alpha: A value in the range of 0.0 to 1.0 representing the **alpha/opacity/transparency** percent.
init(
@Clamping(0 ... 255) red: Int,
@Clamping(0 ... 255) green: Int = 0,
@Clamping(0 ... 255) blue: Int = 0,
@Clamping(0.0 ... 1.0) alpha: Double = 1.0
) {
self.red = Double(red) / 255.0
self.green = Double(green) / 255.0
self.blue = Double(blue) / 255.0
self.alpha = alpha
}
/// Initialize a `Pigment` using `Int` values leaning towards the 'Green' spectrum.
///
/// - parameters:
/// - green: A value in the range of 0 to 255 representing the **green** bits
/// - blue: A value in the range of 0 to 255 representing the **blue** bits
/// - red: A value in the range of 0 to 255 representing the **red** bits
/// - alpha: A value in the range of 0.0 to 1.0 representing the **alpha/opacity/transparency** percent.
init(
@Clamping(0 ... 255) green: Int,
@Clamping(0 ... 255) blue: Int = 0,
@Clamping(0 ... 255) red: Int = 0,
@Clamping(0.0 ... 1.0) alpha: Double = 1.0
) {
self.red = Double(red) / 255.0
self.green = Double(green) / 255.0
self.blue = Double(blue) / 255.0
self.alpha = alpha
}
/// Initialize a `Pigment` using `Int` values leaning towards the 'Blue' spectrum.
///
/// - parameters:
/// - blue: A value in the range of 0 to 255 representing the **blue** bits
/// - red: A value in the range of 0 to 255 representing the **red** bits
/// - green: A value in the range of 0 to 255 representing the **green** bits
/// - alpha: A value in the range of 0.0 to 1.0 representing the **alpha/opacity/transparency** percent.
init(
@Clamping(0 ... 255) blue: Int,
@Clamping(0 ... 255) red: Int = 0,
@Clamping(0 ... 255) green: Int = 0,
@Clamping(0.0 ... 1.0) alpha: Double = 1.0
) {
self.red = Double(red) / 255.0
self.green = Double(green) / 255.0
self.blue = Double(blue) / 255.0
self.alpha = alpha
}
/// Initialize a `Pigment` using variadic `Int` values.
///
/// All _values_ should be expressed in the range of 0 to 255.
///
/// - parameters:
/// - values: A number of `Int` which are mapped to **red**, **green**, **blue** in that order.
/// - alpha: A value in the range of 0.0 to 1.0 representing the **alpha/opacity/transparency** percent.
init(
_ values: Int...,
@Clamping(0 ... 1) alpha: Double
) {
if values.count > 0 {
red = Double(values[0]) / 255.0
} else {
red = 0.0
}
if values.count > 1 {
green = Double(values[1]) / 255.0
} else {
green = 0.0
}
if values.count > 2 {
blue = Double(values[2]) / 255.0
} else {
blue = 0.0
}
self.alpha = alpha
}
}

View file

@ -0,0 +1,470 @@
import Foundation
public extension Pigment {
@available(*, deprecated, renamed: "Name")
typealias Keyword = Name
enum Name: String, CaseIterable {
case aliceBlue
case antiqueWhite
case aqua
case aquamarine
case azure
case beige
case bisque
case black
case blanchedAlmond
case blue
case blueViolet
case brown
case burlywood
case cadetBlue
case chartreuse
case chocolate
case coral
case cornflowerBlue
case cornsilk
case crimson
case cyan
case darkBlue
case darkCyan
case darkGoldenrod
case darkGray
case darkGreen
case darkGrey
case darkKhaki
case darkMagenta
case darkOliveGreen
case darkOrange
case darkOrchid
case darkRed
case darkSalmon
case darkSeagreen
case darkSlateBlue
case darkSlateGray
case darkSlateGrey
case darkTurquoise
case darkViolet
case deepPink
case deepSkyblue
case dimGray
case dimGrey
case dodgerBlue
case firebrick
case floralWhite
case forestGreen
case fuchsia
case gainsboro
case ghostWhite
case gold
case goldenrod
case gray
case green
case greenYellow
case grey
case honeydew
case hotPink
case indianRed
case indigo
case ivory
case khaki
case lavender
case lavenderBlush
case lawnGreen
case lemonChiffon
case lightBlue
case lightCoral
case lightCyan
case lightGoldenrodYellow
case lightGray
case lightGreen
case lightGrey
case lightPink
case lightSalmon
case lightSeagreen
case lightSkyBlue
case lightSlateGray
case lightSlateGrey
case lightSteelBlue
case lightYellow
case lime
case limeGreen
case linen
case magenta
case maroon
case mediumAquamarine
case mediumBlue
case mediumOrchid
case mediumPurple
case mediumSeagreen
case mediumSlateBlue
case mediumSpringGreen
case mediumTurquoise
case mediumVioletRed
case midnightBlue
case mintCream
case mistyRose
case moccasin
case navajoWhite
case navy
case oldLace
case olive
case oliveDrab
case orange
case orangeRed
case orchid
case paleGoldenrod
case paleGreen
case paleTurquoise
case paleVioletRed
case papayaWhip
case peachPuff
case peru
case pink
case plum
case powderBlue
case purple
case red
case rosyBrown
case royalBlue
case saddleBrown
case salmon
case sandyBrown
case seagreen
case seashell
case sienna
case silver
case skyBlue
case slateBlue
case slateGray
case slateGrey
case snow
case springGreen
case steelBlue
case tan
case teal
case thistle
case tomato
case turquoise
case violet
case wheat
case white
case whitesmoke
case yellow
case yellowGreen
public var rgb: (red: Int, green: Int, blue: Int) {
switch self {
case .aliceBlue: (240, 248, 255)
case .antiqueWhite: (250, 235, 215)
case .aqua: (0, 255, 255)
case .aquamarine: (127, 255, 212)
case .azure: (240, 255, 255)
case .beige: (245, 245, 220)
case .bisque: (255, 228, 196)
case .black: (0, 0, 0)
case .blanchedAlmond: (255, 235, 205)
case .blue: (0, 0, 255)
case .blueViolet: (138, 43, 226)
case .brown: (165, 42, 42)
case .burlywood: (222, 184, 135)
case .cadetBlue: (95, 158, 160)
case .chartreuse: (127, 255, 0)
case .chocolate: (210, 105, 30)
case .coral: (255, 127, 80)
case .cornflowerBlue: (100, 149, 237)
case .cornsilk: (255, 248, 220)
case .crimson: (220, 20, 60)
case .cyan: (0, 255, 255)
case .darkBlue: (0, 0, 139)
case .darkCyan: (0, 139, 139)
case .darkGoldenrod: (184, 134, 11)
case .darkGray: (169, 169, 169)
case .darkGreen: (0, 100, 0)
case .darkGrey: (169, 169, 169)
case .darkKhaki: (189, 183, 107)
case .darkMagenta: (139, 0, 139)
case .darkOliveGreen: (85, 107, 47)
case .darkOrange: (255, 140, 0)
case .darkOrchid: (153, 50, 204)
case .darkRed: (139, 0, 0)
case .darkSalmon: (233, 150, 122)
case .darkSeagreen: (143, 188, 143)
case .darkSlateBlue: (72, 61, 139)
case .darkSlateGray: (47, 79, 79)
case .darkSlateGrey: (47, 79, 79)
case .darkTurquoise: (0, 206, 209)
case .darkViolet: (148, 0, 211)
case .deepPink: (255, 20, 147)
case .deepSkyblue: (0, 191, 255)
case .dimGray: (105, 105, 105)
case .dimGrey: (105, 105, 105)
case .dodgerBlue: (30, 144, 255)
case .firebrick: (178, 34, 34)
case .floralWhite: (255, 250, 240)
case .forestGreen: (34, 139, 34)
case .fuchsia: (255, 0, 255)
case .gainsboro: (220, 220, 220)
case .ghostWhite: (248, 248, 255)
case .gold: (255, 215, 0)
case .goldenrod: (218, 165, 32)
case .gray: (128, 128, 128)
case .green: (0, 128, 0)
case .greenYellow: (173, 255, 47)
case .grey: (128, 128, 128)
case .honeydew: (240, 255, 240)
case .hotPink: (255, 105, 180)
case .indianRed: (205, 92, 92)
case .indigo: (75, 0, 130)
case .ivory: (255, 255, 240)
case .khaki: (240, 230, 140)
case .lavender: (230, 230, 250)
case .lavenderBlush: (255, 240, 245)
case .lawnGreen: (124, 252, 0)
case .lemonChiffon: (255, 250, 205)
case .lightBlue: (173, 216, 230)
case .lightCoral: (240, 128, 128)
case .lightCyan: (224, 255, 255)
case .lightGoldenrodYellow: (250, 250, 210)
case .lightGray: (211, 211, 211)
case .lightGreen: (144, 238, 144)
case .lightGrey: (211, 211, 211)
case .lightPink: (255, 182, 193)
case .lightSalmon: (255, 160, 122)
case .lightSeagreen: (32, 178, 170)
case .lightSkyBlue: (135, 206, 250)
case .lightSlateGray: (119, 136, 153)
case .lightSlateGrey: (119, 136, 153)
case .lightSteelBlue: (176, 196, 222)
case .lightYellow: (255, 255, 224)
case .lime: (0, 255, 0)
case .limeGreen: (50, 205, 50)
case .linen: (250, 240, 230)
case .magenta: (255, 0, 255)
case .maroon: (128, 0, 0)
case .mediumAquamarine: (102, 205, 170)
case .mediumBlue: (0, 0, 205)
case .mediumOrchid: (186, 85, 211)
case .mediumPurple: (147, 112, 219)
case .mediumSeagreen: (60, 179, 113)
case .mediumSlateBlue: (123, 104, 238)
case .mediumSpringGreen: (0, 250, 154)
case .mediumTurquoise: (72, 209, 204)
case .mediumVioletRed: (199, 21, 133)
case .midnightBlue: (25, 25, 112)
case .mintCream: (245, 255, 250)
case .mistyRose: (255, 228, 225)
case .moccasin: (255, 228, 181)
case .navajoWhite: (255, 222, 173)
case .navy: (0, 0, 128)
case .oldLace: (253, 245, 230)
case .olive: (128, 128, 0)
case .oliveDrab: (107, 142, 35)
case .orange: (255, 165, 0)
case .orangeRed: (255, 69, 0)
case .orchid: (218, 112, 214)
case .paleGoldenrod: (238, 232, 170)
case .paleGreen: (152, 251, 152)
case .paleTurquoise: (175, 238, 238)
case .paleVioletRed: (219, 112, 147)
case .papayaWhip: (255, 239, 213)
case .peachPuff: (255, 218, 185)
case .peru: (205, 133, 63)
case .pink: (255, 192, 203)
case .plum: (221, 160, 221)
case .powderBlue: (176, 224, 230)
case .purple: (128, 0, 128)
case .red: (255, 0, 0)
case .rosyBrown: (188, 143, 143)
case .royalBlue: (65, 105, 225)
case .saddleBrown: (139, 69, 19)
case .salmon: (250, 128, 114)
case .sandyBrown: (244, 164, 96)
case .seagreen: (46, 139, 87)
case .seashell: (255, 245, 238)
case .sienna: (160, 82, 45)
case .silver: (192, 192, 192)
case .skyBlue: (135, 206, 235)
case .slateBlue: (106, 90, 205)
case .slateGray: (112, 128, 144)
case .slateGrey: (112, 128, 144)
case .snow: (255, 250, 250)
case .springGreen: (0, 255, 127)
case .steelBlue: (70, 130, 180)
case .tan: (210, 180, 140)
case .teal: (0, 128, 128)
case .thistle: (216, 191, 216)
case .tomato: (255, 99, 71)
case .turquoise: (64, 224, 208)
case .violet: (238, 130, 238)
case .wheat: (245, 222, 179)
case .white: (255, 255, 255)
case .whitesmoke: (245, 245, 245)
case .yellow: (255, 255, 0)
case .yellowGreen: (154, 205, 50)
}
}
public var pigment: Pigment {
switch self {
case .aliceBlue: Pigment(240, 248, 255, alpha: 1.0)
case .antiqueWhite: Pigment(250, 235, 215, alpha: 1.0)
case .aqua: Pigment(0, 255, 255, alpha: 1.0)
case .aquamarine: Pigment(127, 255, 212, alpha: 1.0)
case .azure: Pigment(240, 255, 255, alpha: 1.0)
case .beige: Pigment(245, 245, 220, alpha: 1.0)
case .bisque: Pigment(255, 228, 196, alpha: 1.0)
case .black: Pigment(0, 0, 0, alpha: 1.0)
case .blanchedAlmond: Pigment(255, 235, 205, alpha: 1.0)
case .blue: Pigment(0, 0, 255, alpha: 1.0)
case .blueViolet: Pigment(138, 43, 226, alpha: 1.0)
case .brown: Pigment(165, 42, 42, alpha: 1.0)
case .burlywood: Pigment(222, 184, 135, alpha: 1.0)
case .cadetBlue: Pigment(95, 158, 160, alpha: 1.0)
case .chartreuse: Pigment(127, 255, 0, alpha: 1.0)
case .chocolate: Pigment(210, 105, 30, alpha: 1.0)
case .coral: Pigment(255, 127, 80, alpha: 1.0)
case .cornflowerBlue: Pigment(100, 149, 237, alpha: 1.0)
case .cornsilk: Pigment(255, 248, 220, alpha: 1.0)
case .crimson: Pigment(220, 20, 60, alpha: 1.0)
case .cyan: Pigment(0, 255, 255, alpha: 1.0)
case .darkBlue: Pigment(0, 0, 139, alpha: 1.0)
case .darkCyan: Pigment(0, 139, 139, alpha: 1.0)
case .darkGoldenrod: Pigment(184, 134, 11, alpha: 1.0)
case .darkGray: Pigment(169, 169, 169, alpha: 1.0)
case .darkGreen: Pigment(0, 100, 0, alpha: 1.0)
case .darkGrey: Pigment(169, 169, 169, alpha: 1.0)
case .darkKhaki: Pigment(189, 183, 107, alpha: 1.0)
case .darkMagenta: Pigment(139, 0, 139, alpha: 1.0)
case .darkOliveGreen: Pigment(85, 107, 47, alpha: 1.0)
case .darkOrange: Pigment(255, 140, 0, alpha: 1.0)
case .darkOrchid: Pigment(153, 50, 204, alpha: 1.0)
case .darkRed: Pigment(139, 0, 0, alpha: 1.0)
case .darkSalmon: Pigment(233, 150, 122, alpha: 1.0)
case .darkSeagreen: Pigment(143, 188, 143, alpha: 1.0)
case .darkSlateBlue: Pigment(72, 61, 139, alpha: 1.0)
case .darkSlateGray: Pigment(47, 79, 79, alpha: 1.0)
case .darkSlateGrey: Pigment(47, 79, 79, alpha: 1.0)
case .darkTurquoise: Pigment(0, 206, 209, alpha: 1.0)
case .darkViolet: Pigment(148, 0, 211, alpha: 1.0)
case .deepPink: Pigment(255, 20, 147, alpha: 1.0)
case .deepSkyblue: Pigment(0, 191, 255, alpha: 1.0)
case .dimGray: Pigment(105, 105, 105, alpha: 1.0)
case .dimGrey: Pigment(105, 105, 105, alpha: 1.0)
case .dodgerBlue: Pigment(30, 144, 255, alpha: 1.0)
case .firebrick: Pigment(178, 34, 34, alpha: 1.0)
case .floralWhite: Pigment(255, 250, 240, alpha: 1.0)
case .forestGreen: Pigment(34, 139, 34, alpha: 1.0)
case .fuchsia: Pigment(255, 0, 255, alpha: 1.0)
case .gainsboro: Pigment(220, 220, 220, alpha: 1.0)
case .ghostWhite: Pigment(248, 248, 255, alpha: 1.0)
case .gold: Pigment(255, 215, 0, alpha: 1.0)
case .goldenrod: Pigment(218, 165, 32, alpha: 1.0)
case .gray: Pigment(128, 128, 128, alpha: 1.0)
case .green: Pigment(0, 128, 0, alpha: 1.0)
case .greenYellow: Pigment(173, 255, 47, alpha: 1.0)
case .grey: Pigment(128, 128, 128, alpha: 1.0)
case .honeydew: Pigment(240, 255, 240, alpha: 1.0)
case .hotPink: Pigment(255, 105, 180, alpha: 1.0)
case .indianRed: Pigment(205, 92, 92, alpha: 1.0)
case .indigo: Pigment(75, 0, 130, alpha: 1.0)
case .ivory: Pigment(255, 255, 240, alpha: 1.0)
case .khaki: Pigment(240, 230, 140, alpha: 1.0)
case .lavender: Pigment(230, 230, 250, alpha: 1.0)
case .lavenderBlush: Pigment(255, 240, 245, alpha: 1.0)
case .lawnGreen: Pigment(124, 252, 0, alpha: 1.0)
case .lemonChiffon: Pigment(255, 250, 205, alpha: 1.0)
case .lightBlue: Pigment(173, 216, 230, alpha: 1.0)
case .lightCoral: Pigment(240, 128, 128, alpha: 1.0)
case .lightCyan: Pigment(224, 255, 255, alpha: 1.0)
case .lightGoldenrodYellow: Pigment(250, 250, 210, alpha: 1.0)
case .lightGray: Pigment(211, 211, 211, alpha: 1.0)
case .lightGreen: Pigment(144, 238, 144, alpha: 1.0)
case .lightGrey: Pigment(211, 211, 211, alpha: 1.0)
case .lightPink: Pigment(255, 182, 193, alpha: 1.0)
case .lightSalmon: Pigment(255, 160, 122, alpha: 1.0)
case .lightSeagreen: Pigment(32, 178, 170, alpha: 1.0)
case .lightSkyBlue: Pigment(135, 206, 250, alpha: 1.0)
case .lightSlateGray: Pigment(119, 136, 153, alpha: 1.0)
case .lightSlateGrey: Pigment(119, 136, 153, alpha: 1.0)
case .lightSteelBlue: Pigment(176, 196, 222, alpha: 1.0)
case .lightYellow: Pigment(255, 255, 224, alpha: 1.0)
case .lime: Pigment(0, 255, 0, alpha: 1.0)
case .limeGreen: Pigment(50, 205, 50, alpha: 1.0)
case .linen: Pigment(250, 240, 230, alpha: 1.0)
case .magenta: Pigment(255, 0, 255, alpha: 1.0)
case .maroon: Pigment(128, 0, 0, alpha: 1.0)
case .mediumAquamarine: Pigment(102, 205, 170, alpha: 1.0)
case .mediumBlue: Pigment(0, 0, 205, alpha: 1.0)
case .mediumOrchid: Pigment(186, 85, 211, alpha: 1.0)
case .mediumPurple: Pigment(147, 112, 219, alpha: 1.0)
case .mediumSeagreen: Pigment(60, 179, 113, alpha: 1.0)
case .mediumSlateBlue: Pigment(123, 104, 238, alpha: 1.0)
case .mediumSpringGreen: Pigment(0, 250, 154, alpha: 1.0)
case .mediumTurquoise: Pigment(72, 209, 204, alpha: 1.0)
case .mediumVioletRed: Pigment(199, 21, 133, alpha: 1.0)
case .midnightBlue: Pigment(25, 25, 112, alpha: 1.0)
case .mintCream: Pigment(245, 255, 250, alpha: 1.0)
case .mistyRose: Pigment(255, 228, 225, alpha: 1.0)
case .moccasin: Pigment(255, 228, 181, alpha: 1.0)
case .navajoWhite: Pigment(255, 222, 173, alpha: 1.0)
case .navy: Pigment(0, 0, 128, alpha: 1.0)
case .oldLace: Pigment(253, 245, 230, alpha: 1.0)
case .olive: Pigment(128, 128, 0, alpha: 1.0)
case .oliveDrab: Pigment(107, 142, 35, alpha: 1.0)
case .orange: Pigment(255, 165, 0, alpha: 1.0)
case .orangeRed: Pigment(255, 69, 0, alpha: 1.0)
case .orchid: Pigment(218, 112, 214, alpha: 1.0)
case .paleGoldenrod: Pigment(238, 232, 170, alpha: 1.0)
case .paleGreen: Pigment(152, 251, 152, alpha: 1.0)
case .paleTurquoise: Pigment(175, 238, 238, alpha: 1.0)
case .paleVioletRed: Pigment(219, 112, 147, alpha: 1.0)
case .papayaWhip: Pigment(255, 239, 213, alpha: 1.0)
case .peachPuff: Pigment(255, 218, 185, alpha: 1.0)
case .peru: Pigment(205, 133, 63, alpha: 1.0)
case .pink: Pigment(255, 192, 203, alpha: 1.0)
case .plum: Pigment(221, 160, 221, alpha: 1.0)
case .powderBlue: Pigment(176, 224, 230, alpha: 1.0)
case .purple: Pigment(128, 0, 128, alpha: 1.0)
case .red: Pigment(255, 0, 0, alpha: 1.0)
case .rosyBrown: Pigment(188, 143, 143, alpha: 1.0)
case .royalBlue: Pigment(65, 105, 225, alpha: 1.0)
case .saddleBrown: Pigment(139, 69, 19, alpha: 1.0)
case .salmon: Pigment(250, 128, 114, alpha: 1.0)
case .sandyBrown: Pigment(244, 164, 96, alpha: 1.0)
case .seagreen: Pigment(46, 139, 87, alpha: 1.0)
case .seashell: Pigment(255, 245, 238, alpha: 1.0)
case .sienna: Pigment(160, 82, 45, alpha: 1.0)
case .silver: Pigment(192, 192, 192, alpha: 1.0)
case .skyBlue: Pigment(135, 206, 235, alpha: 1.0)
case .slateBlue: Pigment(106, 90, 205, alpha: 1.0)
case .slateGray: Pigment(112, 128, 144, alpha: 1.0)
case .slateGrey: Pigment(112, 128, 144, alpha: 1.0)
case .snow: Pigment(255, 250, 250, alpha: 1.0)
case .springGreen: Pigment(0, 255, 127, alpha: 1.0)
case .steelBlue: Pigment(70, 130, 180, alpha: 1.0)
case .tan: Pigment(210, 180, 140, alpha: 1.0)
case .teal: Pigment(0, 128, 128, alpha: 1.0)
case .thistle: Pigment(216, 191, 216, alpha: 1.0)
case .tomato: Pigment(255, 99, 71, alpha: 1.0)
case .turquoise: Pigment(64, 224, 208, alpha: 1.0)
case .violet: Pigment(238, 130, 238, alpha: 1.0)
case .wheat: Pigment(245, 222, 179, alpha: 1.0)
case .white: Pigment(255, 255, 255, alpha: 1.0)
case .whitesmoke: Pigment(245, 245, 245, alpha: 1.0)
case .yellow: Pigment(255, 255, 0, alpha: 1.0)
case .yellowGreen: Pigment(154, 205, 50, alpha: 1.0)
}
}
}
init(
_ name: Name,
@Clamping(0 ... 1) alpha: Double = 1.0
) {
red = Double(name.rgb.red) / 255.0
green = Double(name.rgb.green) / 255.0
blue = Double(name.rgb.blue) / 255.0
self.alpha = alpha
}
}

View file

@ -0,0 +1,76 @@
import Foundation
public extension Pigment {
init(_ value: String, alpha: Double = 1.0) {
if let keyword = Name.allCases.first(where: { $0.rawValue.caseInsensitiveCompare(value) == .orderedSame }) {
red = keyword.pigment.red
green = keyword.pigment.green
blue = keyword.pigment.blue
self.alpha = alpha
return
}
if ExtendedKeyword.allCases.contains(where: { $0.rawValue.caseInsensitiveCompare(value) == .orderedSame }) {
red = 1.0
green = 1.0
blue = 1.0
self.alpha = alpha
return
}
var hex = value
if hex.hasPrefix("#") {
hex = String(hex.dropFirst())
}
guard let hexValue = Int(hex, radix: 16) else {
red = 1.0
green = 1.0
blue = 1.0
self.alpha = alpha
return
}
switch hex.count {
case 3:
let values = Self.hex3(hex: hexValue)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
case 4:
let values = Self.hex4(hex: hexValue)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
case 6:
let values = Self.hex6(hex: hexValue, alpha: alpha)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
#if !os(watchOS)
case 8:
let values = Self.hex8(hex: hexValue)
red = values.red
green = values.green
blue = values.blue
self.alpha = values.alpha
#endif
default:
red = 1.0
green = 1.0
blue = 1.0
self.alpha = alpha
}
}
}
private extension Pigment {
enum ExtendedKeyword: String, CaseIterable {
case none
case clear
case transparent
}
}

View file

@ -0,0 +1,9 @@
#if canImport(SwiftUI)
import SwiftUI
public extension Pigment {
var color: Color {
Color(red: red, green: green, blue: blue, opacity: alpha)
}
}
#endif

View file

@ -0,0 +1,37 @@
import Foundation
#if canImport(UIKit)
import UIKit
public extension Pigment {
init(_ color: UIColor) {
var redComponent: CGFloat = 1.0
var greenComponent: CGFloat = 1.0
var blueComponent: CGFloat = 1.0
var alphaComponent: CGFloat = 1.0
guard color.getRed(&redComponent, green: &greenComponent, blue: &blueComponent, alpha: &alphaComponent) else {
// TODO: Fail Initializer? Default Colors?
red = redComponent
green = greenComponent
blue = blueComponent
alpha = alphaComponent
return
}
red = redComponent
green = greenComponent
blue = blueComponent
alpha = alphaComponent
}
var uiColor: UIColor {
UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
}
public extension UIColor {
var pigment: Pigment {
Pigment(self)
}
}
#endif

View file

@ -0,0 +1,53 @@
/// A platform agnostic representation of Color
///
/// The components - red, green, blue, & alpha - are maintained as a floating-point representation.
/// Each value can range from 0.0 to 1.0 (e.g. 0 to 100 percent).
///
/// 'Pure White' is represented by values all equal to **1.0**.
public struct Pigment: Sendable {
public let colorSpace: ColorSpace = .rgba
public let red: Double
public let green: Double
public let blue: Double
public let alpha: Double
public init(
@Clamping(0 ... 1) red: Double = 1.0,
@Clamping(0 ... 1) green: Double = 1.0,
@Clamping(0 ... 1) blue: Double = 1.0,
@Clamping(0 ... 1) alpha: Double = 1.0
) {
self.red = red
self.green = green
self.blue = blue
self.alpha = alpha
}
}
extension Pigment: CustomStringConvertible {
public var description: String {
String(format: "Pigment(red: %.4f, green: %.4f, blue: %.4f, alpha: %.2f)", red, green, blue, alpha)
}
}
extension Pigment: Equatable {
public static func == (lhs: Pigment, rhs: Pigment) -> Bool {
guard lhs.red == rhs.red else {
return false
}
guard lhs.green == rhs.green else {
return false
}
guard lhs.blue == rhs.blue else {
return false
}
guard lhs.alpha == rhs.alpha else {
return false
}
guard lhs.colorSpace == rhs.colorSpace else {
return false
}
return true
}
}

18
third-party/SwiftMath/BUILD vendored Normal file
View file

@ -0,0 +1,18 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SwiftMath",
module_name = "SwiftMath",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/AppBundle",
],
visibility = [
"//visibility:public",
],
)

21
third-party/SwiftMath/LICENSE vendored Executable file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Computer Inspirations
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

714
third-party/SwiftMath/README.md vendored Normal file
View file

@ -0,0 +1,714 @@
# SwiftMath
`SwiftMath` provides a full Swift implementation of [iosMath](https://travis-ci.org/kostub/iosMath)
for displaying beautifully rendered math equations in iOS and MacOS applications. It typesets formulae written
using LaTeX in a `UILabel` equivalent class. It uses the same typesetting rules as LaTeX and
so the equations are rendered exactly as LaTeX would render them.
Please also check out [SwiftMathDemo](https://github.com/mgriebling/SwiftMathDemo.git) for examples of how to use `SwiftMath`
from SwiftUI.
`SwiftMath` is similar to [MathJax](https://www.mathjax.org) or
[KaTeX](https://github.com/Khan/KaTeX) for the web but for native iOS or MacOS
applications without having to use a `UIWebView` and Javascript. More
importantly, it is significantly faster than using a `UIWebView`.
`SwiftMath` is a Swift translation of the latest `iosMath` v0.9.5 release but includes bug fixes
and enhancements like a new \lbar (lambda bar) character and cyrillic alphabet support.
The original `iosMath` test suites have also been translated to Swift and run without errors.
Note: Error test conditions are ignored to avoid tagging everything with silly `throw`s.
Please let me know of any bugs or bug fixes that you find.
`SwiftMath` prepackages everything needed for direct access via the Swift Package Manager.
## Examples
Here are screenshots of some formulae that were rendered with this library:
```LaTeX
x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}
```
![Quadratic Formula](img/quadratic-light.png#gh-light-mode-only)
![Quadratic Formula](img/quadratic-dark.png#gh-dark-mode-only)
```LaTeX
f(x) = \int\limits_{-\infty}^\infty\!\hat f(\xi)\,e^{2 \pi i \xi x}\,\mathrm{d}\xi
```
![Calculus](img/calculus-light.png#gh-light-mode-only)
![Calculus](img/calculus-dark.png#gh-dark-mode-only)
```LaTeX
\frac{1}{n}\sum_{i=1}^{n}x_i \geq \sqrt[n]{\prod_{i=1}^{n}x_i}
```
![AM-GM](img/amgm-light.png#gh-light-mode-only)
![AM-GM](img/amgm-dark.png#gh-dark-mode-only)
```LaTex
\frac{1}{\left(\sqrt{\phi \sqrt{5}}-\phi\\right) e^{\frac25 \pi}}
= 1+\frac{e^{-2\pi}} {1 +\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}} {1+\frac{e^{-8\pi}} {1+\cdots} } } }
```
![Ramanujan Identity](img/ramanujan-light.png#gh-light-mode-only)
![Ramanujan Identity](img/ramanujan-dark.png#gh-dark-mode-only)
More examples are included in [EXAMPLES](EXAMPLES.md)
## Fonts
Here are previews of the included fonts:
![](img/FontsPreview.png#gh-dark-mode-only)
![](img/FontsPreviewLight.png#gh-light-mode-only)
## Requirements
`SwiftMath` works on iOS 11+ or MacOS 12+. It depends
on the following Apple frameworks:
* Foundation.framework
* CoreGraphics.framework
* QuartzCore.framework
* CoreText.framework
Additionally for iOS it requires:
* UIKit.framework
Additionally for MacOS it requires:
* AppKit.framework
## Installation
### Swift Package
`SwiftMath` is available from [SwiftMath](https://github.com/mgriebling/SwiftMath.git).
To use it in your code, just add the https://github.com/mgriebling/SwiftMath.git path to
XCode's package manager.
## Usage
The library provides a class `MTMathUILabel` which is a `UIView` that
supports rendering math equations. To display an equation simply create
an `MTMathUILabel` as follows:
```swift
import SwiftMath
let label = MTMathUILabel()
label.latex = "x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}"
```
Adding `MTMathUILabel` as a sub-view of your `UIView` will render the
quadratic formula example shown above.
The following code creates a SwiftUI component called `MathView` encapsulating the MTMathUILabel:
```swift
import SwiftUI
import SwiftMath
struct MathView: UIViewRepresentable {
var equation: String
var font: MathFont = .latinModernFont
var textAlignment: MTTextAlignment = .center
var fontSize: CGFloat = 30
var labelMode: MTMathUILabelMode = .text
var insets: MTEdgeInsets = MTEdgeInsets()
func makeUIView(context: Context) -> MTMathUILabel {
let view = MTMathUILabel()
view.setContentHuggingPriority(.required, for: .vertical)
view.setContentCompressionResistancePriority(.required, for: .vertical)
return view
}
func updateUIView(_ view: MTMathUILabel, context: Context) {
view.latex = equation
let font = MTFontManager().font(withName: font.rawValue, size: fontSize)
font?.fallbackFont = UIFont.systemFont(ofSize: fontSize)
view.font = font
view.textAlignment = textAlignment
view.labelMode = labelMode
view.textColor = MTColor(Color.primary)
view.contentInsets = insets
view.invalidateIntrinsicContentSize()
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: MTMathUILabel, context: Context) -> CGSize? {
// Enable line wrapping by passing proposed width to the label
if let width = proposal.width, width.isFinite, width > 0 {
uiView.preferredMaxLayoutWidth = width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}
return nil
}
}
```
For code that works with SwiftUI running natively under MacOS use the following:
```swift
import SwiftUI
import SwiftMath
struct MathView: NSViewRepresentable {
var equation: String
var font: MathFont = .latinModernFont
var textAlignment: MTTextAlignment = .center
var fontSize: CGFloat = 30
var labelMode: MTMathUILabelMode = .text
var insets: MTEdgeInsets = MTEdgeInsets()
func makeNSView(context: Context) -> MTMathUILabel {
let view = MTMathUILabel()
view.setContentHuggingPriority(.required, for: .vertical)
view.setContentCompressionResistancePriority(.required, for: .vertical)
return view
}
func updateNSView(_ view: MTMathUILabel, context: Context) {
view.latex = equation
let font = MTFontManager().font(withName: font.rawValue, size: fontSize)
font?.fallbackFont = NSFont.systemFont(ofSize: fontSize)
view.font = font
view.textAlignment = textAlignment
view.labelMode = labelMode
view.textColor = MTColor(Color.primary)
view.contentInsets = insets
view.invalidateIntrinsicContentSize()
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView: MTMathUILabel, context: Context) -> CGSize? {
// Enable line wrapping by passing proposed width to the label
if let width = proposal.width, width.isFinite, width > 0 {
nsView.preferredMaxLayoutWidth = width
let size = nsView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}
return nil
}
}
```
### Automatic Line Wrapping
`SwiftMath` supports automatic line wrapping (multiline display) for mathematical content. The implementation uses **interatom line breaking** which breaks equations at atom boundaries (between mathematical elements) rather than within them, preserving the semantic structure of the mathematics.
#### Using Line Wrapping with UIKit/AppKit
For direct `MTMathUILabel` usage, set the `preferredMaxLayoutWidth` property:
```swift
let label = MTMathUILabel()
label.latex = "\\text{Calculer le discriminant }\\Delta=b^{2}-4ac\\text{ avec }a=1\\text{, }b=-1\\text{, }c=-5"
label.font = MTFontManager.fontManager.defaultFont
// Enable line wrapping by setting a maximum width
label.preferredMaxLayoutWidth = 235
```
You can also use `sizeThatFits` to calculate the size with a width constraint:
```swift
let constrainedSize = label.sizeThatFits(CGSize(width: 235, height: .greatestFiniteMagnitude))
```
#### Using Line Wrapping with SwiftUI
The `MathView` examples above include `sizeThatFits()` which automatically enables line wrapping when SwiftUI proposes a width constraint. No additional configuration is needed:
```swift
VStack(alignment: .leading, spacing: 8) {
MathView(
equation: "\\text{Calculer le discriminant }\\Delta=b^{2}-4ac\\text{ avec }a=1\\text{, }b=-1\\text{, }c=-5",
fontSize: 17,
labelMode: .text
)
}
.frame(maxWidth: 235) // The equation will break across multiple lines
```
#### Line Wrapping Behavior and Capabilities
SwiftMath implements **two complementary line breaking mechanisms**:
##### 1. Interatom Line Breaking (Primary)
Breaks equations **between atoms** (mathematical elements) when content exceeds the width constraint. This is the preferred method as it maintains semantic integrity.
##### 2. Universal Line Breaking (Fallback)
For very long text within single atoms, breaks at Unicode word boundaries using Core Text with number protection (prevents splitting numbers like "3.14").
See `MULTILINE_IMPLEMENTATION_NOTES.md` for implementation details and recent changes.
#### Fully Supported Cases
These atom types work perfectly with interatom line breaking:
**✅ Variables and ordinary text:**
```swift
label.latex = "a b c d e f g h i j k l m n o p"
label.preferredMaxLayoutWidth = 150
// Breaks between individual variables at natural boundaries
```
**✅ Binary operators (+, -, ×, ÷):**
```swift
label.latex = "a+b+c+d+e+f+g+h"
label.preferredMaxLayoutWidth = 100
// Breaks cleanly: "a+b+c+d+"
// "e+f+g+h"
```
**✅ Relations (=, <, >, ≤, ≥, etc.):**
```swift
label.latex = "a=1, b=2, c=3, d=4, e=5"
label.preferredMaxLayoutWidth = 120
// Breaks after commas and operators
```
**✅ Mixed text and simple math:**
```swift
label.latex = "\\text{Calculer }\\Delta=b^{2}-4ac\\text{ avec }a=1\\text{, }b=-1"
label.preferredMaxLayoutWidth = 200
// Breaks between text and math atoms naturally
```
**✅ Punctuation (commas, periods):**
```swift
label.latex = "\\text{First, second, third, fourth, fifth}"
label.preferredMaxLayoutWidth = 150
// Breaks at commas and spaces
```
**✅ Brackets and parentheses (simple):**
```swift
label.latex = "(a+b)+(c+d)+(e+f)"
label.preferredMaxLayoutWidth = 120
// Breaks between parenthesized groups
```
**✅ Greek letters and symbols:**
```swift
label.latex = "\\alpha+\\beta+\\gamma+\\delta+\\epsilon+\\zeta"
label.preferredMaxLayoutWidth = 150
// Breaks between Greek letters
```
**✅ Fractions (NEW!):**
```swift
label.latex = "a+\\frac{1}{2}+b+\\frac{3}{4}+c"
label.preferredMaxLayoutWidth = 150
// Fractions stay inline if they fit, break to new line only when needed
// Example: "a + ½ + b" stays on one line if it fits
```
**✅ Radicals/Square roots (NEW!):**
```swift
label.latex = "x+\\sqrt{2}+y+\\sqrt{3}+z"
label.preferredMaxLayoutWidth = 150
// Radicals stay inline if they fit, break to new line only when needed
// Example: "x + √2 + y" stays on one line if it fits
```
**✅ Mixed fractions and radicals (NEW!):**
```swift
label.latex = "a+\\frac{1}{2}+\\sqrt{3}+b"
label.preferredMaxLayoutWidth = 200
// Breaks between complex mathematical elements
```
#### Limited Support Cases
These cases work but with some constraints:
**✅ Large operators (NEW!):**
```swift
label.latex = "a + \\sum_{i=1}^{n} x_i + \\int_{0}^{1} f(x)dx + b"
label.preferredMaxLayoutWidth = 200
// Large operators stay inline when they fit!
// Includes height checking for operators with limits
```
**✅ Delimited expressions (NEW!):**
```swift
label.latex = "(a+b) + \\left(\\frac{c}{d}\\right) + e"
label.preferredMaxLayoutWidth = 200
// Delimiters stay inline when they fit!
// Inner content respects width constraints and wraps naturally
```
**✅ Colored expressions (NEW!):**
```swift
label.latex = "a + \\color{red}{b + c + d} + e"
label.preferredMaxLayoutWidth = 200
// Colored sections stay inline when they fit!
// Inner content respects width constraints and wraps properly
```
**✅ Matrices/tables (NEW!):**
```swift
label.latex = "A = \\begin{pmatrix} 1 & 2 \\end{pmatrix} + B"
label.preferredMaxLayoutWidth = 200
// Small matrices stay inline when they fit!
```
**✅ Atoms with superscripts/subscripts (IMPROVED!):**
```swift
label.latex = "a^{2}+b^{2}+c^{2}+d^{2}+e^{2}"
label.preferredMaxLayoutWidth = 150
// Now works with width-based breaking!
// Scripted atoms participate in smart line breaking decisions
```
**✅ Math accents:**
```swift
label.latex = "\\hat{x} + \\tilde{y} + \\bar{z} + \\vec{w}"
label.preferredMaxLayoutWidth = 150
// Common accents (\hat, \tilde, \bar, \vec) work well
// Vector arrows (\overrightarrow, etc.) supported with stretching
```
**⚠️ Very long single text atoms:**
```swift
label.latex = "\\text{This is an extremely long piece of text within a single text command}"
label.preferredMaxLayoutWidth = 200
// Uses Unicode word boundary breaking with Core Text
// Protects numbers from being split (e.g., "3.14" stays together)
```
**Note**: Breaks within the text atom rather than between atoms, which is expected behavior for very long continuous text.
#### Best Practices
**DO:**
- Use interatom breaking for simple equations with operators and relations
- Use for mixed text and math where you want natural breaks
- Use for long sequences of variables, numbers, and operators
- Set appropriate `preferredMaxLayoutWidth` based on your layout needs
**DON'T:**
- Expect natural breaking in expressions with large operators (∑, ∫, etc. - not yet optimized)
- Expect natural breaking in expressions with \left...\right delimiters (not yet optimized)
- Use extremely narrow widths (less than ~80pt) which may cause poor breaks
#### Examples
**Excellent use case (discriminant formula):**
```swift
label.latex = "\\text{Calculer le discriminant }\\Delta=b^{2}-4ac\\text{ avec }a=1\\text{, }b=-1\\text{, }c=-5"
label.preferredMaxLayoutWidth = 235
// ✅ Breaks naturally at good points between atoms
```
**Good use case (simple arithmetic):**
```swift
label.latex = "5+10+15+20+25+30+35+40+45+50"
label.preferredMaxLayoutWidth = 150
// ✅ Breaks between operators cleanly
```
**Excellent use case (fractions inline - NEW!):**
```swift
label.latex = "a+\\frac{1}{2}+b+\\frac{3}{4}+c"
label.preferredMaxLayoutWidth = 200
// ✅ Fractions stay inline when they fit!
// Breaks: "a + ½ + b" on line 1, "+ ¾ + c" on line 2
```
**Excellent use case (radicals inline - NEW!):**
```swift
label.latex = "x+\\sqrt{2}+y+\\sqrt{3}+z"
label.preferredMaxLayoutWidth = 150
// ✅ Radicals stay inline when they fit!
// Example: "x + √2 + y" on line 1, "+ √3 + z" on line 2
```
**Alternative for complex expressions:**
```swift
// Instead of trying to break this:
label.latex = "x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}"
// Consider it as a single display equation without width constraint
label.preferredMaxLayoutWidth = 0 // No breaking
```
#### Technical Details
- **Line spacing**: Dynamic line height based on actual content (tall fractions get more space, regular content stays compact)
- **Breaking algorithm**: Greedy with look-ahead and break quality scoring - prefers breaking after operators over other positions
- **Width calculation**: Includes inter-element spacing according to TeX spacing rules
- **Number protection**: Numbers in patterns like "3.14", "1,000", etc. are kept intact
- **Supports locales**: English, French, Swiss number formats
- **Advanced features**: Tokenization infrastructure with phase-based processing (preprocessing → tokenization → line fitting → display generation)
For complete implementation details including recent improvements (dynamic line height, break quality scoring, early exit optimization), see [MULTILINE_IMPLEMENTATION_NOTES.md](MULTILINE_IMPLEMENTATION_NOTES.md).
### Included Features
This is a list of formula types that the library currently supports:
* Simple algebraic equations
* Fractions and continued fractions (including `\frac`, `\dfrac`, `\tfrac`, `\cfrac`)
* Exponents and subscripts
* Trigonometric formulae (including inverse hyperbolic: `\arcsinh`, `\arccosh`, etc.)
* Square roots and n-th roots
* Calculus symbols - limits, derivatives, integrals (including `\iint`, `\iiint`, `\iiiint`)
* Big operators (e.g. product, sum)
* Big delimiters (using `\left` and `\right`)
* Manual delimiter sizing (`\big`, `\Big`, `\bigg`, `\Bigg` and variants)
* Greek alphabet
* Bold Greek symbols (`\boldsymbol`)
* Combinatorics (`\binom`, `\choose` etc.)
* Geometry symbols (e.g. angle, congruence etc.)
* Ratios, proportions, percentages
* Math spacing
* Overline and underline
* Math accents (including `\hat`, `\tilde`, `\bar`, `\vec`, `\dot`, `\ddot`, etc.)
* Vector arrows (`\vec`, `\overrightarrow`, `\overleftarrow`, `\overleftrightarrow` with automatic stretching)
* Wide accents (`\widehat`, `\widetilde`)
* Operator names (`\operatorname{name}`)
* Matrices (including `\smallmatrix` and starred variants like `pmatrix*` with alignment)
* Multi-line subscripts and limits (`\substack`)
* Equation alignment
* Change bold, roman, caligraphic and other font styles (`\bf`, `\text`, etc.)
* Style commands (`\displaystyle`, `\textstyle`)
* Custom operators (`\operatorname`, `\operatorname*`)
* Dirac notation (`\bra`, `\ket`, `\braket`)
* Most commonly used math symbols
* Colors for both text and background
* **Inline and display math mode delimiters** (see below)
* **Automatic line wrapping** (see Automatic Line Wrapping section)
### LaTeX Math Delimiters
`SwiftMath` now supports all standard LaTeX math delimiters for both inline and display modes. The parser automatically detects and handles these delimiters:
#### Inline Math (Text Style)
Use these delimiters for inline math within text, which renders more compactly:
```swift
// Dollar signs (TeX style)
label.latex = "$E = mc^2$"
// Parentheses (LaTeX style)
label.latex = "\\(\\sum_{i=1}^{n} x_i\\)"
// Cases environment in inline mode
label.latex = "\\(\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}\\)"
```
#### Display Math (Display Style)
Use these delimiters for standalone equations with larger operators and limits:
```swift
// Double dollar signs (TeX style)
label.latex = "$$\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}$$"
// Square brackets (LaTeX style)
label.latex = "\\[\\sum_{k=1}^{n} k^2 = \\frac{n(n+1)(2n+1)}{6}\\]"
// Equation environment
label.latex = "\\begin{equation} x^2 + y^2 = z^2 \\end{equation}"
// Cases environment in display mode
label.latex = "\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}"
```
**Note:** The difference between inline and display modes:
- **Inline mode** (`$...$` or `\(...\)`) renders compactly, suitable for math within text
- **Display mode** (`$$...$$`, `\[...\]`, or environments) renders with larger operators and limits positioned above/below
All delimiters are automatically stripped during parsing, and the math mode is set appropriately. No additional configuration is needed!
#### Backward Compatibility
Equations without explicit delimiters continue to work as before, defaulting to display mode:
```swift
label.latex = "x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}" // Works as always
```
#### Programmatic API
For advanced use cases where you need to parse LaTeX and determine the detected style programmatically, use the `buildWithStyle` method:
```swift
// Parse LaTeX and get both the math list and detected style
let (mathList, style) = MTMathListBuilder.buildWithStyle(fromString: "\\[x^2 + y^2 = z^2\\]")
// style will be .display for \[...\] or $$...$$
// style will be .text for \(...\) or $...$
// Create a display with the detected style
if let mathList = mathList {
let display = MTTypesetter.createLineForMathList(mathList, font: myFont, style: style)
// Use the display for rendering
}
```
This is particularly useful when building custom renderers or when you need to respect the user's choice of delimiter style.
Note: SwiftMath only supports the commands in LaTeX's math mode. There is
also no language support for other than west European langugages and some
Cyrillic characters. There would be two ways to support more languages:
1) Find a math font compatible with `SwiftMath` that contains all the glyphs
for that language.
2) Add support to `SwiftMath` for standard Unicode fonts that contain all
langauge glyphs.
Of these two, the first is much easier. However, if you want a challenge,
try to tackle the second option.
### Example
The [SwiftMathDemo](https://github.com/mgriebling/SwiftMathDemo) is a SwiftUI version
of the Objective-C demo included in `iosMath` that uses `SwiftMath` as a Swift package dependency.
### Advanced configuration
`MTMathUILabel` supports some advanced configuration options:
##### Math mode
You can change the mode of the `MTMathUILabel` between Display Mode
(equivalent to `$$` or `\[` in LaTeX) and Text Mode (equivalent to `$`
or `\(` in LaTeX). The default style is Display. To switch to Text
simply:
```swift
label.labelMode = .text
```
##### Text Alignment
The default alignment of the equations is left. This can be changed to
center or right as follows:
```swift
label.textAlignment = .center
```
##### Font size
The default font-size is 30pt. You can change it as follows:
```swift
label.fontSize = 25
```
##### Font
The default font is *Latin Modern Math*. This can be changed as:
```swift
label.font = MTFontManager.fontmanager.termesFont(withSize:20)
```
This project has 12 fonts bundled with it, but you can use any OTF math
font. A python script is included that generates the `.plist` files
required for an `.otf` font to work with `SwiftMath`. If you generate
(and test) any other fonts please contribute them back to this project for
others to benefit.
Note: The `KpMath-Light`, `KpMath-Sans`, `Asana` fonts currently incorrectly
render very large radicals. It appears that the font files do
not properly define the offsets required to typeset these glyphs. If
anyone can fix this, it would be greatly appreciated.
##### Text Color
The default color of the rendered equation is black. You can change
it to any other color as follows:
```swift
label.textColor = .red
```
It is also possible to set different colors for different parts of the
equation. Just access the `displayList` field and set the `textColor`
of the underlying displays of which you want to change the color.
##### Fallback Font for Unicode Text
By default, math fonts only support a limited set of characters (Latin, Greek, common math symbols).
To display other Unicode characters like Chinese, Japanese, Korean, emoji, or other scripts in `\text{}`
commands, you can configure a fallback font:
```swift
let mathFont = MTFontManager().font(withName: MathFont.latinModernFont.rawValue, size: 30)
// Set a fallback font for unsupported characters (defaults to nil)
#if os(iOS) || os(visionOS)
let systemFont = UIFont.systemFont(ofSize: 30)
mathFont?.fallbackFont = CTFontCreateWithName(systemFont.fontName as CFString, 30, nil)
#elseif os(macOS)
let systemFont = NSFont.systemFont(ofSize: 30)
mathFont?.fallbackFont = CTFontCreateWithName(systemFont.fontName as CFString, 30, nil)
#endif
label.font = mathFont
label.latex = "\\text{Hello 世界 🌍}" // English, Chinese, and emoji
```
When the main math font doesn't contain a glyph for a character, the fallback font will be used automatically.
This is particularly useful for:
- Chinese text: `\text{中文}`
- Japanese text: `\text{日本語}`
- Korean text: `\text{한국어}`
- Emoji: `\text{Math is fun! 🎉📐}`
- Mixed scripts: `\text{Equation: 方程式}`
**Note**: The fallback font only applies to characters within `\text{}` commands, not regular math mode.
##### Custom Commands
You can define your own commands that are not already predefined. This is
similar to macros is LaTeX. To define your own command use:
```swift
MTMathAtomFactory.addLatexSymbol("lcm", value: MTMathAtomFactory.operator(withName: "lcm", limits: false))
```
This creates an `\lcm` command that can be used in the LaTeX.
##### Content Insets
The `MTMathUILabel` has `contentInsets` for finer control of placement of the
equation in relation to the view.
If you need to set it you can do as follows:
```swift
label.contentInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 20)
```
##### Error handling
If the LaTeX text given to `MTMathUILabel` is
invalid or if it contains commands that aren't currently supported then
an error message will be displayed instead of the label.
This error can be programmatically retrieved as `label.error`. If you
prefer not to display anything then set:
```swift
label.displayErrorInline = true
```
## Future Enhancements
Note this is not a complete implementation of LaTeX math mode. There are
some pieces that are missing and may be included in future updates:
* `\middle` delimiter for use between `\left` and `\right`
* Some fine spacing commands (`\:`, `\;`, `\!` - note that `\,` works)
For a complete list of features and their implementation status, see [MISSING_FEATURES.md](MISSING_FEATURES.md).
## License
`SwiftMath` is available under the MIT license. See the [LICENSE](./LICENSE)
file for more info.
### Fonts
This distribution contains the following fonts. These fonts are
licensed as follows:
* Latin Modern Math:
[GUST Font License](GUST-FONT-LICENSE.txt)
* Tex Gyre Termes:
[GUST Font License](GUST-FONT-LICENSE.txt)
* [XITS Math](https://github.com/khaledhosny/xits-math):
[Open Font License](OFL.txt)
* [KpMath Light/KpMath Sans](http://scripts.sil.org/OFL):
[SIL Open Font License](OFL.txt)

View file

@ -0,0 +1,194 @@
//
// MTFontMathTableV2.swift
//
//
// Created by Peter Tang on 15/9/2023.
//
import Foundation
import CoreGraphics
import CoreText
internal class MTFontMathTableV2: MTFontMathTable {
private let mathFont: MathFont
private let fontSize: CGFloat
private let unitsPerEm: UInt
private let mTable: NSDictionary
init(mathFont: MathFont, size: CGFloat, unitsPerEm: UInt) {
self.mathFont = mathFont
self.fontSize = size
self.unitsPerEm = unitsPerEm
mTable = mathFont.rawMathTable()
super.init(withFont: mathFont.mtfont(size: fontSize), mathTable: mTable)
super._mathTable = nil
// disable all possible access to _mathTable in superclass!
}
override var _mathTable: NSDictionary? {
set { fatalError("\(#function) change to _mathTable \(mathFont.rawValue) not allowed.") }
get { mTable }
}
override var muUnit: CGFloat { fontSize/18 }
override func fontUnitsToPt(_ fontUnits:Int) -> CGFloat {
CGFloat(fontUnits) * fontSize / CGFloat(unitsPerEm)
}
override func constantFromTable(_ constName: String) -> CGFloat {
guard let consts = mTable[kConstants] as? NSDictionary, let val = consts[constName] as? NSNumber else {
return .zero
}
return fontUnitsToPt(val.intValue)
}
override func percentFromTable(_ percentName: String) -> CGFloat {
guard let consts = mTable[kConstants] as? NSDictionary, let val = consts[percentName] as? NSNumber else {
return .zero
}
return CGFloat(val.floatValue) / 100
}
/** Returns an Array of all the vertical variants of the glyph if any. If
there are no variants for the glyph, the array contains the given glyph. */
override func getVerticalVariantsForGlyph(_ glyph: CGGlyph) -> [NSNumber?] {
guard let variants = mTable[kVertVariants] as? NSDictionary else { return [] }
return self.getVariantsForGlyph(glyph, inDictionary: variants)
}
/** Returns an Array of all the horizontal variants of the glyph if any. If
there are no variants for the glyph, the array contains the given glyph. */
override func getHorizontalVariantsForGlyph(_ glyph: CGGlyph) -> [NSNumber?] {
guard let variants = mTable[kHorizVariants] as? NSDictionary else { return [] }
return self.getVariantsForGlyph(glyph, inDictionary:variants)
}
override func getVariantsForGlyph(_ glyph: CGGlyph, inDictionary variants: NSDictionary) -> [NSNumber?] {
let font = mathFont.mtfont(size: fontSize)
let glyphName = font.get(nameForGlyph: glyph)
var glyphArray = [NSNumber]()
let variantGlyphs = variants[glyphName] as? NSArray
guard let variantGlyphs = variantGlyphs, variantGlyphs.count != .zero else {
// There are no extra variants, so just add the current glyph to it.
let glyph = font.get(glyphWithName: glyphName)
glyphArray.append(NSNumber(value:glyph))
return glyphArray
}
for gvn in variantGlyphs {
if let glyphVariantName = gvn as? String {
let variantGlyph = font.get(glyphWithName: glyphVariantName)
glyphArray.append(NSNumber(value:variantGlyph))
}
}
return glyphArray
}
/** Returns a larger vertical variant of the given glyph if any.
If there is no larger version, this returns the current glyph.
- Parameter glyph: The glyph to find a larger variant for
- Parameter forDisplayStyle: If true, selects the largest appropriate variant for display style.
If false, selects the next larger variant (incremental sizing).
- Returns: A larger glyph variant, or the original glyph if no variants exist
*/
override func getLargerGlyph(_ glyph: CGGlyph, forDisplayStyle: Bool = false) -> CGGlyph {
let font = mathFont.mtfont(size: fontSize)
let glyphName = font.get(nameForGlyph: glyph)
guard let variants = mTable[kVertVariants] as? NSDictionary,
let variantGlyphs = variants[glyphName] as? NSArray, variantGlyphs.count != .zero else {
// There are no extra variants, so just returnt the current glyph.
return glyph
}
if forDisplayStyle {
// For display style, select a large variant suitable for mathematical display mode
// Display integrals should be significantly larger (~2.2em) for visual prominence
let count = variantGlyphs.count
// Strategy: Use the largest variant, but avoid extreme sizes for fonts with many variants
let targetIndex: Int
if count <= 2 {
// Small variant list: use the last one (e.g., integral.size1 at ~2.2em)
targetIndex = count - 1
} else if count <= 4 {
// Medium variant list: use second-to-last to avoid extremes
targetIndex = count - 2
} else {
// Large variant list (like texgyretermes with 6 variants):
// Use variant at ~60% position to get appropriate display size (~2.0em)
// For 7 variants (0-6), this gives index 4
targetIndex = min(count - 2, Int(Double(count) * 0.6))
}
if let glyphVariantName = variantGlyphs[targetIndex] as? String {
let variantGlyph = font.get(glyphWithName: glyphVariantName)
return variantGlyph
}
} else {
// Text/inline style: use incremental sizing for moderate enlargement
// Find the first variant with a different name
for gvn in variantGlyphs {
if let glyphVariantName = gvn as? String, glyphVariantName != glyphName {
let variantGlyph = font.get(glyphWithName: glyphVariantName)
return variantGlyph
}
}
}
// We did not find any variants of this glyph so return it.
return glyph
}
/** Returns the italic correction for the given glyph if any. If there
isn't any this returns 0. */
override func getItalicCorrection(_ glyph: CGGlyph) -> CGFloat {
let font = mathFont.mtfont(size: fontSize)
let glyphName = font.get(nameForGlyph: glyph)
guard let italics = mTable[kItalic] as? NSDictionary, let val = italics[glyphName] as? NSNumber else {
return .zero
}
// if val is nil, this returns 0.
return fontUnitsToPt(val.intValue)
}
override func getTopAccentAdjustment(_ glyph: CGGlyph) -> CGFloat {
let font = mathFont.mtfont(size: fontSize)
let glyphName = font.get(nameForGlyph: glyph)
guard let accents = mTable[kAccents] as? NSDictionary, let val = accents[glyphName] as? NSNumber else {
// If no top accent is defined then it is the center of the advance width.
var glyph = glyph
var advances = CGSize.zero
CTFontGetAdvancesForGlyphs(font.ctFont, .horizontal, &glyph, &advances, 1)
return advances.width/2
}
return fontUnitsToPt(val.intValue)
}
override func getVerticalGlyphAssembly(forGlyph glyph: CGGlyph) -> [GlyphPart] {
let font = mathFont.mtfont(size: fontSize)
let glyphName = font.get(nameForGlyph: glyph)
guard let assemblyTable = mTable[kVertAssembly] as? NSDictionary,
let assemblyInfo = assemblyTable[glyphName] as? NSDictionary,
let parts = assemblyInfo[kAssemblyParts] as? NSArray else {
// No vertical assembly defined for glyph
// parts should always have been defined, but if it isn't return nil
return []
}
var rv = [GlyphPart]()
for part in parts {
guard let partInfo = part as? NSDictionary,
let adv = partInfo["advance"] as? NSNumber,
let end = partInfo["endConnector"] as? NSNumber,
let start = partInfo["startConnector"] as? NSNumber,
let ext = partInfo["extender"] as? NSNumber,
let glyphName = partInfo["glyph"] as? String else { continue }
let fullAdvance = fontUnitsToPt(adv.intValue)
let endConnectorLength = fontUnitsToPt(end.intValue)
let startConnectorLength = fontUnitsToPt(start.intValue)
let isExtender = ext.boolValue
let glyph = font.get(glyphWithName: glyphName)
let part = GlyphPart(glyph: glyph, fullAdvance: fullAdvance,
startConnectorLength: startConnectorLength,
endConnectorLength: endConnectorLength,
isExtender: isExtender)
rv.append(part)
}
return rv
}
}

View file

@ -0,0 +1,68 @@
//
// MTFontV2.swift
//
//
// Created by Peter Tang on 15/9/2023.
//
import Foundation
import CoreGraphics
import CoreText
extension MathFont {
public func mtfont(size: CGFloat) -> MTFontV2 {
MTFontV2(font: self, size: size)
}
}
public final class MTFontV2: MTFont {
let font: MathFont
let size: CGFloat
private let _cgFont: CGFont
private let _ctFont: CTFont
private let unitsPerEm: UInt
private var _mathTab: MTFontMathTableV2?
init(font: MathFont = .latinModernFont, size: CGFloat) {
self.font = font
self.size = size
// MathFont cgfont and ctfont are fast & threadsafe, keep a local copy is cheaper than
// handling via NSLock
self._cgFont = font.cgFont()
self._ctFont = font.ctFont(withSize: size)
self.unitsPerEm = self._ctFont.unitsPerEm
super.init()
super.defaultCGFont = nil
super.ctFont = nil
super.mathTable = nil
super.rawMathTable = nil
}
override var defaultCGFont: CGFont! {
set { fatalError("\(#function): change to \(font.fontName) not allowed.") }
get { _cgFont }
}
override var ctFont: CTFont! {
set { fatalError("\(#function): change to \(font.fontName) not allowed.") }
get { _ctFont }
}
private let mtfontV2LockOnMathTable = NSLock()
override var mathTable: MTFontMathTable? {
set { fatalError("\(#function): change to \(font.rawValue) not allowed.") }
get {
guard _mathTab == nil else { return _mathTab }
//Note: lazy _mathTab initialization is now threadsafe.
mtfontV2LockOnMathTable.lock()
defer { mtfontV2LockOnMathTable.unlock() }
if _mathTab == nil {
_mathTab = MTFontMathTableV2(mathFont: font, size: size, unitsPerEm: unitsPerEm)
}
return _mathTab
}
}
override var rawMathTable: NSDictionary? {
set { fatalError("\(#function): change to \(font.rawValue) not allowed.") }
get { fatalError("\(#function): access to \(font.rawValue) not allowed.") }
}
public override func copy(withSize size: CGFloat) -> MTFont {
MTFontV2(font: font, size: size)
}
}

View file

@ -0,0 +1,225 @@
//
// MathFont.swift
//
//
// Created by Peter Tang on 10/9/2023.
//
#if os(iOS) || os(visionOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Now available for everyone to use
public enum MathFont: String, CaseIterable, Identifiable {
public var id: Self { self } // Makes things simpler for SwiftUI
case latinModernFont = "latinmodern-math"
case kpMathLightFont = "KpMath-Light"
case kpMathSansFont = "KpMath-Sans"
case xitsFont = "xits-math"
case termesFont = "texgyretermes-math"
case asanaFont = "Asana-Math"
case eulerFont = "Euler-Math"
case firaFont = "FiraMath-Regular"
case notoSansFont = "NotoSansMath-Regular"
case libertinusFont = "LibertinusMath-Regular"
case garamondFont = "Garamond-Math"
case leteSansFont = "LeteSansMath"
var fontFamilyName: String {
switch self {
case .latinModernFont: "Latin Modern Math"
case .kpMathLightFont: "KpMath"
case .kpMathSansFont: "KpMath"
case .xitsFont: "XITS Math"
case .termesFont: "TeX Gyre Termes Math"
case .asanaFont: "Asana Math"
case .eulerFont: "Euler Math"
case .firaFont: "Fira Math"
case .notoSansFont: "Noto Sans Math"
case .libertinusFont: "Libertinus Math"
case .garamondFont: "Garamond-Math" // PostScript name is "Garamond-Math", not "Garamond Math"
case .leteSansFont: "Lete Sans Math"
}
}
var postScriptName: String {
switch self {
case .latinModernFont: "LatinModernMath-Regular"
case .kpMathLightFont: "KpMath-Light"
case .kpMathSansFont: "KpMath-Sans"
case .xitsFont: "XITSMath"
case .termesFont: "TeXGyreTermesMath-Regular"
case .asanaFont: "Asana-Math"
case .eulerFont: "Euler-Math"
case .firaFont: "FiraMath-Regular"
case .notoSansFont: "NotoSansMath-Regular"
case .libertinusFont: "LibertinusMath-Regular"
case .garamondFont: "Garamond-Math"
case .leteSansFont: "LeteSansMath"
}
}
var fontName: String { self.rawValue }
public func cgFont() -> CGFont {
BundleManager.manager.obtainCGFont(font: self)
}
public func ctFont(withSize size: CGFloat) -> CTFont {
BundleManager.manager.obtainCTFont(font: self, withSize: size)
}
internal func rawMathTable() -> NSDictionary {
BundleManager.manager.obtainRawMathTable(font: self)
}
//Note: Below code are no longer supported, unable to tell if UIFont/NSFont is threadsafe, not used in SwiftMath.
// #if os(iOS) || os(visionOS)
// public func uiFont(withSize size: CGFloat) -> UIFont? {
// UIFont(name: fontName, size: size)
// }
// #endif
// #if os(macOS)
// public func nsFont(withSize size: CGFloat) -> NSFont? {
// NSFont(name: fontName, size: size)
// }
// #endif
}
internal extension CTFont {
/** The size of this font in points. */
var fontSize: CGFloat {
CTFontGetSize(self)
}
var unitsPerEm: UInt {
return UInt(CTFontGetUnitsPerEm(self))
}
}
private class BundleManager {
//Note: below should be lightweight and without threadsafe problem.
static internal let manager = BundleManager()
private var cgFonts = [MathFont: CGFont]()
private var ctFonts = [CTFontSizePair: CTFont]()
private var rawMathTables = [MathFont: NSDictionary]()
private let threadSafeQueue = DispatchQueue(label: "com.smartmath.mathfont.threadsafequeue",
qos: .userInitiated,
attributes: .concurrent)
private func registerCGFont(mathFont: MathFont) throws {
guard let frameworkBundleURL = Bundle.main.url(forResource: "mathFonts", withExtension: "bundle"),
let resourceBundleURL = Bundle(url: frameworkBundleURL)?.path(forResource: mathFont.rawValue, ofType: "otf") else {
throw FontError.fontPathNotFound
}
guard let fontData = NSData(contentsOfFile: resourceBundleURL), let dataProvider = CGDataProvider(data: fontData) else {
throw FontError.invalidFontFile
}
guard let defaultCGFont = CGFont(dataProvider) else {
throw FontError.initFontError
}
cgFonts[mathFont] = defaultCGFont
/// This does not load the complete math font, it only has about half the glyphs of the full math font.
/// In particular it does not have the math italic characters which breaks our variable rendering.
/// So we first load a CGFont from the file and then convert it to a CTFont.
var errorRef: Unmanaged<CFError>? = nil
guard CTFontManagerRegisterGraphicsFont(defaultCGFont, &errorRef) else {
throw FontError.registerFailed
}
let postsript = (defaultCGFont.postScriptName as? String) ?? ""
let cgfontName = (defaultCGFont.fullName as? String) ?? ""
let threadName = Thread.isMainThread ? "main" : "global"
debugPrint("mathFonts bundle resource: \(mathFont.rawValue), font: \(cgfontName), ps: \(postsript) registered on \(threadName).")
}
private func registerMathTable(mathFont: MathFont) throws {
guard let frameworkBundleURL = Bundle.main.url(forResource: "mathFonts", withExtension: "bundle"),
let mathTablePlist = Bundle(url: frameworkBundleURL)?.url(forResource: mathFont.rawValue, withExtension:"plist") else {
throw FontError.fontPathNotFound
}
guard let rawMathTable = NSDictionary(contentsOf: mathTablePlist),
let version = rawMathTable["version"] as? String,
version == "1.3" else {
throw FontError.invalidMathTable
}
rawMathTables[mathFont] = rawMathTable
let threadName = Thread.isMainThread ? "main" : "global"
debugPrint("mathFonts bundle resource: \(mathFont.rawValue).plist registered on \(threadName).")
}
private func onDemandRegistration(mathFont: MathFont) {
guard threadSafeQueue.sync(execute: { cgFonts[mathFont] }) == nil else { return }
// Note: resourceLoading is now serialized.
threadSafeQueue.sync(flags: .barrier, execute: { [weak self] in
if self?.cgFonts[mathFont] == nil {
do {
try BundleManager.manager.registerCGFont(mathFont: mathFont)
try BundleManager.manager.registerMathTable(mathFont: mathFont)
} catch {
fatalError("MTMathFonts:\(#function) ondemand loading failed, mathFont \(mathFont.rawValue), reason \(error)")
}
}
})
}
fileprivate func obtainCGFont(font: MathFont) -> CGFont {
onDemandRegistration(mathFont: font)
guard let cgFont = threadSafeQueue.sync(execute: { cgFonts[font] }) else {
fatalError("\(#function) unable to locate CGFont \(font.fontName)")
}
return cgFont
}
fileprivate func obtainCTFont(font: MathFont, withSize size: CGFloat) -> CTFont {
onDemandRegistration(mathFont: font)
let fontSizePair = CTFontSizePair(font: font, size: size)
let ctFont = threadSafeQueue.sync(execute: { ctFonts[fontSizePair] })
guard ctFont == nil else { return ctFont! }
guard let cgFont = threadSafeQueue.sync(execute: { cgFonts[font] }) else {
fatalError("\(#function) unable to locate CGFont \(font.fontName) to create CTFont")
}
//Note: ctfont creation and caching is now threadsafe.
guard threadSafeQueue.sync(execute: { ctFonts[fontSizePair] }) == nil else { return ctFonts[fontSizePair]! }
return threadSafeQueue.sync(flags: .barrier, execute: {
if let ctfont = ctFonts[fontSizePair] {
return ctfont
} else {
let result = CTFontCreateWithGraphicsFont(cgFont, size, nil, nil)
ctFonts[fontSizePair] = result
return result
}
})
}
fileprivate func obtainRawMathTable(font: MathFont) -> NSDictionary {
onDemandRegistration(mathFont: font)
guard let mathTable = threadSafeQueue.sync(execute: { rawMathTables[font] } ) else {
fatalError("\(#function) unable to locate mathTable: \(font.rawValue).plist")
}
return mathTable
}
deinit {
ctFonts.removeAll()
var errorRef: Unmanaged<CFError>? = nil
cgFonts.values.forEach { cgFont in
CTFontManagerUnregisterGraphicsFont(cgFont, &errorRef)
}
cgFonts.removeAll()
}
public enum FontError: Error {
case invalidFontFile
case fontPathNotFound
case initFontError
case registerFailed
case invalidMathTable
}
private struct CTFontSizePair: Hashable {
let font: MathFont
let size: CGFloat
}
}

View file

@ -0,0 +1,123 @@
//
// MathImage.swift
//
//
// Created by Peter Tang on 15/9/2023.
//
import Foundation
#if os(iOS) || os(visionOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
public struct MathImage {
public var font: MathFont = .latinModernFont
public var fontSize: CGFloat
public var textColor: MTColor
public var labelMode: MTMathUILabelMode
public var textAlignment: MTTextAlignment
public var contentInsets: MTEdgeInsets = MTEdgeInsetsZero
public let latex: String
private(set) var intrinsicContentSize = CGSize.zero
public init(latex: String, fontSize: CGFloat, textColor: MTColor, labelMode: MTMathUILabelMode = .display, textAlignment: MTTextAlignment = .center) {
self.latex = latex
self.fontSize = fontSize
self.textColor = textColor
self.labelMode = labelMode
self.textAlignment = textAlignment
}
}
extension MathImage {
public var currentStyle: MTLineStyle {
switch labelMode {
case .display: return .display
case .text: return .text
}
}
private func intrinsicContentSize(_ displayList: MTMathListDisplay) -> CGSize {
CGSize(width: displayList.width + contentInsets.left + contentInsets.right,
height: displayList.ascent + displayList.descent + contentInsets.top + contentInsets.bottom)
}
public struct LayoutInfo {
public var ascent: CGFloat = 0
public var descent: CGFloat = 0
public init(ascent: CGFloat, descent: CGFloat) {
self.ascent = ascent
self.descent = descent
}
}
public mutating func asImage() -> (NSError?, MTImage?, LayoutInfo?) {
func layoutImage(size: CGSize, displayList: MTMathListDisplay) {
var textX = CGFloat(0)
switch self.textAlignment {
case .left: textX = contentInsets.left
case .center: textX = (size.width - contentInsets.left - contentInsets.right - displayList.width) / 2 + contentInsets.left
case .right: textX = size.width - displayList.width - contentInsets.right
}
let availableHeight = size.height - contentInsets.bottom - contentInsets.top
// center things vertically
var height = displayList.ascent + displayList.descent
if height < fontSize/2 {
height = fontSize/2 // set height to half the font size
}
let textY = (availableHeight - height) / 2 + displayList.descent + contentInsets.bottom
displayList.position = CGPoint(x: textX, y: textY)
}
var error: NSError?
let mtfont: MTFont? = font.mtfont(size: fontSize)
guard let mathList = MTMathListBuilder.build(fromString: latex, error: &error), error == nil,
let displayList = MTTypesetter.createLineForMathList(mathList, font: mtfont, style: currentStyle) else {
return (error, nil, nil)
}
intrinsicContentSize = intrinsicContentSize(displayList)
displayList.textColor = textColor
let size = intrinsicContentSize.regularized
layoutImage(size: size, displayList: displayList)
#if os(iOS) || os(visionOS)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { rendererContext in
rendererContext.cgContext.saveGState()
rendererContext.cgContext.concatenate(.flippedVertically(size.height))
displayList.draw(rendererContext.cgContext)
rendererContext.cgContext.restoreGState()
}
return (nil, image, LayoutInfo(ascent: displayList.ascent, descent: displayList.descent))
#endif
#if os(macOS)
let image = NSImage(size: size, flipped: false) { bounds in
guard let context = NSGraphicsContext.current?.cgContext else { return false }
context.saveGState()
displayList.draw(context)
context.restoreGState()
return true
}
return (nil, image, LayoutInfo(ascent: displayList.ascent, descent: displayList.descent))
#endif
}
}
private extension CGAffineTransform {
static func flippedVertically(_ height: CGFloat) -> CGAffineTransform {
var transform = CGAffineTransform(scaleX: 1, y: -1)
transform = transform.translatedBy(x: 0, y: -height)
return transform
}
}
extension CGSize {
fileprivate var regularized: CGSize {
CGSize(width: ceil(width), height: ceil(height))
}
}

View file

@ -0,0 +1,35 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by .
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
#if os(macOS)
extension MTBezierPath {
func addLine(to point: CGPoint) {
self.line(to: point)
}
}
extension MTView {
var backgroundColor:MTColor? {
get {
MTColor(cgColor: self.layer?.backgroundColor ?? MTColor.clear.cgColor)
}
set {
self.layer?.backgroundColor = MTColor.clear.cgColor
self.wantsLayer = true
}
}
}
#endif

View file

@ -0,0 +1,28 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Markus Sähn.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
extension MTColor {
public convenience init?(fromHexString hexString:String) {
if hexString.isEmpty { return nil }
if !hexString.hasPrefix("#") { return nil }
var rgbValue = UInt64(0)
let scanner = Scanner(string: hexString)
scanner.charactersToBeSkipped = CharacterSet(charactersIn: "#")
scanner.scanHexInt64(&rgbValue)
self.init(red: CGFloat((rgbValue & 0xFF0000) >> 16)/255.0,
green: CGFloat((rgbValue & 0xFF00) >> 8)/255.0,
blue: CGFloat((rgbValue & 0xFF))/255.0,
alpha: 1.0)
}
}

View file

@ -0,0 +1,40 @@
import Foundation
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by .
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
#if os(iOS) || os(visionOS)
import UIKit
public typealias MTView = UIView
public typealias MTColor = UIColor
public typealias MTBezierPath = UIBezierPath
public typealias MTLabel = UILabel
public typealias MTEdgeInsets = UIEdgeInsets
public typealias MTRect = CGRect
public typealias MTImage = UIImage
let MTEdgeInsetsZero = UIEdgeInsets.zero
func MTGraphicsGetCurrentContext() -> CGContext? { UIGraphicsGetCurrentContext() }
#else
import AppKit
public typealias MTView = NSView
public typealias MTColor = NSColor
public typealias MTBezierPath = NSBezierPath
public typealias MTEdgeInsets = NSEdgeInsets
public typealias MTRect = NSRect
public typealias MTImage = NSImage
let MTEdgeInsetsZero = NSEdgeInsets.init(top: 0, left: 0, bottom: 0, right: 0)
func MTGraphicsGetCurrentContext() -> CGContext? { NSGraphicsContext.current?.cgContext }
#endif

View file

@ -0,0 +1,81 @@
import Foundation
import CoreText
import AppBundle
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Kostub Deshmukh.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
public class MTFont {
var defaultCGFont: CGFont!
var ctFont: CTFont!
var mathTable: MTFontMathTable?
var rawMathTable: NSDictionary?
/// Fallback font for characters not supported by the main math font.
/// Defaults to the system font at the same size. This is particularly useful
/// for rendering text in \text{} commands with characters outside the math font's coverage
/// (e.g., Chinese, Japanese, Korean, emoji, etc.)
public var fallbackFont: CTFont?
init() {}
/// `MTFont(fontWithName:)` does not load the complete math font, it only has about half the glyphs of the full math font.
/// In particular it does not have the math italic characters which breaks our variable rendering.
/// So we first load a CGFont from the file and then convert it to a CTFont.
convenience init(fontWithName name: String, size:CGFloat) {
self.init()
//print("Loading font \(name)")
let bundle = getAppBundle()
let fontPath = bundle.path(forResource: name, ofType: "otf")
let fontDataProvider = CGDataProvider(filename: fontPath!)
self.defaultCGFont = CGFont(fontDataProvider!)!
//print("Num glyphs: \(self.defaultCGFont.numberOfGlyphs)")
self.ctFont = CTFontCreateWithGraphicsFont(self.defaultCGFont, size, nil, nil);
//print("Loading associated .plist")
let mathTablePlist = bundle.url(forResource:name, withExtension:"plist")
self.rawMathTable = NSDictionary(contentsOf: mathTablePlist!)
self.mathTable = MTFontMathTable(withFont:self, mathTable:rawMathTable!)
}
static var fontBundle:Bundle {
// Uses bundle for class so that this can be access by the unit tests.
Bundle(url: Bundle.main.url(forResource: "mathFonts", withExtension: "bundle")!)!
}
/** Returns a copy of this font but with a different size. */
public func copy(withSize size: CGFloat) -> MTFont {
let newFont = MTFont()
newFont.defaultCGFont = self.defaultCGFont
newFont.ctFont = CTFontCreateWithGraphicsFont(self.defaultCGFont, size, nil, nil)
newFont.rawMathTable = self.rawMathTable
newFont.mathTable = MTFontMathTable(withFont: newFont, mathTable: newFont.rawMathTable!)
return newFont
}
func get(nameForGlyph glyph:CGGlyph) -> String {
let name = defaultCGFont.name(for: glyph) as? String
return name ?? ""
}
func get(glyphWithName name:String) -> CGGlyph {
defaultCGFont.getGlyphWithGlyphName(name: name as CFString)
}
/** The size of this font in points. */
public var fontSize:CGFloat { CTFontGetSize(self.ctFont) }
deinit {
self.ctFont = nil
self.defaultCGFont = nil
}
}

View file

@ -0,0 +1,93 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Kostub Deshmukh.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
public class MTFontManager {
static public private(set) var manager: MTFontManager = {
MTFontManager()
}()
let kDefaultFontSize = CGFloat(20)
static var fontManager : MTFontManager {
return manager
}
public init() { }
@RWLocked
var nameToFontMap = [String: MTFont]()
public func font(withName name:String, size:CGFloat) -> MTFont? {
var f = self.nameToFontMap[name]
if f == nil {
f = MTFont(fontWithName: name, size: size)
self.nameToFontMap[name] = f
}
if f!.fontSize == size { return f }
else { return f!.copy(withSize: size) }
}
public func latinModernFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "latinmodern-math", size: size)
}
public func kpMathLightFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "KpMath-Light", size: size)
}
public func kpMathSansFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "KpMath-Sans", size: size)
}
public func xitsFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "xits-math", size: size)
}
public func termesFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "texgyretermes-math", size: size)
}
public func asanaFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "Asana-Math", size: size)
}
public func eulerFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "Euler-Math", size: size)
}
public func firaRegularFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "FiraMath-Regular", size: size)
}
public func notoSansRegularFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "NotoSansMath-Regular", size: size)
}
public func libertinusRegularFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "LibertinusMath-Regular", size: size)
}
public func garamondMathFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "Garamond-Math", size: size)
}
public func leteSansFont(withSize size:CGFloat) -> MTFont? {
MTFontManager.fontManager.font(withName: "LeteSansMath", size: size)
}
public var defaultFont: MTFont? {
MTFontManager.fontManager.latinModernFont(withSize: kDefaultFontSize)
}
}

View file

@ -0,0 +1,355 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Kostub Deshmukh.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import CoreText
struct GlyphPart {
/// The glyph that represents this part
var glyph: CGGlyph!
/// Full advance width/height for this part, in the direction of the extension in points.
var fullAdvance: CGFloat = 0
/// Advance width/ height of the straight bar connector material at the beginning of the glyph in points.
var startConnectorLength: CGFloat = 0
/// Advance width/ height of the straight bar connector material at the end of the glyph in points.
var endConnectorLength: CGFloat = 0
/// If this part is an extender. If set, the part can be skipped or repeated.
var isExtender: Bool = false
}
/** This class represents the Math table of an open type font.
The math table is documented here: https://www.microsoft.com/typography/otspec/math.htm
How the constants in this class affect the display is documented here:
http://www.tug.org/TUGboat/tb30-1/tb94vieth.pdf
Note: We don't parse the math table from the open type font. Rather we parse it
in python and convert it to a .plist file which is easily consumed by this class.
This approach is preferable to spending an inordinate amount of time figuring out
how to parse the returned NSData object using the open type rules.
Remark: This class is not meant to be used outside of this library.
*/
class MTFontMathTable {
// The font for this math table.
public private(set) weak var font:MTFont? // @property (nonatomic, readonly, weak) MTFont* font;
var _unitsPerEm: UInt
var _fontSize: CGFloat
var _mathTable: NSDictionary!
let kConstants = "constants"
/** MU unit in points */
var muUnit:CGFloat { _fontSize/18 }
func fontUnitsToPt(_ fontUnits:Int) -> CGFloat {
CGFloat(fontUnits) * _fontSize / CGFloat(_unitsPerEm)
}
init(withFont font: MTFont?, mathTable:NSDictionary) {
assert(font != nil, "font has nil value")
assert(font!.ctFont != nil, "font.ctFont has nil value")
self.font = font
// do domething with font
_unitsPerEm = UInt(CTFontGetUnitsPerEm(font!.ctFont))
_fontSize = font!.fontSize;
_mathTable = mathTable
let version = _mathTable["version"] as! String
if version != "1.3" {
NSException(name: NSExceptionName.internalInconsistencyException, reason: "Invalid version of math table plist: \(version)").raise()
}
}
func constantFromTable(_ constName:String) -> CGFloat {
let consts = _mathTable[kConstants] as! NSDictionary?
let val = consts![constName] as! NSNumber?
return fontUnitsToPt(val!.intValue)
}
func percentFromTable(_ percentName:String) -> CGFloat {
let consts = _mathTable[kConstants] as! NSDictionary?
let val = consts![percentName] as! NSNumber?
return CGFloat(val!.floatValue) / 100
}
/// Math Font Metrics from the opentype specification
// MARK: - Fractions
var fractionNumeratorDisplayStyleShiftUp:CGFloat { constantFromTable("FractionNumeratorDisplayStyleShiftUp") } // \sigma_8 in TeX
var fractionNumeratorShiftUp:CGFloat { constantFromTable("FractionNumeratorShiftUp") } // \sigma_9 in TeX
var fractionDenominatorDisplayStyleShiftDown:CGFloat { constantFromTable("FractionDenominatorDisplayStyleShiftDown") } // \sigma_11 in TeX
var fractionDenominatorShiftDown:CGFloat { constantFromTable("FractionDenominatorShiftDown") } // \sigma_12 in TeX
var fractionNumeratorDisplayStyleGapMin:CGFloat { constantFromTable("FractionNumDisplayStyleGapMin") } // 3 * \xi_8 in TeX
var fractionNumeratorGapMin:CGFloat { constantFromTable("FractionNumeratorGapMin") } // \xi_8 in TeX
var fractionDenominatorDisplayStyleGapMin:CGFloat { constantFromTable("FractionDenomDisplayStyleGapMin") } // 3 * \xi_8 in TeX
var fractionDenominatorGapMin:CGFloat { constantFromTable("FractionDenominatorGapMin") } // \xi_8 in TeX
var fractionRuleThickness:CGFloat { constantFromTable("FractionRuleThickness") } // \xi_8 in TeX
var skewedFractionHorizonalGap:CGFloat { constantFromTable("SkewedFractionHorizontalGap") } // \sigma_20 in TeX
var skewedFractionVerticalGap:CGFloat { constantFromTable("SkewedFractionVerticalGap") } // \sigma_21 in TeX
// MARK: - Non-standard
/// FractionDelimiterSize and FractionDelimiterDisplayStyleSize are not constants
/// specified in the OpenType Math specification. Rather these are proposed LuaTeX extensions
/// for the TeX parameters \sigma_20 (delim1) and \sigma_21 (delim2). Since these do not
/// exist in the fonts that we have, we use the same approach as LuaTeX and use the fontSize
/// to determine these values. The constants used match the metrics values of the original TeX fonts.
/// Note: An alternative approach is to use DelimitedSubFormulaMinHeight for \sigma21 and use a factor
/// of 2 to get \sigma 20 as proposed in Vieth paper.
/// The XeTeX implementation sets \sigma21 = fontSize and \sigma20 = DelimitedSubFormulaMinHeight which
/// will produce smaller delimiters.
/// Of all the approaches we've implemented LuaTeX's approach since it mimics LaTeX most accurately.
var fractionDelimiterSize: CGFloat { 1.01 * _fontSize }
/// Modified constant from 2.4 to 2.39 for better visual appearance.
var fractionDelimiterDisplayStyleSize: CGFloat { 2.39 * _fontSize }
// MARK: - Stacks
var stackTopDisplayStyleShiftUp:CGFloat { constantFromTable("StackTopDisplayStyleShiftUp") } // \sigma_8 in TeX
var stackTopShiftUp:CGFloat { constantFromTable("StackTopShiftUp") } // \sigma_10 in TeX
var stackDisplayStyleGapMin:CGFloat { constantFromTable("StackDisplayStyleGapMin") } // 7 \xi_8 in TeX
var stackGapMin:CGFloat { constantFromTable("StackGapMin") } // 3 \xi_8 in TeX
var stackBottomDisplayStyleShiftDown:CGFloat { constantFromTable("StackBottomDisplayStyleShiftDown") } // \sigma_11 in TeX
var stackBottomShiftDown:CGFloat { constantFromTable("StackBottomShiftDown") } // \sigma_12 in TeX
var stretchStackBottomShiftDown:CGFloat { constantFromTable("StretchStackBottomShiftDown") }
var stretchStackGapAboveMin:CGFloat { constantFromTable("StretchStackGapAboveMin") }
var stretchStackGapBelowMin:CGFloat { constantFromTable("StretchStackGapBelowMin") }
var stretchStackTopShiftUp:CGFloat { constantFromTable("StretchStackTopShiftUp") }
// MARK: - super/sub scripts
var superscriptShiftUp:CGFloat { constantFromTable("SuperscriptShiftUp") } // \sigma_13, \sigma_14 in TeX
var superscriptShiftUpCramped:CGFloat { constantFromTable("SuperscriptShiftUpCramped") } // \sigma_15 in TeX
var subscriptShiftDown:CGFloat { constantFromTable("SubscriptShiftDown") } // \sigma_16, \sigma_17 in TeX
var superscriptBaselineDropMax:CGFloat { constantFromTable("SuperscriptBaselineDropMax") } // \sigma_18 in TeX
var subscriptBaselineDropMin:CGFloat { constantFromTable("SubscriptBaselineDropMin") } // \sigma_19 in TeX
var superscriptBottomMin:CGFloat { constantFromTable("SuperscriptBottomMin") } // 1/4 \sigma_5 in TeX
var subscriptTopMax:CGFloat { constantFromTable("SubscriptTopMax") } // 4/5 \sigma_5 in TeX
var subSuperscriptGapMin:CGFloat { constantFromTable("SubSuperscriptGapMin") } // 4 \xi_8 in TeX
var superscriptBottomMaxWithSubscript:CGFloat { constantFromTable("SuperscriptBottomMaxWithSubscript") } // 4/5 \sigma_5 in TeX
var spaceAfterScript:CGFloat { constantFromTable("SpaceAfterScript") }
// MARK: - radicals
var radicalExtraAscender:CGFloat { constantFromTable("RadicalExtraAscender") } // \xi_8 in Tex
var radicalRuleThickness:CGFloat { constantFromTable("RadicalRuleThickness") } // \xi_8 in Tex
var radicalDisplayStyleVerticalGap:CGFloat { constantFromTable("RadicalDisplayStyleVerticalGap") } // \xi_8 + 1/4 \sigma_5 in Tex
var radicalVerticalGap:CGFloat { constantFromTable("RadicalVerticalGap") } // 5/4 \xi_8 in Tex
var radicalKernBeforeDegree:CGFloat { constantFromTable("RadicalKernBeforeDegree") } // 5 mu in Tex
var radicalKernAfterDegree:CGFloat { constantFromTable("RadicalKernAfterDegree") } // -10 mu in Tex
var radicalDegreeBottomRaisePercent:CGFloat { percentFromTable("RadicalDegreeBottomRaisePercent") } // 60% in Tex
// MARK: - Limits
var upperLimitBaselineRiseMin:CGFloat { constantFromTable("UpperLimitBaselineRiseMin") } // \xi_11 in TeX
var upperLimitGapMin:CGFloat { constantFromTable("UpperLimitGapMin") } // \xi_9 in TeX
var lowerLimitGapMin:CGFloat { constantFromTable("LowerLimitGapMin") } // \xi_10 in TeX
var lowerLimitBaselineDropMin:CGFloat { constantFromTable("LowerLimitBaselineDropMin") } // \xi_12 in TeX
var limitExtraAscenderDescender:CGFloat { 0 } // \xi_13 in TeX, not present in OpenType so we always set it to 0.
// MARK: - Underline
var underbarVerticalGap:CGFloat { constantFromTable("UnderbarVerticalGap") } // 3 \xi_8 in TeX
var underbarRuleThickness:CGFloat { constantFromTable("UnderbarRuleThickness") } // \xi_8 in TeX
var underbarExtraDescender:CGFloat { constantFromTable("UnderbarExtraDescender") } // \xi_8 in TeX
// MARK: - Overline
var overbarVerticalGap:CGFloat { constantFromTable("OverbarVerticalGap") } // 3 \xi_8 in TeX
var overbarRuleThickness:CGFloat { constantFromTable("OverbarRuleThickness") } // \xi_8 in TeX
var overbarExtraAscender:CGFloat { constantFromTable("OverbarExtraAscender") } // \xi_8 in TeX
// MARK: - Constants
var axisHeight:CGFloat { constantFromTable("AxisHeight") } // \sigma_22 in TeX
var scriptScaleDown:CGFloat { percentFromTable("ScriptPercentScaleDown") }
var scriptScriptScaleDown:CGFloat { percentFromTable("ScriptScriptPercentScaleDown") }
var mathLeading:CGFloat { constantFromTable("MathLeading") }
var delimitedSubFormulaMinHeight:CGFloat { constantFromTable("DelimitedSubFormulaMinHeight") }
// MARK: - Accent
var accentBaseHeight:CGFloat { constantFromTable("AccentBaseHeight") } // \fontdimen5 in TeX (x-height)
var flattenedAccentBaseHeight:CGFloat { constantFromTable("FlattenedAccentBaseHeight") }
// MARK: - Variants
let kVertVariants = "v_variants"
let kHorizVariants = "h_variants"
/** Returns an Array of all the vertical variants of the glyph if any. If
there are no variants for the glyph, the array contains the given glyph. */
func getVerticalVariantsForGlyph( _ glyph:CGGlyph) -> [NSNumber?] {
let variants = _mathTable[kVertVariants] as! NSDictionary?
return self.getVariantsForGlyph(glyph, inDictionary: variants!)
}
/** Returns an Array of all the horizontal variants of the glyph if any. If
there are no variants for the glyph, the array contains the given glyph. */
func getHorizontalVariantsForGlyph( _ glyph:CGGlyph) -> [NSNumber?] {
let variants = _mathTable[kHorizVariants] as! NSDictionary
return self.getVariantsForGlyph(glyph, inDictionary:variants)
}
func getVariantsForGlyph(_ glyph: CGGlyph, inDictionary variants:NSDictionary) -> [NSNumber?] {
let glyphName = self.font!.get(nameForGlyph: glyph)
let variantGlyphs = variants[glyphName] as! NSArray?
var glyphArray = [NSNumber]()
if variantGlyphs == nil || variantGlyphs?.count == 0 {
// There are no extra variants, so just add the current glyph to it.
let glyph = self.font!.get(glyphWithName: glyphName)
glyphArray.append(NSNumber(value:glyph))
return glyphArray
}
for gvn in variantGlyphs! {
let glyphVariantName = gvn as! String?
let variantGlyph = self.font?.get(glyphWithName: glyphVariantName!)
glyphArray.append(NSNumber(value:variantGlyph!))
}
return glyphArray
}
/** Returns a larger vertical variant of the given glyph if any.
If there is no larger version, this returns the current glyph.
- Parameter glyph: The glyph to find a larger variant for
- Parameter forDisplayStyle: If true, selects the largest appropriate variant for display style.
If false, selects the next larger variant (incremental sizing).
- Returns: A larger glyph variant, or the original glyph if no variants exist
*/
func getLargerGlyph(_ glyph:CGGlyph, forDisplayStyle: Bool = false) -> CGGlyph {
let variants = _mathTable[kVertVariants] as! NSDictionary?
let glyphName = self.font?.get(nameForGlyph: glyph)
let variantGlyphs = variants![glyphName!] as! NSArray?
if variantGlyphs == nil || variantGlyphs?.count == 0 {
// There are no extra variants, so just returnt the current glyph.
return glyph
}
if forDisplayStyle {
// For display style, select a large variant suitable for mathematical display mode
// Display integrals should be significantly larger (~2.2em) for visual prominence
let count = variantGlyphs!.count
// Strategy: Use the largest variant, but avoid extreme sizes for fonts with many variants
let targetIndex: Int
if count <= 2 {
// Small variant list: use the last one (e.g., integral.size1 at ~2.2em)
targetIndex = count - 1
} else if count <= 4 {
// Medium variant list: use second-to-last to avoid extremes
targetIndex = count - 2
} else {
// Large variant list (like texgyretermes with 6 variants):
// Use variant at ~60% position to get appropriate display size (~2.0em)
// For 7 variants (0-6), this gives index 4
targetIndex = min(count - 2, Int(Double(count) * 0.6))
}
if let glyphVariantName = variantGlyphs![targetIndex] as? String {
let variantGlyph = self.font?.get(glyphWithName: glyphVariantName)
return variantGlyph!
}
} else {
// Text/inline style: use incremental sizing for moderate enlargement
// Find the first variant with a different name
for gvn in variantGlyphs! {
let glyphVariantName = gvn as! String?
if glyphVariantName != glyphName {
let variantGlyph = self.font?.get(glyphWithName: glyphVariantName!)
return variantGlyph!
}
}
}
// We did not find any variants of this glyph so return it.
return glyph;
}
// MARK: - Italic Correction
let kItalic = "italic"
/** Returns the italic correction for the given glyph if any. If there
isn't any this returns 0. */
func getItalicCorrection(_ glyph: CGGlyph) -> CGFloat {
let italics = _mathTable[kItalic] as! NSDictionary?
let glyphName = self.font?.get(nameForGlyph: glyph)
let val = italics![glyphName!] as! NSNumber?
// if val is nil, this returns 0.
return self.fontUnitsToPt(val?.intValue ?? 0)
}
// MARK: - Accents
let kAccents = "accents"
/** Returns the adjustment to the top accent for the given glyph if any.
If there isn't any this returns -1. */
func getTopAccentAdjustment(_ glyph: CGGlyph) -> CGFloat {
var glyph = glyph
let accents = _mathTable[kAccents] as! NSDictionary?
let glyphName = self.font?.get(nameForGlyph: glyph)
let val = accents![glyphName!] as! NSNumber?
if let val = val {
return self.fontUnitsToPt(val.intValue)
} else {
// If no top accent is defined then it is the center of the advance width.
var advances = CGSize.zero
CTFontGetAdvancesForGlyphs(self.font!.ctFont, .horizontal, &glyph, &advances, 1)
return advances.width/2
}
}
// MARK: - Glyph Construction
/** Minimum overlap of connecting glyphs during glyph construction */
var minConnectorOverlap:CGFloat { constantFromTable("MinConnectorOverlap") }
let kVertAssembly = "v_assembly"
let kAssemblyParts = "parts"
/** Returns an array of the glyph parts to be used for constructing vertical variants
of this glyph. If there is no glyph assembly defined, returns an empty array. */
func getVerticalGlyphAssembly(forGlyph glyph:CGGlyph) -> [GlyphPart] {
let assemblyTable = _mathTable[kVertAssembly] as! NSDictionary?
let glyphName = self.font?.get(nameForGlyph: glyph)
let assemblyInfo = assemblyTable![glyphName!] as! NSDictionary?
if assemblyInfo == nil {
// No vertical assembly defined for glyph
return []
}
let parts = assemblyInfo![kAssemblyParts] as! NSArray?
if parts == nil {
// parts should always have been defined, but if it isn't return nil
return []
}
var rv = [GlyphPart]()
for part in parts! {
let partInfo = part as! NSDictionary?
var part = GlyphPart()
let adv = partInfo!["advance"] as! NSNumber?
part.fullAdvance = self.fontUnitsToPt(adv!.intValue)
let end = partInfo!["endConnector"] as! NSNumber?
part.endConnectorLength = self.fontUnitsToPt(end!.intValue)
let start = partInfo!["startConnector"] as! NSNumber?
part.startConnectorLength = self.fontUnitsToPt(start!.intValue)
let ext = partInfo!["extender"] as! NSNumber?
part.isExtender = ext!.boolValue
let glyphName = partInfo!["glyph"] as! String?
part.glyph = self.font?.get(glyphWithName: glyphName!)
rv.append(part)
}
return rv
}
}

View file

@ -0,0 +1,37 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by .
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import SwiftUI
#if os(macOS)
public class MTLabel : NSTextField {
init() {
super.init(frame: .zero)
self.stringValue = ""
self.isBezeled = false
self.drawsBackground = false
self.isEditable = false
self.isSelectable = false
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
// MARK: - Customized getter and setter methods for property text.
var text:String? {
get { super.stringValue }
set { super.stringValue = newValue! }
}
}
#endif

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,117 @@
//
// File.swift
//
//
// Created by Peter Tang on 12/9/2023.
//
import Foundation
#if os(iOS) || os(visionOS)
import UIKit
#endif
#if os(macOS)
import AppKit
#endif
public class MTMathImage {
public var font: MTFont? = MTFontManager.fontManager.defaultFont
public var fontSize:CGFloat {
set {
_fontSize = newValue
let font = font?.copy(withSize: newValue)
self.font = font // also forces an update
}
get { _fontSize }
}
private var _fontSize:CGFloat = 0
public let textColor: MTColor
public let labelMode: MTMathUILabelMode
public let textAlignment: MTTextAlignment
public var contentInsets: MTEdgeInsets = MTEdgeInsetsZero
public let latex: String
private(set) var intrinsicContentSize = CGSize.zero
public init(latex: String, fontSize: CGFloat, textColor: MTColor, labelMode: MTMathUILabelMode = .display, textAlignment: MTTextAlignment = .center) {
self.latex = latex
self.textColor = textColor
self.labelMode = labelMode
self.textAlignment = textAlignment
self.fontSize = fontSize
}
}
extension MTMathImage {
public var currentStyle: MTLineStyle {
switch labelMode {
case .display: return .display
case .text: return .text
}
}
private func intrinsicContentSize(_ displayList: MTMathListDisplay) -> CGSize {
CGSize(width: displayList.width + contentInsets.left + contentInsets.right,
height: displayList.ascent + displayList.descent + contentInsets.top + contentInsets.bottom)
}
public func asImage() -> (NSError?, MTImage?) {
func layoutImage(size: CGSize, displayList: MTMathListDisplay) {
var textX = CGFloat(0)
switch self.textAlignment {
case .left: textX = contentInsets.left
case .center: textX = (size.width - contentInsets.left - contentInsets.right - displayList.width) / 2 + contentInsets.left
case .right: textX = size.width - displayList.width - contentInsets.right
}
let availableHeight = size.height - contentInsets.bottom - contentInsets.top
// center things vertically
var height = displayList.ascent + displayList.descent
if height < fontSize/2 {
height = fontSize/2 // set height to half the font size
}
let textY = (availableHeight - height) / 2 + displayList.descent + contentInsets.bottom
displayList.position = CGPoint(x: textX, y: textY)
}
var error: NSError?
guard let mathList = MTMathListBuilder.build(fromString: latex, error: &error), error == nil,
let displayList = MTTypesetter.createLineForMathList(mathList, font: font, style: currentStyle) else {
return (error, nil)
}
intrinsicContentSize = intrinsicContentSize(displayList)
displayList.textColor = textColor
let size = intrinsicContentSize
layoutImage(size: size, displayList: displayList)
#if os(iOS) || os(visionOS)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { rendererContext in
rendererContext.cgContext.saveGState()
rendererContext.cgContext.concatenate(.flippedVertically(size.height))
displayList.draw(rendererContext.cgContext)
rendererContext.cgContext.restoreGState()
}
return (nil, image)
#endif
#if os(macOS)
let image = NSImage(size: size, flipped: false) { bounds in
guard let context = NSGraphicsContext.current?.cgContext else { return false }
context.saveGState()
displayList.draw(context)
context.restoreGState()
return true
}
return (nil, image)
#endif
}
}
private extension CGAffineTransform {
static func flippedVertically(_ height: CGFloat) -> CGAffineTransform {
var transform = CGAffineTransform(scaleX: 1, y: -1)
transform = transform.translatedBy(x: 0, y: -height)
return transform
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,881 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Kostub Deshmukh.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import QuartzCore
import CoreText
import SwiftUI
func isIos6Supported() -> Bool {
if !MTDisplay.initialized {
#if os(iOS) || os(visionOS)
let reqSysVer = "6.0"
let currSysVer = UIDevice.current.systemVersion
if currSysVer.compare(reqSysVer, options: .numeric) != .orderedAscending {
MTDisplay.supported = true
}
#else
MTDisplay.supported = true
#endif
MTDisplay.initialized = true
}
return MTDisplay.supported
}
// The Downshift protocol allows an MTDisplay to be shifted down by a given amount.
protocol DownShift {
var shiftDown:CGFloat { set get }
}
// MARK: - MTDisplay
/// The base class for rendering a math equation.
public class MTDisplay:NSObject {
// needed for isIos6Supported() func above
static var initialized = false
static var supported = false
/// Draws itself in the given graphics context.
public func draw(_ context:CGContext) {
if self.localBackgroundColor != nil {
context.saveGState()
context.setBlendMode(.normal)
context.setFillColor(self.localBackgroundColor!.cgColor)
context.fill(self.displayBounds())
context.restoreGState()
}
}
/// Gets the bounding rectangle for the MTDisplay
func displayBounds() -> CGRect {
CGRectMake(self.position.x, self.position.y - self.descent, self.width, self.ascent + self.descent)
}
/// For debugging. Shows the object in quick look in Xcode.
#if os(iOS) || os(visionOS)
func debugQuickLookObject() -> Any {
let size = CGSizeMake(self.width, self.ascent + self.descent);
UIGraphicsBeginImageContext(size);
// get a reference to that context we created
let context = UIGraphicsGetCurrentContext()!
// translate/flip the graphics context (for transforming from CG* coords to UI* coords
context.translateBy(x: 0, y: size.height);
context.scaleBy(x: 1.0, y: -1.0);
// move the position to (0,0)
context.translateBy(x: -self.position.x, y: -self.position.y);
// Move the line up by self.descent
context.translateBy(x: 0, y: self.descent);
// Draw self on context
self.draw(context)
// generate a new UIImage from the graphics context we drew onto
let img = UIGraphicsGetImageFromCurrentImageContext()
return img as Any
}
#endif
/// The distance from the axis to the top of the display
public var ascent:CGFloat = 0
/// The distance from the axis to the bottom of the display
public var descent:CGFloat = 0
/// The width of the display
public var width:CGFloat = 0
/// Position of the display with respect to the parent view or display.
var position = CGPoint.zero
/// The range of characters supported by this item
public var range:NSRange=NSMakeRange(0, 0)
/// Whether the display has a subscript/superscript following it.
public var hasScript:Bool = false
/// The text color for this display
var textColor: MTColor?
/// The local color, if the color was mutated local with the color command
var localTextColor: MTColor?
/// The background color for this display
var localBackgroundColor: MTColor?
}
/// Special class to be inherited from that implements the DownShift protocol
class MTDisplayDS : MTDisplay, DownShift {
var shiftDown: CGFloat = 0
}
// MARK: - MTCTLineDisplay
/// A rendering of a single CTLine as an MTDisplay
public class MTCTLineDisplay : MTDisplay {
/// The CTLine being displayed
public var line:CTLine!
/// The attributed string used to generate the CTLineRef. Note setting this does not reset the dimensions of
/// the display. So set only when
var attributedString:NSAttributedString? {
didSet {
line = CTLineCreateWithAttributedString(attributedString!)
}
}
/// An array of MTMathAtoms that this CTLine displays. Used for indexing back into the MTMathList
public fileprivate(set) var atoms = [MTMathAtom]()
init(withString attrString:NSAttributedString?, position:CGPoint, range:NSRange, font:MTFont?, atoms:[MTMathAtom]) {
super.init()
self.position = position
self.attributedString = attrString
self.line = CTLineCreateWithAttributedString(attrString!)
self.range = range
self.atoms = atoms
// We can't use typographic bounds here as the ascent and descent returned are for the font and not for the line.
// CRITICAL FIX for accented character clipping:
// Use the MAXIMUM of typographic width and visual width to account for glyph overhang.
// - Typographic width = advance width (how far the cursor moves)
// - Visual width = actual glyph extent (CGRectGetMaxX of glyph path bounds)
// Some glyphs (especially italic/oblique accented characters) extend beyond their advance width.
// Using max() ensures we account for overhang while maintaining proper spacing for normal glyphs.
let typographicWidth = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
if isIos6Supported() {
let bounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
self.ascent = max(0, CGRectGetMaxY(bounds) - 0);
self.descent = max(0, 0 - CGRectGetMinY(bounds));
// Use the maximum of visual and typographic width to handle both:
// 1. Overhanging glyphs (visual > typographic) - prevents clipping
// 2. Normal glyphs (typographic >= visual) - maintains correct spacing
let visualWidth = CGRectGetMaxX(bounds)
self.width = max(typographicWidth, visualWidth);
} else {
// Our own implementation of the ios6 function to get glyph path bounds.
self.computeDimensions(font, typographicWidth: typographicWidth)
}
}
override var textColor: MTColor? {
set {
super.textColor = newValue
let attrStr = attributedString!.mutableCopy() as! NSMutableAttributedString
let foregroundColor = NSAttributedString.Key(kCTForegroundColorAttributeName as String)
attrStr.addAttribute(foregroundColor, value:self.textColor!.cgColor, range:NSMakeRange(0, attrStr.length))
self.attributedString = attrStr
}
get { super.textColor }
}
func computeDimensions(_ font:MTFont?, typographicWidth: CGFloat) {
let runs = CTLineGetGlyphRuns(line) as NSArray
var maxVisualWidth: CGFloat = 0
var currentX: CGFloat = 0
for obj in runs {
let run = obj as! CTRun?
let numGlyphs = CTRunGetGlyphCount(run!)
var glyphs = [CGGlyph]()
glyphs.reserveCapacity(numGlyphs)
CTRunGetGlyphs(run!, CFRangeMake(0, numGlyphs), &glyphs);
let bounds = CTFontGetBoundingRectsForGlyphs(font!.ctFont, .horizontal, glyphs, nil, numGlyphs);
let ascent = max(0, CGRectGetMaxY(bounds) - 0);
// Descent is how much the line goes below the origin. However if the line is all above the origin, then descent can't be negative.
let descent = max(0, 0 - CGRectGetMinY(bounds));
if (ascent > self.ascent) {
self.ascent = ascent;
}
if (descent > self.descent) {
self.descent = descent;
}
// Calculate visual width using glyph extent
// Get the rightmost edge of this run's glyphs
let runVisualWidth = CGRectGetMaxX(bounds)
let runRightEdge = currentX + runVisualWidth
// Get advances to know where next run starts
var advances = [CGSize](repeating: CGSize.zero, count: numGlyphs)
CTRunGetAdvances(run!, CFRangeMake(0, numGlyphs), &advances)
for advance in advances {
currentX += advance.width
}
if (runRightEdge > maxVisualWidth) {
maxVisualWidth = runRightEdge
}
}
// Use maximum of typographic and visual width
self.width = max(typographicWidth, maxVisualWidth)
}
override public func draw(_ context: CGContext) {
super.draw(context)
context.saveGState()
context.textPosition = self.position
CTLineDraw(line, context)
context.restoreGState()
}
}
// MARK: - MTMathListDisplay
/// An MTLine is a rendered form of MTMathList in one line.
/// It can render itself using the draw method.
public class MTMathListDisplay : MTDisplay {
/**
The type of position for a line, i.e. subscript/superscript or regular.
*/
public enum LinePosition : Int {
/// Regular
case regular
/// Positioned at a subscript
case ssubscript
/// Positioned at a superscript
case superscript
}
/// Where the line is positioned
public var type:LinePosition = .regular
/// An array of MTDisplays which are positioned relative to the position of the
/// the current display.
public fileprivate(set) var subDisplays = [MTDisplay]()
/// If a subscript or superscript this denotes the location in the parent MTList. For a
/// regular list this is NSNotFound
public var index: Int = 0
init(withDisplays displays:[MTDisplay], range:NSRange) {
super.init()
self.subDisplays = displays
self.position = CGPoint.zero
self.type = .regular
self.index = NSNotFound
self.range = range
self.recomputeDimensions()
}
override var textColor: MTColor? {
set {
super.textColor = newValue
for displayAtom in self.subDisplays {
if displayAtom.localTextColor == nil {
displayAtom.textColor = newValue
} else {
displayAtom.textColor = displayAtom.localTextColor
}
}
}
get { super.textColor }
}
override public func draw(_ context: CGContext) {
super.draw(context)
context.saveGState()
// Make the current position the origin as all the positions of the sub atoms are relative to the origin.
context.translateBy(x: self.position.x, y: self.position.y)
context.textPosition = CGPoint.zero
// draw each atom separately
for displayAtom in self.subDisplays {
displayAtom.draw(context)
}
context.restoreGState()
}
func recomputeDimensions() {
var max_ascent:CGFloat = 0
var max_descent:CGFloat = 0
var max_width:CGFloat = 0
for atom in self.subDisplays {
let ascent = max(0, atom.position.y + atom.ascent);
if (ascent > max_ascent) {
max_ascent = ascent;
}
let descent = max(0, 0 - (atom.position.y - atom.descent));
if (descent > max_descent) {
max_descent = descent;
}
let width = atom.width + atom.position.x;
if (width > max_width) {
max_width = width;
}
}
self.ascent = max_ascent;
self.descent = max_descent;
self.width = max_width;
}
}
// MARK: - MTFractionDisplay
/// Rendering of an MTFraction as an MTDisplay
public class MTFractionDisplay : MTDisplay {
/** A display representing the numerator of the fraction. Its position is relative
to the parent and is not treated as a sub-display.
*/
public fileprivate(set) var numerator:MTMathListDisplay?
/** A display representing the denominator of the fraction. Its position is relative
to the parent is not treated as a sub-display.
*/
public fileprivate(set) var denominator:MTMathListDisplay?
var numeratorUp:CGFloat=0 { didSet { self.updateNumeratorPosition() } }
var denominatorDown:CGFloat=0 { didSet { self.updateDenominatorPosition() } }
var linePosition:CGFloat=0
var lineThickness:CGFloat=0
init(withNumerator numerator:MTMathListDisplay?, denominator:MTMathListDisplay?, position:CGPoint, range:NSRange) {
super.init()
self.numerator = numerator;
self.denominator = denominator;
self.position = position;
// CRITICAL FIX: Handle invalid ranges gracefully
// When table cells are typeset independently with maxWidth, atoms may have
// ranges that are invalid in the cell's context (e.g., (0,0) or other issues)
// Instead of crashing with assertion, normalize the range
if range.length == 0 {
// Create a dummy range with length 1 at the given location
self.range = NSMakeRange(range.location, 1)
} else {
self.range = range;
// Still assert for debugging if range length is something unexpected
assert(self.range.length == 1, "Fraction range length not 1 - range (\(range.location), \(range.length)")
}
}
override public var ascent:CGFloat {
set { super.ascent = newValue }
get { (numerator?.ascent ?? 0) + self.numeratorUp }
}
override public var descent:CGFloat {
set { super.descent = newValue }
get { (denominator?.descent ?? 0) + self.denominatorDown }
}
override public var width:CGFloat {
set { super.width = newValue }
get { max(numerator?.width ?? 0, denominator?.width ?? 0) }
}
func updateDenominatorPosition() {
guard denominator != nil else { return }
denominator!.position = CGPointMake(self.position.x + (self.width - denominator!.width)/2, self.position.y - self.denominatorDown)
}
func updateNumeratorPosition() {
guard numerator != nil else { return }
numerator!.position = CGPointMake(self.position.x + (self.width - numerator!.width)/2, self.position.y + self.numeratorUp)
}
override var position: CGPoint {
set {
super.position = newValue
self.updateDenominatorPosition()
self.updateNumeratorPosition()
}
get { super.position }
}
override var textColor: MTColor? {
set {
super.textColor = newValue
numerator?.textColor = newValue
denominator?.textColor = newValue
}
get { super.textColor }
}
override public func draw(_ context:CGContext) {
super.draw(context)
numerator?.draw(context)
denominator?.draw(context)
context.saveGState()
self.textColor?.setStroke()
// draw the horizontal line
// Note: line thickness of 0 draws the thinnest possible line - we want no line so check for 0s
if self.lineThickness > 0 {
let path = MTBezierPath()
path.move(to: CGPointMake(self.position.x, self.position.y + self.linePosition))
path.addLine(to: CGPointMake(self.position.x + self.width, self.position.y + self.linePosition))
path.lineWidth = self.lineThickness
path.stroke()
}
context.restoreGState()
}
}
// MARK: - MTRadicalDisplay
/// Rendering of an MTRadical as an MTDisplay
class MTRadicalDisplay : MTDisplay {
/** A display representing the radicand of the radical. Its position is relative
to the parent is not treated as a sub-display.
*/
public fileprivate(set) var radicand:MTMathListDisplay?
/** A display representing the degree of the radical. Its position is relative
to the parent is not treated as a sub-display.
*/
public fileprivate(set) var degree:MTMathListDisplay?
override var position: CGPoint {
set {
super.position = newValue
self.updateRadicandPosition()
}
get { super.position }
}
override var textColor: MTColor? {
set {
super.textColor = newValue
self.radicand?.textColor = newValue
self.degree?.textColor = newValue
}
get { super.textColor }
}
private var _radicalGlyph:MTDisplay?
private var _radicalShift:CGFloat=0
var topKern:CGFloat=0
var lineThickness:CGFloat=0
init(withRadicand radicand:MTMathListDisplay?, glyph:MTDisplay, position:CGPoint, range:NSRange) {
super.init()
self.radicand = radicand
_radicalGlyph = glyph
_radicalShift = 0
self.position = position
self.range = range
}
func setDegree(_ degree:MTMathListDisplay?, fontMetrics:MTFontMathTable?) {
// sets up the degree of the radical
var kernBefore = fontMetrics!.radicalKernBeforeDegree;
let kernAfter = fontMetrics!.radicalKernAfterDegree;
let raise = fontMetrics!.radicalDegreeBottomRaisePercent * (self.ascent - self.descent);
// The layout is:
// kernBefore, raise, degree, kernAfter, radical
self.degree = degree;
// the radical is now shifted by kernBefore + degree.width + kernAfter
_radicalShift = kernBefore + degree!.width + kernAfter;
if _radicalShift < 0 {
// we can't have the radical shift backwards, so instead we increase the kernBefore such
// that _radicalShift will be 0.
kernBefore -= _radicalShift;
_radicalShift = 0;
}
// Note: position of degree is relative to parent.
self.degree!.position = CGPointMake(self.position.x + kernBefore, self.position.y + raise);
// Update the width by the _radicalShift
self.width = _radicalShift + _radicalGlyph!.width + self.radicand!.width;
// update the position of the radicand
self.updateRadicandPosition()
}
func updateRadicandPosition() {
// The position of the radicand includes the position of the MTRadicalDisplay
// This is to make the positioning of the radical consistent with fractions and
// have the cursor position finding algorithm work correctly.
// move the radicand by the width of the radical sign
self.radicand!.position = CGPointMake(self.position.x + _radicalShift + _radicalGlyph!.width, self.position.y);
}
override public func draw(_ context: CGContext) {
super.draw(context)
// draw the radicand & degree at its position
self.radicand?.draw(context)
self.degree?.draw(context)
context.saveGState();
self.textColor?.setStroke()
self.textColor?.setFill()
// Make the current position the origin as all the positions of the sub atoms are relative to the origin.
context.translateBy(x: self.position.x + _radicalShift, y: self.position.y);
context.textPosition = CGPoint.zero
// Draw the glyph.
_radicalGlyph?.draw(context)
// Draw the VBOX
// for the kern of, we don't need to draw anything.
let heightFromTop = topKern;
// draw the horizontal line with the given thickness
let path = MTBezierPath()
let lineStart = CGPointMake(_radicalGlyph!.width, self.ascent - heightFromTop - self.lineThickness / 2); // subtract half the line thickness to center the line
let lineEnd = CGPointMake(lineStart.x + self.radicand!.width, lineStart.y);
path.move(to: lineStart)
path.addLine(to: lineEnd)
path.lineWidth = lineThickness
path.lineCapStyle = .round
path.stroke()
context.restoreGState();
}
}
// MARK: - MTGlyphDisplay
/// Rendering a glyph as a display
class MTGlyphDisplay : MTDisplayDS {
var glyph:CGGlyph!
var font:MTFont?
/// Horizontal scale factor for stretching glyphs (1.0 = no scaling)
var scaleX: CGFloat = 1.0
init(withGlpyh glyph:CGGlyph, range:NSRange, font:MTFont?) {
super.init()
self.font = font
self.glyph = glyph
self.position = CGPoint.zero
self.range = range
}
override public func draw(_ context: CGContext) {
super.draw(context)
context.saveGState()
self.textColor?.setFill()
// Make the current position the origin as all the positions of the sub atoms are relative to the origin.
context.translateBy(x: self.position.x, y: self.position.y - self.shiftDown);
// Apply horizontal scaling if needed (for stretchy arrows)
if scaleX != 1.0 {
context.scaleBy(x: scaleX, y: 1.0)
}
context.textPosition = CGPoint.zero
var pos = CGPoint.zero
CTFontDrawGlyphs(font!.ctFont, &glyph, &pos, 1, context);
context.restoreGState();
}
override var ascent:CGFloat {
set { super.ascent = newValue }
get { super.ascent - self.shiftDown }
}
override var descent:CGFloat {
set { super.descent = newValue }
get { super.descent + self.shiftDown }
}
}
// MARK: - MTGlyphConstructionDisplay
class MTGlyphConstructionDisplay:MTDisplayDS {
var glyphs = [CGGlyph]()
var positions = [CGPoint]()
var font:MTFont?
var numGlyphs:Int=0
init(withGlyphs glyphs:[NSNumber?], offsets:[NSNumber?], font:MTFont?) {
super.init()
assert(glyphs.count == offsets.count, "Glyphs and offsets need to match")
self.numGlyphs = glyphs.count;
self.glyphs = [CGGlyph](repeating: CGGlyph(), count: self.numGlyphs) //malloc(sizeof(CGGlyph) * _numGlyphs);
self.positions = [CGPoint](repeating: CGPoint.zero, count: self.numGlyphs) //malloc(sizeof(CGPoint) * _numGlyphs);
for i in 0 ..< self.numGlyphs {
self.glyphs[i] = glyphs[i]!.uint16Value
self.positions[i] = CGPointMake(0, CGFloat(offsets[i]!.floatValue))
}
self.font = font
self.position = CGPoint.zero
}
override public func draw(_ context: CGContext) {
super.draw(context)
context.saveGState()
self.textColor?.setFill()
// Make the current position the origin as all the positions of the sub atoms are relative to the origin.
context.translateBy(x: self.position.x, y: self.position.y - self.shiftDown)
context.textPosition = CGPoint.zero
// Draw the glyphs.
CTFontDrawGlyphs(font!.ctFont, glyphs, positions, numGlyphs, context)
context.restoreGState()
}
override var ascent:CGFloat {
set { super.ascent = newValue }
get { super.ascent - self.shiftDown }
}
override var descent:CGFloat {
set { super.descent = newValue }
get { super.descent + self.shiftDown }
}
}
// MARK: - MTLargeOpLimitsDisplay
/// Rendering a large operator with limits as an MTDisplay
class MTLargeOpLimitsDisplay : MTDisplay {
/** A display representing the upper limit of the large operator. Its position is relative
to the parent is not treated as a sub-display.
*/
var upperLimit:MTMathListDisplay?
/** A display representing the lower limit of the large operator. Its position is relative
to the parent is not treated as a sub-display.
*/
var lowerLimit:MTMathListDisplay?
var limitShift:CGFloat=0
var upperLimitGap:CGFloat=0 { didSet { self.updateUpperLimitPosition() } }
var lowerLimitGap:CGFloat=0 { didSet { self.updateLowerLimitPosition() } }
var extraPadding:CGFloat=0
var nucleus:MTDisplay?
init(withNucleus nucleus:MTDisplay?, upperLimit:MTMathListDisplay?, lowerLimit:MTMathListDisplay?, limitShift:CGFloat, extraPadding:CGFloat) {
super.init()
self.upperLimit = upperLimit;
self.lowerLimit = lowerLimit;
self.nucleus = nucleus;
var maxWidth = max(nucleus!.width, upperLimit?.width ?? 0)
maxWidth = max(maxWidth, lowerLimit?.width ?? 0)
self.limitShift = limitShift;
self.upperLimitGap = 0;
self.lowerLimitGap = 0;
self.extraPadding = extraPadding; // corresponds to \xi_13 in TeX
self.width = maxWidth;
}
override var ascent:CGFloat {
set { super.ascent = newValue }
get {
if self.upperLimit != nil {
return nucleus!.ascent + extraPadding + self.upperLimit!.ascent + upperLimitGap + self.upperLimit!.descent
} else {
return nucleus!.ascent
}
}
}
override var descent:CGFloat {
set { super.descent = newValue }
get {
if self.lowerLimit != nil {
return nucleus!.descent + extraPadding + lowerLimitGap + self.lowerLimit!.descent + self.lowerLimit!.ascent;
} else {
return nucleus!.descent;
}
}
}
override var position: CGPoint {
set {
super.position = newValue
self.updateLowerLimitPosition()
self.updateUpperLimitPosition()
self.updateNucleusPosition()
}
get { super.position }
}
func updateLowerLimitPosition() {
if self.lowerLimit != nil {
// The position of the lower limit includes the position of the MTLargeOpLimitsDisplay
// This is to make the positioning of the radical consistent with fractions and radicals
// Move the starting point to below the nucleus leaving a gap of _lowerLimitGap and subtract
// the ascent to to get the baseline. Also center and shift it to the left by _limitShift.
self.lowerLimit!.position = CGPointMake(self.position.x - limitShift + (self.width - lowerLimit!.width)/2,
self.position.y - nucleus!.descent - lowerLimitGap - self.lowerLimit!.ascent);
}
}
func updateUpperLimitPosition() {
if self.upperLimit != nil {
// The position of the upper limit includes the position of the MTLargeOpLimitsDisplay
// This is to make the positioning of the radical consistent with fractions and radicals
// Move the starting point to above the nucleus leaving a gap of _upperLimitGap and add
// the descent to to get the baseline. Also center and shift it to the right by _limitShift.
self.upperLimit!.position = CGPointMake(self.position.x + limitShift + (self.width - self.upperLimit!.width)/2,
self.position.y + nucleus!.ascent + upperLimitGap + self.upperLimit!.descent);
}
}
func updateNucleusPosition() {
// Center the nucleus
nucleus?.position = CGPointMake(self.position.x + (self.width - nucleus!.width)/2, self.position.y);
}
override var textColor: MTColor? {
set {
super.textColor = newValue
self.upperLimit?.textColor = newValue
self.lowerLimit?.textColor = newValue
nucleus?.textColor = newValue
}
get { super.textColor }
}
override func draw(_ context:CGContext) {
super.draw(context)
// Draw the elements.
self.upperLimit?.draw(context)
self.lowerLimit?.draw(context)
nucleus?.draw(context)
}
}
// MARK: - MTLineDisplay
/// Rendering of an list with an overline or underline
class MTLineDisplay : MTDisplay {
/** A display representing the inner list that is underlined. Its position is relative
to the parent is not treated as a sub-display.
*/
var inner:MTMathListDisplay?
var lineShiftUp:CGFloat=0
var lineThickness:CGFloat=0
init(withInner inner:MTMathListDisplay?, position:CGPoint, range:NSRange) {
super.init()
self.inner = inner;
self.position = position;
self.range = range;
}
override var textColor: MTColor? {
set {
super.textColor = newValue
inner?.textColor = newValue
}
get { super.textColor }
}
override var position: CGPoint {
set {
super.position = newValue
self.updateInnerPosition()
}
get { super.position }
}
override func draw(_ context:CGContext) {
super.draw(context)
self.inner?.draw(context)
context.saveGState();
self.textColor?.setStroke()
// draw the horizontal line
let path = MTBezierPath()
let lineStart = CGPointMake(self.position.x, self.position.y + self.lineShiftUp);
let lineEnd = CGPointMake(lineStart.x + self.inner!.width, lineStart.y);
path.move(to:lineStart)
path.addLine(to: lineEnd)
path.lineWidth = self.lineThickness;
path.stroke()
context.restoreGState();
}
func updateInnerPosition() {
self.inner?.position = CGPointMake(self.position.x, self.position.y);
}
}
// MARK: - MTAccentDisplay
/// Rendering an accent as a display
class MTAccentDisplay : MTDisplay {
/** A display representing the inner list that is accented. Its position is relative
to the parent is not treated as a sub-display.
*/
var accentee:MTMathListDisplay?
/** A display representing the accent. Its position is relative to the current display.
*/
var accent:MTGlyphDisplay?
init(withAccent glyph:MTGlyphDisplay?, accentee:MTMathListDisplay?, range:NSRange) {
super.init()
self.accent = glyph
self.accentee = accentee
self.accentee?.position = CGPoint.zero
self.range = range
}
override var textColor: MTColor? {
set {
super.textColor = newValue
accentee?.textColor = newValue
accent?.textColor = newValue
}
get { super.textColor }
}
override var position: CGPoint {
set {
super.position = newValue
self.updateAccenteePosition()
}
get { super.position }
}
func updateAccenteePosition() {
self.accentee?.position = CGPointMake(self.position.x, self.position.y);
}
override func draw(_ context:CGContext) {
super.draw(context)
self.accentee?.draw(context)
context.saveGState();
context.translateBy(x: self.position.x, y: self.position.y);
context.textPosition = CGPoint.zero
self.accent?.draw(context)
context.restoreGState();
}
}

View file

@ -0,0 +1,197 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Kostub Deshmukh.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
/**
* An index that points to a particular character in the MTMathList. The index is a LinkedList that represents
* a path from the beginning of the MTMathList to reach a particular atom in the list. The next node of the path
* is represented by the subIndex. The path terminates when the subIndex is nil.
*
* If there is a subIndex, the subIndexType denotes what branch the path takes (i.e. superscript, subscript,
* numerator, denominator etc.).
* e.g in the expression 25^{2/4} the index of the character 4 is represented as:
* (1, superscript) -> (0, denominator) -> (0, none)
* This can be interpreted as start at index 1 (i.e. the 5) go up to the superscript.
* Then look at index 0 (i.e. 2/4) and go to the denominator. Then look up index 0 (i.e. the 4) which this final
* index.
*
* The level of an index is the number of nodes in the LinkedList to get to the final path.
*/
public class MTMathListIndex {
/**
The type of the subindex.
The type of the subindex denotes what branch the path to the atom that this index points to takes.
*/
public enum MTMathListSubIndexType: Int {
/// The index denotes the whole atom, subIndex is nil.
case none = 0
/// The position in the subindex is an index into the nucleus
case nucleus
/// The subindex indexes into the superscript.
case superscript
/// The subindex indexes into the subscript
case ssubscript
/// The subindex indexes into the numerator (only valid for fractions)
case numerator
/// The subindex indexes into the denominator (only valid for fractions)
case denominator
/// The subindex indexes into the radicand (only valid for radicals)
case radicand
/// The subindex indexes into the degree (only valid for radicals)
case degree
}
/// The index of the associated atom.
var atomIndex: Int
/// The type of subindex, e.g. superscript, numerator etc.
var subIndexType: MTMathListSubIndexType = .none
/// The index into the sublist.
var subIndex: MTMathListIndex?
var finalIndex: Int {
if self.subIndexType == .none {
return self.atomIndex
} else {
return self.subIndex?.finalIndex ?? 0
}
}
/// Returns the previous index if present. Returns `nil` if there is no previous index.
func prevIndex() -> MTMathListIndex? {
if self.subIndexType == .none {
if self.atomIndex > 0 {
return MTMathListIndex(level0Index: self.atomIndex - 1)
}
} else {
if let prevSubIndex = self.subIndex?.prevIndex() {
return MTMathListIndex(at: self.atomIndex, with: prevSubIndex, type: self.subIndexType)
}
}
return nil
}
/// Returns the next index.
func nextIndex() -> MTMathListIndex {
if self.subIndexType == .none {
return MTMathListIndex(level0Index: self.atomIndex + 1)
} else if self.subIndexType == .nucleus {
return MTMathListIndex(at: self.atomIndex + 1, with: self.subIndex, type: self.subIndexType)
} else {
return MTMathListIndex(at: self.atomIndex, with: self.subIndex?.nextIndex(), type: self.subIndexType)
}
}
/**
* Returns true if this index represents the beginning of a line. Note there may be multiple lines in a MTMathList,
* e.g. a superscript or a fraction numerator. This returns true if the innermost subindex points to the beginning of a
* line.
*/
func isBeginningOfLine() -> Bool { self.finalIndex == 0 }
func isAtSameLevel(with index: MTMathListIndex?) -> Bool {
if self.subIndexType != index?.subIndexType {
return false
} else if self.subIndexType == .none {
// No subindexes, they are at the same level.
return true
} else if (self.atomIndex != index?.atomIndex) {
return false
} else {
return self.subIndex?.isAtSameLevel(with: index?.subIndex) ?? false
}
}
/** Returns the type of the innermost sub index. */
func finalSubIndexType() -> MTMathListSubIndexType {
if self.subIndex?.subIndex != nil {
return self.subIndex!.finalSubIndexType()
} else {
return self.subIndexType
}
}
/** Returns true if any of the subIndexes of this index have the given type. */
func hasSubIndex(ofType type: MTMathListSubIndexType) -> Bool {
if self.subIndexType == type {
return true
} else {
return self.subIndex?.hasSubIndex(ofType: type) ?? false
}
}
func levelUp(with subIndex: MTMathListIndex?, type: MTMathListSubIndexType) -> MTMathListIndex {
if self.subIndexType == .none {
return MTMathListIndex(at: self.atomIndex, with: subIndex, type: type)
}
return MTMathListIndex(at: self.atomIndex, with: self.subIndex?.levelUp(with: subIndex, type: type), type: self.subIndexType)
}
func levelDown() -> MTMathListIndex? {
if self.subIndexType == .none {
return nil
}
if let subIndexDown = self.subIndex?.levelDown() {
return MTMathListIndex(at: self.atomIndex, with: subIndexDown, type: self.subIndexType)
} else {
return MTMathListIndex(level0Index: self.atomIndex)
}
}
/** Factory function to create a `MTMathListIndex` with no subindexes.
@param index The index of the atom that the `MTMathListIndex` points at.
*/
public init(level0Index: Int) {
self.atomIndex = level0Index
}
public convenience init(at location: Int, with subIndex: MTMathListIndex?, type: MTMathListSubIndexType) {
self.init(level0Index: location)
self.subIndexType = type
self.subIndex = subIndex
}
}
extension MTMathListIndex: CustomStringConvertible {
public var description: String {
if self.subIndex != nil {
return "[\(self.atomIndex), \(self.subIndexType.rawValue):\(self.subIndex!)]"
}
return "[\(self.atomIndex)]"
}
}
extension MTMathListIndex: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(self.atomIndex)
hasher.combine(self.subIndexType)
hasher.combine(self.subIndex)
}
}
extension MTMathListIndex: Equatable {
public static func ==(lhs: MTMathListIndex, rhs: MTMathListIndex) -> Bool {
if lhs.atomIndex != rhs.atomIndex || lhs.subIndexType != rhs.subIndexType {
return false
}
if rhs.subIndex != nil {
return rhs.subIndex == lhs.subIndex
} else {
return lhs.subIndex == nil
}
}
}

View file

@ -0,0 +1,83 @@
import Foundation
#if os(iOS) || os(visionOS)
import UIKit
#endif
#if os(macOS)
import AppKit
#endif
public struct MTMathRenderedFormula {
public let image: MTImage
public let size: CGSize
public let width: CGFloat
public let ascent: CGFloat
public let descent: CGFloat
public init(image: MTImage, size: CGSize, width: CGFloat, ascent: CGFloat, descent: CGFloat) {
self.image = image
self.size = size
self.width = width
self.ascent = ascent
self.descent = descent
}
}
public enum MTMathRenderer {
public static func render(latex: String, fontSize: CGFloat, textColor: MTColor, mode: MTMathUILabelMode = .display) -> MTMathRenderedFormula? {
guard let font = MTFontManager.fontManager.defaultFont?.copy(withSize: fontSize) else {
return nil
}
var error: NSError?
guard let mathList = MTMathListBuilder.build(fromString: latex, error: &error), error == nil else {
return nil
}
let style: MTLineStyle
switch mode {
case .display:
style = .display
case .text:
style = .text
}
guard let displayList = MTTypesetter.createLineForMathList(mathList, font: font, style: style) else {
return nil
}
displayList.textColor = textColor
let width = max(1.0, ceil(displayList.width))
let height = max(1.0, ceil(displayList.ascent + displayList.descent))
let size = CGSize(width: width, height: height)
displayList.position = CGPoint(x: 0.0, y: displayList.descent)
#if os(iOS) || os(visionOS)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { rendererContext in
rendererContext.cgContext.saveGState()
var transform = CGAffineTransform(scaleX: 1.0, y: -1.0)
transform = transform.translatedBy(x: 0.0, y: -size.height)
rendererContext.cgContext.concatenate(transform)
displayList.draw(rendererContext.cgContext)
rendererContext.cgContext.restoreGState()
}
return MTMathRenderedFormula(image: image, size: size, width: displayList.width, ascent: displayList.ascent, descent: displayList.descent)
#endif
#if os(macOS)
let image = NSImage(size: size, flipped: false) { _ in
guard let context = NSGraphicsContext.current?.cgContext else {
return false
}
context.saveGState()
displayList.draw(context)
context.restoreGState()
return true
}
return MTMathRenderedFormula(image: image, size: size, width: displayList.width, ascent: displayList.ascent, descent: displayList.descent)
#endif
}
}

View file

@ -0,0 +1,587 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Kostub Deshmukh.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import CoreText
/**
Different display styles supported by the `MTMathUILabel`.
The only significant difference between the two modes is how fractions
and limits on large operators are displayed.
*/
public enum MTMathUILabelMode {
/// Display mode. Equivalent to $$ in TeX
case display
/// Text mode. Equivalent to $ in TeX.
case text
}
/**
Horizontal text alignment for `MTMathUILabel`.
*/
public enum MTTextAlignment : UInt {
/// Align left.
case left
/// Align center.
case center
/// Align right.
case right
}
/** The main view for rendering math.
`MTMathLabel` accepts either a string in LaTeX or an `MTMathList` to display. Use
`MTMathList` directly only if you are building it programmatically (e.g. using an
editor), otherwise using LaTeX is the preferable method.
The math display is centered vertically in the label. The default horizontal alignment is
is left. This can be changed by setting `textAlignment`. The math is default displayed in
*Display* mode. This can be changed using `labelMode`.
When created it uses `[MTFontManager defaultFont]` as its font. This can be changed using
the `font` parameter.
*/
@IBDesignable
public class MTMathUILabel : MTView {
/** The `MTMathList` to render. Setting this will remove any
`latex` that has already been set. If `latex` has been set, this will
return the parsed `MTMathList` if the `latex` parses successfully. Use this
setting if the `MTMathList` has been programmatically constructed, otherwise it
is preferred to use `latex`.
*/
public var mathList:MTMathList? {
set {
_mathList = newValue
_error = nil
_latex = MTMathListBuilder.mathListToString(newValue)
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _mathList }
}
private var _mathList:MTMathList?
/** The latex string to be displayed. Setting this will remove any `mathList` that
has been set. If latex has not been set, this will return the latex output for the
`mathList` that is set.
@see error */
@IBInspectable
public var latex:String {
set {
_latex = newValue
_error = nil
var error : NSError? = nil
_mathList = MTMathListBuilder.build(fromString: newValue, error: &error)
if error != nil {
_mathList = nil
_error = error
self.errorLabel?.text = error!.localizedDescription
self.errorLabel?.frame = self.bounds
self.errorLabel?.isHidden = !self.displayErrorInline
} else {
self.errorLabel?.isHidden = true
}
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _latex }
}
private var _latex = ""
/** This contains any error that occurred when parsing the latex. */
public var error:NSError? { _error }
private var _error:NSError?
/** If true, if there is an error it displays the error message inline. Default true. */
public var displayErrorInline = true
/** If true, enables detailed debug logging for intrinsicContentSize calculations.
This helps diagnose rendering issues like missing row breaks in aligned environments.
Default false. */
public var debugLogging = false
/** The MTFont to use for rendering. */
public var font:MTFont? {
set {
guard newValue != nil else { return }
_font = newValue
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _font }
}
private var _font:MTFont?
/** Convenience method to just set the size of the font without changing the fontface. */
@IBInspectable
public var fontSize:CGFloat {
set {
_fontSize = newValue
let font = font?.copy(withSize: newValue)
self.font = font // also forces an update
}
get { _fontSize }
}
private var _fontSize:CGFloat=0
/** This sets the text color of the rendered math formula. The default color is black. */
@IBInspectable
public var textColor:MTColor? {
set {
guard newValue != nil else { return }
_textColor = newValue
self.displayList?.textColor = newValue
self.setNeedsDisplay()
}
get { _textColor }
}
private var _textColor:MTColor?
/** The minimum distance from the margin of the view to the rendered math. This value is
`UIEdgeInsetsZero` by default. This is useful if you need some padding between the math and
the border/background color. sizeThatFits: will have its returned size increased by these insets.
*/
@IBInspectable
public var contentInsets:MTEdgeInsets {
set {
_contentInsets = newValue
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _contentInsets }
}
private var _contentInsets = MTEdgeInsetsZero
/** The Label mode for the label. The default mode is Display */
public var labelMode:MTMathUILabelMode {
set {
_labelMode = newValue
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _labelMode }
}
private var _labelMode = MTMathUILabelMode.display
/** Horizontal alignment for the text. The default is align left. */
public var textAlignment:MTTextAlignment {
set {
_textAlignment = newValue
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _textAlignment }
}
private var _textAlignment = MTTextAlignment.left
/** The internal display of the MTMathUILabel. This is for advanced use only. */
public var displayList: MTMathListDisplay? { _displayList }
private var _displayList:MTMathListDisplay?
/** The preferred maximum width (in points) for a multiline label.
Set this property to enable line wrapping based on available width. */
public var preferredMaxLayoutWidth: CGFloat {
set {
_preferredMaxLayoutWidth = newValue
_displayList = nil // Clear cached display list when width constraint changes
self.invalidateIntrinsicContentSize()
self.setNeedsLayout()
}
get { _preferredMaxLayoutWidth }
}
private var _preferredMaxLayoutWidth: CGFloat = 0
public var currentStyle:MTLineStyle {
switch _labelMode {
case .display: return .display
case .text: return .text
}
}
public var errorLabel: MTLabel?
public override init(frame: CGRect) {
super.init(frame: frame)
self.initCommon()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
self.initCommon()
}
func initCommon() {
#if os(macOS)
self.layer?.isGeometryFlipped = true
#else
self.layer.isGeometryFlipped = true
self.clipsToBounds = true
#endif
_fontSize = 20
_contentInsets = MTEdgeInsetsZero
_labelMode = .display
let font = MTFontManager.fontManager.defaultFont
self.font = font
_textAlignment = .left
_displayList = nil
displayErrorInline = true
self.backgroundColor = MTColor.clear
_textColor = MTColor.black
let label = MTLabel()
self.errorLabel = label
#if os(macOS)
label.layer?.isGeometryFlipped = true
#else
label.layer.isGeometryFlipped = true
#endif
label.isHidden = true
label.textColor = MTColor.red
self.addSubview(label)
}
override public func draw(_ dirtyRect: MTRect) {
super.draw(dirtyRect)
if self.mathList == nil { return }
if self.font == nil { return }
// Ensure display list is created before drawing
if _displayList == nil {
_layoutSubviews()
}
guard let displayList = _displayList else { return }
// drawing code
let context = MTGraphicsGetCurrentContext()!
context.saveGState()
// CRITICAL FIX for clipping: If the displayList is wider than our bounds,
// expand the clipping rect to prevent content clipping.
// This handles cases where preferredMaxLayoutWidth is a hint but the content
// cannot fit within it even with line breaking.
let contentWidth = displayList.width + contentInsets.left + contentInsets.right
if contentWidth > bounds.size.width {
// Content is wider than bounds - expand clip rect
let expandedRect = CGRect(
x: bounds.origin.x,
y: bounds.origin.y,
width: contentWidth,
height: bounds.size.height
)
context.clip(to: expandedRect)
}
displayList.draw(context)
context.restoreGState()
}
func _layoutSubviews() {
guard _mathList != nil && self.font != nil else {
_displayList = nil
errorLabel?.frame = self.bounds
self.setNeedsDisplay()
return
}
// Ensure we have a valid font before attempting to typeset
if self.font == nil {
// No valid font - try to get default font
if let defaultFont = MTFontManager.fontManager.defaultFont {
self._font = defaultFont
} else {
// Cannot typeset without a font, clear display list
_displayList = nil
errorLabel?.frame = self.bounds
self.setNeedsDisplay()
return
}
}
// Use the effective width for layout
let effectiveWidth = _preferredMaxLayoutWidth > 0 ? _preferredMaxLayoutWidth : bounds.size.width
var availableWidth = effectiveWidth - contentInsets.left - contentInsets.right
// CRITICAL FIX: Ensure availableWidth is never negative
// Negative maxWidth passed to MTTypesetter can cause "Negative value is not representable" crashes
availableWidth = max(0, availableWidth)
_displayList = MTTypesetter.createLineForMathList(_mathList, font: self.font, style: currentStyle, maxWidth: availableWidth)
guard let displayList = _displayList else {
// Empty or invalid input - nothing to display
return
}
displayList.textColor = textColor
var textX = CGFloat(0)
switch self.textAlignment {
case .left: textX = contentInsets.left
case .center: textX = (bounds.size.width - contentInsets.left - contentInsets.right - displayList.width) / 2 + contentInsets.left
case .right: textX = bounds.size.width - displayList.width - contentInsets.right
}
let availableHeight = bounds.size.height - contentInsets.bottom - contentInsets.top
// center things vertically
var height = displayList.ascent + displayList.descent
if height < fontSize/2 {
height = fontSize/2 // set height to half the font size
}
let textY = (availableHeight - height) / 2 + displayList.descent + contentInsets.bottom
displayList.position = CGPointMake(textX, textY)
errorLabel?.frame = self.bounds
self.setNeedsDisplay()
}
func _sizeThatFits(_ size:CGSize) -> CGSize {
// Check if we have empty latex (empty string case)
if _latex.isEmpty {
// Empty latex - return zero size
return CGSize(width: 0, height: 0)
}
guard _mathList != nil else {
// No content - return no-intrinsic-size marker
return CGSize(width: -1, height: -1)
}
// Ensure we have a valid font before attempting to typeset
if self.font == nil {
// No valid font - try to get default font
if let defaultFont = MTFontManager.fontManager.defaultFont {
self._font = defaultFont
} else {
// Cannot typeset without a font
return CGSize(width: -1, height: -1)
}
}
// Determine the maximum width to use
var maxWidth: CGFloat = 0
if _preferredMaxLayoutWidth > 0 {
maxWidth = _preferredMaxLayoutWidth - contentInsets.left - contentInsets.right
// CRITICAL FIX: Ensure maxWidth is never negative
// If contentInsets exceed available width, clamp to 0
maxWidth = max(0, maxWidth)
} else if size.width > 0 {
maxWidth = size.width - contentInsets.left - contentInsets.right
// CRITICAL FIX: Ensure maxWidth is never negative
maxWidth = max(0, maxWidth)
}
var displayList:MTMathListDisplay? = nil
displayList = MTTypesetter.createLineForMathList(_mathList, font: self.font, style: currentStyle, maxWidth: maxWidth)
guard displayList != nil else {
// Failed to create display list
return CGSize(width: -1, height: -1)
}
var resultWidth = displayList!.width + contentInsets.left + contentInsets.right
var resultHeight = displayList!.ascent + displayList!.descent + contentInsets.top + contentInsets.bottom
// DEBUG LOGGING for width calculation
if debugLogging {
print("\n=== MTMathUILabel intrinsicContentSize DEBUG ===")
print("LaTeX: \(self.latex)")
// Show raw bytes to help debug escaping issues
let latexBytes = Array(self.latex.utf8.prefix(100))
print("LaTeX bytes (first 100): \(latexBytes)")
print("preferredMaxLayoutWidth: \(_preferredMaxLayoutWidth)")
print("size constraint: \(size)")
print("maxWidth passed to typesetter: \(maxWidth)")
print("displayList.width: \(displayList!.width)")
print("displayList.ascent: \(displayList!.ascent)")
print("displayList.descent: \(displayList!.descent)")
print("Number of subDisplays: \(displayList!.subDisplays.count)")
// Count lines by unique Y positions
let yPositions = Set(displayList!.subDisplays.map { $0.position.y })
print("Number of lines (unique Y positions): \(yPositions.count)")
print("contentInsets: \(contentInsets)")
print("resultWidth (before clamping): \(resultWidth)")
print("resultHeight (before clamping): \(resultHeight)")
// HELPFUL WARNING: Detect common escaping mistake
// Check for environments that typically have multiple rows but only 1 row is rendered
let multiRowEnvs = ["aligned", "align", "eqnarray", "gather", "split"]
let latexLower = self.latex.lowercased()
for env in multiRowEnvs {
if latexLower.contains("\\begin{\(env)}") {
// Check if there's a table with only 1 row
if let ml = _mathList {
for atom in ml.atoms {
if let table = atom as? MTMathTable, table.numRows == 1 {
// Check if latex contains single backslash (spacing) where double might be intended
// Pattern: \\ followed by non-backslash (like space or letter)
if self.latex.contains("\\ ") || self.latex.range(of: #"\\[a-zA-Z]"#, options: .regularExpression) != nil {
print("⚠️ WARNING: '\(env)' environment has only 1 row.")
print(" If you intended multiple rows, use '\\\\\\\\' (4 backslashes in Swift strings)")
print(" or use raw strings: #\"...42\\\\ \\\\dfrac...\"#")
print(" Current input has '\\\\ ' which is a SPACING command, not a row break.")
}
}
}
}
}
}
}
// CRITICAL FIX: Ensure dimensions are never negative
// Negative values cause crashes in NSRange calculations and SwiftUI layout
resultWidth = max(0, resultWidth)
resultHeight = max(0, resultHeight)
// CRITICAL FIX for accented character clipping:
// The preferredMaxLayoutWidth is a HINT for line breaking, NOT a hard constraint.
// If the typesetter cannot fit content within that width (even with line breaking),
// we MUST return the actual content width to prevent clipping.
//
// ONLY clamp in extreme cases to prevent layout explosion (>50% over or >100pt over)
if _preferredMaxLayoutWidth > 0 && resultWidth > _preferredMaxLayoutWidth {
let overflow = resultWidth - _preferredMaxLayoutWidth
let overflowPercent = (overflow / _preferredMaxLayoutWidth) * 100
if debugLogging {
print(" Content exceeds preferredMaxLayoutWidth:")
print(" preferredMaxLayoutWidth: \(_preferredMaxLayoutWidth)")
print(" resultWidth: \(resultWidth)")
print(" overflow: \(overflow) (\(String(format: "%.1f", overflowPercent))%)")
// Check line breaking
let yPositions = Set(displayList!.subDisplays.map { $0.position.y })
print(" hasMultipleLines: \(yPositions.count > 1) (yPositions: \(yPositions.count))")
// Check if any content would be clipped at different widths
print(" SubDisplay analysis:")
for (i, sub) in displayList!.subDisplays.enumerated() {
let rightEdge = sub.position.x + sub.width
let clippedAtPreferred = rightEdge > _preferredMaxLayoutWidth
let clippedAtResult = rightEdge > resultWidth
print(" Sub[\(i)]: rightEdge=\(rightEdge) clippedAt\(_preferredMaxLayoutWidth)=\(clippedAtPreferred) clippedAt\(resultWidth)=\(clippedAtResult)")
}
}
// ONLY clamp for truly excessive overflow (>50% or >100pt)
// This prevents layout explosion while allowing normal overflow
let extremeOverflowThreshold: CGFloat = max(_preferredMaxLayoutWidth * 0.5, 100.0)
if overflow > extremeOverflowThreshold {
// Extreme overflow - clamp to prevent layout issues
let clampedWidth = _preferredMaxLayoutWidth + extremeOverflowThreshold
if debugLogging {
print(" ⚠️ EXTREME OVERFLOW - clamping from \(resultWidth) to \(clampedWidth)")
print(" ⚠️ WARNING: This will cause content clipping!")
}
resultWidth = clampedWidth
} else {
// Normal overflow - keep actual content width to prevent clipping
if debugLogging {
print(" ✓ Normal overflow - keeping actual width \(resultWidth) to prevent clipping")
}
// resultWidth stays as is - NO CLAMPING
}
} else if _preferredMaxLayoutWidth == 0 && size.width > 0 && resultWidth > size.width {
// Similar tolerance for size.width constraint
let tolerance = max(size.width * 0.05, 10.0)
let maxAllowedWidth = size.width + tolerance
if debugLogging {
print(" Exceeds size.width constraint:")
print(" tolerance: \(tolerance)")
print(" maxAllowedWidth: \(maxAllowedWidth)")
print(" overflow amount: \(resultWidth - size.width)")
}
if resultWidth <= maxAllowedWidth {
// Within tolerance - use actual content width
// resultWidth stays as is
if debugLogging {
print(" ✓ Within tolerance - keeping actual width")
}
} else {
if debugLogging {
print(" ⚠️ CLAMPING to maxAllowedWidth (may clip content!)")
}
resultWidth = maxAllowedWidth
}
}
if debugLogging {
print(" Final resultWidth: \(resultWidth)")
print(" Final resultHeight: \(resultHeight)")
// Check if any display elements would be clipped
if let display = displayList {
print(" Display subdisplays: \(display.subDisplays.count)")
let yPositions = Set(display.subDisplays.map { $0.position.y }).sorted()
print(" Unique Y positions (lines): \(yPositions.count) -> \(yPositions)")
for (i, sub) in display.subDisplays.enumerated() {
let rightEdge = sub.position.x + sub.width
let clipped = rightEdge > resultWidth
// Extract text content if this is a CTLineDisplay
var textContent = ""
if let ctLineDisplay = sub as? MTCTLineDisplay,
let attrString = ctLineDisplay.attributedString {
textContent = " text=\"\(attrString.string)\""
}
print(" Sub[\(i)]: type=\(type(of: sub)), y=\(sub.position.y), x=\(sub.position.x), width=\(sub.width), rightEdge=\(rightEdge)\(textContent)\(clipped ? " ⚠️ CLIPPED" : "")")
// Show internal structure for MTMathListDisplay
if let mathListDisplay = sub as? MTMathListDisplay, !mathListDisplay.subDisplays.isEmpty {
print(" → Contains \(mathListDisplay.subDisplays.count) sub-displays:")
for (j, innerSub) in mathListDisplay.subDisplays.enumerated() {
var innerTextContent = ""
if let innerCTLineDisplay = innerSub as? MTCTLineDisplay,
let innerAttrString = innerCTLineDisplay.attributedString {
innerTextContent = " text=\"\(innerAttrString.string)\""
}
print(" [\(j)]: type=\(type(of: innerSub)), y=\(innerSub.position.y), x=\(innerSub.position.x), width=\(innerSub.width)\(innerTextContent)")
}
}
if clipped {
print(" ⚠️ CLIPPING: rightEdge \(rightEdge) > resultWidth \(resultWidth)")
print(" Clipped amount: \(rightEdge - resultWidth)")
}
}
}
print("=== END DEBUG ===\n")
}
return CGSize(width: resultWidth, height: resultHeight)
}
#if os(macOS)
public func sizeThatFits(_ size: CGSize) -> CGSize {
return _sizeThatFits(size)
}
#else
public override func sizeThatFits(_ size: CGSize) -> CGSize {
return _sizeThatFits(size)
}
#endif
#if os(macOS)
func setNeedsDisplay() { self.needsDisplay = true }
func setNeedsLayout() { self.needsLayout = true }
public override var fittingSize: CGSize { _sizeThatFits(CGSizeZero) }
public override var intrinsicContentSize: CGSize { _sizeThatFits(CGSizeZero) }
override public func layout() {
self._layoutSubviews()
super.layout()
}
#else
public override var intrinsicContentSize: CGSize { _sizeThatFits(CGSizeZero) }
override public func layoutSubviews() { _layoutSubviews() }
#endif
}

View file

@ -0,0 +1,68 @@
//
// MTTypesetter+Tokenization.swift
// SwiftMath
//
// Created by Claude Code on 2025-12-16.
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import CoreGraphics
extension MTTypesetter {
/// Create a line for a math list using the new tokenization approach
/// This is an alternative to the existing createLineForMathList that uses
/// pre-tokenization and greedy line fitting
static func createLineForMathListWithTokenization(
_ mathList: MTMathList?,
font: MTFont?,
style: MTLineStyle,
cramped: Bool,
spaced: Bool,
maxWidth: CGFloat
) -> MTMathListDisplay? {
guard let mathList = mathList else { return nil }
guard let font = font else { return nil }
guard !mathList.atoms.isEmpty else {
// Return empty display instead of nil (matches KaTeX behavior)
return MTMathListDisplay(withDisplays: [], range: NSMakeRange(0, 0))
}
// Phase 0: Preprocess atoms to fuse ordinary characters
// This is critical for accents and other structures where multi-character
// text like "xyzw" should stay together as a single atom
let preprocessedAtoms = MTTypesetter.preprocessMathList(mathList)
// Phase 1: Tokenize atoms into breakable elements
let tokenizer = MTAtomTokenizer(font: font, style: style, cramped: cramped, maxWidth: maxWidth)
let elements = tokenizer.tokenize(preprocessedAtoms)
guard !elements.isEmpty else { return nil }
// Phase 2: Fit elements into lines
let margin = spaced ? font.mathTable?.muUnit ?? 0 : 0
let fitter = MTLineFitter(maxWidth: maxWidth, margin: margin)
let fittedLines = fitter.fitLines(elements)
// Phase 3: Generate displays from fitted lines
let generator = MTDisplayGenerator(font: font, style: style)
let displays = generator.generateDisplays(from: fittedLines, startPosition: CGPoint.zero)
// Determine range from atoms
let range: NSRange
if let firstAtom = mathList.atoms.first, let lastAtom = mathList.atoms.last {
let start = firstAtom.indexRange.location
let end = NSMaxRange(lastAtom.indexRange)
range = NSMakeRange(start, end - start)
} else {
range = NSMakeRange(0, 0)
}
// Create and return the math list display
let mathListDisplay = MTMathListDisplay(withDisplays: displays, range: range)
return mathListDisplay
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,89 @@
//
// Created by Mike Griebling on 2022-12-31.
// Translated from an Objective-C implementation by Kostub Deshmukh.
//
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
public struct UnicodeSymbol {
static let multiplication = "\u{00D7}"
static let division = "\u{00F7}"
static let fractionSlash = "\u{2044}"
static let whiteSquare = "\u{25A1}"
static let blackSquare = "\u{25A0}"
static let lessEqual = "\u{2264}"
static let greaterEqual = "\u{2265}"
static let notEqual = "\u{2260}"
static let squareRoot = "\u{221A}" // \sqrt
static let cubeRoot = "\u{221B}"
static let infinity = "\u{221E}" // \infty
static let angle = "\u{2220}" // \angle
static let degree = "\u{00B0}" // \circ
static let capitalGreekStart = UInt32(0x0391)
static let capitalGreekEnd = UInt32(0x03A9)
static let lowerGreekStart = UInt32(0x03B1)
static let lowerGreekEnd = UInt32(0x03C9)
static let planksConstant = UInt32(0x210e)
static let lowerItalicStart = UInt32(0x1D44E)
static let capitalItalicStart = UInt32(0x1D434)
static let greekLowerItalicStart = UInt32(0x1D6FC)
static let greekCapitalItalicStart = UInt32(0x1D6E2)
static let greekSymbolItalicStart = UInt32(0x1D716)
static let mathCapitalBoldStart = UInt32(0x1D400)
static let mathLowerBoldStart = UInt32(0x1D41A)
static let greekCapitalBoldStart = UInt32(0x1D6A8)
static let greekLowerBoldStart = UInt32(0x1D6C2)
static let greekSymbolBoldStart = UInt32(0x1D6DC)
static let numberBoldStart = UInt32(0x1D7CE)
static let mathCapitalBoldItalicStart = UInt32(0x1D468)
static let mathLowerBoldItalicStart = UInt32(0x1D482)
static let greekCapitalBoldItalicStart = UInt32(0x1D71C)
static let greekLowerBoldItalicStart = UInt32(0x1D736)
static let greekSymbolBoldItalicStart = UInt32(0x1D750)
static let mathCapitalScriptStart = UInt32(0x1D49C)
static let mathCapitalTTStart = UInt32(0x1D670)
static let mathLowerTTStart = UInt32(0x1D68A)
static let numberTTStart = UInt32(0x1D7F6)
static let mathCapitalSansSerifStart = UInt32(0x1D5A0)
static let mathLowerSansSerifStart = UInt32(0x1D5BA)
static let numberSansSerifStart = UInt32(0x1D7E2)
static let mathCapitalFrakturStart = UInt32(0x1D504)
static let mathLowerFrakturStart = UInt32(0x1D51E)
static let mathCapitalBlackboardStart = UInt32(0x1D538)
static let mathLowerBlackboardStart = UInt32(0x1D552)
static let numberBlackboardStart = UInt32(0x1D7D8)
}
extension Character {
var utf32Char: UTF32Char { self.unicodeScalars.map { $0.value }.reduce(0, +) }
var isLowerEnglish : Bool { self >= "a" && self <= "z" }
var isUpperEnglish : Bool { self >= "A" && self <= "Z" }
var isNumber : Bool { self >= "0" && self <= "9" }
var isLowerGreek : Bool {
let uch = self.utf32Char
return uch >= UnicodeSymbol.lowerGreekStart && uch <= UnicodeSymbol.lowerGreekEnd
}
var isCapitalGreek : Bool {
let uch = self.utf32Char
return uch >= UnicodeSymbol.capitalGreekStart && uch <= UnicodeSymbol.capitalGreekEnd
}
var greekSymbolOrder : UInt32? {
let greekSymbols : [UTF32Char] = [0x03F5, 0x03D1, 0x03F0, 0x03D5, 0x03F1, 0x03D6]
let index = greekSymbols.firstIndex(of: self.utf32Char)
if let pos = index { return UInt32(pos) }
return nil
}
var isGreekSymbol : Bool { self.greekSymbolOrder != nil }
}

View file

@ -0,0 +1,58 @@
import Foundation
final class RWLock {
init() {
pthread_rwlock_init(&lock, nil)
}
deinit {
pthread_rwlock_destroy(&lock)
}
func read<T>(_ block: () -> T) -> T {
pthread_rwlock_rdlock(&lock)
defer { pthread_rwlock_unlock(&lock) }
return block()
}
func readWrite<T>(_ block: () -> T) -> T {
pthread_rwlock_wrlock(&lock)
defer { pthread_rwlock_unlock(&lock) }
return block()
}
private var lock = pthread_rwlock_t()
}
@propertyWrapper
struct RWLocked<T> {
init(wrappedValue: T) {
value = wrappedValue
}
var wrappedValue: T {
get {
lock.read {
value
}
}
set {
lock.readWrite {
value = newValue
}
}
}
@discardableResult
mutating func readWrite(_ block: (inout T) -> Void) -> (oldValue: T, newValue: T) {
lock.readWrite {
let old = value
block(&value)
return (old, value)
}
}
private var value: T
private let lock = RWLock()
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,115 @@
//
// MTBreakableElement.swift
// SwiftMath
//
// Created by Claude Code on 2025-12-16.
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import CoreGraphics
// MARK: - MTElementContent
/// Represents the content type of a breakable element
enum MTElementContent {
/// Simple text content
case text(String)
/// Pre-rendered display (fraction, radical, etc.)
case display(MTDisplay)
/// Math operator with spacing
case `operator`(String, type: MTMathAtomType)
/// Explicit spacing
case space(CGFloat)
/// Superscript or subscript display
case script(MTDisplay, isSuper: Bool)
}
// MARK: - MTBreakableElement
/// Represents a breakable element with pre-calculated width and break rules
struct MTBreakableElement {
// MARK: Display properties
/// The content of this element
let content: MTElementContent
/// Pre-calculated width (cached)
let width: CGFloat
/// Height of the element
let height: CGFloat
/// Distance from baseline to top
let ascent: CGFloat
/// Distance from baseline to bottom
let descent: CGFloat
// MARK: Breaking rules
/// Can break BEFORE this element?
let isBreakBefore: Bool
/// Can break AFTER this element?
let isBreakAfter: Bool
/// Penalty for breaking before (0=good, 100=bad, 150=never)
let penaltyBefore: Int
/// Penalty for breaking after (0=good, 100=bad, 150=never)
let penaltyAfter: Int
// MARK: Relationship tracking
/// Elements with same groupId must stay together
let groupId: UUID?
/// Parent element ID (for scripts)
let parentId: UUID?
// MARK: Source tracking
/// Original atom this element was created from
let originalAtom: MTMathAtom
/// Index range in the original math list
let indexRange: NSRange
// MARK: Optional attributes
/// Text color for this element
let color: MTColor?
/// Background color for this element
let backgroundColor: MTColor?
// MARK: Atomicity flag
/// If true, NEVER break this element internally
let indivisible: Bool
}
// MARK: - Penalty Constants
/// Penalty values for line breaking decisions
enum MTBreakPenalty {
/// Best break points (operators, relations)
static let best = 0
/// Good break points (ordinary atoms, after scripts)
static let good = 10
/// Moderate penalty (before fractions, radicals)
static let moderate = 15
/// Acceptable break points
static let acceptable = 50
/// Bad break points (avoid if possible)
static let bad = 100
/// Never break here (grouped elements)
static let never = 150
}

View file

@ -0,0 +1,389 @@
//
// MTDisplayGenerator.swift
// SwiftMath
//
// Created by Claude Code on 2025-12-16.
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import CoreGraphics
import CoreText
/// Generates MTDisplay objects from fitted lines of breakable elements
class MTDisplayGenerator {
// MARK: - Properties
let font: MTFont
let style: MTLineStyle
let widthCalculator: MTElementWidthCalculator
// MARK: - Initialization
init(font: MTFont, style: MTLineStyle) {
self.font = font
self.style = style
self.widthCalculator = MTElementWidthCalculator(font: font, style: style)
}
// MARK: - Display Generation
/// Generate displays from fitted lines
func generateDisplays(from lines: [[MTBreakableElement]], startPosition: CGPoint) -> [MTDisplay] {
var allDisplays: [MTDisplay] = []
var currentY = startPosition.y
// Minimum spacing between lines (20% of font size for breathing room)
let minimumLineSpacing = font.fontSize * 0.2
for (index, line) in lines.enumerated() {
let (lineDisplays, currentLineMetrics) = generateLine(line, at: CGPoint(x: startPosition.x, y: currentY))
allDisplays.append(contentsOf: lineDisplays)
// Calculate spacing for next line based on actual content heights
if index < lines.count - 1 {
let nextLine = lines[index + 1]
let nextLineAscent = nextLine.map { $0.ascent }.max() ?? 0
// Space needed = current line's descent + minimum spacing + next line's ascent
let spaceNeeded = currentLineMetrics.descent + minimumLineSpacing + nextLineAscent
// Ensure minimum spacing of 1.2x font size for readability
let minSpacing = font.fontSize * 1.2
currentY -= max(spaceNeeded, minSpacing)
}
}
return allDisplays
}
/// Line metrics for spacing calculation
struct LineMetrics {
let ascent: CGFloat
let descent: CGFloat
var height: CGFloat { ascent + descent }
}
/// Generate displays for a single line
private func generateLine(_ elements: [MTBreakableElement], at position: CGPoint) -> ([MTDisplay], LineMetrics) {
var displays: [MTDisplay] = []
var xOffset: CGFloat = 0
// Calculate line metrics
let lineAscent = elements.map { $0.ascent }.max() ?? 0
let lineDescent = elements.map { $0.descent }.max() ?? 0
// Baseline y position
let baseline = position.y
var i = 0
while i < elements.count {
let element = elements[i]
// Check if this is part of a group (base + scripts)
if let groupId = element.groupId {
// Collect all elements in this group
var groupElements: [MTBreakableElement] = []
var j = i
while j < elements.count && elements[j].groupId == groupId {
groupElements.append(elements[j])
j += 1
}
// Render the group
let groupAdvance = renderGroup(groupElements, at: CGPoint(x: position.x + xOffset, y: baseline), displays: &displays)
xOffset += groupAdvance
i = j
} else {
// Regular element (not part of a group)
// CRITICAL: For operators, spacing should be split evenly before and after
// The element.width includes both spacing, but we need to position the operator
// with half spacing before it
var spacingBefore: CGFloat = 0
if case .operator(let op, _) = element.content {
// Get the actual text width vs element width to calculate spacing
let textWidth = widthCalculator.measureText(op)
let totalSpacing = element.width - textWidth
spacingBefore = totalSpacing / 2
}
let elementPosition = CGPoint(x: position.x + xOffset + spacingBefore, y: baseline)
switch element.content {
case .text(let text):
let display = createTextDisplay(text, at: elementPosition, element: element)
displays.append(display)
case .display(let preRenderedDisplay):
// Use pre-rendered display (fraction, radical, etc.)
let mutableDisplay = preRenderedDisplay
mutableDisplay.position = elementPosition
displays.append(mutableDisplay)
case .operator(let op, _):
let display = createTextDisplay(op, at: elementPosition, element: element)
displays.append(display)
case .script:
// Standalone script (shouldn't happen, but handle gracefully)
break
case .space:
// No display for space, just advance position
break
}
xOffset += element.width
i += 1
}
}
return (displays, LineMetrics(ascent: lineAscent, descent: lineDescent))
}
/// Render a group of elements (base + scripts) and return the horizontal advance
private func renderGroup(_ groupElements: [MTBreakableElement], at position: CGPoint, displays: inout [MTDisplay]) -> CGFloat {
var baseWidth: CGFloat = 0
var superscriptWidth: CGFloat = 0
var subscriptWidth: CGFloat = 0
var baseXOffset: CGFloat = 0
// Check if this group has any scripts
let hasScripts = groupElements.contains { element in
if case .script = element.content {
return true
}
return false
}
// Track the start index of base displays for dimension adjustment
let baseDisplayStartIndex = displays.count
// First pass: render base elements and collect script widths
for element in groupElements {
switch element.content {
case .script:
// Skip scripts in first pass
break
default:
// Render base element
let basePosition = CGPoint(x: position.x + baseXOffset, y: position.y)
switch element.content {
case .text(let text):
let display = createTextDisplay(text, at: basePosition, element: element, hasScript: hasScripts)
displays.append(display)
case .display(let preRenderedDisplay):
let mutableDisplay = preRenderedDisplay
mutableDisplay.position = basePosition
displays.append(mutableDisplay)
case .operator(let op, _):
let display = createTextDisplay(op, at: basePosition, element: element, hasScript: hasScripts)
displays.append(display)
default:
break
}
baseWidth += element.width
baseXOffset += element.width
}
}
// Second pass: collect script information for joint positioning
var superscriptDisplay: MTDisplay? = nil
var subscriptDisplay: MTDisplay? = nil
var hasBothScripts = false
for element in groupElements {
if case .script(let scriptDisplay, let isSuper) = element.content {
if isSuper {
superscriptDisplay = scriptDisplay
} else {
subscriptDisplay = scriptDisplay
}
}
}
hasBothScripts = superscriptDisplay != nil && subscriptDisplay != nil
// Third pass: render scripts with proper positioning
var superScriptShiftUp: CGFloat = 0
var subscriptShiftDown: CGFloat = 0
// Check if base is a glyph (not CTLineDisplay) for special positioning
// For glyphs (like large operators), position scripts relative to glyph edges
var isGlyphBase = false
for disp in displays[baseDisplayStartIndex..<displays.count] {
if disp is MTGlyphDisplay {
isGlyphBase = true
// Get script font metrics for display-based positioning
let scriptStyle: MTLineStyle = (style == .display || style == .text) ? .script : .scriptOfScript
let scriptFontSize = MTTypesetter.getStyleSize(scriptStyle, font: font)
let scriptFont = font.copy(withSize: scriptFontSize)
if let scriptFontMetrics = scriptFont.mathTable {
// Position scripts relative to the glyph's edges (matches MTTypesetter line 571-572)
superScriptShiftUp = disp.ascent - scriptFontMetrics.superscriptBaselineDropMax
subscriptShiftDown = disp.descent + scriptFontMetrics.subscriptBaselineDropMin
}
break
}
}
for element in groupElements {
if case .script(let scriptDisplay, let isSuper) = element.content {
guard let mathTable = font.mathTable else { continue }
if !isGlyphBase {
// Standard positioning for text (CTLineDisplay)
if isSuper {
superScriptShiftUp = mathTable.superscriptShiftUp
superScriptShiftUp = max(superScriptShiftUp, scriptDisplay.descent + mathTable.superscriptBottomMin)
} else {
subscriptShiftDown = mathTable.subscriptShiftDown
subscriptShiftDown = max(subscriptShiftDown, scriptDisplay.ascent - mathTable.subscriptTopMax)
}
} else {
// For glyphs, apply the minimum constraints (matches MTTypesetter line 594-595, 581-582)
if isSuper {
superScriptShiftUp = max(superScriptShiftUp, mathTable.superscriptShiftUp)
superScriptShiftUp = max(superScriptShiftUp, scriptDisplay.descent + mathTable.superscriptBottomMin)
} else {
subscriptShiftDown = max(subscriptShiftDown, mathTable.subscriptShiftDown)
subscriptShiftDown = max(subscriptShiftDown, scriptDisplay.ascent - mathTable.subscriptTopMax)
}
}
if isSuper {
superscriptWidth = element.width
} else {
subscriptWidth = element.width
}
}
}
// If both scripts present, apply joint positioning adjustments
if hasBothScripts, let superDisp = superscriptDisplay, let subDisp = subscriptDisplay,
let mathTable = font.mathTable {
let subSuperScriptGap = (superScriptShiftUp - superDisp.descent) + (subscriptShiftDown - subDisp.ascent)
if subSuperScriptGap < mathTable.subSuperscriptGapMin {
// Set the gap to at least the minimum
subscriptShiftDown += mathTable.subSuperscriptGapMin - subSuperScriptGap
let superscriptBottomDelta = mathTable.superscriptBottomMaxWithSubscript - (superScriptShiftUp - superDisp.descent)
if superscriptBottomDelta > 0 {
// Superscript is lower than the max allowed by the font with a subscript
superScriptShiftUp += superscriptBottomDelta
subscriptShiftDown -= superscriptBottomDelta
}
}
}
// Calculate italic correction (delta) for superscript positioning
// Superscripts are positioned at baseWidth + delta, subscripts at baseWidth
var delta: CGFloat = 0
if superscriptDisplay != nil {
// Get italic correction from the base display if it's a glyph
for disp in displays[baseDisplayStartIndex..<displays.count] {
if let glyphDisplay = disp as? MTGlyphDisplay,
let mathTable = font.mathTable {
delta = mathTable.getItalicCorrection(glyphDisplay.glyph)
break
}
}
}
// Fourth pass: create wrapped script displays with final positions
for element in groupElements {
if case .script(let scriptDisplay, let isSuper) = element.content {
let scriptShift = isSuper ? superScriptShiftUp : -subscriptShiftDown
let scriptType: MTMathListDisplay.LinePosition = isSuper ? .superscript : .ssubscript
// Superscripts get delta added, subscripts don't (matches MTTypesetter line 622, 624)
let deltaOffset = isSuper ? delta : 0
let scriptPosition = CGPoint(x: position.x + baseWidth + deltaOffset, y: position.y + scriptShift)
// Reset the scriptDisplay's position to (0, 0) since it will be positioned by the wrapper
let mutableScript = scriptDisplay
mutableScript.position = CGPoint.zero
let wrappedScript = MTMathListDisplay(
withDisplays: [mutableScript],
range: scriptDisplay.range
)
wrappedScript.type = scriptType
wrappedScript.position = scriptPosition
wrappedScript.index = 0 // Index of the base atom this script belongs to
displays.append(wrappedScript)
}
}
// Calculate horizontal advance: base width + max(script widths) + spacing
// This matches MTTypesetter.makeScripts() logic (line 626)
// Superscript width includes delta, subscript doesn't
let scriptWidth = max(superscriptWidth + delta, subscriptWidth)
let spaceAfterScript = font.mathTable?.spaceAfterScript ?? 0
let totalWidth = baseWidth + scriptWidth + spaceAfterScript
// If this group has scripts, adjust the base display's dimensions to include scripts
// This matches the legacy typesetter behavior where display.ascent == line.ascent
if hasScripts && displays.count > baseDisplayStartIndex {
// Calculate the full extent of the group including scripts
var maxAscent: CGFloat = 0
var maxDescent: CGFloat = 0
for i in baseDisplayStartIndex..<displays.count {
let disp = displays[i]
let topY = disp.position.y + disp.ascent
let bottomY = disp.position.y - disp.descent
maxAscent = max(maxAscent, topY - position.y)
maxDescent = max(maxDescent, position.y - bottomY)
}
// Update the first base display's dimensions to reflect the full extent
// Width should be base + script, without the spaceAfterScript (that's for cursor advancement)
let baseDisplay = displays[baseDisplayStartIndex]
baseDisplay.ascent = maxAscent
baseDisplay.descent = maxDescent
baseDisplay.width = baseWidth + scriptWidth
displays[baseDisplayStartIndex] = baseDisplay
}
return totalWidth
}
/// Create a text display for a character or string
private func createTextDisplay(_ text: String, at position: CGPoint, element: MTBreakableElement, hasScript: Bool = false) -> MTDisplay {
let attrString = NSMutableAttributedString(string: text)
attrString.addAttribute(
NSAttributedString.Key(kCTFontAttributeName as String),
value: font.ctFont as Any,
range: NSMakeRange(0, attrString.length)
)
// If the atom was fused (multiple ordinary chars combined), use fusedAtoms
// Otherwise, use the original atom
let atoms: [MTMathAtom]
if !element.originalAtom.fusedAtoms.isEmpty {
atoms = element.originalAtom.fusedAtoms
} else {
atoms = [element.originalAtom]
}
let display = MTCTLineDisplay(
withString: attrString,
position: position,
range: element.indexRange,
font: font,
atoms: atoms
)
// Mark if this base element has associated scripts
display.hasScript = hasScript
return display
}
}

View file

@ -0,0 +1,88 @@
//
// MTDisplayPreRenderer.swift
// SwiftMath
//
// Created by Claude Code on 2025-12-16.
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import CoreGraphics
/// Pre-renders complex atoms (fractions, radicals, etc.) as MTDisplay objects during tokenization
class MTDisplayPreRenderer {
// MARK: - Properties
let font: MTFont
let style: MTLineStyle
let cramped: Bool
// MARK: - Initialization
init(font: MTFont, style: MTLineStyle, cramped: Bool) {
self.font = font
self.style = style
self.cramped = cramped
}
// MARK: - Script Rendering
/// Render a script (superscript or subscript) as a display
func renderScript(_ mathList: MTMathList, isSuper: Bool) -> MTDisplay? {
let scriptStyle = getScriptStyle()
let scriptCramped = isSuper ? cramped : true // Subscripts are always cramped
// Scale the font for the script style
let scriptFontSize = MTTypesetter.getStyleSize(scriptStyle, font: font)
let scriptFont = font.copy(withSize: scriptFontSize)
guard let display = MTTypesetter.createLineForMathList(
mathList,
font: scriptFont,
style: scriptStyle,
cramped: scriptCramped,
spaced: false
) else {
return nil
}
// If the result is a MTMathListDisplay with a single subdisplay, unwrap it
// This matches the behavior of the legacy typesetter
if display.subDisplays.count == 1 {
return display.subDisplays[0]
}
return display
}
/// Get the appropriate style for scripts
private func getScriptStyle() -> MTLineStyle {
switch style {
case .display, .text:
return .script
case .script, .scriptOfScript:
return .scriptOfScript
}
}
// MARK: - Helper Methods
/// Pre-render a simple math list without width constraints
/// Used for rendering content inside fractions, radicals, etc.
func renderMathList(_ mathList: MTMathList?, style renderStyle: MTLineStyle? = nil, cramped renderCramped: Bool? = nil) -> MTDisplay? {
guard let mathList = mathList else { return nil }
let actualStyle = renderStyle ?? style
let actualCramped = renderCramped ?? cramped
return MTTypesetter.createLineForMathList(
mathList,
font: font,
style: actualStyle,
cramped: actualCramped,
spaced: false
)
}
}

View file

@ -0,0 +1,165 @@
//
// MTElementWidthCalculator.swift
// SwiftMath
//
// Created by Claude Code on 2025-12-16.
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import CoreText
import CoreGraphics
/// Calculates widths for breakable elements with appropriate spacing
class MTElementWidthCalculator {
// MARK: - Properties
let font: MTFont
let style: MTLineStyle
// MARK: - Initialization
init(font: MTFont, style: MTLineStyle) {
self.font = font
self.style = style
}
// MARK: - Text Width Measurement
/// Measure width of simple text
func measureText(_ text: String) -> CGFloat {
guard !text.isEmpty else { return 0 }
let attrString = NSAttributedString(string: text, attributes: [
kCTFontAttributeName as NSAttributedString.Key: font.ctFont as Any
])
let line = CTLineCreateWithAttributedString(attrString as CFAttributedString)
return CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
}
// MARK: - Operator Width Measurement
/// Measure width of operator with appropriate spacing
func measureOperator(_ op: String, type: MTMathAtomType) -> CGFloat {
let baseWidth = measureText(op)
let spacing = getOperatorSpacing(type)
return baseWidth + spacing
}
/// Get spacing for an operator (both sides)
private func getOperatorSpacing(_ type: MTMathAtomType) -> CGFloat {
guard let mathTable = font.mathTable else { return 0 }
let muUnit = mathTable.muUnit
switch type {
case .binaryOperator:
// Binary operators: 4mu on each side = 8mu total
return 2 * muUnit * 4
case .relation:
// Relations: 5mu on each side = 10mu total
return 2 * muUnit * 5
case .largeOperator:
// Large operators in inline mode: 1mu on each side
if style == .display || style == .text {
return 0 // In display mode, handled by MTLargeOpLimitsDisplay
}
return 2 * muUnit * 1
default:
return 0
}
}
// MARK: - Display Width Measurement
/// Measure width of a pre-rendered display
func measureDisplay(_ display: MTDisplay) -> CGFloat {
return display.width
}
// MARK: - Space Width Measurement
/// Get width of explicit spacing command
func measureSpace(_ spaceType: MTMathAtomType) -> CGFloat {
guard let mathTable = font.mathTable else { return 0 }
let muUnit = mathTable.muUnit
// Note: These are the explicit spacing commands in LaTeX
// \, = thin space (3mu)
// \: = medium space (4mu)
// \; = thick space (5mu)
// \quad = 1em
// \qquad = 2em
switch spaceType {
case .space:
// Default space - context dependent
// For now, use thin space
return muUnit * 3
default:
return 0
}
}
/// Measure explicit space value
func measureExplicitSpace(_ width: CGFloat) -> CGFloat {
return width
}
// MARK: - Inter-element Spacing
/// Get inter-element spacing between two atom types
func getInterElementSpacing(left: MTMathAtomType, right: MTMathAtomType) -> CGFloat {
let leftIndex = getInterElementSpaceArrayIndexForType(left, row: true)
let rightIndex = getInterElementSpaceArrayIndexForType(right, row: false)
let spaceArray = getInterElementSpaces()[Int(leftIndex)]
let spaceType = spaceArray[Int(rightIndex)]
guard spaceType != .invalid else {
// Should not happen in well-formed math
return 0
}
let spaceMultiplier = getSpacingInMu(spaceType)
if spaceMultiplier > 0, let mathTable = font.mathTable {
return CGFloat(spaceMultiplier) * mathTable.muUnit
}
return 0
}
/// Get spacing multiplier in mu units
private func getSpacingInMu(_ spaceType: InterElementSpaceType) -> Int {
switch style {
case .display, .text:
switch spaceType {
case .none, .invalid:
return 0
case .thin:
return 3
case .nsThin, .nsMedium, .nsThick:
// ns = non-script, same as regular in display/text mode
switch spaceType {
case .nsThin: return 3
case .nsMedium: return 4
case .nsThick: return 5
default: return 0
}
}
case .script, .scriptOfScript:
switch spaceType {
case .none, .invalid:
return 0
case .thin:
return 3
case .nsThin, .nsMedium, .nsThick:
// In script mode, ns types don't add space
return 0
}
}
}
}

View file

@ -0,0 +1,266 @@
//
// MTLineFitter.swift
// SwiftMath
//
// Created by Claude Code on 2025-12-16.
// This software may be modified and distributed under the terms of the
// MIT license. See the LICENSE file for details.
//
import Foundation
import CoreGraphics
/// Fits breakable elements into lines respecting width constraints and break rules
class MTLineFitter {
// MARK: - Properties
let maxWidth: CGFloat
let margin: CGFloat
// MARK: - Initialization
init(maxWidth: CGFloat, margin: CGFloat = 0) {
self.maxWidth = maxWidth
self.margin = margin
}
// MARK: - Line Fitting
/// Fit elements into lines using greedy algorithm with backtracking
func fitLines(_ elements: [MTBreakableElement]) -> [[MTBreakableElement]] {
guard !elements.isEmpty else { return [] }
guard maxWidth > 0 else { return [elements] } // No width constraint
let debugPunctuation = !"".isEmpty // Enable to debug line breaking issues
if debugPunctuation {
print("\n=== MTLineFitter: fitting \(elements.count) elements, maxWidth=\(maxWidth) ===")
for (idx, elem) in elements.enumerated() {
if case .text(let t) = elem.content {
print("[\(idx)] '\(t)' breakBefore=\(elem.isBreakBefore) breakAfter=\(elem.isBreakAfter) width=\(elem.width)")
}
}
}
var lines: [[MTBreakableElement]] = [[]]
var currentWidth: CGFloat = 0
var i = 0
while i < elements.count {
let element = elements[i]
if debugPunctuation, case .text(let t) = element.content {
print("\n Processing element[\(i)]: '\(t)' breakBefore=\(element.isBreakBefore)")
}
// Handle grouped elements (base + scripts)
if let groupId = element.groupId {
let (groupElements, nextIndex) = collectGroup(elements, startIndex: i, groupId: groupId)
// Calculate group width correctly for scripts
// Scripts overlap vertically, so width = max(script widths), not sum
let groupWidth = calculateGroupWidth(groupElements)
// Check if group fits on current line
if !lines.last!.isEmpty && currentWidth + groupWidth > maxWidth - margin {
// Group doesn't fit - check if first element of group can start a new line
if groupElements.first?.isBreakBefore ?? true {
// Can start new line
lines.append([])
currentWidth = 0
} else {
// Cannot start new line - keep with previous line (allow overflow)
// This handles cases like punctuation after base+script groups
}
}
// Add entire group to current line
lines[lines.count - 1].append(contentsOf: groupElements)
currentWidth += groupWidth
i = nextIndex
continue
}
// Check if element fits on current line
if !lines.last!.isEmpty && currentWidth + element.width > maxWidth - margin {
if debugPunctuation, case .text = element.content {
print(" Doesn't fit (width=\(currentWidth) + \(element.width) > \(maxWidth)), current line has \(lines.last!.count) elements")
}
// Element doesn't fit - find best break point in current line
if let breakIndex = findBestBreak(in: lines[lines.count - 1]) {
if debugPunctuation {
print(" Found break at index \(breakIndex) out of \(lines.last!.count) elements")
if breakIndex < lines.last!.count {
if case .text(let t) = lines.last![breakIndex].content {
print(" Break at element: '\(t)'")
}
}
}
// Found a break point - move elements from breakIndex onward to next line
let moveElements = Array(lines[lines.count - 1][breakIndex...])
let oldLine = Array(lines[lines.count - 1][..<breakIndex])
// Verify the first element being moved can start a line
// (findBestBreak already ensures this, but double-check)
if moveElements.first?.isBreakBefore ?? true {
// Move elements to new line
lines[lines.count - 1] = oldLine
lines.append(moveElements)
currentWidth = moveElements.reduce(0) { $0 + $1.width }
// Now check if current element should go on new line or stay with old line
if debugPunctuation, case .text(let t) = element.content {
print(" Checking element '\(t)' (i=\(i)): breakBefore=\(element.isBreakBefore)")
}
if !element.isBreakBefore {
// CRITICAL FIX: Current element cannot start a line, so it's part of the
// unbreakable sequence that was just moved to the new line.
// Add it to the NEW line, not the old line!
// Example: "matrices" breaks before 'm', so 'm','a','t','r','i' move to new line.
// When we process 'c', it can't start a line, so it must join the new line.
if debugPunctuation, case .text(let t) = element.content {
print(" -> Adding '\(t)' to new line (part of unbreakable sequence)")
}
lines[lines.count - 1].append(element)
currentWidth += element.width
i += 1
continue
} else {
if debugPunctuation, case .text(let t) = element.content {
print(" -> Adding '\(t)' to new line (can start line)")
}
}
// Current element can start a line, will be added to new line below
} else {
// Should not happen if findBestBreak is correct, but handle gracefully
// Keep elements on current line (allow overflow)
lines[lines.count - 1].append(contentsOf: moveElements)
currentWidth += moveElements.reduce(0) { $0 + $1.width }
}
} else {
// No good break point found
// Check if current element can start a new line
if element.isBreakBefore {
// Element can start new line
lines.append([])
currentWidth = 0
} else {
// Element cannot start a new line (e.g., closing punctuation)
// Keep it on current line even if it causes overflow
// This respects punctuation rules over width constraints
lines[lines.count - 1].append(element)
currentWidth += element.width
i += 1
continue
}
}
}
// Add element to current line (may overflow if indivisible and too wide)
lines[lines.count - 1].append(element)
currentWidth += element.width
i += 1
}
let finalLines = lines.filter { !$0.isEmpty }
if debugPunctuation {
print("\n=== Final lines: ===")
for (lineIdx, line) in finalLines.enumerated() {
print("Line \(lineIdx):")
for elem in line {
if case .text(let t) = elem.content {
print(" '\(t)'", terminator: "")
}
}
print()
}
}
return finalLines
}
// MARK: - Helper Methods
/// Calculate the correct width for a group of elements (e.g., base + scripts)
/// Scripts overlap vertically, so the group width is not the sum of all widths
private func calculateGroupWidth(_ groupElements: [MTBreakableElement]) -> CGFloat {
// For grouped elements (base + scripts), just sum all widths
// The display generator will handle the actual positioning and overlap
// This is just for line fitting purposes
return groupElements.reduce(0) { $0 + $1.width }
}
/// Collect all elements that share the same groupId
private func collectGroup(_ elements: [MTBreakableElement], startIndex: Int, groupId: UUID) -> ([MTBreakableElement], Int) {
var groupElements: [MTBreakableElement] = []
var index = startIndex
while index < elements.count && elements[index].groupId == groupId {
groupElements.append(elements[index])
index += 1
}
return (groupElements, index)
}
/// Find the best break point in a line
/// Returns the index where the break should occur (elements from this index move to next line)
private func findBestBreak(in line: [MTBreakableElement]) -> Int? {
var bestIndex: Int? = nil
var lowestPenalty = Int.max
let debugBreak = !"".isEmpty // Enable to debug break point selection
// Scan from right to left to prefer breaking later in the line
// Note: Skip the last element (idx == line.count - 1) because breaking after it
// would move 0 elements to the next line, which is pointless
for (idx, element) in line.enumerated().reversed() {
// Skip the last element - we need to move at least 1 element to the next line
if idx >= line.count - 1 {
continue
}
// Can we break after this element?
let canBreakAfter = element.isBreakAfter
let penaltyAfter = element.penaltyAfter
// Check if next element (which would move to new line) allows breaking before it
let canBreakBeforeNext = line[idx + 1].isBreakBefore
let penaltyBeforeNext = line[idx + 1].penaltyBefore
// We can break here only if BOTH:
// 1. Current element allows breaking after it
// 2. Next element allows breaking before it
if canBreakAfter && canBreakBeforeNext {
let totalPenalty = max(penaltyAfter, penaltyBeforeNext)
if totalPenalty < lowestPenalty {
if debugBreak && idx < line.count - 1 {
let currText = if case .text(let t) = element.content { t } else { "?" }
let nextText = if case .text(let t) = line[idx + 1].content { t } else { "?" }
print(" Considering break: '\(currText)' | '\(nextText)' at idx=\(idx), penalty=\(totalPenalty)")
}
bestIndex = idx + 1
lowestPenalty = totalPenalty
}
}
}
if debugBreak {
print(" Best break: index=\(bestIndex ?? -1), penalty=\(lowestPenalty)")
}
// Only return if we found an acceptable break point
if let index = bestIndex, lowestPenalty <= MTBreakPenalty.bad {
return index
}
return nil
}
/// Check if a line width exceeds the maximum
private func exceedsMaxWidth(_ width: CGFloat) -> Bool {
return width > maxWidth - margin
}
}

19
third-party/SwiftSVG/BUILD vendored Normal file
View file

@ -0,0 +1,19 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SwiftSVG",
module_name = "SwiftSVG",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//third-party/XMLCoder",
"//third-party/Swift2D",
],
visibility = [
"//visibility:public",
],
)

View file

@ -0,0 +1,93 @@
import Swift2D
import XMLCoder
/// Basic shape, used to draw circles based on a center point and a radius.
///
/// The arc of a circle element begins at the "3 o'clock" point on the radius and progresses towards the
/// "9 o'clock" point. The starting point and direction of the arc are affected by the user space transform
/// in the same manner as the geometry of the element.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle)
/// | [W3](https://www.w3.org/TR/SVG11/shapes.html#CircleElement)
public struct Circle: Element {
/// The x-axis coordinate of the center of the circle.
public var x: Double = 0.0
/// The y-axis coordinate of the center of the circle.
public var y: Double = 0.0
/// The radius of the circle.
public var r: Double = 0.0
// MARK: CoreAttributes
public var id: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case x = "cx"
case y = "cy"
case r
case id
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
public init(x: Double, y: Double, r: Double) {
self.x = x
self.y = y
self.r = r
}
}
extension Circle: CustomStringConvertible {
public var description: String {
let desc = "<circle cx=\"\(x)\" cy=\"\(y)\" r=\"\(r)\""
return desc + " \(attributeDescription) />"
}
}
extension Circle: DirectionalCommandRepresentable {
public func commands(clockwise: Bool) throws -> [Path.Command] {
EllipseProcessor(circle: self).commands(clockwise: clockwise)
}
}
extension Circle: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
.attribute
}
}
extension Circle: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
.attribute
}
}

View file

@ -0,0 +1,15 @@
/// Elements conforming to `CommandRepresentable` can be expressed in the form of `Path.Command`s.
public protocol CommandRepresentable {
func commands() throws -> [Path.Command]
}
public protocol DirectionalCommandRepresentable: CommandRepresentable {
func commands(clockwise: Bool) throws -> [Path.Command]
}
public extension DirectionalCommandRepresentable {
/// Defaults to anti/counter-clockwise commands.
func commands() throws -> [Path.Command] {
try commands(clockwise: false)
}
}

View file

@ -0,0 +1,58 @@
public protocol Container {
var circles: [Circle]? { get set }
var ellipses: [Ellipse]? { get set }
var groups: [Group]? { get set }
var lines: [Line]? { get set }
var paths: [Path]? { get set }
var polygons: [Polygon]? { get set }
var polylines: [Polyline]? { get set }
var rectangles: [Rectangle]? { get set }
var texts: [Text]? { get set }
}
enum ContainerKeys: String, CodingKey {
case circles = "circle"
case ellipses = "ellipse"
case groups = "g"
case lines = "line"
case paths = "path"
case polylines = "polyline"
case polygons = "polygon"
case rectangles = "rect"
case texts = "text"
}
public extension Container {
var containerDescription: String {
var contents: String = ""
let circles = circles?.compactMap(\.description) ?? []
circles.forEach { contents.append("\n\($0)") }
let ellipses = ellipses?.compactMap(\.description) ?? []
ellipses.forEach { contents.append("\n\($0)") }
let groups = groups?.compactMap(\.description) ?? []
groups.forEach { contents.append("\n\($0)") }
let lines = lines?.compactMap(\.description) ?? []
lines.forEach { contents.append("\n\($0)") }
let paths = paths?.compactMap(\.description) ?? []
paths.forEach { contents.append("\n\($0)") }
let polylines = polylines?.compactMap(\.description) ?? []
polylines.forEach { contents.append("\n\($0)") }
let polygons = polygons?.compactMap(\.description) ?? []
polygons.forEach { contents.append("\n\($0)") }
let rectangles = rectangles?.compactMap(\.description) ?? []
rectangles.forEach { contents.append("\n\($0)") }
let texts = texts?.compactMap(\.description) ?? []
texts.forEach { contents.append("\n\($0)") }
return contents
}
}

View file

@ -0,0 +1,17 @@
public protocol CoreAttributes {
var id: String? { get set }
}
enum CoreAttributesKeys: String, CodingKey {
case id
}
public extension CoreAttributes {
var coreDescription: String {
if let id {
"\(CoreAttributesKeys.id.rawValue)=\"\(id)\""
} else {
""
}
}
}

View file

@ -0,0 +1,46 @@
public protocol Element: CoreAttributes, PresentationAttributes, StylingAttributes {}
public extension Element {
var attributeDescription: String {
var components: [String] = []
if !coreDescription.isEmpty {
components.append(coreDescription)
}
if !presentationDescription.isEmpty {
components.append(presentationDescription)
}
if !stylingDescription.isEmpty {
components.append(stylingDescription)
}
return components.joined(separator: " ")
}
}
public extension CommandRepresentable where Self: Element {
/// When a `Path` is accessed on an element, the path that is returned should have the supplied transformations
/// applied.
///
/// For instance, if
/// * a `Path.data` contains relative elements,
/// * and `transformations` contains a `.translate`
///
/// Than the path created will not only use 'absolute' instructions, but those instructions will be modified to
/// include the required transformation.
func path(applying transformations: [Transformation] = []) throws -> Path {
var _transformations = transformations
_transformations.append(contentsOf: self.transformations)
let commands = try commands().map { $0.applying(transformations: _transformations) }
var path = Path(commands: commands)
path.fillColor = fillColor
path.fillOpacity = fillOpacity
path.strokeColor = strokeColor
path.strokeOpacity = strokeOpacity
path.strokeWidth = strokeWidth
return path
}
}

View file

@ -0,0 +1,93 @@
import Swift2D
import XMLCoder
/// SVG basic shape, used to create ellipses based on a center coordinate, and both their x and y radius.
///
/// The arc of an ellipse element begins at the "3 o'clock" point on the radius and progresses towards the
/// "9 o'clock" point. The starting point and direction of the arc are affected by the user space transform in the same
/// manner as the geometry of the element.
public struct Ellipse: Element {
/// The x position of the ellipse.
public var x: Double = 0.0
/// The y position of the ellipse.
public var y: Double = 0.0
/// The radius of the ellipse on the x axis.
public var rx: Double = 0.0
/// The radius of the ellipse on the y axis.
public var ry: Double = 0.0
// MARK: CoreAttributes
public var id: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case x = "cx"
case y = "cy"
case rx
case ry
case id
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
public init(x: Double, y: Double, rx: Double, ry: Double) {
self.x = x
self.y = y
self.rx = rx
self.ry = ry
}
}
extension Ellipse: CustomStringConvertible {
public var description: String {
let desc = "<ellipse cx=\"\(x)\" cy=\"\(y)\" rx=\"\(rx)\" ry=\"\(ry)\""
return desc + " \(attributeDescription) />"
}
}
extension Ellipse: DirectionalCommandRepresentable {
public func commands(clockwise: Bool) throws -> [Path.Command] {
EllipseProcessor(ellipse: self).commands(clockwise: clockwise)
}
}
extension Ellipse: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
.attribute
}
}
extension Ellipse: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
.attribute
}
}

View file

@ -0,0 +1,37 @@
import Swift2D
extension Point {
static var nan: Point {
Point(x: Double.nan, y: Double.nan)
}
var hasNaN: Bool {
x.isNaN || y.isNaN
}
/// Returns a copy of the instance with the **x** value replaced with the provided value.
func with(x value: Double) -> Point {
Point(x: value, y: y)
}
/// Returns a copy of the instance with the **y** value replaced with the provided value.
func with(y value: Double) -> Point {
Point(x: x, y: value)
}
/// Adjusts the **x** value by the provided amount.
///
/// This will explicitly check for `.isNaN`, and if encountered, will simply
/// use the provided value.
func adjusting(x value: Double) -> Point {
(x.isNaN) ? with(x: value) : with(x: x + value)
}
/// Adjusts the **y** value by the provided amount.
///
/// This will explicitly check for `.isNaN`, and if encountered, will simply
/// use the provided value.
func adjusting(y value: Double) -> Point {
(y.isNaN) ? with(y: value) : with(y: y + value)
}
}

44
third-party/SwiftSVG/Sources/Fill.swift vendored Normal file
View file

@ -0,0 +1,44 @@
import Swift2D
public struct Fill {
public var color: String?
public var opacity: Double?
public var rule: Rule = .nonZero
public init() {}
/// Presentation attribute defining the algorithm to use to determine the inside part of a shape.
///
/// The default `Rule` is `.nonzero`.
public enum Rule: String, Sendable, Codable, CaseIterable {
/// The value evenodd determines the "insideness" of a point in the shape by drawing a ray from that point to
/// infinity in any direction and counting the number of path segments from the given shape that the ray
/// crosses. If this number is odd, the point is inside; if even, the point is outside.
case evenOdd = "evenodd"
/// The value nonzero determines the "insideness" of a point in the shape by drawing a ray from that point to
/// infinity in any direction, and then examining the places where a segment of the shape crosses the ray.
/// Starting with a count of zero, add one each time a path segment crosses the ray from left to right and
/// subtract one each time a path segment crosses the ray from right to left. After counting the crossings, if
/// the result is zero then the point is outside the path. Otherwise, it is inside.
case nonZero = "nonzero"
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
guard let rule = Rule(rawValue: rawValue) else {
print("Attempts to decode Fill.Rule with rawValue: '\(rawValue)'")
self = .nonZero
return
}
self = rule
}
}
}
extension Fill.Rule: CustomStringConvertible {
public var description: String {
rawValue
}
}

152
third-party/SwiftSVG/Sources/Group.swift vendored Normal file
View file

@ -0,0 +1,152 @@
import XMLCoder
/// A container used to group other SVG elements.
///
/// Grouping constructs, when used in conjunction with the desc and title elements, provide information
/// about document structure and semantics.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g)
/// | [W3](https://www.w3.org/TR/SVG11/struct.html#Groups)
public struct Group: Container, Element {
// Container
public var circles: [Circle]?
public var ellipses: [Ellipse]?
public var groups: [Group]?
public var lines: [Line]?
public var paths: [Path]?
public var polygons: [Polygon]?
public var polylines: [Polyline]?
public var rectangles: [Rectangle]?
public var texts: [Text]?
// MARK: CoreAttributes
public var id: String?
public var title: String?
public var desc: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case circles = "circle"
case ellipses = "ellipse"
case groups = "g"
case lines = "line"
case paths = "path"
case polylines = "polyline"
case polygons = "polygon"
case rectangles = "rect"
case texts = "text"
case id
case title
case desc
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
/// A representation of all the sub-`Path`s in the `Group`.
public func subpaths(applying transformations: [Transformation] = []) throws -> [Path] {
var _transformations = transformations
_transformations.append(contentsOf: self.transformations)
var output: [Path] = []
if let circles {
try output.append(contentsOf: circles.compactMap { try $0.path(applying: _transformations) })
}
if let ellipses {
try output.append(contentsOf: ellipses.compactMap { try $0.path(applying: _transformations) })
}
if let rectangles {
try output.append(contentsOf: rectangles.compactMap { try $0.path(applying: _transformations) })
}
if let polygons {
try output.append(contentsOf: polygons.compactMap { try $0.path(applying: _transformations) })
}
if let polylines {
try output.append(contentsOf: polylines.compactMap { try $0.path(applying: _transformations) })
}
if let paths {
try output.append(contentsOf: paths.map { try $0.path(applying: _transformations) })
}
if let groups {
try groups.forEach {
try output.append(contentsOf: $0.subpaths(applying: _transformations))
}
}
return output
}
}
extension Group: CustomStringConvertible {
public var description: String {
var contents: String = ""
if let title {
contents.append("\n<title>\(title)</title>")
}
if let desc {
contents.append("\n<desc>\(desc)</desc>")
}
contents.append(containerDescription)
return "<g \(attributeDescription) >\(contents)\n</g>"
}
}
extension Group: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
if let _ = ContainerKeys(stringValue: key.stringValue) {
return .element
}
return .attribute
}
}
extension Group: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
if let _ = ContainerKeys(stringValue: key.stringValue) {
return .element
}
return .attribute
}
}

View file

@ -0,0 +1,88 @@
import Foundation
import Swift2D
struct EllipseProcessor {
let x: Double
let y: Double
let rx: Double
let ry: Double
/// The _optimal_ offset for control points when representing a
/// circle/ellipse as 4 bezier curves.
///
/// [Stack Overflow](https://stackoverflow.com/questions/1734745/how-to-create-circle-with-bézier-curves)
static func controlPointOffset(_ radius: Double) -> Double {
(Double(4.0 / 3.0) * tan(Double.pi / 8.0)) * radius
}
init(ellipse: Ellipse) {
x = ellipse.x
y = ellipse.y
rx = ellipse.rx
ry = ellipse.ry
}
init(circle: Circle) {
x = circle.x
y = circle.y
rx = circle.r
ry = circle.r
}
func commands(clockwise: Bool) -> [Path.Command] {
var commands: [Path.Command] = []
let xOffset = Self.controlPointOffset(rx)
let yOffset = Self.controlPointOffset(ry)
let zero = Point(x: x + rx, y: y)
let ninety = Point(x: x, y: y - ry)
let oneEighty = Point(x: x - rx, y: y)
let twoSeventy = Point(x: x, y: y + ry)
var cp1: Point = .zero
var cp2: Point = .zero
// Starting at degree 0 (the right most point)
commands.append(.moveTo(point: zero))
if clockwise {
cp1 = Point(x: zero.x, y: zero.y + yOffset)
cp2 = Point(x: twoSeventy.x + xOffset, y: twoSeventy.y)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: twoSeventy))
cp1 = Point(x: twoSeventy.x - xOffset, y: twoSeventy.y)
cp2 = Point(x: oneEighty.x, y: oneEighty.y + yOffset)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: oneEighty))
cp1 = Point(x: oneEighty.x, y: oneEighty.y - yOffset)
cp2 = Point(x: ninety.x - xOffset, y: ninety.y)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: ninety))
cp1 = Point(x: ninety.x + xOffset, y: ninety.y)
cp2 = Point(x: zero.x, y: zero.y - yOffset)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: zero))
} else {
cp1 = Point(x: zero.x, y: zero.y - yOffset)
cp2 = Point(x: ninety.x + xOffset, y: ninety.y)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: ninety))
cp1 = Point(x: ninety.x - xOffset, y: ninety.y)
cp2 = Point(x: oneEighty.x, y: oneEighty.y - yOffset)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: oneEighty))
cp1 = Point(x: oneEighty.x, y: oneEighty.y + yOffset)
cp2 = Point(x: twoSeventy.x - xOffset, y: twoSeventy.y)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: twoSeventy))
cp1 = Point(x: twoSeventy.x + xOffset, y: twoSeventy.y)
cp2 = Point(x: zero.x, y: zero.y + yOffset)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: zero))
}
commands.append(.closePath)
return commands
}
}

View file

@ -0,0 +1,12 @@
import Foundation
struct PathProcessor {
let data: String
func commands() throws -> [Path.Command] {
let parser = Path.ComponentParser()
let components = try Path.Component.components(from: data)
return try parser.parse(components)
}
}

View file

@ -0,0 +1,52 @@
import Foundation
import Swift2D
struct PolygonProcessor {
let points: String
func commands() throws -> [Path.Command] {
let pairs = points.components(separatedBy: " ")
let components = pairs.flatMap { $0.components(separatedBy: ",") }
guard components.count > 0 else {
return []
}
guard components.count % 2 == 0 else {
// An odd number of components means that parsing probably failed
return []
}
var commands: [Path.Command] = []
var firstValue: Bool = true
for (idx, component) in components.enumerated() {
guard let _value = Double(component) else {
return commands
}
let value = Double(_value)
if firstValue {
if idx == 0 {
commands.append(.moveTo(point: Point(x: value, y: .nan)))
} else {
commands.append(.lineTo(point: Point(x: value, y: .nan)))
}
firstValue = false
} else {
let count = commands.count
guard let modified = try? commands.last?.adjustingArgument(at: 1, by: value) else {
return commands
}
commands[count - 1] = modified
firstValue = true
}
}
commands.append(.closePath)
return commands
}
}

View file

@ -0,0 +1,54 @@
import Foundation
import Swift2D
struct PolylineProcessor {
let points: String
func commands() throws -> [Path.Command] {
let pairs = points.components(separatedBy: " ")
let components = pairs.flatMap { $0.components(separatedBy: ",") }
let values = components.compactMap { Double($0) }.map { Double($0) }
guard values.count > 2 else {
// More than just a starting point is required.
return []
}
guard values.count % 2 == 0 else {
// An odd number of components means that parsing probably failed
return []
}
var commands: [Path.Command] = []
let move = values.prefix(upTo: 2)
let segments = values.suffix(from: 2)
commands.append(.moveTo(point: Point(x: move[0], y: move[1])))
var _value: Double = .nan
for value in segments {
if _value.isNaN {
_value = value
} else {
commands.append(.lineTo(point: Point(x: _value, y: value)))
_value = .nan
}
}
let reversedSegments = segments.dropLast(2).reversed()
for value in reversedSegments {
if _value.isNaN {
_value = value
} else {
commands.append(.lineTo(point: Point(x: _value, y: value)))
_value = .nan
}
}
commands.append(.closePath)
return commands
}
}

View file

@ -0,0 +1,175 @@
import Swift2D
struct RectangleProcessor {
let rectangle: Rectangle
func commands(clockwise: Bool) -> [Path.Command] {
var rx = rectangle.rx
var ry = rectangle.ry
if let _rx = rx, _rx > (rectangle.width / 2.0) {
rx = rectangle.width / 2.0
}
if let _ry = ry, _ry > (rectangle.height / 2.0) {
ry = rectangle.height / 2.0
}
var commands: [Path.Command] = []
switch (rx, ry) {
case (.some(let radiusX), .some(let radiusY)) where radiusX != radiusY:
// Use Cubic Bezier Curve to form rounded corners
// TODO: Verify that the control points are right
var cp1: Point = .zero
var cp2: Point = .zero
var point: Point = Point(x: rectangle.x + radiusX, y: rectangle.y)
commands.append(.moveTo(point: point))
if clockwise {
point = .init(x: rectangle.x + rectangle.width - radiusX, y: rectangle.y)
commands.append(.lineTo(point: point))
cp1 = .init(x: rectangle.x + rectangle.width, y: rectangle.y)
cp2 = cp1
point = .init(x: rectangle.x + rectangle.width, y: rectangle.y + radiusY)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: point))
point = .init(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height - radiusY)
commands.append(.lineTo(point: point))
cp1 = .init(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height)
cp2 = cp1
point = .init(x: rectangle.x + rectangle.width - radiusX, y: rectangle.y + rectangle.height)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: point))
point = .init(x: rectangle.x + radiusX, y: rectangle.y + rectangle.height)
commands.append(.lineTo(point: point))
cp1 = .init(x: rectangle.x, y: rectangle.y + rectangle.height)
cp2 = cp1
point = .init(x: rectangle.x, y: rectangle.y + rectangle.height - radiusY)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: point))
point = .init(x: rectangle.x, y: rectangle.y + radiusY)
commands.append(.lineTo(point: point))
cp1 = .init(x: rectangle.x, y: rectangle.y)
cp2 = cp1
point = .init(x: rectangle.x + radiusX, y: rectangle.y)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: point))
} else {
cp1 = .init(x: rectangle.x, y: rectangle.y)
cp2 = cp1
point = .init(x: rectangle.x, y: rectangle.y + radiusY)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: point))
point = .init(x: rectangle.x, y: rectangle.y + rectangle.height - radiusY)
commands.append(.lineTo(point: point))
cp1 = .init(x: rectangle.x, y: rectangle.y + rectangle.height)
cp2 = cp1
point = .init(x: rectangle.x + radiusX, y: rectangle.y + rectangle.height)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: point))
point = .init(x: rectangle.x + rectangle.width - radiusX, y: rectangle.y + rectangle.height)
commands.append(.lineTo(point: point))
cp1 = .init(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height)
cp2 = cp1
point = .init(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height - radiusY)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: point))
point = .init(x: rectangle.x + rectangle.width, y: rectangle.y + radiusY)
commands.append(.lineTo(point: point))
cp1 = .init(x: rectangle.x + rectangle.width, y: rectangle.y)
cp2 = cp1
point = .init(x: rectangle.x + rectangle.width - radiusX, y: rectangle.y)
commands.append(.cubicBezierCurve(cp1: cp1, cp2: cp2, point: point))
}
case (.some(let radius), .none), (.none, .some(let radius)), (.some(let radius), _):
// use Quadratic Bezier Curve to form rounded corners
var cp: Point = .zero
var point: Point = Point(x: rectangle.x + radius, y: rectangle.y)
commands.append(.moveTo(point: point))
if clockwise {
point = .init(x: (rectangle.x + rectangle.width) - radius, y: rectangle.y)
commands.append(.lineTo(point: point))
cp = .init(x: rectangle.x + rectangle.width, y: rectangle.y)
point = .init(x: rectangle.x + rectangle.width, y: rectangle.y + radius)
commands.append(.quadraticBezierCurve(cp: cp, point: point))
point = .init(x: rectangle.x + rectangle.width, y: (rectangle.y + rectangle.height) - radius)
commands.append(.lineTo(point: point))
cp = .init(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height)
point = .init(x: rectangle.x + rectangle.width - radius, y: rectangle.y + rectangle.height)
commands.append(.quadraticBezierCurve(cp: cp, point: point))
point = .init(x: rectangle.x + radius, y: rectangle.y + rectangle.height)
commands.append(.lineTo(point: point))
cp = .init(x: rectangle.x, y: rectangle.y + rectangle.height)
point = .init(x: rectangle.x, y: rectangle.y + rectangle.height - radius)
commands.append(.quadraticBezierCurve(cp: cp, point: point))
point = .init(x: rectangle.x, y: rectangle.y + radius)
commands.append(.lineTo(point: point))
cp = .init(x: rectangle.x, y: rectangle.y)
point = .init(x: rectangle.x + radius, y: rectangle.y)
commands.append(.quadraticBezierCurve(cp: cp, point: point))
} else {
cp = .init(x: rectangle.x, y: rectangle.y)
point = .init(x: rectangle.x, y: rectangle.y + radius)
commands.append(.quadraticBezierCurve(cp: cp, point: point))
point = .init(x: rectangle.x, y: rectangle.y + rectangle.height - radius)
commands.append(.lineTo(point: point))
cp = .init(x: rectangle.x, y: rectangle.y + rectangle.height)
point = .init(x: rectangle.x + radius, y: rectangle.y + rectangle.height)
commands.append(.quadraticBezierCurve(cp: cp, point: point))
point = .init(x: rectangle.x + rectangle.width - radius, y: rectangle.y + rectangle.height)
commands.append(.lineTo(point: point))
cp = .init(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height)
point = .init(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height - radius)
commands.append(.quadraticBezierCurve(cp: cp, point: point))
point = .init(x: rectangle.x + rectangle.width, y: rectangle.y + radius)
commands.append(.lineTo(point: point))
cp = .init(x: rectangle.x + rectangle.width, y: rectangle.y)
point = .init(x: rectangle.x + rectangle.width - radius, y: rectangle.y)
commands.append(.quadraticBezierCurve(cp: cp, point: point))
}
case (.none, .none):
// draw three line segments.
commands.append(.moveTo(point: Point(x: rectangle.x, y: rectangle.y)))
if clockwise {
commands.append(.lineTo(point: Point(x: rectangle.x + rectangle.width, y: rectangle.y)))
commands.append(.lineTo(point: Point(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height)))
commands.append(.lineTo(point: Point(x: rectangle.x, y: rectangle.y + rectangle.height)))
} else {
commands.append(.lineTo(point: Point(x: rectangle.x, y: rectangle.y + rectangle.height)))
commands.append(.lineTo(point: Point(x: rectangle.x + rectangle.width, y: rectangle.y + rectangle.height)))
commands.append(.lineTo(point: Point(x: rectangle.x + rectangle.width, y: rectangle.y)))
}
}
commands.append(.closePath)
return commands
}
}

98
third-party/SwiftSVG/Sources/Line.swift vendored Normal file
View file

@ -0,0 +1,98 @@
import Swift2D
import XMLCoder
/// SVG basic shape used to create a line connecting two points.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/line)
/// | [W3](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/line)
public struct Line: Element {
/// Defines the x-axis coordinate of the line starting point.
public var x1: Double = 0.0
/// Defines the x-axis coordinate of the line ending point.
public var y1: Double = 0.0
/// Defines the y-axis coordinate of the line starting point.
public var x2: Double = 0.0
/// Defines the y-axis coordinate of the line ending point.
public var y2: Double = 0.0
// MARK: CoreAttributes
public var id: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case x1
case y1
case x2
case y2
case id
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
public init(x1: Double, y1: Double, x2: Double, y2: Double) {
self.x1 = x1
self.y1 = y1
self.x2 = x2
self.y2 = y2
}
}
extension Line: CommandRepresentable {
public func commands() throws -> [Path.Command] {
[
.moveTo(point: Point(x: x1, y: y1)),
.lineTo(point: Point(x: x2, y: y2)),
.lineTo(point: Point(x: x1, y: y1)),
.closePath,
]
}
}
extension Line: CustomStringConvertible {
public var description: String {
let desc = "<line x1=\"\(x1)\" y1=\"\(y1)\" x2=\"\(x2)\" y2=\"\(y2)\""
return desc + " \(attributeDescription) />"
}
}
extension Line: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
.attribute
}
}
extension Line: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
.attribute
}
}

View file

@ -0,0 +1,276 @@
import Foundation
import Swift2D
public extension Path {
/// Path commands are instructions that define a path to be drawn.
///
/// Each command is composed of a command letter and numbers that represent the command parameters.
enum Command: Equatable, Sendable, CustomStringConvertible {
/// Moves the current drawing point
case moveTo(point: Point)
/// Draw a straight line from the current point to the point provided
case lineTo(point: Point)
/// Draw a smooth curve using three points (+ origin)
case cubicBezierCurve(cp1: Point, cp2: Point, point: Point)
/// Draw a smooth curve using two points (+ origin)
case quadraticBezierCurve(cp: Point, point: Point)
/// Draw a curve defined as a portion of an ellipse
case ellipticalArcCurve(rx: Double, ry: Double, angle: Double, largeArc: Bool, clockwise: Bool, point: Point)
/// ClosePath instructions draw a straight line from the current position to the first point in the path.
case closePath
public enum Prefix: Character, CaseIterable {
case move = "M"
case relativeMove = "m"
case line = "L"
case relativeLine = "l"
case horizontalLine = "H"
case relativeHorizontalLine = "h"
case verticalLine = "V"
case relativeVerticalLine = "v"
case cubicBezierCurve = "C"
case relativeCubicBezierCurve = "c"
case smoothCubicBezierCurve = "S"
case relativeSmoothCubicBezierCurve = "s"
case quadraticBezierCurve = "Q"
case relativeQuadraticBezierCurve = "q"
case smoothQuadraticBezierCurve = "T"
case relativeSmoothQuadraticBezierCurve = "t"
case ellipticalArcCurve = "A"
case relativeEllipticalArcCurve = "a"
case close = "Z"
case relativeClose = "z"
public static var characterSet: CharacterSet {
CharacterSet(charactersIn: allCases.map { String($0.rawValue) }.joined())
}
}
public enum Coordinates {
case absolute
case relative
}
public enum Error: Swift.Error {
case message(String)
case invalidAdjustment(Path.Command)
case invalidArgumentPosition(Int, Path.Command)
case invalidRelativeCommand
}
public var description: String {
switch self {
case .moveTo(let point):
return "\(Prefix.move.rawValue)\(point.x),\(point.y)"
case .lineTo(let point):
return "\(Prefix.line.rawValue)\(point.x),\(point.y)"
case .cubicBezierCurve(let cp1, let cp2, let point):
return "\(Prefix.cubicBezierCurve.rawValue)\(cp1.x),\(cp1.y) \(cp2.x),\(cp2.y) \(point.x),\(point.y)"
case .quadraticBezierCurve(let cp, let point):
return "\(Prefix.quadraticBezierCurve.rawValue)\(cp.x),\(cp.y) \(point.x),\(point.y)"
case .ellipticalArcCurve(let rx, let ry, let angle, let largeArc, let clockwise, let point):
let la = largeArc ? 1 : 0
let cw = clockwise ? 1 : 0
return "\(Prefix.ellipticalArcCurve.rawValue)\(rx) \(ry) \(angle) \(la) \(cw) \(point.x) \(point.y)"
case .closePath:
return "\(Prefix.close.rawValue)"
}
}
/// The primary point that dictates the commands action.
public var point: Point {
switch self {
case .moveTo(let point): point
case .lineTo(let point): point
case .cubicBezierCurve(_, _, let point): point
case .quadraticBezierCurve(_, let point): point
case .ellipticalArcCurve(_, _, _, _, _, let point): point
case .closePath: .zero
}
}
}
}
public extension Path.Command {
/// Applies the provided `Transformation` to the instances values.
func applying(transformation: Transformation) -> Path.Command {
switch transformation {
case .translate(let x, let y):
switch self {
case .moveTo(let point):
let _point = point.adjusting(x: x).adjusting(y: y)
return .moveTo(point: _point)
case .lineTo(let point):
let _point = point.adjusting(x: x).adjusting(y: y)
return .lineTo(point: _point)
case .cubicBezierCurve(let cp1, let cp2, let point):
let _cp1 = cp1.adjusting(x: x).adjusting(y: y)
let _cp2 = cp2.adjusting(x: x).adjusting(y: y)
let _point = point.adjusting(x: x).adjusting(y: y)
return .cubicBezierCurve(cp1: _cp1, cp2: _cp2, point: _point)
case .quadraticBezierCurve(let cp, let point):
let _cp = cp.adjusting(x: x).adjusting(y: y)
let _point = point.adjusting(x: x).adjusting(y: y)
return .quadraticBezierCurve(cp: _cp, point: _point)
case .ellipticalArcCurve(let rx, let ry, let angle, let largeArc, let clockwise, let point):
let _point = point.adjusting(x: x).adjusting(y: y)
return .ellipticalArcCurve(rx: rx, ry: ry, angle: angle, largeArc: largeArc, clockwise: clockwise, point: _point)
case .closePath:
return self
}
case .matrix:
// TODO: What should occur here?
return self
}
}
/// Applies multiple transformations in the order they are specified.
func applying(transformations: [Transformation]) -> Path.Command {
var command = self
for transformation in transformations {
command = command.applying(transformation: transformation)
}
return command
}
}
extension Path.Command {
/// Determines if all values are provided (i.e. !.isNaN)
var isComplete: Bool {
switch self {
case .moveTo(let point), .lineTo(let point):
!point.hasNaN
case .cubicBezierCurve(let cp1, let cp2, let point):
!cp1.hasNaN && !cp2.hasNaN && !point.hasNaN
case .quadraticBezierCurve(let cp, let point):
!cp.hasNaN && !point.hasNaN
case .ellipticalArcCurve(let rx, let ry, let angle, _, _, let point):
!rx.isNaN && !ry.isNaN && !angle.isNaN && !point.hasNaN
case .closePath:
true
}
}
/// The last control point used in drawing the path.
///
/// Only valid for curves.
var lastControlPoint: Point? {
switch self {
case .cubicBezierCurve(_, let cp2, _):
cp2
case .quadraticBezierCurve(let cp, _):
cp
default:
nil
}
}
/// A mirror representation of `lastControlPoint`.
var lastControlPointMirror: Point? {
guard let cp = lastControlPoint else {
return nil
}
return Point(x: point.x + (point.x - cp.x), y: point.y + (point.y - cp.y))
}
/// The total number of argument values the command requires.
var arguments: Int {
switch self {
case .moveTo: 2
case .lineTo: 2
case .cubicBezierCurve: 6
case .quadraticBezierCurve: 4
case .ellipticalArcCurve: 7
case .closePath: 0
}
}
/// Adjusts a Command argument by a specified amount.
///
/// A `Point` consumes two positions. So, in the example `.quadraticBezierCurve(cp: .zero, point: .zero)`:
/// * position 0 = Control Point X
/// * position 1 = Control Point Y
/// * position 2 = Point X
/// * position 3 = Point Y
///
/// - parameter position: The index of the argument parameter to adjust.
/// - parameter value: The value to add to the existing value. If the current value equal `.isNaN`, than the
/// supplied value is used as-is.
/// - throws: `Path.Command.Error`
func adjustingArgument(at position: Int, by value: Double) throws -> Path.Command {
switch self {
case .moveTo(let point):
switch position {
case 0:
return .moveTo(point: point.adjusting(x: value))
case 1:
return .moveTo(point: point.adjusting(y: value))
default:
throw Path.Command.Error.invalidArgumentPosition(position, self)
}
case .lineTo(let point):
switch position {
case 0:
return .lineTo(point: point.adjusting(x: value))
case 1:
return .lineTo(point: point.adjusting(y: value))
default:
throw Path.Command.Error.invalidArgumentPosition(position, self)
}
case .cubicBezierCurve(let cp1, let cp2, let point):
switch position {
case 0:
return .cubicBezierCurve(cp1: cp1.adjusting(x: value), cp2: cp2, point: point)
case 1:
return .cubicBezierCurve(cp1: cp1.adjusting(y: value), cp2: cp2, point: point)
case 2:
return .cubicBezierCurve(cp1: cp1, cp2: cp2.adjusting(x: value), point: point)
case 3:
return .cubicBezierCurve(cp1: cp1, cp2: cp2.adjusting(y: value), point: point)
case 4:
return .cubicBezierCurve(cp1: cp1, cp2: cp2, point: point.adjusting(x: value))
case 5:
return .cubicBezierCurve(cp1: cp1, cp2: cp2, point: point.adjusting(y: value))
default:
throw Path.Command.Error.invalidArgumentPosition(position, self)
}
case .quadraticBezierCurve(let cp, let point):
switch position {
case 0:
return .quadraticBezierCurve(cp: cp.adjusting(x: value), point: point)
case 1:
return .quadraticBezierCurve(cp: cp.adjusting(y: value), point: point)
case 2:
return .quadraticBezierCurve(cp: cp, point: point.adjusting(x: value))
case 3:
return .quadraticBezierCurve(cp: cp, point: point.adjusting(y: value))
default:
throw Path.Command.Error.invalidArgumentPosition(position, self)
}
case .ellipticalArcCurve(let rx, let ry, let angle, let largeArc, let clockwise, let point):
switch position {
case 0:
return .ellipticalArcCurve(rx: value, ry: ry, angle: angle, largeArc: largeArc, clockwise: clockwise, point: point)
case 1:
return .ellipticalArcCurve(rx: rx, ry: value, angle: angle, largeArc: largeArc, clockwise: clockwise, point: point)
case 2:
return .ellipticalArcCurve(rx: rx, ry: ry, angle: value, largeArc: largeArc, clockwise: clockwise, point: point)
case 3:
return .ellipticalArcCurve(rx: rx, ry: ry, angle: angle, largeArc: !value.isZero, clockwise: clockwise, point: point)
case 4:
return .ellipticalArcCurve(rx: rx, ry: ry, angle: angle, largeArc: largeArc, clockwise: !value.isZero, point: point)
case 5:
return .ellipticalArcCurve(rx: rx, ry: ry, angle: angle, largeArc: largeArc, clockwise: clockwise, point: point.adjusting(x: value))
case 6:
return .ellipticalArcCurve(rx: rx, ry: ry, angle: angle, largeArc: largeArc, clockwise: clockwise, point: point.adjusting(y: value))
default:
throw Path.Command.Error.invalidArgumentPosition(position, self)
}
case .closePath:
throw Path.Command.Error.invalidAdjustment(self)
}
}
}

View file

@ -0,0 +1,98 @@
import Foundation
public extension Path {
/// A unit of a SVG path data string.
enum Component {
case prefix(Command.Prefix)
case value(Double)
/// Interprets a `Path` `data` attribute into individual `Component`s for command processing.
public static func components(from data: String) throws -> [Component] {
var blocks: [String] = []
var block: String = ""
for scalar in data.unicodeScalars {
if scalar == "e" {
// Account for exponential value notation.
block.append(String(scalar))
continue
}
if CharacterSet.letters.contains(scalar) {
if !block.isEmpty {
blocks.append(block)
block = ""
}
blocks.append(String(scalar))
continue
}
if CharacterSet.whitespaces.contains(scalar) {
if !block.isEmpty {
blocks.append(block)
block = ""
}
continue
}
if CharacterSet(charactersIn: ",").contains(scalar) {
if !block.isEmpty {
blocks.append(block)
block = ""
}
continue
}
if CharacterSet(charactersIn: "-").contains(scalar) {
if !block.isEmpty, block.last != "e" {
// Again, account for exponential values.
blocks.append(block)
block = ""
}
block.append(String(scalar))
continue
}
if CharacterSet(charactersIn: ".").contains(scalar) {
if block.contains(".") {
// Already decimal value, this is a new value
blocks.append(block)
block = ""
}
block.append(String(scalar))
continue
}
if CharacterSet.decimalDigits.contains(scalar) {
block.append(String(scalar))
continue
}
print("Unhandled Character: \(scalar)")
}
if !block.isEmpty {
blocks.append(block)
block = ""
}
return blocks
.filter { !$0.isEmpty }
.compactMap {
if let prefix = Path.Command.Prefix(rawValue: $0.first!) {
.prefix(prefix)
} else if let value = Double($0) {
.value(value)
} else {
// throw in the future?
nil
}
}
}
}
}

View file

@ -0,0 +1,275 @@
import Swift2D
public extension Path {
/// Utility used to construct a collection of `Path.Command` from a collection of `Path.Component`.
class ComponentParser {
/// The command currently being built
private var command: Path.Command?
/// Coordinate system being used
private var coordinates: Path.Command.Coordinates = .absolute
/// The argument position of the _command_ to be processed.
private var position: Int = 0
/// Indicates that only a single value will be processed on the next component pass.
private var singleValue: Bool = false
/// The originating coordinates of the path.
private var pathOrigin: Point = .nan
/// The last point as processed by the parser.
private var currentPoint: Point = .zero
public init() {}
public func parse(_ components: [Path.Component]) throws -> [Path.Command] {
var commands: [Path.Command] = []
try components.forEach { component in
if let command = try parse(component, lastCommand: commands.last) {
commands.append(command)
}
}
return commands
}
private func parse(_ component: Path.Component, lastCommand: Path.Command?) throws -> Path.Command? {
switch component {
case .prefix(let prefix):
setup(prefix: prefix, lastCommand: lastCommand)
case .value(let value):
try process(value: value, lastCommand: lastCommand)
}
}
private func setup(prefix: Path.Command.Prefix, lastCommand: Path.Command?) -> Path.Command? {
position = 0
singleValue = false
switch prefix {
case .move:
command = .moveTo(point: .nan)
coordinates = .absolute
case .relativeMove:
command = .moveTo(point: currentPoint)
coordinates = .relative
case .line:
command = .lineTo(point: .nan)
coordinates = .absolute
case .relativeLine:
command = .lineTo(point: currentPoint)
coordinates = .relative
case .horizontalLine:
command = .lineTo(point: currentPoint.with(x: .nan))
coordinates = .absolute
case .relativeHorizontalLine:
command = .lineTo(point: currentPoint)
coordinates = .relative
singleValue = true
case .verticalLine:
command = .lineTo(point: currentPoint.with(y: .nan))
coordinates = .absolute
position = 1
case .relativeVerticalLine:
command = .lineTo(point: currentPoint)
coordinates = .relative
position = 1
singleValue = true
case .cubicBezierCurve:
command = .cubicBezierCurve(cp1: .nan, cp2: .nan, point: .nan)
coordinates = .absolute
case .relativeCubicBezierCurve:
command = .cubicBezierCurve(cp1: currentPoint, cp2: currentPoint, point: currentPoint)
coordinates = .relative
case .smoothCubicBezierCurve:
if case .cubicBezierCurve(_, let cp, _) = lastCommand {
command = .cubicBezierCurve(cp1: cp.reflecting(around: currentPoint), cp2: .nan, point: .nan)
} else {
command = .cubicBezierCurve(cp1: currentPoint, cp2: .nan, point: .nan)
}
coordinates = .absolute
position = 2
case .relativeSmoothCubicBezierCurve:
if case .cubicBezierCurve(_, let cp, _) = lastCommand {
command = .cubicBezierCurve(cp1: cp.reflecting(around: cp.reflecting(around: currentPoint)), cp2: currentPoint, point: currentPoint)
} else {
command = .cubicBezierCurve(cp1: currentPoint, cp2: currentPoint, point: currentPoint)
}
coordinates = .relative
position = 2
case .quadraticBezierCurve:
command = .quadraticBezierCurve(cp: .nan, point: .nan)
coordinates = .absolute
case .relativeQuadraticBezierCurve:
command = .quadraticBezierCurve(cp: currentPoint, point: currentPoint)
coordinates = .relative
case .smoothQuadraticBezierCurve:
if case .quadraticBezierCurve(let cp, _) = lastCommand {
command = .quadraticBezierCurve(cp: cp.reflecting(around: currentPoint), point: .nan)
} else {
command = .quadraticBezierCurve(cp: currentPoint, point: .nan)
}
coordinates = .absolute
position = 2
case .relativeSmoothQuadraticBezierCurve:
if case .quadraticBezierCurve(let cp, _) = lastCommand {
command = .quadraticBezierCurve(cp: cp.reflecting(around: currentPoint), point: currentPoint)
} else {
command = .quadraticBezierCurve(cp: currentPoint, point: currentPoint)
}
coordinates = .relative
position = 2
case .ellipticalArcCurve:
command = .ellipticalArcCurve(rx: .nan, ry: .nan, angle: .nan, largeArc: false, clockwise: false, point: .nan)
coordinates = .absolute
case .relativeEllipticalArcCurve:
command = .ellipticalArcCurve(rx: .nan, ry: .nan, angle: .nan, largeArc: false, clockwise: false, point: currentPoint)
coordinates = .relative
case .close, .relativeClose:
currentPoint = pathOrigin
reset()
return .closePath
}
return nil
}
private func process(value: Double, lastCommand: Path.Command?) throws -> Path.Command? {
if let command {
try continueCommand(command, with: value)
} else {
try nextCommand(with: value, lastCommand: lastCommand)
}
if let command, command.isComplete {
switch coordinates {
case .relative:
guard position == -1 else {
return nil
}
fallthrough
case .absolute:
currentPoint = command.point
if case .moveTo = command {
pathOrigin = command.point
}
reset()
return command
}
} else {
return nil
}
}
private func continueCommand(_ command: Path.Command, with value: Double) throws {
switch command {
case .moveTo, .cubicBezierCurve, .quadraticBezierCurve, .ellipticalArcCurve:
self.command = try command.adjustingArgument(at: position, by: value)
switch coordinates {
case .absolute:
position += 1
case .relative:
switch position {
case 0 ... (command.arguments - 2):
position += 1
case command.arguments - 1:
position = -1
default:
break // throw?
}
}
case .lineTo:
self.command = try command.adjustingArgument(at: position, by: value)
switch coordinates {
case .absolute:
position += 1
case .relative:
switch position {
case 0:
if singleValue {
singleValue = false
position = -1
} else {
position += 1
}
case 1:
if singleValue {
singleValue = false
}
position = -1
default:
break // throw?
}
}
case .closePath:
break
}
}
private func nextCommand(with value: Double, lastCommand: Path.Command?) throws {
guard let command = lastCommand else {
throw Path.Command.Error.invalidRelativeCommand
}
switch command {
case .moveTo:
switch coordinates {
case .absolute:
self.command = .lineTo(point: Point(x: value, y: .nan))
position = 1
case .relative:
let c = Path.Command.lineTo(point: command.point)
self.command = try c.adjustingArgument(at: 0, by: value)
position = 1
}
case .lineTo:
switch coordinates {
case .absolute:
self.command = .lineTo(point: Point(x: value, y: .nan))
position = 1
case .relative:
let c = Path.Command.lineTo(point: command.point)
self.command = try c.adjustingArgument(at: 0, by: value)
position = 1
}
case .cubicBezierCurve:
switch coordinates {
case .absolute:
self.command = .cubicBezierCurve(cp1: Point(x: value, y: .nan), cp2: .nan, point: .nan)
position = 1
case .relative:
let c = Path.Command.cubicBezierCurve(cp1: command.point, cp2: command.point, point: command.point)
self.command = try c.adjustingArgument(at: 0, by: value)
position = 1
}
case .quadraticBezierCurve:
switch coordinates {
case .absolute:
self.command = .quadraticBezierCurve(cp: Point(x: value, y: .nan), point: .nan)
position = 1
case .relative:
let c = Path.Command.quadraticBezierCurve(cp: command.point, point: command.point)
self.command = try c.adjustingArgument(at: 0, by: value)
position = 1
}
case .ellipticalArcCurve:
switch coordinates {
case .absolute:
self.command = .ellipticalArcCurve(rx: value, ry: .nan, angle: .nan, largeArc: false, clockwise: false, point: .nan)
position = 1
case .relative:
let c = Path.Command.ellipticalArcCurve(rx: .nan, ry: .nan, angle: .nan, largeArc: false, clockwise: false, point: command.point)
self.command = try c.adjustingArgument(at: 0, by: value)
position = 1
}
case .closePath:
break
}
}
private func reset() {
command = nil
coordinates = .absolute
position = 0
singleValue = false
}
}
}

101
third-party/SwiftSVG/Sources/Path.swift vendored Normal file
View file

@ -0,0 +1,101 @@
import XMLCoder
/// Generic element to define a shape.
///
/// A path is defined by including a path element in a SVG document which contains a **d="(path data)"**
/// attribute, where the **d** attribute contains the moveto, line, curve (both Cubic and Quadratic Bézier),
/// arc and closepath instructions.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path)
/// | [W3](https://www.w3.org/TR/SVG11/paths.html)
public struct Path: Element {
/// The definition of the outline of a shape.
public var data: String = ""
// MARK: CoreAttributes
public var id: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case data = "d"
case id
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
public init(data: String) {
self.init()
self.data = data
}
public init(commands: [Path.Command]) {
self.init()
data = commands.map(\.description).joined()
}
}
extension Path: CommandRepresentable {
public func commands() throws -> [Command] {
try PathProcessor(data: data).commands()
}
}
extension Path: CustomStringConvertible {
public var description: String {
"<path d=\"\(data)\" \(attributeDescription) />"
}
}
extension Path: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
.attribute
}
}
extension Path: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
.attribute
}
}
extension Path: Equatable {
public static func == (lhs: Path, rhs: Path) -> Bool {
do {
let lhsCommands = try lhs.commands()
let rhsCommands = try rhs.commands()
return lhsCommands == rhsCommands
} catch {
return false
}
}
}

View file

@ -0,0 +1,82 @@
import XMLCoder
/// Defines a closed shape consisting of a set of connected straight line segments.
///
/// The last point is connected to the first point. For open shapes, see the `Polyline` element. If an odd number of
/// coordinates is provided, then the element is in error.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/polygon)
/// | [W3](https://www.w3.org/TR/SVG11/shapes.html#PolygonElement)
public struct Polygon: Element {
/// The points that make up the polygon.
public var points: String = ""
// MARK: CoreAttributes
public var id: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case points
case id
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
public init(points: String) {
self.points = points
}
}
extension Polygon: CommandRepresentable {
public func commands() throws -> [Path.Command] {
try PolygonProcessor(points: points).commands()
}
}
extension Polygon: CustomStringConvertible {
public var description: String {
"<polygon points=\"\(points)\" \(attributeDescription) />"
}
}
extension Polygon: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
.attribute
}
}
extension Polygon: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
.attribute
}
}

View file

@ -0,0 +1,81 @@
import XMLCoder
/// SVG basic shape that creates straight lines connecting several points.
///
/// Typically a polyline is used to create open shapes as the last point doesn't have to be connected to the first
/// point. For closed shapes see the `Polygon` element.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/polyline)
/// | [W3](https://www.w3.org/TR/SVG11/shapes.html#PolylineElement)
public struct Polyline: Element {
public var points: String = ""
// MARK: CoreAttributes
public var id: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case points
case id
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
public init(points: String) {
self.points = points
}
}
extension Polyline: CommandRepresentable {
public func commands() throws -> [Path.Command] {
try PolylineProcessor(points: points).commands()
}
}
extension Polyline: CustomStringConvertible {
public var description: String {
"<polyline points=\"\(points)\" \(attributeDescription) />"
}
}
extension Polyline: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
.attribute
}
}
extension Polyline: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
.attribute
}
}

View file

@ -0,0 +1,120 @@
import Foundation
import Swift2D
public protocol PresentationAttributes {
var fillColor: String? { get set }
var fillOpacity: Double? { get set }
var fillRule: Fill.Rule? { get set }
var strokeColor: String? { get set }
var strokeWidth: Double? { get set }
var strokeOpacity: Double? { get set }
var strokeLineCap: Stroke.LineCap? { get set }
var strokeLineJoin: Stroke.LineJoin? { get set }
var strokeMiterLimit: Double? { get set }
var transform: String? { get set }
}
enum PresentationAttributesKeys: String, CodingKey {
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
}
public extension PresentationAttributes {
var presentationDescription: String {
var attributes: [String] = []
if let fillColor {
attributes.append("\(PresentationAttributesKeys.fillColor.rawValue)=\"\(fillColor)\"")
}
if let fillOpacity {
attributes.append("\(PresentationAttributesKeys.fillOpacity.rawValue)=\"\(fillOpacity)\"")
}
if let fillRule {
attributes.append("\(PresentationAttributesKeys.fillRule.rawValue)=\"\(fillRule.description)\"")
}
if let strokeColor {
attributes.append("\(PresentationAttributesKeys.strokeColor.rawValue)=\"\(strokeColor)\"")
}
if let strokeWidth {
attributes.append("\(PresentationAttributesKeys.strokeWidth.rawValue)=\"\(strokeWidth)\"")
}
if let strokeOpacity {
attributes.append("\(PresentationAttributesKeys.strokeOpacity.rawValue)=\"\(strokeOpacity)\"")
}
if let strokeLineCap {
attributes.append("\(PresentationAttributesKeys.strokeLineCap.rawValue)=\"\(strokeLineCap.description)\"")
}
if let strokeLineJoin {
attributes.append("\(PresentationAttributesKeys.strokeLineJoin.rawValue)=\"\(strokeLineJoin.description)\"")
}
if let strokeMiterLimit {
attributes.append("\(PresentationAttributesKeys.strokeMiterLimit.rawValue)=\"\(strokeMiterLimit)\"")
}
if let transform {
attributes.append("\(PresentationAttributesKeys.transform.rawValue)=\"\(transform)\"")
}
return attributes.joined(separator: " ")
}
var transformations: [Transformation] {
let value = transform?.replacingOccurrences(of: " ", with: "") ?? ""
guard !value.isEmpty else {
return []
}
let values = value.split(separator: ")").map { $0.appending(")") }
return values.compactMap { Transformation($0) }
}
var fill: Fill? {
get {
if fillColor == nil, fillOpacity == nil {
return nil
}
var fill = Fill()
fill.color = fillColor ?? "black"
fill.opacity = fillOpacity ?? 1.0
return fill
}
set {
fillColor = newValue?.color
fillOpacity = newValue?.opacity
fillRule = newValue?.rule
}
}
var stroke: Stroke? {
get {
if strokeColor == nil, strokeOpacity == nil {
return nil
}
var stroke = Stroke()
stroke.color = strokeColor ?? "black"
stroke.opacity = strokeOpacity ?? 1.0
stroke.width = strokeWidth ?? 1.0
stroke.lineCap = strokeLineCap ?? .butt
stroke.lineJoin = strokeLineJoin ?? .miter
stroke.miterLimit = strokeMiterLimit
return stroke
}
set {
strokeColor = newValue?.color
strokeOpacity = newValue?.opacity
strokeWidth = newValue?.width
strokeLineCap = newValue?.lineCap
strokeLineJoin = newValue?.lineJoin
strokeMiterLimit = newValue?.miterLimit
}
}
}

View file

@ -0,0 +1,115 @@
import Swift2D
import XMLCoder
/// Basic SVG shape that draws rectangles, defined by their position, width, and height.
///
/// The values used for the x- and y-axis rounded corner radii are determined implicitly
/// if the rx or ry attributes (or both) are not specified, or are specified but with invalid values.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/rect)
/// | [W3](https://www.w3.org/TR/SVG11/shapes.html#RectElement)
public struct Rectangle: Element {
/// The x-axis coordinate of the side of the rectangle which
/// has the smaller x-axis coordinate value.
public var x: Double = 0.0
/// The y-axis coordinate of the side of the rectangle which
/// has the smaller y-axis coordinate value
public var y: Double = 0.0
/// The width of the rectangle.
public var width: Double = 0.0
/// The height of the rectangle.
public var height: Double = 0.0
/// For rounded rectangles, the x-axis radius of the ellipse used
/// to round off the corners of the rectangle.
public var rx: Double?
/// For rounded rectangles, the y-axis radius of the ellipse used
/// to round off the corners of the rectangle.
public var ry: Double?
// MARK: CoreAttributes
public var id: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case x
case y
case width
case height
case rx
case ry
case id
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
public init(x: Double, y: Double, width: Double, height: Double, rx: Double? = nil, ry: Double? = nil) {
self.x = x
self.y = y
self.width = width
self.height = height
self.rx = rx
self.ry = ry
}
}
extension Rectangle: CustomStringConvertible {
public var description: String {
var desc = "<rect x=\"\(x)\" y=\"\(y)\" width=\"\(width)\" height=\"\(height)\""
if let rx {
desc.append(" rx=\"\(rx)\"")
}
if let ry {
desc.append(" ry=\"\(ry)\"")
}
return desc + " \(attributeDescription) />"
}
}
extension Rectangle: DirectionalCommandRepresentable {
public func commands(clockwise: Bool) throws -> [Path.Command] {
RectangleProcessor(rectangle: self).commands(clockwise: clockwise)
}
}
extension Rectangle: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
.attribute
}
}
extension Rectangle: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
.attribute
}
}

View file

@ -0,0 +1,62 @@
import Foundation
import Swift2D
public extension SVG {
/// Original size of the document image.
///
/// Primarily uses the `viewBox` attribute, and will fallback to the 'pixelSize'
var originalSize: Size {
(viewBoxSize ?? pixelSize) ?? .zero
}
/// Size of the design in a square 'viewBox'.
///
/// All paths created by this framework are outputted in a 'square'.
var outputSize: Size {
let size = (pixelSize ?? viewBoxSize) ?? .zero
let maxDimension = max(size.width, size.height)
return Size(width: maxDimension, height: maxDimension)
}
/// Size derived from the `viewBox` document attribute
var viewBoxSize: Size? {
guard let viewBox else {
return nil
}
let components = viewBox.components(separatedBy: .whitespaces)
guard components.count == 4 else {
return nil
}
guard let width = Double(components[2]) else {
return nil
}
guard let height = Double(components[3]) else {
return nil
}
return Size(width: width, height: height)
}
/// Size derived from the 'width' & 'height' document attributes
var pixelSize: Size? {
guard let width, !width.isEmpty else {
return nil
}
guard let height, !height.isEmpty else {
return nil
}
let widthRawValue = width.replacingOccurrences(of: "px", with: "", options: .caseInsensitive, range: nil)
let heightRawValue = height.replacingOccurrences(of: "px", with: "", options: .caseInsensitive, range: nil)
guard let w = Double(widthRawValue), let h = Double(heightRawValue) else {
return nil
}
return Size(width: w, height: h)
}
}

160
third-party/SwiftSVG/Sources/SVG.swift vendored Normal file
View file

@ -0,0 +1,160 @@
import Foundation
import XMLCoder
/// SVG is a language for describing two-dimensional graphics in XML.
///
/// The svg element is a container that defines a new coordinate system and viewport. It is used as the outermost
/// element of SVG documents, but it can also be used to embed a SVG fragment inside an SVG or HTML document.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg)
/// | [W3](https://www.w3.org/TR/SVG11/)
public struct SVG: Container {
public var viewBox: String?
public var width: String?
public var height: String?
public var title: String?
public var desc: String?
// Container
public var circles: [Circle]?
public var ellipses: [Ellipse]?
public var groups: [Group]?
public var lines: [Line]?
public var paths: [Path]?
public var polygons: [Polygon]?
public var polylines: [Polyline]?
public var rectangles: [Rectangle]?
public var texts: [Text]?
/// A non-optional, non-spaced representation of the `title`.
public var name: String {
let name = title ?? "SVG Document"
let newTitle = name.components(separatedBy: .punctuationCharacters).joined(separator: "_")
return newTitle.replacingOccurrences(of: " ", with: "_")
}
enum CodingKeys: String, CodingKey {
case width
case height
case viewBox
case title
case desc
case circles = "circle"
case ellipses = "ellipse"
case groups = "g"
case lines = "line"
case paths = "path"
case polylines = "polyline"
case polygons = "polygon"
case rectangles = "rect"
case texts = "text"
}
public init() {}
public init(width: Int, height: Int) {
self.width = "\(width)px"
self.height = "\(height)px"
viewBox = "0 0 \(width) \(height)"
}
public static func make(from url: URL) throws -> SVG {
guard FileManager.default.fileExists(atPath: url.path) else {
throw CocoaError(.fileNoSuchFile)
}
let data = try Data(contentsOf: url)
return try make(with: data)
}
public static func make(with data: Data, decoder: XMLDecoder = XMLDecoder()) throws -> SVG {
try decoder.decode(SVG.self, from: data)
}
/// A collection of all `Path`s in the document.
public func subpaths() throws -> [Path] {
var output: [Path] = []
let _transformations: [Transformation] = []
if let circles {
try output.append(contentsOf: circles.compactMap { try $0.path(applying: _transformations) })
}
if let ellipses {
try output.append(contentsOf: ellipses.compactMap { try $0.path(applying: _transformations) })
}
if let rectangles {
try output.append(contentsOf: rectangles.compactMap { try $0.path(applying: _transformations) })
}
if let polygons {
try output.append(contentsOf: polygons.compactMap { try $0.path(applying: _transformations) })
}
if let polylines {
try output.append(contentsOf: polylines.compactMap { try $0.path(applying: _transformations) })
}
if let paths {
try output.append(contentsOf: paths.map { try $0.path(applying: _transformations) })
}
if let groups {
try groups.forEach {
try output.append(contentsOf: $0.subpaths(applying: _transformations))
}
}
return output
}
/// A singular path that represents all of the `Command`s within the document.
public func coalescedPath() throws -> Path {
let paths = try subpaths()
let commands = try paths.flatMap { try $0.commands() }
return Path(commands: commands)
}
}
extension SVG: CustomStringConvertible {
public var description: String {
var contents: String = ""
if let title {
contents.append("\n<title>\(title)</title>")
}
if let desc {
contents.append("\n<desc>\(desc)</desc>")
}
contents.append(containerDescription)
return "<svg viewBox=\"\(viewBox ?? "")\" width=\"\(width ?? "")\" height=\"\(height ?? "")\">\(contents)\n</svg>"
}
}
extension SVG: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
switch key {
case CodingKeys.width, CodingKeys.height, CodingKeys.viewBox:
.attribute
default:
.element
}
}
}
extension SVG: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
switch key {
case CodingKeys.width, CodingKeys.height, CodingKeys.viewBox:
.attribute
default:
.element
}
}
}

View file

@ -0,0 +1,64 @@
import Swift2D
public struct Stroke {
public var color: String?
public var width: Double?
public var opacity: Double?
public var lineCap: LineCap = .butt
public var lineJoin: LineJoin = .miter
public var miterLimit: Double?
public init() {}
/// Presentation attribute defining the shape to be used at the end of open subpaths when they are stroked.
///
/// The default `LineCap` is `.butt`
public enum LineCap: String, Sendable, Codable, CaseIterable {
/// The stroke for each subpath does not extend beyond its two endpoints.
case butt
/// The end of each subpath the stroke will be extended by a half circle with a diameter equal to the stroke
/// width.
case round
/// The end of each subpath the stroke will be extended by a rectangle with a width equal to half the width of
/// the stroke and a height equal to the width of the stroke.
case square
}
/// Presentation attribute defining the shape to be used at the corners of paths when they are stroked.
///
/// The default `LineJoin` is `.miter`
public enum LineJoin: String, Sendable, Codable, CaseIterable {
/// An arcs corner is to be used to join path segments.
///
/// The arcs shape is formed by extending the outer edges of the stroke at the join point with arcs that have
/// the same curvature as the outer edges at the join point.
case arcs
/// The bevel value indicates that a bevelled corner is to be used to join path segments.
case bevel
/// Indicates that a sharp corner is to be used to join path segments.
///
/// The corner is formed by extending the outer edges of the stroke at the tangents of the path segments until
/// they intersect.
case miter
/// A sharp corner is to be used to join path segments.
///
/// The corner is formed by extending the outer edges of the stroke at the tangents of the path segments until
/// they intersect.
case miterClip = "miter-clip"
/// The round value indicates that a round corner is to be used to join path segments.
case round
}
}
extension Stroke.LineCap: CustomStringConvertible {
public var description: String {
rawValue
}
}
extension Stroke.LineJoin: CustomStringConvertible {
public var description: String {
rawValue
}
}

View file

@ -0,0 +1,17 @@
public protocol StylingAttributes {
var style: String? { get set }
}
enum StylingAttributesKeys: String, CodingKey {
case style
}
public extension StylingAttributes {
var stylingDescription: String {
if let style {
"\(StylingAttributesKeys.style.rawValue)=\"\(style)\""
} else {
""
}
}
}

110
third-party/SwiftSVG/Sources/Text.swift vendored Normal file
View file

@ -0,0 +1,110 @@
import Foundation
import XMLCoder
/// Graphics element consisting of text
///
/// It's possible to apply a gradient, pattern, clipping path, mask, or filter to `Text`, like any other SVG graphics element.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text)
/// | [W3](https://www.w3.org/TR/SVG11/text.html#TextElement)
public struct Text: Element {
public var value: String = ""
public var x: Double?
public var y: Double?
public var dx: Double?
public var dy: Double?
// MARK: CoreAttributes
public var id: String?
// MARK: PresentationAttributes
public var fillColor: String?
public var fillOpacity: Double?
public var fillRule: Fill.Rule?
public var strokeColor: String?
public var strokeWidth: Double?
public var strokeOpacity: Double?
public var strokeLineCap: Stroke.LineCap?
public var strokeLineJoin: Stroke.LineJoin?
public var strokeMiterLimit: Double?
public var transform: String?
// MARK: StylingAttributes
public var style: String?
enum CodingKeys: String, CodingKey {
case value = ""
case x
case y
case dx
case dy
case id
case fillColor = "fill"
case fillOpacity = "fill-opacity"
case fillRule = "fill-rule"
case strokeColor = "stroke"
case strokeWidth = "stroke-width"
case strokeOpacity = "stroke-opacity"
case strokeLineCap = "stroke-linecap"
case strokeLineJoin = "stroke-linejoin"
case strokeMiterLimit = "stroke-miterlimit"
case transform
case style
}
public init() {}
public init(value: String) {
self.value = value
}
}
extension Text: CustomStringConvertible {
public var description: String {
var components: [String] = []
if let x, !x.isNaN, !x.isZero {
components.append(String(format: "x=\"%.5f\"", x))
}
if let y, !y.isNaN, !y.isZero {
components.append(String(format: "y=\"%.5f\"", y))
}
if let dx, !dx.isNaN, !dx.isZero {
components.append(String(format: "dx=\"%.5f\"", dx))
}
if let dy, !dy.isNaN, !dy.isZero {
components.append(String(format: "dy=\"%.5f\"", dy))
}
components.append(attributeDescription)
return "<text " + components.joined(separator: " ") + " >\(value)</text>"
}
}
extension Text: DynamicNodeDecoding {
public static func nodeDecoding(for key: any CodingKey) -> XMLDecoder.NodeDecoding {
switch key {
case CodingKeys.value:
.element
default:
.attribute
}
}
}
extension Text: DynamicNodeEncoding {
public static func nodeEncoding(for key: any CodingKey) -> XMLEncoder.NodeEncoding {
switch key {
case CodingKeys.value:
.element
default:
.attribute
}
}
}

View file

@ -0,0 +1,113 @@
import Foundation
import Swift2D
/// A modification that should be applied to an element and its children.
///
/// If a list of transforms is provided, then the net effect is as if each transform had been specified separately in
/// the order provided.
///
/// For example,
/// ```
/// <g transform="translate(-10,-20) scale(2) rotate(45) translate(5,10)">
/// <!-- graphics elements go here -->
/// </g>
/// ```
/// is functionally equivalent to:
/// ```
/// <g transform="translate(-10,-20)">
/// <g transform="scale(2)">
/// <g transform="rotate(45)">
/// <g transform="translate(5,10)">
/// <!-- graphics elements go here -->
/// </g>
/// </g>
/// </g>
/// </g>
/// ```
///
/// The transform attribute is applied to an element before processing any other coordinate or length values supplied
/// for that element.
///
/// ## Documentation
/// [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform)
/// | [W3](https://www.w3.org/TR/SVG11/coords.html#TransformAttribute)
public enum Transformation {
/// Moves an object by x & y. (Y is assumed to be '0' if not provided)
case translate(x: Double, y: Double)
/// Specifies a transformation in the form of a transformation matrix of six values.
case matrix(a: Double, b: Double, c: Double, d: Double, e: Double, f: Double)
public enum Prefix: String, CaseIterable {
case translate
case matrix
}
/// Initializes a new `Transformation` with a raw SVG transformation string.
public init?(_ string: String) {
guard let prefix = Prefix.allCases.first(where: { string.lowercased().hasPrefix($0.rawValue) }) else {
return nil
}
switch prefix {
case .translate:
guard let start = string.firstIndex(of: "(") else {
return nil
}
guard let stop = string.lastIndex(of: ")") else {
return nil
}
var substring = String(string[start ... stop])
substring = substring.replacingOccurrences(of: "(", with: "")
substring = substring.replacingOccurrences(of: ")", with: "")
var components = substring.split(separator: " ", omittingEmptySubsequences: true).map { String($0) }
components = components.flatMap { $0.components(separatedBy: ",") }
let values = components.compactMap { Double($0) }.map { Double($0) }
guard values.count > 0 else {
return nil
}
if values.count > 1 {
self = .translate(x: values[0], y: values[1])
} else {
self = .translate(x: values[0], y: 0.0)
}
case .matrix:
guard let start = string.firstIndex(of: "(") else {
return nil
}
guard let stop = string.lastIndex(of: ")") else {
return nil
}
var substring = String(string[start ... stop])
substring = substring.replacingOccurrences(of: "(", with: "")
substring = substring.replacingOccurrences(of: ")", with: "")
var components = substring.split(separator: " ", omittingEmptySubsequences: true).map { String($0) }
components = components.flatMap { $0.components(separatedBy: ",") }
let values = components.compactMap { Double($0) }.map { Double($0) }
guard values.count > 5 else {
return nil
}
self = .matrix(a: values[0], b: values[1], c: values[2], d: values[3], e: values[4], f: values[5])
}
}
}
extension Transformation: CustomStringConvertible {
public var description: String {
switch self {
case .translate(let x, let y):
"translate(\(x), \(y))"
case .matrix(let a, let b, let c, let d, let e, let f):
"matrix(\(a), \(b), \(c), \(d), \(e), \(f))"
}
}
}

19
third-party/VectorPlus/BUILD vendored Normal file
View file

@ -0,0 +1,19 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "VectorPlus",
module_name = "VectorPlus",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//third-party/SwiftColor",
"//third-party/SwiftSVG",
],
visibility = [
"//visibility:public",
],
)

View file

@ -0,0 +1,72 @@
import Swift2D
import SwiftColor
import SwiftSVG
#if canImport(CoreGraphics)
import CoreGraphics
public extension CGContext {
func render(path: Path, from: Rect, to: Rect) throws {
saveGState()
let cgPath = CGMutablePath()
let commands = (try? path.commands()) ?? []
for (idx, command) in commands.enumerated() {
let previous: Point? = if idx > 0 {
commands[idx - 1].previousPoint
} else {
nil
}
cgPath.addCommand(command, from: from, to: to, previousPoint: previous)
}
if let fill = path.fill {
let _color = Pigment(fill.color ?? "black")
if _color.alpha != 0.0 {
let cgColor = CGColor.make(_color)
let color = cgColor.copy(alpha: CGFloat(fill.opacity ?? 1.0)) ?? cgColor
let rule = fill.rule.cgFillRule
setFillColor(color)
addPath(cgPath)
fillPath(using: rule)
}
}
if let stroke = path.stroke {
let _color = Pigment(stroke.color ?? "black")
if _color.alpha != 0.0 {
let cgColor = CGColor.make(_color)
let color = cgColor.copy(alpha: CGFloat(stroke.opacity ?? 1.0)) ?? cgColor
let width = stroke.width ?? 1.0
let lineWidth = width * (to.size.width / from.size.width)
setLineWidth(CGFloat(lineWidth))
setStrokeColor(color)
setLineCap(stroke.lineCap.cgLineCap)
setLineJoin(stroke.lineJoin.cgLineJoin)
if let miterLimit = stroke.miterLimit, stroke.lineJoin == .miter {
setMiterLimit(CGFloat(miterLimit))
}
addPath(cgPath)
strokePath()
}
}
if path.fill == nil, path.stroke == nil {
let color = CGColor(srgbRed: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
setFillColor(color)
addPath(cgPath)
fillPath(using: path.fill?.rule.cgFillRule ?? .winding)
}
restoreGState()
}
func render(path: Path, in rect: Rect) throws {
try render(path: path, from: rect, to: rect)
}
}
#endif

View file

@ -0,0 +1,45 @@
import Swift2D
import SwiftSVG
#if canImport(CoreGraphics)
import CoreGraphics
public extension CGMutablePath {
/// Adds a `Path.Command` to a _CoreGraphics path_, using the provided rectangles to correctly scale the parameters.
///
/// - parameter command: The `Path.Command` to append
/// - parameter from: The `Rect` which originally had the instruction. This is typically the `Document.originalSize`.
/// - parameter to: The `Rect` defining the new size.
/// - parameter previousPoint: The last `Point`, used for Elliptical Arc calculations
func addCommand(_ command: Path.Command, from: Rect, to: Rect, previousPoint: Point? = nil) {
let translated = command.translate(from: from, to: to)
switch translated {
case .moveTo(let point):
move(to: CGPoint(point))
case .lineTo(let point):
addLine(to: CGPoint(point))
case .cubicBezierCurve(let cp1, let cp2, let point):
addCurve(to: CGPoint(point), control1: CGPoint(cp1), control2: CGPoint(cp2))
case .quadraticBezierCurve(let cp, let point):
addQuadCurve(to: CGPoint(point), control: CGPoint(cp))
case .ellipticalArcCurve(_, _, _, _, _, let point):
guard let previousPoint else {
addLine(to: CGPoint(point))
return
}
do {
let curves = try command.convertToCubicBezierCurves(with: previousPoint)
for curve in curves {
addCommand(curve, from: from, to: to)
}
} catch {
print(error)
addLine(to: CGPoint(point))
}
case .closePath:
closeSubpath()
}
}
}
#endif

View file

@ -0,0 +1,14 @@
import Foundation
import SwiftSVG
#if canImport(CoreGraphics)
import CoreGraphics
public extension Fill.Rule {
var cgFillRule: CGPathFillRule {
switch self {
case .evenOdd: .evenOdd
case .nonZero: .winding
}
}
}
#endif

View file

@ -0,0 +1,37 @@
import Swift2D
import SwiftSVG
#if canImport(CoreGraphics)
import CoreGraphics
public extension SVG {
func path(size: Size) -> CGPath {
guard size.height > 0.0, size.width > 0.0 else {
return CGMutablePath()
}
guard let paths = try? subpaths() else {
return CGMutablePath()
}
let from = Rect(origin: .zero, size: originalSize)
let to = Rect(origin: .zero, size: size)
let path = CGMutablePath()
for p in paths {
let commands = (try? p.commands()) ?? []
for (idx, command) in commands.enumerated() {
let previous: Point? = if idx > 0 {
commands[idx - 1].previousPoint
} else {
nil
}
path.addCommand(command, from: from, to: to, previousPoint: previous)
}
}
return path
}
}
#endif

View file

@ -0,0 +1,25 @@
import Foundation
import SwiftSVG
#if canImport(CoreGraphics)
import CoreGraphics
public extension Stroke.LineCap {
var cgLineCap: CGLineCap {
switch self {
case .butt: .butt
case .round: .round
case .square: .square
}
}
}
public extension Stroke.LineJoin {
var cgLineJoin: CGLineJoin {
switch self {
case .bevel: .bevel
case .arcs, .miter, .miterClip: .miter
case .round: .round
}
}
}
#endif

View file

@ -0,0 +1,29 @@
import SwiftColor
import SwiftSVG
public extension Fill {
@available(*, deprecated, renamed: "pigment")
var swiftColor: Pigment? { pigment }
var pigment: Pigment? {
guard let color, !color.isEmpty else {
return nil
}
let _color = Pigment(color)
guard _color.alpha != 0.0 else {
return nil
}
return _color
}
}
public extension Fill.Rule {
var coreGraphicsDescription: String {
switch self {
case .evenOdd: ".evenOdd"
case .nonZero: ".winding"
}
}
}

View file

@ -0,0 +1,34 @@
import Swift2D
import SwiftSVG
public extension Path {
func asCoreGraphicsDescription(variable: String = "path", originalSize: Size) throws -> String {
var outputs: [String] = []
let commands = (try? commands()) ?? []
for (idx, command) in commands.enumerated() {
let previous: Point? = if idx > 0 {
commands[idx - 1].previousPoint
} else {
nil
}
let method = command.coreGraphicsDescription(originalSize: originalSize, previousPoint: previous)
let code = "\(variable)\(method)"
outputs.append(code)
}
return outputs.joined(separator: "\n ")
}
}
public extension Path.Command {
var previousPoint: Point {
switch self {
case .moveTo(let point): point
case .lineTo(let point): point
case .cubicBezierCurve(_, _, let point): point
case .quadraticBezierCurve(_, let point): point
case .ellipticalArcCurve(_, _, _, _, _, let point): point
case .closePath: .zero
}
}
}

View file

@ -0,0 +1,216 @@
import Foundation
import Swift2D
import SwiftSVG
public extension Path.Command {
/// Uses the _Power of Math_ to translate a commands controls/points from one `Rect` to another `Rect`.
func translate(from: Rect, to: Rect) -> Path.Command {
switch self {
case .moveTo(let point):
let _point = VectorPoint(point: point, in: from).translate(to: to)
return .moveTo(point: _point)
case .lineTo(let point):
let _point = VectorPoint(point: point, in: from).translate(to: to)
return .lineTo(point: _point)
case .cubicBezierCurve(let cp1, let cp2, let point):
let _cp1 = VectorPoint(point: cp1, in: from).translate(to: to)
let _cp2 = VectorPoint(point: cp2, in: from).translate(to: to)
let _point = VectorPoint(point: point, in: from).translate(to: to)
return .cubicBezierCurve(cp1: _cp1, cp2: _cp2, point: _point)
case .quadraticBezierCurve(let cp, let point):
let _cp = VectorPoint(point: cp, in: from).translate(to: to)
let _point = VectorPoint(point: point, in: from).translate(to: to)
return .quadraticBezierCurve(cp: _cp, point: _point)
case .ellipticalArcCurve(let rx, let ry, let angle, let largeArc, let clockwise, let point):
let _rx = rx * (from.size.maxRadius / to.size.minRadius)
let _ry = ry * (from.size.maxRadius / to.size.minRadius)
let _point = VectorPoint(point: point, in: from).translate(to: to)
return .ellipticalArcCurve(rx: _rx, ry: _ry, angle: angle, largeArc: largeArc, clockwise: clockwise, point: _point)
case .closePath:
return self
}
}
}
public extension Path.Command {
func coreGraphicsDescription(originalSize: Size, previousPoint: Point? = nil) -> String {
let rect = Rect(origin: .zero, size: originalSize)
switch self {
case .moveTo(let point):
let _point = VectorPoint(point: point, in: rect)
return ".move(to: \(_point.coreGraphicsDescription))"
case .lineTo(let point):
let _point = VectorPoint(point: point, in: rect)
return ".addLine(to: \(_point.coreGraphicsDescription))"
case .cubicBezierCurve(let cp1, let cp2, let point):
let _cp1 = VectorPoint(point: cp1, in: rect)
let _cp2 = VectorPoint(point: cp2, in: rect)
let _point = VectorPoint(point: point, in: rect)
return ".addCurve(to: \(_point.coreGraphicsDescription), control1: \(_cp1.coreGraphicsDescription), control2: \(_cp2.coreGraphicsDescription))"
case .quadraticBezierCurve(let cp, let point):
let _cp = VectorPoint(point: cp, in: rect)
let _point = VectorPoint(point: point, in: rect)
return ".addQuadCurve(to: \(_point.coreGraphicsDescription), control: \(_cp.coreGraphicsDescription))"
case .ellipticalArcCurve(_, _, _, _, _, let point):
guard let previousPoint else {
return Path.Command.lineTo(point: point).coreGraphicsDescription(originalSize: originalSize)
}
do {
let curves = try convertToCubicBezierCurves(with: previousPoint)
return curves.map { $0.coreGraphicsDescription(originalSize: originalSize) }.joined(separator: "\n")
} catch {
print(error)
return Path.Command.lineTo(point: point).coreGraphicsDescription(originalSize: originalSize)
}
case .closePath:
return ".closeSubpath()"
}
}
}
extension Path.Command {
/// Converts an `.ellipticalArcCurve` into one or more `.cubicBezierCurve`s.
/// https://github.com/colinmeinke/svg-arc-to-cubic-bezier/blob/master/src/index.js
func convertToCubicBezierCurves(with previousPoint: Point) throws -> [Path.Command] {
guard case let .ellipticalArcCurve(rx, ry, angle, largeArg, clockwise, point) = self else {
throw Path.Command.Error.message("\(#function); Only .ellipticalArcCurve is allowed.")
}
var curves: [Path.Command] = []
guard rx > 0.0, ry > 0.0 else {
throw Path.Command.Error.message("\(#function); rx/ry must be greater than 0.0 (zero).")
}
let sinφ = sin(angle * (.pi * 2.0) / 360.0)
let cosφ = cos(angle * (.pi * 2.0) / 360.0)
let pxp = cosφ * (previousPoint.x - point.x) / 2 + sinφ * (previousPoint.y - point.y) / 2.0
let pyp = -sinφ * (previousPoint.x - point.x) / 2 + cosφ * (previousPoint.y - point.y) / 2.0
guard pxp != 0.0, pyp != 0.0 else {
throw Path.Command.Error.message("\(#function); math")
}
var _rx = abs(rx)
var _ry = abs(ry)
let λ = pow(pxp, 2.0) / pow(_rx, 2.0) + pow(pyp, 2.0) / pow(_ry, 2.0)
if λ > 1.0 {
_rx *= sqrt(λ)
_ry *= sqrt(λ)
}
let _arcCenter = arcCenter(previousPoint: previousPoint, point: point, rx: _rx, ry: _ry, largeArc: largeArg, clockwise: clockwise, sinφ: sinφ, cosφ: cosφ, pxp: pxp, pyp: pyp)
let center = _arcCenter.center
var angle1 = _arcCenter.angle1
var angle2 = _arcCenter.angle2
var ratio = abs(angle2) / ((.pi * 2.0) / 4.0)
if abs(1.0 - ratio) < 0.0000001 {
ratio = 1.0
}
let segments = max(ceil(ratio), 1)
angle2 /= segments
var rawCurves: [(Point, Point, Point)] = []
for _ in 0 ... Int(segments) {
rawCurves.append(approximateUnitArc(angle1: angle1, angle2: angle2))
angle1 += angle2
}
for rawCurf in rawCurves {
let _cp1 = mapToEllipse(point: rawCurf.0, rx: _rx, ry: _ry, sinφ: sinφ, cosφ: cosφ, center: center)
let _cp2 = mapToEllipse(point: rawCurf.1, rx: _rx, ry: _ry, sinφ: sinφ, cosφ: cosφ, center: center)
let _point = mapToEllipse(point: rawCurf.2, rx: _rx, ry: _ry, sinφ: sinφ, cosφ: cosφ, center: center)
curves.append(.cubicBezierCurve(cp1: _cp1, cp2: _cp2, point: _point))
}
return curves
}
}
private func arcCenter(previousPoint: Point, point: Point, rx: Double, ry: Double, largeArc: Bool, clockwise: Bool, sinφ: Double, cosφ: Double, pxp: Double, pyp: Double) ->
(center: Point, angle1: Double, angle2: Double)
{
let rxsq = pow(rx, 2.0)
let rysq = pow(ry, 2.0)
let pxpsq = pow(pxp, 2.0)
let pypsq = pow(pyp, 2.0)
var radicant = (rxsq * rysq) - (rxsq * pypsq) - (rysq * pxpsq)
if radicant < 0.0 {
radicant = 0.0
}
radicant /= (rxsq * pypsq) + (rysq * pxpsq)
radicant = sqrt(radicant) * (largeArc == clockwise ? -1.0 : 1.0)
let centerxp = radicant * rx / ry * pyp
let centeryp = radicant * -ry / rx * pxp
let centerx = cosφ * centerxp - sinφ * centeryp + (previousPoint.x + point.x) / 2.0
let centery = sinφ * centerxp + cosφ * centeryp + (previousPoint.x + point.x) / 2.0
let vx1 = (pxp - centerxp) / rx
let vy1 = (pyp - centeryp) / ry
let vx2 = (-pxp - centerxp) / rx
let vy2 = (-pyp - centeryp) / ry
let angle1 = vectorAngle(u: Point(x: 1, y: 0), v: Point(x: vx1, y: vy1))
var angle2 = vectorAngle(u: Point(x: vx1, y: vy1), v: Point(x: vx2, y: vy2))
if clockwise == false, angle2 > 0.0 {
angle2 -= (.pi * 2.0)
} else if clockwise == true, angle2 < 0.0 {
angle2 += (.pi * 2.0)
}
return (Point(x: centerx, y: centery), angle1, angle2)
}
private func vectorAngle(u: Point, v: Point) -> Double {
let sign: Double = ((u.x * v.y - u.y * v.x) < 0.0) ? -1.0 : 1.0
var dot = u.x * v.x + u.y * v.y
if dot > 1.0 {
dot = 1.0
} else if dot < -1.0 {
dot = -1.0
}
return sign * acos(dot)
}
private func approximateUnitArc(angle1: Double, angle2: Double) -> (Point, Point, Point) {
// If 90 degree circular arc, use a constant
// as derived from http://spencermortensen.com/articles/bezier-circle
let a: Double = switch angle2 {
case 1.5707963267948966:
0.551915024494
case -1.5707963267948966:
-0.551915024494
default:
4.0 / 3.0 * tan(angle2 / 4.0)
}
let x1 = cos(angle1)
let y1 = sin(angle1)
let x2 = cos(angle1 + angle2)
let y2 = sin(angle1 + angle2)
return (Point(x: x1 - y1 * a, y: y1 + x1 * a), Point(x: x2 + y2 * a, y: y2 - x2 * 1), Point(x: x2, y: y2))
}
private func mapToEllipse(point: Point, rx: Double, ry: Double, sinφ: Double, cosφ: Double, center: Point) -> Point {
let x = point.x * rx
let y = point.y * ry
let xp = cosφ * x - sinφ * y
let yp = sinφ * x + cosφ * y
return Point(x: xp + center.x, y: yp + center.y)
}

View file

@ -0,0 +1,7 @@
import SwiftColor
public extension Pigment {
var coreGraphicsDescription: String {
"CGColor(srgbRed: \(red), green: \(green), blue: \(blue), alpha: \(alpha))"
}
}

View file

@ -0,0 +1,7 @@
import Swift2D
public extension Point {
var coreGraphicsDescription: String {
"CGPoint(x: \(x), y: \(y))"
}
}

View file

@ -0,0 +1,7 @@
import Swift2D
public extension Rect {
var coreGraphicsDescription: String {
"CGRect(origin: \(origin.coreGraphicsDescription), size: \(size.coreGraphicsDescription))"
}
}

View file

@ -0,0 +1,324 @@
import Foundation
import Swift2D
import SwiftSVG
import XMLCoder
public extension SVG {
static func encodeDocument(_ document: SVG, encoder: XMLEncoder = XMLEncoder()) throws -> Data {
let rootAttributes: [String: String] = [
"version": "1.1",
"xmlns": "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink",
]
let header = XMLHeader(version: 1.0, encoding: "UTF-8", standalone: nil)
return try encoder.encode(document, withRootKey: "svg", rootAttributes: rootAttributes, header: header)
}
static func appleSymbols(path: Path, in rect: Rect) throws -> SVG {
var document = SVG(width: 3300, height: 2200)
document.groups = try [.appleSymbolsNotes, .appleSymbolsGuides, .appleSymbols(path: path, in: rect)]
return document
}
}
public extension Group {
static var appleSymbolsNotes: Group {
var group = Group()
group.id = "Notes"
group.rectangles = []
group.lines = []
group.texts = []
group.groups = []
var artboard = Rectangle(x: 0, y: 0, width: 3300, height: 2200)
artboard.id = "artboard"
artboard.style = "fill:white;opacity:1"
group.rectangles?.append(artboard)
var topLine = Line(x1: 263, y1: 292, x2: 3036, y2: 292)
topLine.style = "fill:none;stroke:black;opacity:1;stroke-width:0.5;"
group.lines?.append(topLine)
var bottomLine = Line(x1: 263, y1: 1903, x2: 3036, y2: 1903)
bottomLine.style = "fill:none;stroke:black;opacity:1;stroke-width:0.5;"
group.lines?.append(bottomLine)
var text = Text()
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;font-weight:bold;"
text.transform = "matrix(1 0 0 1 263 322)"
text.value = "Weight/Scale Variations"
group.texts?.append(text)
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;text-anchor:middle"
text.transform = "matrix(1 0 0 1 559.711 322)"
text.value = "Ultralight"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 856.422 322)"
text.value = "Thin"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 1153.13 322)"
text.value = "Light"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 1449.84 322)"
text.value = "Regular"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 1746.56 322)"
text.value = "Medium"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 2043.27 322)"
text.value = "Semibold"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 2339.98 322)"
text.value = "Bold"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 2636.69 322)"
text.value = "Heavy"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 2933.4 322)"
text.value = "Black"
group.texts?.append(text)
var path = Path(data: "M 9.24805 0.830078 C 13.5547 0.830078 17.1387 -2.74414 17.1387 -7.05078 C 17.1387 -11.3574 13.5449 -14.9316 9.23828 -14.9316 C 4.94141 -14.9316 1.36719 -11.3574 1.36719 -7.05078 C 1.36719 -2.74414 4.95117 0.830078 9.24805 0.830078 Z M 9.24805 -0.654297 C 5.70312 -0.654297 2.87109 -3.49609 2.87109 -7.05078 C 2.87109 -10.6055 5.69336 -13.4473 9.23828 -13.4473 C 12.793 -13.4473 15.6348 -10.6055 15.6445 -7.05078 C 15.6543 -3.49609 12.8027 -0.654297 9.24805 -0.654297 Z M 9.22852 -3.42773 C 9.69727 -3.42773 9.9707 -3.74023 9.9707 -4.25781 L 9.9707 -6.31836 L 12.1973 -6.31836 C 12.6953 -6.31836 13.0371 -6.57227 13.0371 -7.04102 C 13.0371 -7.51953 12.7148 -7.7832 12.1973 -7.7832 L 9.9707 -7.7832 L 9.9707 -10.0098 C 9.9707 -10.5273 9.69727 -10.8496 9.22852 -10.8496 C 8.75977 -10.8496 8.50586 -10.5078 8.50586 -10.0098 L 8.50586 -7.7832 L 6.29883 -7.7832 C 5.78125 -7.7832 5.44922 -7.51953 5.44922 -7.04102 C 5.44922 -6.57227 5.80078 -6.31836 6.29883 -6.31836 L 8.50586 -6.31836 L 8.50586 -4.25781 C 8.50586 -3.75977 8.75977 -3.42773 9.22852 -3.42773 Z")
var subGroup = Group("", path: path, transform: "matrix(1 0 0 1 263 1933)")
group.groups?.append(subGroup)
path.data = "M 11.709 2.91016 C 17.1582 2.91016 21.6699 -1.60156 21.6699 -7.05078 C 21.6699 -12.4902 17.1484 -17.0117 11.6992 -17.0117 C 6.25977 -17.0117 1.74805 -12.4902 1.74805 -7.05078 C 1.74805 -1.60156 6.26953 2.91016 11.709 2.91016 Z M 11.709 1.25 C 7.09961 1.25 3.41797 -2.44141 3.41797 -7.05078 C 3.41797 -11.6504 7.08984 -15.3516 11.6992 -15.3516 C 16.3086 -15.3516 20 -11.6504 20.0098 -7.05078 C 20.0195 -2.44141 16.3184 1.25 11.709 1.25 Z M 11.6895 -2.41211 C 12.207 -2.41211 12.5195 -2.77344 12.5195 -3.33984 L 12.5195 -6.23047 L 15.5762 -6.23047 C 16.123 -6.23047 16.5039 -6.51367 16.5039 -7.03125 C 16.5039 -7.55859 16.1426 -7.86133 15.5762 -7.86133 L 12.5195 -7.86133 L 12.5195 -10.9277 C 12.5195 -11.5039 12.207 -11.8555 11.6895 -11.8555 C 11.1719 -11.8555 10.8789 -11.4844 10.8789 -10.9277 L 10.8789 -7.86133 L 7.83203 -7.86133 C 7.26562 -7.86133 6.89453 -7.55859 6.89453 -7.03125 C 6.89453 -6.51367 7.28516 -6.23047 7.83203 -6.23047 L 10.8789 -6.23047 L 10.8789 -3.33984 C 10.8789 -2.79297 11.1719 -2.41211 11.6895 -2.41211 Z"
subGroup.paths = [path]
subGroup.transform = "matrix(1 0 0 1 281.506 1933)"
group.groups?.append(subGroup)
path.data = "M 14.9707 5.67383 C 21.9336 5.67383 27.6953 -0.078125 27.6953 -7.04102 C 27.6953 -14.0039 21.9238 -19.7559 14.9609 -19.7559 C 8.00781 -19.7559 2.25586 -14.0039 2.25586 -7.04102 C 2.25586 -0.078125 8.01758 5.67383 14.9707 5.67383 Z M 14.9707 3.85742 C 8.93555 3.85742 4.08203 -1.00586 4.08203 -7.04102 C 4.08203 -13.0762 8.92578 -17.9395 14.9609 -17.9395 C 21.0059 -17.9395 25.8594 -13.0762 25.8691 -7.04102 C 25.8789 -1.00586 21.0156 3.85742 14.9707 3.85742 Z M 14.9512 -1.06445 C 15.5176 -1.06445 15.8691 -1.45508 15.8691 -2.06055 L 15.8691 -6.13281 L 20.1074 -6.13281 C 20.6934 -6.13281 21.1133 -6.46484 21.1133 -7.02148 C 21.1133 -7.59766 20.7227 -7.93945 20.1074 -7.93945 L 15.8691 -7.93945 L 15.8691 -12.1875 C 15.8691 -12.8027 15.5176 -13.1934 14.9512 -13.1934 C 14.3848 -13.1934 14.0625 -12.7832 14.0625 -12.1875 L 14.0625 -7.93945 L 9.83398 -7.93945 C 9.21875 -7.93945 8.80859 -7.59766 8.80859 -7.02148 C 8.80859 -6.46484 9.23828 -6.13281 9.83398 -6.13281 L 14.0625 -6.13281 L 14.0625 -2.06055 C 14.0625 -1.47461 14.3848 -1.06445 14.9512 -1.06445 Z"
subGroup.paths = [path]
subGroup.transform = "matrix(1 0 0 1 304.924 1933)"
group.groups?.append(subGroup)
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;font-weight:bold;"
text.transform = "matrix(1 0 0 1 263 1953)"
text.value = "Design Variations"
group.texts?.append(text)
text.style = "none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;"
text.transform = "matrix(1 0 0 1 263 1971)"
text.value = "Symbols are supported in up to nine weights and three scales."
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 263 1989)"
text.value = "For optimal layout with text and other symbols, vertically align"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 263 2007)"
text.value = "symbols with the adjacent text."
group.texts?.append(text)
var rect = Rectangle(x: 776, y: 1919, width: 3, height: 14)
rect.style = "fill:#00AEEF;stroke:none;opacity:0.4;"
group.rectangles?.append(rect)
path.data = "M 10.5273 0 L 12.373 0 L 7.17773 -14.0918 L 5.43945 -14.0918 L 0.244141 0 L 2.08984 0 L 3.50586 -4.0332 L 9.11133 -4.0332 Z M 6.2793 -11.9531 L 6.33789 -11.9531 L 8.59375 -5.52734 L 4.02344 -5.52734 Z"
subGroup.paths = [path]
subGroup.transform = "matrix(1 0 0 1 779 1933)"
group.groups?.append(subGroup)
rect.x = 791.617
group.rectangles?.append(rect)
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;font-weight:bold;"
text.transform = "matrix(1 0 0 1 776 1953)"
text.value = "Margins"
group.texts?.append(text)
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;"
text.transform = "matrix(1 0 0 1 776 1971)"
text.value = "Leading and trailing margins on the left and right side of each symbol"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 776 1989)"
text.value = "can be adjusted by modifying the width of the blue rectangles."
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 776 2007)"
text.value = "Modifications are automatically applied proportionally to all"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 776 2025)"
text.value = "scales and weights."
group.texts?.append(text)
path.data = "M 2.83203 3.11523 L 4.375 4.6582 C 5.22461 5.48828 6.19141 5.41992 7.06055 4.46289 L 17.2754 -6.66016 C 17.7051 -6.36719 18.0957 -6.37695 18.5645 -6.47461 L 19.6094 -6.68945 L 20.3027 -5.99609 L 20.2539 -5.47852 C 20.1855 -4.95117 20.3516 -4.53125 20.8496 -4.0332 L 21.6602 -3.22266 C 22.168 -2.71484 22.8223 -2.68555 23.3008 -3.16406 L 26.5527 -6.41602 C 27.0312 -6.89453 27.0117 -7.54883 26.5039 -8.05664 L 25.6836 -8.87695 C 25.1855 -9.375 24.7754 -9.55078 24.2383 -9.47266 L 23.7109 -9.41406 L 23.0566 -10.0781 L 23.3398 -11.2207 C 23.4863 -11.7871 23.3398 -12.2559 22.7148 -12.8613 L 20.3027 -15.2539 C 16.7578 -18.7793 12.2266 -18.6719 9.11133 -15.5371 C 8.69141 -15.1074 8.64258 -14.5215 8.91602 -14.0918 C 9.15039 -13.7207 9.62891 -13.4961 10.2734 -13.6621 C 11.7871 -14.043 13.3008 -13.9258 14.7852 -12.9199 L 14.1602 -11.3379 C 13.9258 -10.752 13.9453 -10.2734 14.1797 -9.83398 L 3.01758 0.439453 C 2.08008 1.30859 1.97266 2.25586 2.83203 3.11523 Z M 10.6738 -15.1465 C 13.3398 -17.1387 16.6504 -16.8262 19.0527 -14.4141 L 21.6797 -11.8066 C 21.9141 -11.5723 21.9434 -11.3867 21.8848 -11.0938 L 21.5039 -9.53125 L 23.0762 -7.95898 L 24.043 -8.04688 C 24.3262 -8.07617 24.4141 -8.05664 24.6387 -7.83203 L 25.2637 -7.20703 L 22.5098 -4.46289 L 21.8848 -5.07812 C 21.6602 -5.30273 21.6406 -5.40039 21.6699 -5.68359 L 21.7578 -6.64062 L 20.1953 -8.20312 L 18.5742 -7.89062 C 18.291 -7.83203 18.1445 -7.83203 17.9102 -8.07617 L 15.7324 -10.2539 C 15.5078 -10.4883 15.4785 -10.625 15.6055 -10.9473 L 16.5527 -13.2227 C 14.9512 -14.7559 12.8418 -15.6055 10.8008 -14.9512 C 10.7129 -14.9219 10.6445 -14.9414 10.6152 -14.9805 C 10.5859 -15.0293 10.5859 -15.0781 10.6738 -15.1465 Z M 4.10156 2.41211 C 3.61328 1.91406 3.78906 1.61133 4.12109 1.30859 L 15.0781 -8.80859 L 16.3086 -7.57812 L 6.15234 3.34961 C 5.84961 3.68164 5.46875 3.7793 5.06836 3.37891 Z"
subGroup.paths = [path]
subGroup.transform = "matrix(1 0 0 1 1289 1933)"
group.groups?.append(subGroup)
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;font-weight:bold;"
text.transform = "matrix(1 0 0 1 1289 1953)"
text.value = "Exporting"
group.texts?.append(text)
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;"
text.transform = "matrix(1 0 0 1 1289 1971)"
text.value = "Symbols should be outlined when exporting to ensure the"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 1289 1989)"
text.value = "design is preserved when submitting to Xcode."
group.texts?.append(text)
text.id = "template-version"
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;text-anchor:end;"
text.transform = "matrix(1 0 0 1 3036 1933)"
text.value = "Template v.2.0"
group.texts?.append(text)
text.id = nil
text.transform = "matrix(1 0 0 1 3036 1969)"
text.value = "Typeset at 100 points"
group.texts?.append(text)
text.style = "stroke:none;fill:black;font-family:-apple-system,\"SF Pro Display\",\"SF Pro Text\",Helvetica,sans-serif;"
text.transform = "matrix(1 0 0 1 263 726)"
text.value = "Small"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 263 1156)"
text.value = "Medium"
group.texts?.append(text)
text.transform = "matrix(1 0 0 1 263 1586)"
text.value = "Large"
group.texts?.append(text)
return group
}
static var appleSymbolsGuides: Group {
var group = Group()
group.id = "Guides"
group.groups = []
group.lines = []
var hRef = Group()
hRef.id = "H-reference"
hRef.style = "fill:#27AAE1;stroke:none;"
hRef.transform = "matrix(1 0 0 1 339 696)"
hRef.paths = [Path(data: "M 54.9316 0 L 57.666 0 L 30.5664 -70.459 L 28.0762 -70.459 L 0.976562 0 L 3.66211 0 L 12.9395 -24.4629 L 45.7031 -24.4629 Z M 29.1992 -67.0898 L 29.4434 -67.0898 L 44.8242 -26.709 L 13.8184 -26.709 Z")]
group.groups?.append(hRef)
var baseline = Line(x1: 263, y1: 696, x2: 3036, y2: 696)
baseline.id = "Baseline-S"
baseline.style = "fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.577;"
group.lines?.append(baseline)
var capline = Line(x1: 263, y1: 625.541, x2: 3036, y2: 625.541)
capline.id = "Capline-S"
capline.style = "fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.577;"
group.lines?.append(capline)
hRef.transform = "matrix(1 0 0 1 339 1126)"
hRef.paths = [Path(data: "M 54.9316 0 L 57.666 0 L 30.5664 -70.459 L 28.0762 -70.459 L 0.976562 0 L 3.66211 0 L 12.9395 -24.4629 L 45.7031 -24.4629 Z M 29.1992 -67.0898 L 29.4434 -67.0898 L 44.8242 -26.709 L 13.8184 -26.709 Z")]
group.groups?.append(hRef)
baseline.id = "Baseline-M"
baseline.y1 = 1126
baseline.y2 = 1126
group.lines?.append(baseline)
capline.id = "Capline-M"
capline.y1 = 1055.54
capline.y2 = 1055.54
group.lines?.append(capline)
hRef.transform = "matrix(1 0 0 1 339 1556)"
hRef.paths = [Path(data: "M 54.9316 0 L 57.666 0 L 30.5664 -70.459 L 28.0762 -70.459 L 0.976562 0 L 3.66211 0 L 12.9395 -24.4629 L 45.7031 -24.4629 Z M 29.1992 -67.0898 L 29.4434 -67.0898 L 44.8242 -26.709 L 13.8184 -26.709 Z")]
group.groups?.append(hRef)
baseline.id = "Baseline-L"
baseline.y1 = 1556
baseline.y2 = 1556
group.lines?.append(baseline)
capline.id = "Capline-L"
capline.y1 = 1485.54
capline.y2 = 1485.54
group.lines?.append(capline)
var margin = Line(x1: 1399.72, y1: 1030.79, x2: 1399.72, y2: 1150.12)
margin.id = "left-margin"
margin.style = "fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;"
group.lines?.append(margin)
margin = Line(x1: 1499.97, y1: 1030.79, x2: 1499.97, y2: 1150.12)
margin.id = "right-margin"
margin.style = "fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;"
group.lines?.append(margin)
return group
}
static func appleSymbols(path: Path, in rect: Rect) throws -> Group {
var group = Group()
group.id = "Symbols"
let translations: [(name: String, size: Float, center: Point)] = [
("Ultralight-S", 0.75, .init(x: 559.0, y: 661.0)),
("Thin-S", 0.76, .init(x: 857.0, y: 661.0)),
("Light-S", 0.78, .init(x: 1153.0, y: 661.0)),
("Regular-S", 0.79, .init(x: 1449.5, y: 661.0)),
("Medium-S", 0.80, .init(x: 1747.0, y: 661.0)),
("Semibold-S", 0.81, .init(x: 2043.0, y: 661.0)),
("Bold-S", 0.82, .init(x: 2340.0, y: 661.0)),
("Heavy-S", 0.85, .init(x: 2636.5, y: 661.0)),
("Black-S", 0.86, .init(x: 2933.0, y: 661.0)),
("Ultralight-M", 0.95, .init(x: 559.0, y: 1091.0)),
("Thin-M", 0.96, .init(x: 857.0, y: 1091.0)),
("Light-M", 0.98, .init(x: 1153.0, y: 1091.0)),
("Regular-M", 1.00, .init(x: 1449.5, y: 1091.0)),
("Medium-M", 1.02, .init(x: 1747.0, y: 1091.0)),
("Semibold-M", 1.03, .init(x: 2043.0, y: 1091.0)),
("Bold-M", 1.05, .init(x: 2340.0, y: 1091.0)),
("Heavy-M", 1.07, .init(x: 2636.5, y: 1091.0)),
("Black-M", 1.10, .init(x: 2933.0, y: 1091.0)),
("Ultralight-L", 1.22, .init(x: 559.0, y: 1521.0)),
("Thin-L", 1.24, .init(x: 857.0, y: 1521.0)),
("Light-L", 1.26, .init(x: 1153.0, y: 1521.0)),
("Regular-L", 1.28, .init(x: 1449.5, y: 1521.0)),
("Medium-L", 1.30, .init(x: 1747.0, y: 1521.0)),
("Semibold-L", 1.31, .init(x: 2043.0, y: 1521.0)),
("Bold-L", 1.33, .init(x: 2340.0, y: 1521.0)),
("Heavy-L", 1.36, .init(x: 2636.5, y: 1521.0)),
("Black-L", 1.39, .init(x: 2933.0, y: 1521.0)),
]
group.groups = try translations.map { symbol -> Group in
let size = Size(width: 100.0 * symbol.size, height: 100.0 * symbol.size)
let to = Rect(origin: .zero, size: size)
let commands = try path.commands().map { $0.translate(from: rect, to: to) }
let p = Path(commands: commands)
let matrixOrigin = Point(x: symbol.center.x - size.width / 2.0, y: symbol.center.y - size.height / 2.0)
let matrix: Transformation = .matrix(a: 1, b: 0, c: 0, d: 1, e: matrixOrigin.x, f: matrixOrigin.y)
return Group(symbol.name, path: p, transform: matrix.description)
}
return group
}
}
private extension Group {
init(_ id: String, path: Path, transform: String) {
self.init()
self.id = id
self.transform = transform
paths = [path]
}
}

View file

@ -0,0 +1,56 @@
import Foundation
import Swift2D
import SwiftSVG
public extension SVG {
func asImageViewSubclass() throws -> String {
let instructions = try asCoreGraphicsDescription()
let renders = try asCGContextDescription()
return imageViewSubclassTemplate
.replacingOccurrences(of: "{{name}}", with: name)
.replacingOccurrences(of: "{{width}}", with: String(format: "%.1f", originalSize.width))
.replacingOccurrences(of: "{{height}}", with: String(format: "%.1f", originalSize.height))
.replacingOccurrences(of: "{{instructions}}", with: instructions)
.replacingOccurrences(of: "{{render}}", with: renders)
}
}
private extension SVG {
func asCoreGraphicsDescription(variable: String = "path") throws -> String {
try subpaths().map { try $0.asCoreGraphicsDescription(variable: variable, originalSize: originalSize) }.joined(separator: "\n ")
}
func asCGContextDescription() throws -> String {
var outputs: [String] = []
let paths = try subpaths()
try paths.forEach { path in
let instructions = try path.asCoreGraphicsDescription(variable: "path", originalSize: originalSize)
let fillColor = path.fill?.pigment?.coreGraphicsDescription ?? "nil"
let fillOpacity = (path.fillOpacity != nil) ? "\(path.fillOpacity!)" : "nil"
let fillRule = (path.fillRule ?? .nonZero).coreGraphicsDescription
let strokeColor = path.stroke?.pigment?.coreGraphicsDescription ?? "nil"
let strokeOpacity = (path.strokeOpacity != nil) ? "\(path.strokeOpacity!)" : "nil"
let strokeWidth = (path.strokeWidth != nil) ? "\(path.strokeWidth!) * (size.width / width)" : "nil"
let strokeLineCap = (path.strokeLineCap != nil) ? "\(path.strokeLineCap!.coreGraphicsDescription)" : "nil"
let strokeLineJoin = (path.strokeLineJoin != nil) ? "\(path.strokeLineJoin!.coreGraphicsDescription)" : "nil"
let strokeMiterLimit = (path.strokeMiterLimit != nil) ? "\(path.strokeMiterLimit!)" : "nil"
outputs.append(contextTemplate
.replacingOccurrences(of: "{{instructions}}", with: instructions)
.replacingOccurrences(of: "{{fillColor}}", with: fillColor)
.replacingOccurrences(of: "{{fillOpacity}}", with: fillOpacity)
.replacingOccurrences(of: "{{fillRule}}", with: fillRule)
.replacingOccurrences(of: "{{strokeColor}}", with: strokeColor)
.replacingOccurrences(of: "{{strokeOpacity}}", with: strokeOpacity)
.replacingOccurrences(of: "{{strokeWidth}}", with: strokeWidth)
.replacingOccurrences(of: "{{strokeLineCap}}", with: strokeLineCap)
.replacingOccurrences(of: "{{strokeLineJoin}}", with: strokeLineJoin)
.replacingOccurrences(of: "{{strokeMiterLimit}}", with: strokeMiterLimit)
)
}
return outputs.joined(separator: "\n ")
}
}

View file

@ -0,0 +1,7 @@
import Swift2D
public extension Size {
var coreGraphicsDescription: String {
"CGSize(width: \(width), height: \(height))"
}
}

View file

@ -0,0 +1,40 @@
import SwiftColor
import SwiftSVG
public extension Stroke {
@available(*, deprecated, renamed: "pigment")
var swiftColor: Pigment? { pigment }
var pigment: Pigment? {
guard let color, !color.isEmpty else {
return nil
}
let _color = Pigment(color)
guard _color.alpha != 0.0 else {
return nil
}
return _color
}
}
public extension Stroke.LineCap {
var coreGraphicsDescription: String {
switch self {
case .butt: ".butt"
case .round: ".round"
case .square: ".square"
}
}
}
public extension Stroke.LineJoin {
var coreGraphicsDescription: String {
switch self {
case .bevel: ".bevel"
case .arcs, .miter, .miterClip: ".miter"
case .round: ".round"
}
}
}

View file

@ -0,0 +1,177 @@
let imageViewSubclassTemplate: String = """
#if canImport(UIKit)
import UIKit
@IBDesignable
public class {{name}}: UIImageView {
public static let width: CGFloat = {{width}}
public static let height: CGFloat = {{height}}
public let width: CGFloat = {{width}}
public let height: CGFloat = {{height}}
public var widthToHeightAspectRatio: CGFloat {
guard width != .nan, width > 0.0 else {
return 0.0
}
guard height != .nan, height > 0.0 else {
return 0.0
}
return width / height
}
public var heightToWidthAspectRatio: CGFloat {
guard height != .nan, height > 0.0 else {
return 0.0
}
guard width != .nan, width > 0.0 else {
return 0.0
}
return height / width
}
public override init(frame: CGRect) {
super.init(frame: frame)
updateSubviews()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
updateSubviews()
}
public override var intrinsicContentSize: CGSize {
return CGSize(width: width, height: height)
}
public override var bounds: CGRect {
didSet {
updateSubviews()
}
}
public override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
updateSubviews()
}
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
updateSubviews()
}
public func updateSubviews() {
image = Self.image(size: bounds.size)
}
public static func path(size: CGSize) -> CGPath {
guard size.height > 0.0 && size.width > 0.0 else {
return CGMutablePath()
}
let radius = max(size.width / 2.0, size.height / 2.0)
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
let path = CGMutablePath()
{{instructions}}
return path
}
public static func image(size: CGSize) -> UIImage? {
guard size.height > 0.0 && size.width > 0.0 else {
return nil
}
let radius = max(size.width / 2.0, size.height / 2.0)
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0)
defer {
UIGraphicsEndImageContext()
}
UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
guard let context = UIGraphicsGetCurrentContext() else {
return nil
}
{{render}}
return UIGraphicsGetImageFromCurrentImageContext()
}
private static func radians(_ degree: Float) -> CGFloat {
return CGFloat(degree) * (.pi / CGFloat(180))
}
}
private extension CGContext {
func rendering(_ block: (CGContext) -> Void) {
block(self)
}
}
#endif
"""
let contextTemplate: String = """
context.rendering { (ctx) in
ctx.saveGState()
let path = CGMutablePath()
{{instructions}}
let defaultColor: CGColor = CGColor(srgbRed: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
let pathFillColor: CGColor? = {{fillColor}}
let pathFillOpacity: CGFloat? = {{fillOpacity}}
let pathFillRule: CGPathFillRule = {{fillRule}}
let pathStrokeColor: CGColor? = {{strokeColor}}
let pathStrokeOpacity: CGFloat? = {{strokeOpacity}}
let pathStrokeWidth: CGFloat? = {{strokeWidth}}
let pathStrokeLineCap: CGLineCap? = {{strokeLineCap}}
let pathStrokeLineJoin: CGLineJoin? = {{strokeLineJoin}}
let pathStrokeMiterLimit: CGFloat? = {{strokeMiterLimit}}
if pathFillColor != nil && pathFillOpacity != nil {
let opacity = pathFillOpacity ?? 1.0
let color = (pathFillColor ?? defaultColor).copy(alpha: opacity) ?? defaultColor
ctx.setFillColor(color)
ctx.addPath(path)
ctx.fillPath(using: pathFillRule)
}
if pathStrokeColor != nil && pathStrokeOpacity != nil {
let opacity = pathStrokeOpacity ?? 1.0
let color = (pathStrokeColor ?? defaultColor).copy(alpha: opacity) ?? defaultColor
let lineWidth = pathStrokeWidth ?? 1.0
ctx.setLineWidth(lineWidth)
ctx.setStrokeColor(color)
if let lineCap = pathStrokeLineCap {
ctx.setLineCap(lineCap)
}
if let lineJoin = pathStrokeLineJoin {
ctx.setLineJoin(lineJoin)
if let miterLimit = pathStrokeMiterLimit, lineJoin == .miter {
ctx.setMiterLimit(miterLimit)
}
}
ctx.addPath(path)
ctx.strokePath()
}
if (pathFillColor == nil && pathFillOpacity == nil) && (pathStrokeColor == nil && pathStrokeOpacity == nil) {
ctx.setFillColor(defaultColor)
ctx.addPath(path)
ctx.fillPath(using: pathFillRule)
}
ctx.restoreGState()
}
"""

View file

@ -0,0 +1,43 @@
#if canImport(UIKit)
import Swift2D
import SwiftSVG
import UIKit
public extension SVG {
func uiImage(size: Size) -> UIImage? {
guard size.height > 0.0, size.width > 0.0 else {
return nil
}
let from = Rect(origin: .zero, size: originalSize)
let to = Rect(origin: .zero, size: size)
let paths: [Path]
do {
paths = try subpaths()
} catch {
return nil
}
defer {
UIGraphicsEndImageContext()
}
UIGraphicsBeginImageContextWithOptions(CGSize(size), false, 0.0)
guard let context = UIGraphicsGetCurrentContext() else {
return nil
}
for path in paths {
try? context.render(path: path, from: from, to: to)
}
return UIGraphicsGetImageFromCurrentImageContext()
}
func pngData(size: Size) -> Data? {
uiImage(size: size)?.pngData()
}
}
#endif

Some files were not shown because too many files have changed in this diff Show more