├── .editorconfig ├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Snippets └── DrawShapes.swift ├── Sources ├── CairoGraphics │ ├── Context │ │ ├── CairoContext.swift │ │ └── DefaultGraphicsContext.swift │ ├── Exports.swift │ └── Image │ │ ├── CairoImage.swift │ │ ├── CairoSVG.swift │ │ └── HTTPImageUtils.swift ├── CoreGraphicsGraphics │ ├── Context │ │ ├── CoreGraphicsContext.swift │ │ └── DefaultGraphicsContext.swift │ ├── Exports.swift │ └── Image │ │ └── CoreGraphicsImage.swift ├── Graphics │ ├── Color │ │ ├── Color.swift │ │ └── Colors.swift │ ├── Context │ │ ├── GraphicsContext.swift │ │ ├── GraphicsContextError.swift │ │ └── PixelFormat.swift │ ├── Image │ │ ├── BufferedImage.swift │ │ ├── CollectionImageUtils.swift │ │ ├── Image.swift │ │ ├── ImageError.swift │ │ ├── NoImage.swift │ │ └── Sized.swift │ └── Shape │ │ ├── Ellipse.swift │ │ ├── LineSegment.swift │ │ ├── Polygon.swift │ │ ├── Rectangle.swift │ │ ├── ShapeDefaults.swift │ │ └── Text.swift └── PlatformGraphics │ └── PlatformGraphics.swift └── Tests └── GraphicsTests ├── ColorTests.swift └── ShapeTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{yml, yaml}] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | strategy: 13 | matrix: 14 | include: 15 | - os: 'ubuntu-20.04' 16 | swift: '5.7' 17 | - os: 'macos-13' 18 | swift: '5.9' 19 | 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@v3 23 | # https://github.com/swift-actions/setup-swift/pull/680 24 | - uses: swift-actions/setup-swift@bb83339d1e8577741bdc6c65ba551ce7dc0fb854 25 | with: 26 | swift-version: ${{ matrix.swift }} 27 | - name: Install system dependencies (Linux) 28 | if: runner.os == 'Linux' 29 | run: sudo apt-get update && sudo apt-get install -y libcairo2-dev 30 | - name: Install system dependencies (macOS) 31 | if: runner.os == 'macOS' 32 | run: brew update && brew install pkg-config cairo freetype2 33 | - name: Build 34 | run: swift build 35 | - name: Test 36 | run: swift test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | Output/ 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 fwcd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-cairo", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/fwcd/swift-cairo.git", 7 | "state" : { 8 | "revision" : "9654454540ed412249259f310aa8b658b4359bc2", 9 | "version" : "1.3.4" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-log", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-log.git", 16 | "state" : { 17 | "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5", 18 | "version" : "1.5.4" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-utils", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/fwcd/swift-utils.git", 25 | "state" : { 26 | "revision" : "12e4cb20ae4c4d4339c670bbf4ab72e8c3981fcc", 27 | "version" : "4.0.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swiftsoup", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/scinfu/SwiftSoup.git", 34 | "state" : { 35 | "revision" : "f83c097597094a04124eb6e0d1e894d24129af87", 36 | "version" : "2.7.0" 37 | } 38 | }, 39 | { 40 | "identity" : "xmlcoder", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/MaxDesiatov/XMLCoder.git", 43 | "state" : { 44 | "revision" : "b1e944cbd0ef33787b13f639a5418d55b3bed501", 45 | "version" : "0.17.1" 46 | } 47 | } 48 | ], 49 | "version" : 2 50 | } 51 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-graphics", 8 | platforms: [.macOS(.v13)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library(name: "Graphics", targets: ["Graphics"]), 12 | .library(name: "PlatformGraphics", targets: ["PlatformGraphics"]), 13 | .library(name: "CairoGraphics", targets: ["CairoGraphics"]), 14 | .library(name: "CoreGraphicsGraphics", targets: ["CoreGraphicsGraphics"]), 15 | ], 16 | dependencies: [ 17 | // Dependencies declare other packages that this package depends on. 18 | .package(url: "https://github.com/apple/swift-log.git", from: "1.4.0"), 19 | .package(url: "https://github.com/fwcd/swift-utils.git", from: "4.0.0"), 20 | .package(url: "https://github.com/fwcd/swift-cairo.git", from: "1.3.4"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "Graphics", 27 | dependencies: [ 28 | .product(name: "Logging", package: "swift-log"), 29 | .product(name: "Utils", package: "swift-utils"), 30 | .product(name: "Cairo", package: "swift-cairo"), 31 | ] 32 | ), 33 | .target( 34 | name: "CairoGraphics", 35 | dependencies: [ 36 | .product(name: "Logging", package: "swift-log"), 37 | .product(name: "Utils", package: "swift-utils"), 38 | .product(name: "Cairo", package: "swift-cairo"), 39 | .target(name: "Graphics"), 40 | ] 41 | ), 42 | .target( 43 | name: "CoreGraphicsGraphics", 44 | dependencies: [ 45 | .product(name: "Logging", package: "swift-log"), 46 | .product(name: "Utils", package: "swift-utils"), 47 | .target(name: "Graphics"), 48 | ] 49 | ), 50 | .target( 51 | name: "PlatformGraphics", 52 | dependencies: [ 53 | .product(name: "Logging", package: "swift-log"), 54 | .product(name: "Utils", package: "swift-utils"), 55 | .target(name: "CairoGraphics", condition: .when(platforms: [.android, .windows, .linux])), 56 | .target(name: "CoreGraphicsGraphics", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS, .watchOS])), 57 | ] 58 | ), 59 | .testTarget( 60 | name: "GraphicsTests", 61 | dependencies: [ 62 | .target(name: "Graphics"), 63 | ] 64 | ) 65 | ] 66 | ) 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift Graphics 2 | 3 | [![Build](https://github.com/fwcd/swift-graphics/actions/workflows/build.yml/badge.svg)](https://github.com/fwcd/swift-graphics/actions/workflows/build.yml) 4 | 5 | 2D drawing library for Swift with multiple backends, currently including 6 | 7 | - Cairo (cross-platform) 8 | - Core Graphics (Apple platforms) 9 | 10 | ## Example 11 | 12 | ```swift 13 | import PlatformGraphics 14 | import Utils 15 | 16 | // Create a new image and a graphics context 17 | let ctx = try PlatformGraphicsContext(width: 300, height: 300) 18 | 19 | // Draw some shapes 20 | ctx.draw(line: LineSegment(fromX: 20, y: 20, toX: 50, y: 30)) 21 | ctx.draw(rect: Rectangle(fromX: 80, y: 90, width: 10, height: 40, color: Colors.yellow)) 22 | ctx.draw(text: Text("Test", at: Vec2(x: 0, y: 15))) 23 | ctx.draw(ellipse: Ellipse(centerX: 150, y: 80, radius: 40)) 24 | ctx.draw(polygon: Polygon(points: [ 25 | Vec2(x: 210.0, y: 150.0), 26 | Vec2(x: 100.0, y: 200.0), 27 | Vec2(x: 170.0, y: 250.0) 28 | ], isFilled: false)) 29 | 30 | // Encode the image to a byte buffer 31 | let image = try ctx.makeImage() 32 | let data = try image.pngEncoded() 33 | ``` 34 | 35 | The full example can be found in [`Snippets/DrawShapes.swift`](Snippets/DrawShapes.swift). To run it, invoke 36 | 37 | ```sh 38 | swift run DrawShapes Output/shapes.png 39 | ``` 40 | 41 | The resulting PNG will be written to `Output/shapes.png`. 42 | 43 | ## System Dependencies 44 | 45 | * Swift 5.7+ 46 | * For the Cairo backend (required on Linux, optional on macOS): 47 | * Debian: `apt-get install libcairo2-dev` 48 | * macOS: `brew install cairo` 49 | 50 | ## Building 51 | 52 | ```sh 53 | swift build 54 | ``` 55 | 56 | ## Testing 57 | 58 | ```sh 59 | swift test 60 | ``` 61 | -------------------------------------------------------------------------------- /Snippets/DrawShapes.swift: -------------------------------------------------------------------------------- 1 | import PlatformGraphics 2 | import Foundation 3 | import Utils 4 | 5 | func generatePngImage() throws -> Data { 6 | // Create a new image and a graphics context 7 | let ctx = try PlatformGraphicsContext(width: 300, height: 300) 8 | 9 | // Draw some shapes 10 | ctx.draw(line: LineSegment(fromX: 20, y: 20, toX: 50, y: 30)) 11 | ctx.draw(rect: Rectangle(fromX: 80, y: 90, width: 10, height: 40, color: .yellow)) 12 | ctx.draw(text: Text("Test", at: Vec2(x: 0, y: 15))) 13 | ctx.draw(ellipse: Ellipse(centerX: 150, y: 80, radius: 40)) 14 | ctx.draw(polygon: Polygon(points: [ 15 | Vec2(x: 250.0, y: 120.0), 16 | Vec2(x: 280.0, y: 250.0), 17 | Vec2(x: 200.0, y: 170.0), 18 | Vec2(x: 300.0, y: 170.0), 19 | Vec2(x: 220.0, y: 250.0) 20 | ], isFilled: true)) 21 | ctx.draw(polygon: Polygon(paths: [ 22 | [ 23 | Vec2(x: 20.0, y: 150.0), 24 | Vec2(x: 120.0, y: 150.0), 25 | Vec2(x: 120.0, y: 250.0), 26 | Vec2(x: 20.0, y: 250.0) 27 | ], 28 | [ 29 | Vec2(x: 70.0, y: 170.0), 30 | Vec2(x: 10.0, y: 220.0), 31 | Vec2(x: 130.0, y: 220.0), 32 | ] 33 | ], isFilled: true)) 34 | 35 | // Encode the image to a byte buffer 36 | let image = try ctx.makeImage() 37 | let data = try image.pngEncoded() 38 | 39 | return data 40 | } 41 | 42 | guard CommandLine.argc > 1 else { 43 | print("Usage: \(CommandLine.arguments[0]) [output path]") 44 | exit(1) 45 | } 46 | 47 | // Parse paths from arguments 48 | let path = CommandLine.arguments[1] 49 | let url = URL(fileURLWithPath: path) 50 | let parentUrl = url.deletingLastPathComponent() 51 | 52 | // Generate image and write PNG to file 53 | try FileManager.default.createDirectory(at: parentUrl, withIntermediateDirectories: true) 54 | try generatePngImage().write(to: url) 55 | -------------------------------------------------------------------------------- /Sources/CairoGraphics/Context/CairoContext.swift: -------------------------------------------------------------------------------- 1 | import Cairo 2 | import Graphics 3 | import Utils 4 | 5 | /** 6 | * A graphics context that uses the Cairo library for its drawing primitives. 7 | */ 8 | public final class CairoContext: GraphicsContext { 9 | private let context: Cairo.Context 10 | private let image: CairoImage 11 | 12 | deinit { 13 | flush() 14 | } 15 | 16 | public init(image: CairoImage) { 17 | context = Cairo.Context(surface: image.surface) 18 | self.image = image 19 | } 20 | 21 | public convenience init(width: Int, height: Int, format: PixelFormat) throws { 22 | self.init(image: try CairoImage(width: width, height: height, format: format)) 23 | } 24 | 25 | public func makeImage() throws -> CairoImage { 26 | image 27 | } 28 | 29 | private func markImageAsUnflushed() { 30 | image.flushed = false 31 | } 32 | 33 | public func flush() { 34 | context.surface.flush() 35 | image.flushed = true 36 | } 37 | 38 | public func save() { 39 | markImageAsUnflushed() 40 | context.save() 41 | } 42 | 43 | public func restore() { 44 | markImageAsUnflushed() 45 | context.restore() 46 | } 47 | 48 | public func translate(by offset: Vec2) { 49 | markImageAsUnflushed() 50 | context.translate(x: offset.x, y: offset.y) 51 | } 52 | 53 | public func rotate(by angle: Double) { 54 | markImageAsUnflushed() 55 | context.rotate(angle) 56 | } 57 | 58 | public func draw(line: LineSegment) { 59 | markImageAsUnflushed() 60 | 61 | context.setSource(color: line.color.asDoubleTuple) 62 | context.move(to: line.start.asTuple) 63 | context.line(to: line.end.asTuple) 64 | context.stroke() 65 | } 66 | 67 | public func draw(rect: Rectangle) { 68 | markImageAsUnflushed() 69 | 70 | // Floating point comparison is intended since this flag only allows potential optimizations 71 | var rotated = false 72 | 73 | if let rotation = rect.rotation { 74 | context.save() 75 | context.rotate(rotation) 76 | rotated = true 77 | } 78 | 79 | context.setSource(color: rect.color.asDoubleTuple) 80 | 81 | if let radius = rect.cornerRadius { 82 | context.newSubpath() 83 | context.addArc(center: (rect.topLeft + Vec2(x: radius, y: radius)).asTuple, radius: radius, angle: (Double.pi, (3.0 / 2.0) * Double.pi)) 84 | context.addArc(center: (rect.topRight + Vec2(x: -radius, y: radius)).asTuple, radius: radius, angle: (-Double.pi / 2.0, 0)) 85 | context.addArc(center: (rect.bottomRight + Vec2(x: -radius, y: -radius)).asTuple, radius: radius, angle: (0, Double.pi / 2.0)) 86 | context.addArc(center: (rect.bottomLeft + Vec2(x: radius, y: -radius)).asTuple, radius: radius, angle: (Double.pi / 2.0, Double.pi)) 87 | context.closePath() 88 | } else { 89 | context.addRectangle(x: rect.topLeft.x, y: rect.topLeft.y, width: rect.width, height: rect.height) 90 | } 91 | 92 | if rect.isFilled { 93 | context.fill() 94 | } else { 95 | context.stroke() 96 | } 97 | 98 | if rotated { 99 | context.restore() 100 | } 101 | } 102 | 103 | public func draw(polygon: Polygon) { 104 | guard polygon.paths.count > 0 else { 105 | return 106 | } 107 | 108 | markImageAsUnflushed() 109 | context.setSource(color: polygon.color.asDoubleTuple) 110 | 111 | for path in polygon.paths { 112 | context.move(to: path.first!.asTuple) 113 | for point in path.suffix(path.count - 1) { 114 | context.line(to: point.asTuple) 115 | } 116 | } 117 | 118 | if polygon.isFilled { 119 | context.fill() 120 | } else { 121 | context.stroke() 122 | } 123 | } 124 | 125 | public func draw(image: CairoImage, at position: Vec2, withSize size: Vec2, rotation optionalRotation: Double?) { 126 | draw(surface: image.surface, of: image.size, at: position, withSize: size, rotation: optionalRotation) 127 | } 128 | 129 | public func draw(svg: SVG, at position: Vec2, withSize size: Vec2, rotation optionalRotation: Double?) { 130 | draw(surface: svg.surface, of: svg.size, at: position, withSize: size, rotation: optionalRotation) 131 | } 132 | 133 | private func draw(surface: Surface, of originalSize: Vec2, at position: Vec2, withSize size: Vec2, rotation optionalRotation: Double?) { 134 | markImageAsUnflushed() 135 | 136 | context.save() 137 | 138 | let scaleFactor = Vec2(x: Double(size.x) / Double(originalSize.x), y: Double(size.y) / Double(originalSize.y)) 139 | context.translate(x: position.x, y: position.y) 140 | 141 | if let rotation = optionalRotation { 142 | let center = (size / 2).asDouble 143 | context.translate(x: center.x, y: center.y) 144 | context.rotate(rotation) 145 | context.translate(x: -center.x, y: -center.y) 146 | } 147 | 148 | if originalSize != size { 149 | context.scale(x: scaleFactor.x, y: scaleFactor.y) 150 | } 151 | 152 | context.source = Pattern(surface: surface) 153 | context.paint() 154 | context.restore() 155 | } 156 | 157 | public func draw(text: Text) { 158 | markImageAsUnflushed() 159 | 160 | context.setSource(color: text.color.asDoubleTuple) 161 | context.setFont(size: text.fontSize) 162 | context.move(to: text.position.asTuple) 163 | context.show(text: text.value) 164 | } 165 | 166 | public func draw(ellipse: Ellipse) { 167 | markImageAsUnflushed() 168 | 169 | context.save() 170 | context.translate(x: ellipse.center.x, y: ellipse.center.y) 171 | context.rotate(ellipse.rotation) 172 | context.scale(x: ellipse.radius.x, y: ellipse.radius.y) 173 | context.addArc(center: (x: 0.0, y: 0.0), radius: 1.0, angle: (0, 2.0 * Double.pi)) 174 | context.restore() 175 | 176 | context.save() 177 | context.setSource(color: ellipse.color.asDoubleTuple) 178 | 179 | if ellipse.isFilled { 180 | context.fill() 181 | } else { 182 | context.stroke() 183 | } 184 | 185 | context.restore() 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /Sources/CairoGraphics/Context/DefaultGraphicsContext.swift: -------------------------------------------------------------------------------- 1 | import Graphics 2 | 3 | public typealias DefaultGraphicsContext = CairoContext 4 | public typealias DefaultImage = DefaultGraphicsContext.Image 5 | -------------------------------------------------------------------------------- /Sources/CairoGraphics/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import Graphics 2 | -------------------------------------------------------------------------------- /Sources/CairoGraphics/Image/CairoImage.swift: -------------------------------------------------------------------------------- 1 | import Cairo 2 | import Foundation 3 | import Logging 4 | import Graphics 5 | import Utils 6 | 7 | fileprivate let log = Logger(label: "CairoGraphics.CairoImage") 8 | 9 | private extension PixelFormat { 10 | var imageFormat: ImageFormat { 11 | switch self { 12 | case .rgba32: 13 | return .argb32 14 | case .g8: 15 | return .a8 16 | } 17 | } 18 | } 19 | 20 | /** 21 | * An image that internally wraps a Cairo surface. 22 | */ 23 | public final class CairoImage: BufferedImage { 24 | private let rawSurface: Surface.Image 25 | private var dirty: Bool = false 26 | internal var flushed: Bool = false 27 | 28 | public var width: Int { surface.width } 29 | public var height: Int { surface.height } 30 | 31 | private var flushedSurface: Surface.Image { 32 | if !flushed { 33 | rawSurface.flush() 34 | flushed = true 35 | } 36 | return rawSurface 37 | } 38 | internal var surface: Surface.Image { 39 | if dirty { 40 | rawSurface.markDirty() 41 | dirty = false 42 | } 43 | return rawSurface 44 | } 45 | 46 | // Source: https://www.CairoContext.org/manual/cairo-Image-Surfaces.html#cairo-format-t 47 | private var bytesPerPixel: Int? { 48 | switch surface.format { 49 | case .argb32?: return 4 50 | case .rgb24?: return 4 51 | case .a8?: return 1 52 | case .rgb16565?: return 2 53 | default: return nil 54 | } 55 | } 56 | 57 | init(rawSurface: Surface.Image) { 58 | self.rawSurface = rawSurface 59 | } 60 | 61 | public convenience init(pngData: Data) throws { 62 | self.init(rawSurface: try Surface.Image(png: pngData)) 63 | } 64 | 65 | public func pngEncoded() throws -> Data { 66 | return try surface.writePNG() 67 | } 68 | 69 | 70 | public convenience init(width: Int, height: Int, format: PixelFormat) throws { 71 | let surface = try Surface.Image(format: format.imageFormat, width: width, height: height) 72 | self.init(rawSurface: surface) 73 | } 74 | 75 | public subscript(_ y: Int, _ x: Int) -> Color { 76 | get { 77 | var pixel: Color? = nil 78 | flushedSurface.withUnsafeMutableBytes { ptr in 79 | let i: Int = (y * flushedSurface.stride) + (x * bytesPerPixel!) 80 | let colorPtr = UnsafeMutableRawPointer(ptr + i) 81 | pixel = readColorFrom(pixel: colorPtr) 82 | } 83 | 84 | return pixel ?? .transparent 85 | } 86 | set(newColor) { 87 | dirty = true 88 | flushedSurface.withUnsafeMutableBytes { ptr in 89 | let i: Int = (y * flushedSurface.stride) + (x * bytesPerPixel!) 90 | let colorPtr = UnsafeMutableRawPointer(ptr + i) 91 | store(color: newColor, inPixel: colorPtr) 92 | } 93 | } 94 | } 95 | 96 | /** Converts a color to the image's native representation. */ 97 | private func store(color: Color, inPixel ptr: UnsafeMutableRawPointer) { 98 | switch rawSurface.format { 99 | case .argb32?: 100 | ptr.storeBytes(of: color.argb, as: UInt32.self) 101 | case .rgb24?: 102 | ptr.storeBytes(of: color.rgb, as: UInt32.self) 103 | default: 104 | log.warning("Could not store color \(color) in an image with the format \(surface.format.map { "\($0)" } ?? "nil")") 105 | } 106 | } 107 | 108 | /** Convert's a color in the image's native representation to a color. */ 109 | private func readColorFrom(pixel ptr: UnsafeMutableRawPointer) -> Color? { 110 | switch rawSurface.format { 111 | case .argb32?: 112 | return Color(argb: ptr.load(as: UInt32.self)) 113 | case .rgb24?: 114 | return Color(rgb: ptr.load(as: UInt32.self)) 115 | default: 116 | log.warning("Color not read color from an image with the format \(surface.format.map { "\($0)" } ?? "nil")") 117 | return nil 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/CairoGraphics/Image/CairoSVG.swift: -------------------------------------------------------------------------------- 1 | import Cairo 2 | import Graphics 3 | import Utils 4 | 5 | /** 6 | * A vector graphic that internally wraps a Cairo surface. 7 | */ 8 | public struct SVG: Sized { 9 | let surface: Surface.SVG 10 | public let width: Int 11 | public let height: Int 12 | 13 | public var size: Vec2 { return Vec2(x: width, y: height) } 14 | 15 | init(surface: Surface.SVG, width: Int, height: Int) { 16 | self.surface = surface 17 | self.width = width 18 | self.height = height 19 | } 20 | 21 | public init(svgFilePath filePath: String, width: Int, height: Int) throws { 22 | self.init(surface: try Surface.SVG(filename: filePath, width: Double(width), height: Double(height)), width: width, height: height) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/CairoGraphics/Image/HTTPImageUtils.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | extension HTTPRequest { 4 | public func fetchPNGAsync() -> Promise { 5 | runAsync().mapCatching { try CairoImage(pngData: $0) } 6 | } 7 | 8 | public func fetchPNG() async throws -> CairoImage { 9 | try await CairoImage(pngData: run()) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/CoreGraphicsGraphics/Context/CoreGraphicsContext.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreGraphics) && canImport(CoreText) 2 | import CoreGraphics 3 | import CoreText 4 | import Foundation 5 | import Graphics 6 | import Utils 7 | 8 | #if canImport(AppKit) 9 | import AppKit 10 | #elseif canImport(UIKit) 11 | import UIKit 12 | #endif 13 | 14 | private extension PixelFormat { 15 | var colorSpace: CGColorSpace { 16 | switch self { 17 | case .rgba32: 18 | return CGColorSpaceCreateDeviceRGB() 19 | case .g8: 20 | return CGColorSpaceCreateDeviceGray() 21 | } 22 | } 23 | 24 | var bytesPerPixel: Int { 25 | switch self { 26 | case .rgba32: 27 | return 4 28 | case .g8: 29 | return 1 30 | } 31 | } 32 | 33 | var bitmapInfo: UInt32 { 34 | switch self { 35 | case .rgba32: 36 | return CGBitmapInfo.byteOrder32Big.rawValue | 37 | CGImageAlphaInfo.premultipliedLast.rawValue & 38 | CGBitmapInfo.alphaInfoMask.rawValue 39 | case .g8: 40 | return 0 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * A graphics context that uses CoreGraphics for its drawing primitives. 47 | */ 48 | public final class CoreGraphicsContext: GraphicsContext { 49 | private let cgContext: CGContext 50 | private var dataPointer: UnsafeMutableBufferPointer? 51 | 52 | deinit { 53 | dataPointer?.deallocate() 54 | } 55 | 56 | public init(cgContext: CGContext) { 57 | self.cgContext = cgContext 58 | dataPointer = nil 59 | } 60 | 61 | public init(width: Int, height: Int, format: PixelFormat) throws { 62 | let dataPointer = UnsafeMutableBufferPointer.allocate(capacity: width * height * format.bytesPerPixel) 63 | guard let cgContext = CGContext( 64 | data: dataPointer.baseAddress, 65 | width: width, 66 | height: height, 67 | bitsPerComponent: 8, 68 | bytesPerRow: width * format.bytesPerPixel, 69 | space: format.colorSpace, 70 | bitmapInfo: format.bitmapInfo, 71 | releaseCallback: nil, 72 | releaseInfo: nil 73 | ) else { 74 | throw GraphicsContextError.couldNotCreate(width: width, height: height) 75 | } 76 | 77 | self.dataPointer = dataPointer 78 | self.cgContext = cgContext 79 | } 80 | 81 | public func makeImage() throws -> CoreGraphicsImage { 82 | guard let image = cgContext.makeImage() else { throw GraphicsContextError.couldNotMakeImage } 83 | return CoreGraphicsImage(cgImage: image) 84 | } 85 | 86 | public func flush() { 87 | cgContext.flush() 88 | } 89 | 90 | public func save() { 91 | cgContext.saveGState() 92 | } 93 | 94 | public func restore() { 95 | cgContext.restoreGState() 96 | } 97 | 98 | public func translate(by offset: Vec2) { 99 | cgContext.translateBy(x: offset.x, y: offset.y) 100 | } 101 | 102 | public func rotate(by angle: Double) { 103 | cgContext.rotate(by: angle) 104 | } 105 | 106 | private func setColor(_ color: Color, isFilled: Bool) { 107 | if isFilled { 108 | cgContext.setFillColor(color.asCGColor) 109 | } else { 110 | cgContext.setStrokeColor(color.asCGColor) 111 | } 112 | } 113 | 114 | private func cgPoint(for vec2: Vec2) -> CGPoint { 115 | .init(x: vec2.x, y: Double(cgContext.height) - vec2.y) 116 | } 117 | 118 | private func cgPoint(for vec2: Vec2) -> CGPoint { 119 | .init(x: vec2.x, y: cgContext.height - vec2.y) 120 | } 121 | 122 | private func cgSize(for vec2: Vec2) -> CGSize { 123 | .init(width: vec2.x, height: vec2.y) 124 | } 125 | 126 | private func cgSize(for vec2: Vec2) -> CGSize { 127 | .init(width: vec2.x, height: vec2.y) 128 | } 129 | 130 | private func cgRect(for rect: Rectangle) -> CGRect { 131 | .init(origin: cgPoint(for: rect.bottomLeft), size: cgSize(for: rect.size)) 132 | } 133 | 134 | private func withPath(color: Color, isFilled: Bool, action: () -> Void) { 135 | setColor(color, isFilled: isFilled) 136 | cgContext.beginPath() 137 | action() 138 | if isFilled { 139 | cgContext.fillPath() 140 | } else { 141 | cgContext.strokePath() 142 | } 143 | } 144 | 145 | public func draw(line: LineSegment) { 146 | withPath(color: line.color, isFilled: false) { 147 | cgContext.move(to: cgPoint(for: line.start)) 148 | cgContext.addLine(to: cgPoint(for: line.end)) 149 | } 150 | } 151 | 152 | public func draw(rect: Rectangle) { 153 | withPath(color: rect.color, isFilled: rect.isFilled) { 154 | cgContext.addRect(cgRect(for: rect)) 155 | } 156 | } 157 | 158 | public func draw(polygon: Graphics.Polygon) { 159 | guard polygon.paths.count > 0 else { 160 | return 161 | } 162 | withPath(color: polygon.color, isFilled: polygon.isFilled) { 163 | for path in polygon.paths { 164 | cgContext.addLines(between: path.map { cgPoint(for: $0) }) 165 | } 166 | } 167 | } 168 | 169 | public func draw(ellipse: Ellipse) { 170 | withPath(color: ellipse.color, isFilled: ellipse.isFilled) { 171 | cgContext.addEllipse(in: cgRect(for: ellipse.boundingRectangle)) 172 | } 173 | } 174 | 175 | public func draw(image: CoreGraphicsImage, at position: Vec2, withSize size: Vec2, rotation: Double?) { 176 | cgContext.draw(image.cgImage, in: CGRect(origin: cgPoint(for: position), size: cgSize(for: size))) 177 | } 178 | 179 | public func draw(svg: NoImage, at position: Vec2, withSize size: Vec2, rotation: Double?) { 180 | // Not supported yet 181 | } 182 | 183 | public func draw(text: Text) { 184 | let font = CTFontCreateWithName("Helvetica" as CFString, text.fontSize, nil) 185 | let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: text.color.asCGColor] 186 | let attributedString = NSAttributedString(string: text.value, attributes: attributes) 187 | let line = CTLineCreateWithAttributedString(attributedString) 188 | let bounds = CTLineGetImageBounds(line, cgContext) 189 | let offset = cgPoint(for: text.position) 190 | cgContext.textPosition = CGPoint(x: bounds.minX + offset.x, y: bounds.minY + offset.y) 191 | CTLineDraw(line, cgContext) 192 | } 193 | } 194 | 195 | extension Color { 196 | var asCGColor: CGColor { 197 | CGColor( 198 | red: CGFloat(red) / 255.0, 199 | green: CGFloat(green) / 255.0, 200 | blue: CGFloat(blue) / 255.0, 201 | alpha: CGFloat(alpha) / 255.0 202 | ) 203 | } 204 | } 205 | #endif 206 | -------------------------------------------------------------------------------- /Sources/CoreGraphicsGraphics/Context/DefaultGraphicsContext.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreGraphics) && canImport(CoreText) 2 | import Graphics 3 | 4 | public typealias DefaultGraphicsContext = CoreGraphicsContext 5 | public typealias DefaultImage = DefaultGraphicsContext.Image 6 | #endif 7 | -------------------------------------------------------------------------------- /Sources/CoreGraphicsGraphics/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import Graphics 2 | -------------------------------------------------------------------------------- /Sources/CoreGraphicsGraphics/Image/CoreGraphicsImage.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CoreGraphics) && canImport(ImageIO) 2 | import CoreGraphics 3 | import ImageIO 4 | import Foundation 5 | import Graphics 6 | import UniformTypeIdentifiers 7 | 8 | public struct CoreGraphicsImage: Image { 9 | public let cgImage: CGImage 10 | 11 | public var width: Int { cgImage.width } 12 | public var height: Int { cgImage.height } 13 | 14 | public init(cgImage: CGImage) { 15 | self.cgImage = cgImage 16 | } 17 | 18 | public init(pngData: Data) throws { 19 | guard let dataProvider = CGDataProvider(data: pngData as CFData) else { 20 | throw ImageError.couldNotCreateDataProvider 21 | } 22 | guard let cgImage = CGImage( 23 | pngDataProviderSource: dataProvider, 24 | decode: nil, 25 | shouldInterpolate: false, 26 | intent: .defaultIntent 27 | ) else { 28 | throw ImageError.couldNotReadImage 29 | } 30 | self.init(cgImage: cgImage) 31 | } 32 | 33 | public func pngEncoded() throws -> Data { 34 | guard let data = CFDataCreateMutable(nil, 0), 35 | let dest = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { 36 | throw ImageError.couldNotCreatePngBuffer 37 | } 38 | CGImageDestinationAddImage(dest, cgImage, nil) 39 | guard CGImageDestinationFinalize(dest) else { 40 | throw ImageError.couldNotWritePng 41 | } 42 | return data as Data 43 | } 44 | } 45 | #endif 46 | -------------------------------------------------------------------------------- /Sources/Graphics/Color/Color.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Color: Hashable { 4 | public let red: UInt8 5 | public let green: UInt8 6 | public let blue: UInt8 7 | public let alpha: UInt8 8 | 9 | public var rgb: UInt32 { return (UInt32(red) << 16) | (UInt32(green) << 8) | UInt32(blue) } 10 | public var rgba: UInt32 { return (UInt32(red) << 24) | (UInt32(green) << 16) | (UInt32(blue) << 8) | UInt32(alpha) } 11 | public var argb: UInt32 { return (UInt32(alpha) << 24) | (UInt32(red) << 16) | (UInt32(green) << 8) | UInt32(blue) } 12 | 13 | public var inverted: Color { return Color( 14 | red: 0xFF - red, 15 | green: 0xFF - green, 16 | blue: 0xFF - blue, 17 | alpha: alpha 18 | ) } 19 | 20 | public var luminance: UInt8 { 21 | return UInt8((2 * UInt(red) + 3 * UInt(green) + UInt(blue)) / 6) 22 | } 23 | 24 | public var grayscale: Color { 25 | return Color(red: luminance, green: luminance, blue: luminance) 26 | } 27 | 28 | public var asDoubleTuple: (red: Double, green: Double, blue: Double, alpha: Double) { 29 | return (red: Double(red) / 255.0, green: Double(green) / 255.0, blue: Double(blue) / 255.0, alpha: Double(alpha) / 255.0) 30 | } 31 | 32 | public init(rgb: UInt32) { 33 | red = UInt8((rgb >> 16) & 0xFF) 34 | green = UInt8((rgb >> 8) & 0xFF) 35 | blue = UInt8(rgb & 0xFF) 36 | alpha = 255 37 | } 38 | 39 | public init(rgba: UInt32) { 40 | red = UInt8((rgba >> 24) & 0xFF) 41 | green = UInt8((rgba >> 16) & 0xFF) 42 | blue = UInt8((rgba >> 8) & 0xFF) 43 | alpha = UInt8(rgba & 0xFF) 44 | } 45 | 46 | public init(argb: UInt32) { 47 | alpha = UInt8((argb >> 24) & 0xFF) 48 | red = UInt8((argb >> 16) & 0xFF) 49 | green = UInt8((argb >> 8) & 0xFF) 50 | blue = UInt8(argb & 0xFF) 51 | } 52 | 53 | public init(red: UInt8, green: UInt8, blue: UInt8, alpha: UInt8 = 255) { 54 | self.red = red 55 | self.green = green 56 | self.blue = blue 57 | self.alpha = alpha 58 | } 59 | 60 | public func mapAllChannels(withAlpha: Bool = true, _ transform: (UInt8) throws -> UInt8) rethrows -> Color { 61 | try Color(red: transform(red), green: transform(green), blue: transform(blue), alpha: withAlpha ? transform(alpha) : alpha) 62 | } 63 | 64 | public func with(alpha newAlpha: UInt8) -> Color { 65 | return Color(red: red, green: green, blue: blue, alpha: newAlpha) 66 | } 67 | 68 | public func with(red newRed: UInt8, green newGreen: UInt8, blue newBlue: UInt8) -> Color { 69 | return Color(red: newRed, green: newGreen, blue: newBlue, alpha: alpha) 70 | } 71 | 72 | public func alphaBlend(over bottomLayer: Color) -> Color { 73 | let floatAlpha = Double(alpha) / 255.0 74 | let invAlpha = 1.0 - floatAlpha 75 | return Color( 76 | red: UInt8((Double(red) * floatAlpha) + (Double(bottomLayer.red) * invAlpha)), 77 | green: UInt8((Double(green) * floatAlpha) + (Double(bottomLayer.green) * invAlpha)), 78 | blue: UInt8((Double(blue) * floatAlpha) + Double(bottomLayer.blue) * invAlpha) 79 | ) 80 | } 81 | 82 | public func euclideanDistance(to color: Color, useAlpha: Bool = true) -> Double { 83 | squaredEuclideanDistance(to: color, useAlpha: useAlpha).squareRoot() 84 | } 85 | 86 | public func squaredEuclideanDistance(to color: Color, useAlpha: Bool = true) -> Double { 87 | let (r1, g1, b1, a1) = asDoubleTuple 88 | let (r2, g2, b2, a2) = color.asDoubleTuple 89 | 90 | return pow(r2 - r1, 2) + pow(g2 - g1, 2) + pow(b2 - b1, 2) + (useAlpha ? pow(a2 - a1, 2) : 0) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/Graphics/Color/Colors.swift: -------------------------------------------------------------------------------- 1 | public extension Color { 2 | static let red = Color(red: 255, green: 0, blue: 0) 3 | static let green = Color(red: 0, green: 255, blue: 0) 4 | static let blue = Color(red: 0, green: 0, blue: 255) 5 | static let white = Color(red: 255, green: 255, blue: 255) 6 | static let black = Color(red: 0, green: 0, blue: 0) 7 | static let gray = Color(red: 128, green: 128, blue: 128) 8 | static let yellow = Color(red: 255, green: 255, blue: 0) 9 | static let magenta = Color(red: 255, green: 0, blue: 255) 10 | static let cyan = Color(red: 0, green: 255, blue: 255) 11 | static let transparent = Color(red: 0, green: 0, blue: 0, alpha: 0) 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Graphics/Context/GraphicsContext.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | /** A stateful 2D drawing environment. */ 4 | public protocol GraphicsContext { 5 | associatedtype Image: Sized 6 | associatedtype SVG: Sized 7 | 8 | /** Creates a new context with the given width and height and format. */ 9 | init(width: Int, height: Int, format: PixelFormat) throws 10 | 11 | /** Creates an image from this context. */ 12 | func makeImage() throws -> Image 13 | 14 | /** Flushes the changes to the underlying graphics. */ 15 | func flush() 16 | 17 | /** Pushes the current context's state onto an internal stack. */ 18 | func save() 19 | 20 | /** Pops the last state from the internal stack and restores it. */ 21 | func restore() 22 | 23 | /** Applies a translation by the given offset to this context. */ 24 | func translate(by offset: Vec2) 25 | 26 | /** Applies a rotation by the given angle to this context. */ 27 | func rotate(by angle: Double) 28 | 29 | /** Draws the given line segment to this context. */ 30 | func draw(line: LineSegment) 31 | 32 | /** Draws the given rectangle to this context. */ 33 | func draw(rect: Rectangle) 34 | 35 | /** Draws the given ellipse to this context. */ 36 | func draw(ellipse: Ellipse) 37 | 38 | /** Draws the given image to this context. */ 39 | func draw(image: Image, at position: Vec2, withSize size: Vec2, rotation: Double?) 40 | 41 | /** Draws the given SVG to this context. */ 42 | func draw(svg: SVG, at position: Vec2, withSize size: Vec2, rotation: Double?) 43 | 44 | /** Draws the given text to this context. */ 45 | func draw(text: Text) 46 | 47 | /** Draws the given polygon in this context. */ 48 | func draw(polygon: Polygon) 49 | } 50 | 51 | public extension GraphicsContext { 52 | init (width: Int, height: Int) throws { 53 | try self.init(width: width, height: height, format: .rgba32) 54 | } 55 | } 56 | 57 | public extension GraphicsContext { 58 | func draw(image: Image) { 59 | draw(image: image, at: Vec2(x: 0, y: 0)) 60 | } 61 | 62 | func draw(image: Image, at position: Vec2) { 63 | draw(image: image, at: position, withSize: image.size) 64 | } 65 | 66 | func draw(image: Image, at position: Vec2, rotation: Double?) { 67 | draw(image: image, at: position, withSize: image.size, rotation: rotation) 68 | } 69 | 70 | func draw(image: Image, at position: Vec2, withSize size: Vec2) { 71 | draw(image: image, at: position, withSize: size, rotation: nil) 72 | } 73 | 74 | func draw(svg: SVG) { 75 | draw(svg: svg, at: Vec2(x: 0, y: 0)) 76 | } 77 | 78 | func draw(svg: SVG, at position: Vec2) { 79 | draw(svg: svg, at: position, withSize: svg.size) 80 | } 81 | 82 | func draw(svg: SVG, at position: Vec2, rotation: Double?) { 83 | draw(svg: svg, at: position, withSize: svg.size, rotation: rotation) 84 | } 85 | 86 | func draw(svg: SVG, at position: Vec2, withSize size: Vec2) { 87 | draw(svg: svg, at: position, withSize: size, rotation: nil) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/Graphics/Context/GraphicsContextError.swift: -------------------------------------------------------------------------------- 1 | public enum GraphicsContextError: Error { 2 | case couldNotCreate(width: Int, height: Int) 3 | case couldNotMakeImage 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Graphics/Context/PixelFormat.swift: -------------------------------------------------------------------------------- 1 | /** Supported backend pixel formats **/ 2 | public enum PixelFormat { 3 | /** Full color with alpha in 4 bytes per pixel **/ 4 | case rgba32 5 | 6 | /** Grayscale in 1 byte per pixel **/ 7 | case g8 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Graphics/Image/BufferedImage.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | /** 4 | * A mutable image that provides direct access to its pixels. 5 | */ 6 | public protocol BufferedImage: Image { 7 | init(width: Int, height: Int, format: PixelFormat) throws 8 | 9 | subscript(_ y: Int, _ x: Int) -> Color { get set } 10 | } 11 | 12 | public extension BufferedImage { 13 | init(width: Int, height: Int) throws { 14 | try self.init(width: width, height: height, format: .rgba32) 15 | } 16 | 17 | 18 | init(size: Vec2) throws { 19 | try self.init(size: size, format: .rgba32) 20 | } 21 | 22 | init(size: Vec2, format: PixelFormat) throws { 23 | try self.init(width: size.x, height: size.y, format: format) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Graphics/Image/CollectionImageUtils.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | extension GraphicsContext { 4 | public static func joinHorizontally(images: [Image]) -> Image? { 5 | guard !images.isEmpty else { return nil } 6 | 7 | let totalWidth = images.map { $0.width }.reduce(0, +) 8 | let totalHeight = images.map { $0.height }.max() ?? 0 9 | 10 | guard let ctx = try? Self(width: totalWidth, height: totalHeight) else { return nil } 11 | var pos = Vec2(x: 0.0, y: 0.0) 12 | 13 | for image in images { 14 | ctx.draw(image: image, at: pos) 15 | pos = pos + Vec2(x: Double(image.width), y: 0.0) 16 | } 17 | 18 | return try? ctx.makeImage() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Graphics/Image/Image.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Utils 3 | 4 | /** 5 | * A mutable image that can be constructed from and converted to PNG. 6 | */ 7 | public protocol Image: Sized { 8 | init(pngData: Data) throws 9 | 10 | func pngEncoded() throws -> Data 11 | } 12 | 13 | public extension Image { 14 | init(pngFileUrl url: URL) throws { 15 | let fileManager = FileManager.default 16 | guard fileManager.fileExists(atPath: url.path) else { throw DiskFileError.fileNotFound(url) } 17 | 18 | if let data = fileManager.contents(atPath: url.path) { 19 | try self.init(pngData: data) 20 | } else { 21 | throw DiskFileError.noData("Image at \(url) contained no data") 22 | } 23 | } 24 | 25 | init(pngFilePath: String) throws { 26 | try self.init(pngFileUrl: URL(fileURLWithPath: pngFilePath)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Graphics/Image/ImageError.swift: -------------------------------------------------------------------------------- 1 | public enum ImageError: Error { 2 | case couldNotCreate(width: Int, height: Int) 3 | case couldNotCreateDataProvider 4 | case couldNotReadImage 5 | case couldNotCreatePngBuffer 6 | case couldNotWritePng 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Graphics/Image/NoImage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | * An uninhabited type (similar to ``Never``) that conforms to ``Image``. 5 | */ 6 | public enum NoImage: Image { 7 | public var width: Int { fatalError("Unreachable") } 8 | public var height: Int { fatalError("Unreachable") } 9 | 10 | public init(pngData: Data) throws { 11 | fatalError("Cannot create NoImage") 12 | } 13 | 14 | public func pngEncoded() throws -> Data { 15 | fatalError("Unreachable") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Graphics/Image/Sized.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | public protocol Sized { 4 | var width: Int { get } 5 | var height: Int { get } 6 | } 7 | 8 | public extension Sized { 9 | var size: Vec2 { return Vec2(x: width, y: height) } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Graphics/Shape/Ellipse.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | public struct Ellipse { 4 | public var center: Vec2 5 | public var radius: Vec2 6 | public var color: Color 7 | public var isFilled: Bool 8 | public var rotation: T 9 | 10 | public var boundingRectangle: Rectangle { 11 | Rectangle(topLeft: center - radius, size: Vec2(x: 2, y: 2) * radius, rotation: rotation, color: color, isFilled: isFilled) 12 | } 13 | 14 | public init( 15 | center: Vec2 = Vec2(x: 0, y: 0), 16 | radius: Vec2 = Vec2(x: 1, y: 1), 17 | rotation: T = 0, 18 | color: Color = ShapeDefaults.color, 19 | isFilled: Bool = ShapeDefaults.isFilled 20 | ) { 21 | self.center = center 22 | self.radius = radius 23 | self.color = color 24 | self.isFilled = isFilled 25 | self.rotation = rotation 26 | } 27 | 28 | public init( 29 | centerX: T = 0, 30 | y centerY: T = 0, 31 | radiusX: T = 1, 32 | y radiusY: T = 1, 33 | rotation: T = 0, 34 | color: Color = ShapeDefaults.color, 35 | isFilled: Bool = ShapeDefaults.isFilled 36 | ) { 37 | self.init(center: Vec2(x: centerX, y: centerY), radius: Vec2(x: radiusX, y: radiusY), rotation: rotation, color: color, isFilled: isFilled) 38 | } 39 | 40 | public init( 41 | centerX: T = 0, 42 | y centerY: T = 0, 43 | radius: T = 1, 44 | rotation: T = 0, 45 | color: Color = ShapeDefaults.color, 46 | isFilled: Bool = ShapeDefaults.isFilled 47 | ) { 48 | self.init(centerX: centerX, y: centerY, radiusX: radius, y: radius, rotation: rotation, color: color, isFilled: isFilled) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Graphics/Shape/LineSegment.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | public struct LineSegment { 4 | public let start: Vec2 5 | public let end: Vec2 6 | public let color: Color 7 | 8 | // TODO: Stroke thickness 9 | 10 | public init(from start: Vec2 = Vec2(x: 0, y: 0), to end: Vec2 = Vec2(x: 0, y: 0), color: Color = ShapeDefaults.color) { 11 | self.start = start 12 | self.end = end 13 | self.color = color 14 | } 15 | 16 | public init(fromX startX: T = 0, y startY: T = 0, toX endX: T = 0, y endY: T = 0, color: Color = ShapeDefaults.color) { 17 | self.init(from: Vec2(x: startX, y: startY), to: Vec2(x: endX, y: endY), color: color) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Graphics/Shape/Polygon.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | public struct Polygon { 4 | public let paths: [[Vec2]] 5 | public let color: Color 6 | public let isFilled: Bool 7 | 8 | public init( 9 | paths: [[Vec2]], 10 | color: Color = ShapeDefaults.color, 11 | isFilled: Bool = ShapeDefaults.isFilled 12 | ) { 13 | self.color = color 14 | self.isFilled = isFilled 15 | 16 | self.paths = paths.compactMap { 17 | if $0.count > 1 && $0.first! != $0.last { 18 | var closedPaths = $0 19 | closedPaths.append($0.first!) 20 | return closedPaths 21 | } else if $0.count > 1 { 22 | return $0 23 | } else { 24 | return nil 25 | } 26 | } 27 | } 28 | 29 | public init( 30 | points: [Vec2], 31 | color: Color = ShapeDefaults.color, 32 | isFilled: Bool = ShapeDefaults.isFilled 33 | ) { 34 | self.init(paths: [points], color: color, isFilled: isFilled) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Graphics/Shape/Rectangle.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | public struct Rectangle { 4 | public var topLeft: Vec2 5 | public var size: Vec2 6 | public var color: Color 7 | public var isFilled: Bool 8 | public var rotation: T? 9 | public var cornerRadius: T? 10 | 11 | public var topCenter: Vec2 { return topLeft + Vec2(x: size.x / 2, y: 0) } 12 | public var topRight: Vec2 { return topLeft + Vec2(x: size.x, y: 0) } 13 | 14 | public var bottomLeft: Vec2 { return topLeft + Vec2(x: 0, y: size.y) } 15 | public var bottomCenter: Vec2 { return topLeft + Vec2(x: size.x / 2, y: size.y) } 16 | public var bottomRight: Vec2 { return topLeft + size } 17 | 18 | public var centerLeft: Vec2 { return topLeft + Vec2(x: 0, y: size.y / 2) } 19 | public var center: Vec2 { return topLeft + (size / 2) } 20 | public var centerRight: Vec2 { return topLeft + Vec2(x: size.x, y: size.y / 2) } 21 | 22 | public var width: T { return size.x } 23 | public var height: T { return size.y } 24 | 25 | public init( 26 | topLeft: Vec2 = Vec2(x: 0, y: 0), 27 | size: Vec2 = Vec2(x: 1, y: 1), 28 | rotation: T? = nil, 29 | cornerRadius: T? = nil, 30 | color: Color = ShapeDefaults.color, 31 | isFilled: Bool = ShapeDefaults.isFilled 32 | ) { 33 | self.topLeft = topLeft 34 | self.size = size 35 | self.color = color 36 | self.isFilled = isFilled 37 | self.rotation = rotation 38 | self.cornerRadius = cornerRadius 39 | } 40 | 41 | public init( 42 | fromX x: T, 43 | y: T, 44 | width: T, 45 | height: T, 46 | rotation: T? = nil, 47 | cornerRadius: T? = nil, 48 | color: Color = ShapeDefaults.color, 49 | isFilled: Bool = ShapeDefaults.isFilled 50 | ) { 51 | self.init(topLeft: Vec2(x: x, y: y), size: Vec2(x: width, y: height), rotation: rotation, cornerRadius: cornerRadius, color: color, isFilled: isFilled) 52 | } 53 | } 54 | 55 | extension Rectangle: Sequence where T: Comparable { 56 | public typealias Element = Vec2 57 | 58 | public func makeIterator() -> Iterator { 59 | return Iterator(from: topLeft, to: bottomRight) 60 | } 61 | 62 | public struct Iterator: IteratorProtocol { 63 | private let start: Vec2 64 | private let end: Vec2 65 | private var current: Vec2? 66 | 67 | init(from start: Vec2, to end: Vec2) { 68 | self.start = start 69 | self.end = end 70 | current = start 71 | } 72 | 73 | public mutating func next() -> Vec2? { 74 | guard let pos = current else { return nil } 75 | 76 | if pos.x < end.x { 77 | current = Vec2(x: pos.x + 1, y: pos.y) 78 | } else if pos.y < end.y { 79 | current = Vec2(x: start.x, y: pos.y + 1) 80 | } else { 81 | current = nil 82 | } 83 | 84 | return pos 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/Graphics/Shape/ShapeDefaults.swift: -------------------------------------------------------------------------------- 1 | public struct ShapeDefaults { 2 | public static let color: Color = .white 3 | public static let isFilled = true 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Graphics/Shape/Text.swift: -------------------------------------------------------------------------------- 1 | import Utils 2 | 3 | public struct Text { 4 | public let value: String 5 | public let fontSize: Double 6 | public let position: Vec2 7 | public let color: Color 8 | 9 | public init( 10 | _ value: String, 11 | withSize fontSize: Double = 12, 12 | at position: Vec2 = Vec2(x: 0, y: 12), 13 | color: Color = .white 14 | ) { 15 | self.value = value 16 | self.fontSize = fontSize 17 | self.position = position 18 | self.color = color 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/PlatformGraphics/PlatformGraphics.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CairoGraphics) 2 | 3 | @_exported import CairoGraphics 4 | public typealias PlatformGraphicsContext = CairoContext 5 | 6 | #elseif canImport(CoreGraphicsGraphics) 7 | 8 | @_exported import CoreGraphicsGraphics 9 | public typealias PlatformGraphicsContext = CoreGraphicsContext 10 | 11 | #else 12 | #error("No platform graphics available!") 13 | #endif 14 | 15 | public typealias PlatformImage = PlatformGraphicsContext.Image 16 | -------------------------------------------------------------------------------- /Tests/GraphicsTests/ColorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Graphics 3 | 4 | final class ColorTests: XCTestCase { 5 | func testColor() throws { 6 | let yellowRGB = Color(rgb: 0xFFFF00) 7 | XCTAssertEqual(yellowRGB.red, 0xFF) 8 | XCTAssertEqual(yellowRGB.green, 0xFF) 9 | XCTAssertEqual(yellowRGB.blue, 0) 10 | XCTAssertEqual(yellowRGB.alpha, 0xFF) 11 | XCTAssertEqual(yellowRGB.rgb, 0xFFFF00) 12 | 13 | let transparentCyanRGBA = Color(rgba: 0x00FFFFCC) 14 | XCTAssertEqual(transparentCyanRGBA.red, 0) 15 | XCTAssertEqual(transparentCyanRGBA.green, 0xFF) 16 | XCTAssertEqual(transparentCyanRGBA.blue, 0xFF) 17 | XCTAssertEqual(transparentCyanRGBA.alpha, 0xCC) 18 | XCTAssertEqual(transparentCyanRGBA.rgba, 0x00FFFFCC) 19 | XCTAssertEqual(transparentCyanRGBA.argb, 0xCC00FFFF) 20 | XCTAssertEqual(transparentCyanRGBA.rgb, 0x00FFFF) 21 | 22 | let magentaARGB = Color(argb: 0xAAFF00FF) 23 | XCTAssertEqual(magentaARGB.red, 0xFF) 24 | XCTAssertEqual(magentaARGB.green, 0x00) 25 | XCTAssertEqual(magentaARGB.blue, 0xFF) 26 | XCTAssertEqual(magentaARGB.alpha, 0xAA) 27 | XCTAssertEqual(magentaARGB.argb, 0xAAFF00FF) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/GraphicsTests/ShapeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Utils 3 | @testable import Graphics 4 | 5 | final class ShapeTests: XCTestCase { 6 | func testEmptyPointsList() { 7 | let polygon = Polygon(points: [Vec2]()) 8 | XCTAssertEqual(polygon.paths.count, 0) 9 | } 10 | 11 | func testEmptyPathsList() { 12 | let polygon = Polygon(paths: [[Vec2]]()) 13 | XCTAssertEqual(polygon.paths.count, 0) 14 | } 15 | 16 | func testStrokedPolygon() { 17 | let points = [Vec2(x: 1, y: 2), Vec2(x: 2, y: 2), Vec2(x: 1, y: 1)] 18 | let polygon = Polygon(points: points, isFilled: false) 19 | 20 | XCTAssertEqual(polygon.paths.count, 1, "Should be one path in polygon") 21 | let path = polygon.paths.first! 22 | 23 | XCTAssertEqual(points.count + 1, path.count, "Polygon should have extra points") 24 | XCTAssertEqual(path.first!, path.last!, "Polygon should be closed.") 25 | } 26 | 27 | func testFilledPolygon() { 28 | let points = [Vec2(x: 1, y: 2), Vec2(x: 2, y: 2), Vec2(x: 1, y: 1)] 29 | let polygon = Polygon(points: points, isFilled: true) 30 | 31 | XCTAssertEqual(polygon.paths.count, 1, "Should be one path in polygon") 32 | let path = polygon.paths.first! 33 | 34 | XCTAssertEqual(points.count + 1, path.count, "Polygon should have extra points") 35 | XCTAssertEqual(path.first!, path.last!, "Polygon should be closed.") 36 | } 37 | 38 | func testFilledPolygonAlreadyClosed() { 39 | let points = [Vec2(x: 1, y: 2), Vec2(x: 2, y: 2), Vec2(x: 1, y: 1), Vec2(x: 1, y:2)] 40 | let polygon = Polygon(points: points) 41 | 42 | XCTAssertEqual(polygon.paths.count, 1, "Should be one path in polygon") 43 | let path = polygon.paths.first! 44 | 45 | XCTAssertEqual(points, path, "Polygon should not have extra points") 46 | } 47 | 48 | func testMultiPathPolygon() { 49 | let closedpoints = [Vec2(x: 10, y: 20), Vec2(x: 20, y: 20), Vec2(x: 10, y: 10), Vec2(x: 10, y:20)] 50 | let openpoints = [Vec2(x: 1, y: 2), Vec2(x: 2, y: 2), Vec2(x: 1, y: 1)] 51 | let polygon = Polygon(paths: [closedpoints, openpoints]) 52 | 53 | XCTAssertEqual(polygon.paths.count, 2, "Should be one path in polygon") 54 | 55 | XCTAssertEqual(polygon.paths.first!.count, closedpoints.count, "Closed path shouldn't have more points") 56 | XCTAssertEqual(polygon.paths.last!.count, openpoints.count + 1, "Open path should have an additional point") 57 | } 58 | 59 | func testRectangleCorners() { 60 | let rect = Rectangle(topLeft: Vec2(x: 2, y: 1), size: Vec2(x: 1, y: 3)) 61 | 62 | assertApproxEqual(rect.topLeft, Vec2(x: 2, y: 1)) 63 | assertApproxEqual(rect.topCenter, Vec2(x: 2.5, y: 1)) 64 | assertApproxEqual(rect.topRight, Vec2(x: 3, y: 1)) 65 | 66 | assertApproxEqual(rect.centerLeft, Vec2(x: 2, y: 2.5)) 67 | assertApproxEqual(rect.center, Vec2(x: 2.5, y: 2.5)) 68 | assertApproxEqual(rect.centerRight, Vec2(x: 3, y: 2.5)) 69 | 70 | assertApproxEqual(rect.bottomLeft, Vec2(x: 2, y: 4)) 71 | assertApproxEqual(rect.bottomCenter, Vec2(x: 2.5, y: 4)) 72 | assertApproxEqual(rect.bottomRight, Vec2(x: 3, y: 4)) 73 | } 74 | 75 | private func assertApproxEqual(_ vec1: Vec2, _ vec2: Vec2, accuracy: Double = 0.0001, line: UInt = #line) { 76 | XCTAssertEqual(vec1.x, vec2.x, accuracy: accuracy, line: line) 77 | XCTAssertEqual(vec1.y, vec2.y, accuracy: accuracy, line: line) 78 | } 79 | } 80 | --------------------------------------------------------------------------------