├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .spi.yml ├── Bitmap.podspec ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── Bitmap │ ├── Bitmap+CIImage.swift │ ├── Bitmap+Drawing.swift │ ├── Bitmap+Error.swift │ ├── Bitmap+ImportExport.swift │ ├── Bitmap+Pixel.swift │ ├── Bitmap+Platform.swift │ ├── Bitmap+RGBA.swift │ ├── Bitmap+RGBAData.swift │ ├── Bitmap+RepresentationType.swift │ ├── Bitmap.swift │ ├── PrivacyInfo.xcprivacy │ ├── drawing │ ├── Bitmap+AdjustColorControls.swift │ ├── Bitmap+AdjustingSize.swift │ ├── Bitmap+Blending.swift │ ├── Bitmap+Blur.swift │ ├── Bitmap+Border.swift │ ├── Bitmap+Clip.swift │ ├── Bitmap+ColorInvert.swift │ ├── Bitmap+ColorMapping.swift │ ├── Bitmap+Crop.swift │ ├── Bitmap+Dither.swift │ ├── Bitmap+Erase.swift │ ├── Bitmap+Extract.swift │ ├── Bitmap+FillStroke.swift │ ├── Bitmap+Flip.swift │ ├── Bitmap+Gamma.swift │ ├── Bitmap+Generator.swift │ ├── Bitmap+Grayscale.swift │ ├── Bitmap+Image.swift │ ├── Bitmap+InnerShadow.swift │ ├── Bitmap+Inset.swift │ ├── Bitmap+Masking.swift │ ├── Bitmap+Padding.swift │ ├── Bitmap+Quantize.swift │ ├── Bitmap+Rotation.swift │ ├── Bitmap+Scale.swift │ ├── Bitmap+Scroll.swift │ ├── Bitmap+Shadow.swift │ ├── Bitmap+Text.swift │ ├── Bitmap+Tint.swift │ └── Bitmap+Transparency.swift │ └── utils │ ├── Angle2D.swift │ ├── CGColor+extensions.swift │ ├── CGColor+standard.swift │ ├── CGContext+extensions.swift │ ├── CGContext+innerShadow.swift │ ├── CGImage+extensions.swift │ ├── CGRect+extensions.swift │ └── Clamp.swift └── Tests └── BitmapTests ├── BitmapTests.swift ├── resources ├── 16-squares.png ├── apple-logo-dark.png ├── apple-logo-white.png ├── cat-icon.png ├── cmyk.jpg ├── dog.jpeg ├── food.jpg ├── gps-image.jpg ├── p3test.ppm ├── p6test.ppm ├── qrcode.png ├── sf-bb.jpeg ├── sf-ggb.jpeg └── viking.jpg └── utils ├── MarkdownGenerator.swift └── TestUtils.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: swift build -v 21 | - name: Run tests 22 | run: swift test -v 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm 8 | .netrc -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Bitmap] 5 | -------------------------------------------------------------------------------- /Bitmap.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = 'Bitmap' 4 | s.version = '1.5.0' 5 | s.summary = 'A Swift-y convenience for loading, saving and manipulating bitmap images.' 6 | s.homepage = 'https://github.com/dagronf/Bitmap' 7 | s.license = { :type => 'MIT', :file => 'LICENSE' } 8 | s.author = { 'Darren Ford' => 'dford_au-reg@yahoo.com' } 9 | 10 | s.source = { :git => 'https://github.com/dagronf/Bitmap.git', :tag => s.version.to_s } 11 | s.dependency 'SwiftImageReadWrite', '~> 1.9.2' 12 | 13 | s.module_name = 'Bitmap' 14 | 15 | s.osx.deployment_target = '10.11' 16 | s.ios.deployment_target = '13.0' 17 | s.tvos.deployment_target = '13.0' 18 | s.watchos.deployment_target = '6.0' 19 | 20 | s.osx.framework = 'AppKit' 21 | s.ios.framework = 'UIKit' 22 | s.tvos.framework = 'UIKit' 23 | s.watchos.framework = 'UIKit' 24 | 25 | s.source_files = 'Sources/Bitmap/**/*.swift' 26 | s.resource_bundles = { 27 | 'Bitmap' => 'Sources/Bitmap/PrivacyInfo.xcprivacy' 28 | } 29 | 30 | s.swift_versions = ['5.4', '5.5', '5.6', '5.7', '5.8', '5.9', '5.10'] 31 | 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Darren Ford 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 | "object": { 3 | "pins": [ 4 | { 5 | "package": "SwiftImageReadWrite", 6 | "repositoryURL": "https://github.com/dagronf/SwiftImageReadWrite", 7 | "state": { 8 | "branch": null, 9 | "revision": "42ace2412279f18bc264bc306e96b51c36e12a33", 10 | "version": "1.9.2" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.4 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Bitmap", 7 | platforms: [.macOS(.v10_11), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)], 8 | products: [ 9 | .library( 10 | name: "Bitmap", 11 | targets: ["Bitmap"]), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/dagronf/SwiftImageReadWrite", from: "1.9.2") 15 | ], 16 | targets: [ 17 | .target( 18 | name: "Bitmap", 19 | dependencies: ["SwiftImageReadWrite"], 20 | resources: [ 21 | .copy("PrivacyInfo.xcprivacy"), 22 | ] 23 | ), 24 | .testTarget( 25 | name: "BitmapTests", 26 | dependencies: ["Bitmap"], 27 | resources: [ .process("resources") ] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bitmap 2 | 3 | ![tag](https://img.shields.io/github/v/tag/dagronf/Bitmap) 4 | ![Swift](https://img.shields.io/badge/Swift-5.5-orange.svg) 5 | [![License MIT](https://img.shields.io/badge/license-MIT-magenta.svg)](https://github.com/dagronf/Bitmap/blob/master/LICENSE) 6 | ![SPM](https://img.shields.io/badge/spm-compatible-maroon.svg) 7 | 8 | ![macOS](https://img.shields.io/badge/macOS-10.13+-darkblue) 9 | ![iOS](https://img.shields.io/badge/iOS-13+-crimson) 10 | ![tvOS](https://img.shields.io/badge/tvOS-13+-forestgreen) 11 | ![watchOS](https://img.shields.io/badge/watchOS-6+-indigo) 12 | ![macCatalyst](https://img.shields.io/badge/macCatalyst-2+-orangered) 13 | 14 | A Swift-y convenience for loading/saving and manipulating bitmap images. 15 | 16 | ## Why? 17 | 18 | I wanted a simple Swift interface for wrapping some of the common image manipulations 19 | that I come across in my day-to-day image needs, the primary being easily being able to draw directly into a 20 | bitmap image. 21 | 22 | * Supports all Apple platforms (with some limitations on watchOS) 23 | * supports loading non-RGBA format images (eg. CMYK) by converting the image to RGBA during load. 24 | * objects are easily saved via its `representation` property. 25 | * supports both mutable and immutable methods (functional-style) for manipulating bitmaps. 26 | * bitmap manipulation functions 27 | * `Sendable` support to push bitmap information between tasks. 28 | 29 | ## Definitions 30 | 31 | The `Bitmap` object represents an RGBA image. The image data is stored internally in a simple 1-D array of bytes 32 | representing the R,G,B,A sequence of pixels. 33 | 34 | The bitmap object holds and manages a `CGContext` representation of the bitmap to allow CG- operations directly 35 | on the bitmap itself. 36 | 37 | * `Bitmap`s coordinate system starts at (0, 0) in the bottom left. 38 | * All operations/manipulations/coordinates occur from the bottom left. 39 | 40 | ## Creating a new image 41 | 42 | Nice and simple :-) 43 | 44 | ```swift 45 | var bitmap = try Bitmap(width: 640, height: 480) 46 | ``` 47 | 48 | ## Loading an image into a Bitmap 49 | 50 | Supports any file formats that `NSImage`, `UIImage` and `CGImageSource` support when loading. 51 | 52 | ```swift 53 | // From a file... 54 | var bitmap = try Bitmap(fileURL: ...) 55 | 56 | // From image data 57 | var bitmap = try Bitmap(imageData: ...) 58 | ``` 59 | 60 | ## Saving a bitmap 61 | 62 | Supports any file formats that `NSImage`, `UIImage` and `CGImageSource` support when saving. 63 | 64 | `Bitmap` provides methods for writing to some different formats, such as `png`. 65 | The `representation` property on the bitmap contains the methods for generating 66 | image data in different formats 67 | 68 | ```swift 69 | var bitmap = try Bitmap(fileURL: ...) 70 | ... 71 | let pngData = bitmap.representation?.png() 72 | ``` 73 | 74 | ## Drawing into a bitmap 75 | 76 | ### Drawing into the bitmap's context using CG methods 77 | 78 | ```swift 79 | var bitmap = try Bitmap(width: 640, height: 480) 80 | bitmap.draw { ctx in 81 | ctx.setFillColor(.black) 82 | ctx.fill([CGRect(x: 5, y: 5, width: 20, height: 20)]) 83 | } 84 | ``` 85 | 86 | ### Drawing an image into the bitmap 87 | 88 | ```swift 89 | var bitmap = try Bitmap(width: 640, height: 480) 90 | // Draw an image into a defined rectangle (with optional scaling types aspectFit, aspectFill, axes independent) 91 | bitmap.drawImage(image, in: CGRect(x: 50, y: 50, width: 100, height: 100)) 92 | // Draw a bitmap at a point 93 | bitmap.drawBitmap(bitmap, at: CGPoint(x: 300, y: 300)) 94 | ``` 95 | 96 | ## Getting/setting individual pixels 97 | 98 | You can directly access pixel information in the bitmap using subscripts or the get/setPixel methods on `Bitmap` 99 | 100 | ```swift 101 | var bitmap = Bitmap(...) 102 | 103 | // Retrieve the color of the pixel at x = 4, y = 3. 104 | let rgbaPixel = bitmap[4, 3] // or bitmap.getPixel(x: 4, y: 3) 105 | 106 | // Set the color of the pixel at x = 4, y = 3. Only available when `bitmap` is mutable 107 | bitmap[4, 3] = Bitmap.RGBA(r: 255, g: 0, b: 0, a: 255) // or bitmap.setPixel(x: 4, y: 3, ...) 108 | ``` 109 | 110 | ## Bitmap operations 111 | 112 | `Bitmap` provides a number of built-in operations (such as rotate, tint) that operate on a `Bitmap` instance. 113 | 114 | ### Mutable 115 | 116 | Mutable functions operate on a Bitmap variable. Each method modifies the original bitmap instance. 117 | 118 | ```swift 119 | // Load an image, rotate it and convert it to grayscale 120 | var bitmap = try Bitmap(fileURL: ...) 121 | try bitmap.rotate(by: 1.4) 122 | try bitmap.grayscale() 123 | ``` 124 | 125 | ### Immutable 126 | 127 | Immutable functions work on a copy of the bitmap and return the copy. The original bitmap (which can be a `let` variable 128 | remains untouched. 129 | 130 | Immutable function variations are named with 'ing' in the title, such as `tinting()` or `scaling()`. 131 | 132 | ```swift 133 | // Load an image, rotate it and convert it to grayscale 134 | let bitmap = try Bitmap(fileURL: ...) 135 | let rotated = try bitmap.rotating(by: 1.4) 136 | let grayscale = try rotated.grayscaling() 137 | ``` 138 | 139 | This allows functional-style chaining on a bitmap. 140 | 141 | ```swift 142 | // Functional style 143 | let bitmap = try Bitmap(fileURL: ...) 144 | .rotating(by: 1.4) 145 | .grayscaling() 146 | ``` 147 | 148 | ### Available bitmap manipulation functions 149 | 150 | * Resizing/scaling 151 | * Padding/inset 152 | * Clipping/masking 153 | * Rotation 154 | * Scrolling 155 | * Blurring 156 | * Transparency removal 157 | * Punching holes 158 | * Color manipulation (eg. grayscale, tinting, gamma adjustment, saturation etc) 159 | * Flipping 160 | * Drawing (eg. lines, shapes, paths, fill/stroke) 161 | * Drawing text 162 | * Drawing borders 163 | 164 | … and more! 165 | 166 | ## Sendable support 167 | 168 | You will not be able to directly send a `Bitmap` object between Tasks. 169 | This is due to `Bitmap` internally caching a `CGContext` instance in order to increase performance and 170 | reduce the memory footprint. 171 | 172 | Internally, `Bitmap` keeps pixel information inside a `Bitmap.RGBAData` object which conforms to `Sendable`. 173 | This object can be passed safely between tasks and are easily reconstituted into a new `Bitmap` object. 174 | 175 | ```swift 176 | var myBitmap = try Bitmap(...) 177 | let bitmapData = myBitmap.bitmapData 178 | 179 | // Pass `bitmapData` to another Task 180 | 181 | var myBitmap = try Bitmap(bitmapData) 182 | ``` 183 | 184 | -------- 185 | 186 | # License 187 | 188 | ``` 189 | MIT License 190 | 191 | Copyright (c) 2025 Darren Ford 192 | 193 | Permission is hereby granted, free of charge, to any person obtaining a copy 194 | of this software and associated documentation files (the "Software"), to deal 195 | in the Software without restriction, including without limitation the rights 196 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 197 | copies of the Software, and to permit persons to whom the Software is 198 | furnished to do so, subject to the following conditions: 199 | 200 | The above copyright notice and this permission notice shall be included in all 201 | copies or substantial portions of the Software. 202 | 203 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 204 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 205 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 206 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 207 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 208 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 209 | SOFTWARE. 210 | ``` 211 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+CIImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | // CoreImage additions 21 | 22 | #if canImport(CoreImage) 23 | 24 | import Foundation 25 | import CoreImage 26 | 27 | public extension Bitmap { 28 | /// Create a bitmap from the content of a CIImage 29 | /// - Parameters: 30 | /// - image: The image 31 | /// - context: The CIImage's context, or nil for default 32 | /// - size: The image size 33 | convenience init(_ image: CIImage, context: CIContext? = nil, size: CGSize? = nil) throws { 34 | let context = context ?? CIContext(options: nil) 35 | let rect: CGRect 36 | if let size = size { 37 | rect = CGRect(origin: .zero, size: size) 38 | } 39 | else { 40 | rect = image.extent 41 | // Arbitrary 'large' values here. 42 | if rect.width > 20000 || rect.height > 20000 { 43 | throw BitmapError.invalidContext 44 | } 45 | } 46 | guard let cgImage = context.createCGImage(image, from: rect) else { 47 | throw BitmapError.cannotCreateCGImage 48 | } 49 | try self.init(cgImage) 50 | } 51 | 52 | /// Returns a CIImage representation of the bitmap 53 | @inlinable var ciImage: CIImage? { 54 | guard let cgImage = self.cgImage else { return nil } 55 | return CIImage(cgImage: cgImage) 56 | } 57 | } 58 | 59 | #endif 60 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+Drawing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // MARK: - Drawing 24 | 25 | extension Bitmap { 26 | /// Perform 'block' within a saved GState on a bitmap 27 | /// - Parameter block: The block to perform with a new graphics state 28 | @inlinable @inline(__always) 29 | internal func savingGState(_ block: (CGContext) -> Void) { 30 | self.bitmapContext.savingGState(block) 31 | } 32 | 33 | /// Perform drawing actions within a saved GState on a bitmap 34 | /// - Parameter block: The block to perform within a new graphics state using the bitmap's context 35 | @inlinable @inline(__always) 36 | public func draw(_ block: (CGContext) -> Void) { 37 | self.savingGState(block) 38 | } 39 | 40 | /// Performs drawing operations on a copy of this bitmap 41 | /// - Parameter block: The block to perform within a new graphics state using the bitmap's context 42 | /// - Returns: A new bitmap 43 | func drawing(_ block: (CGContext) -> Void) throws -> Bitmap { 44 | try self.makingCopy { copy in 45 | copy.draw(block) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+Error.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import CoreGraphics 21 | import Foundation 22 | 23 | extension Bitmap { 24 | /// Bitmap errors 25 | public enum BitmapError: Error { 26 | case outOfBounds 27 | case invalidContext 28 | case cannotCreateCGImage 29 | case paddingOrInsetMustBePositiveValue 30 | case rgbaDataMismatchSize 31 | case unableToMask 32 | case cannotFilter 33 | case cannotConvertColorSpace 34 | case cannotConvert 35 | case notImplemented 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+ImportExport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import CoreGraphics 21 | import Foundation 22 | 23 | import SwiftImageReadWrite 24 | 25 | // MARK: - Import 26 | 27 | public extension Bitmap { 28 | /// Load an image from an image format (eg. png data) 29 | convenience init(imageData: Data) throws { 30 | let cgImage = try CGImage.load(data: imageData) 31 | try self.init(cgImage) 32 | } 33 | 34 | /// Load a bitmap from a file URL 35 | convenience init(fileURL: URL) throws { 36 | assert(fileURL.isFileURL) 37 | let cgImage = try CGImage.load(fileURL: fileURL) 38 | try self.init(cgImage) 39 | } 40 | } 41 | 42 | // MARK: - Export 43 | 44 | public extension Bitmap { 45 | /// Generate a representation of the bitmap as an image format 46 | /// 47 | /// Returns `nil` if the bitmap cannot be represented as an image 48 | var representation: Representation? { Representation(self) } 49 | 50 | /// Representation generator 51 | struct Representation { 52 | private let reps: CGImage.ImageRepresentation 53 | private let bitmap: Bitmap 54 | fileprivate init?(_ bitmap: Bitmap) { 55 | guard let reps = bitmap.cgImage?.representation else { return nil } 56 | self.reps = reps 57 | self.bitmap = bitmap 58 | } 59 | 60 | /// Create a png representation of the bitmap 61 | /// - Parameters: 62 | /// - dpi: The image's dpi 63 | /// - Returns: image data 64 | public func png(dpi: CGFloat) throws -> Data { 65 | try self.reps.png(dpi: dpi) 66 | } 67 | 68 | /// Create a png representation of the bitmap 69 | /// - Parameters: 70 | /// - scale: The image's scale value 71 | /// - Returns: image data 72 | public func png(scale: CGFloat = 1) throws -> Data { 73 | try self.reps.png(scale: scale) 74 | } 75 | 76 | /// Create a jpeg representation of the image 77 | /// - Parameters: 78 | /// - dpi: The image's dpi 79 | /// - compression: The compression level to apply (clamped to 0 ... 1) 80 | /// - Returns: image data 81 | public func jpeg(dpi: CGFloat, compression: CGFloat? = nil) throws -> Data { 82 | try self.reps.jpeg(dpi: dpi, compression: compression) 83 | } 84 | 85 | /// Create a jpeg representation of the image 86 | /// - Parameters: 87 | /// - scale: The image's scale value 88 | /// - compression: The compression level to apply (clamped to 0 ... 1) 89 | /// - Returns: image data 90 | public func jpeg(scale: CGFloat = 1, compression: CGFloat? = nil) throws -> Data { 91 | try self.reps.jpeg(scale: scale, compression: compression) 92 | } 93 | 94 | /// Create a tiff representation of the image 95 | /// - Parameters: 96 | /// - dpi: The image's dpi 97 | /// - compression: The compression level to apply (clamped to 0 ... 1) 98 | /// - Returns: image data 99 | public func tiff(dpi: CGFloat, compression: CGFloat? = nil) throws -> Data { 100 | try self.reps.tiff(scale: dpi / 72.0, compression: compression) 101 | } 102 | 103 | /// Create a tiff representation of the image 104 | /// - Parameters: 105 | /// - scale: The image's scale value (for retina-type images eg. @2x == 2) 106 | /// - compression: The compression level to apply (clamped to 0 ... 1) 107 | /// - Returns: image data 108 | public func tiff(scale: CGFloat = 1, compression: CGFloat? = nil) throws -> Data { 109 | try self.reps.tiff(scale: scale, compression: compression) 110 | } 111 | 112 | /// Create a heic representation of the image 113 | /// - Parameters: 114 | /// - scale: The image's scale value (for retina-type images eg. @2x == 2) 115 | /// - compression: The compression level to apply (clamped to 0 ... 1) 116 | /// - Returns: image data 117 | /// 118 | /// Not supported on macOS < 10.13 (throws an error) 119 | public func heic(dpi: CGFloat, compression: CGFloat? = nil) throws -> Data { 120 | try self.reps.heic(scale: dpi / 72.0, compression: compression) 121 | } 122 | 123 | /// Create a heic representation of the image 124 | /// - Parameters: 125 | /// - scale: The image's scale value (for retina-type images eg. @2x == 2) 126 | /// - compression: The compression level to apply (clamped to 0 ... 1) 127 | /// - Returns: image data 128 | /// 129 | /// Not supported on macOS < 10.13 (throws an error) 130 | public func heic(scale: CGFloat = 1, compression: CGFloat? = nil) throws -> Data { 131 | try self.reps.heic(scale: scale, compression: compression) 132 | } 133 | 134 | /// Return a P3 representation of this bitmap 135 | /// - Returns: P3 data 136 | public func p3() throws -> Data { try __p3(bitmap) } 137 | 138 | /// Return a P6 representation of this bitmap 139 | /// - Returns: P6 data 140 | public func p6() throws -> Data { try __p6(bitmap) } 141 | } 142 | } 143 | 144 | // MARK: - PPM support routines 145 | 146 | /// Returns a P3 PPM encoding for the bitmap 147 | private func __p3(_ bitmap: Bitmap) throws -> Data { 148 | let header = "P3\r\n\(bitmap.width) \(bitmap.height)\r\n255\r\n" 149 | let data = bitmap.rawPixels.flatMap { "\($0.r) \($0.g) \($0.b)\r\n" } 150 | guard let encoded = (header + data).data(using: .utf8) else { throw Bitmap.BitmapError.cannotConvert } 151 | return encoded 152 | } 153 | 154 | /// Returns a P6 PPM encoding for the bitmap 155 | private func __p6(_ bitmap: Bitmap) throws -> Data { 156 | var data = Data() 157 | let header = "P6\n\(bitmap.width) \(bitmap.height)\n255\n" 158 | data.append(header.data(using: .ascii)!) 159 | let rawData = bitmap.rawPixels.flatMap { [$0.r, $0.g, $0.b ] } 160 | data.append(contentsOf: rawData) 161 | return data 162 | } 163 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+Pixel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | 22 | public extension Bitmap { 23 | /// A bitmap coordinate 24 | /// 25 | /// Note that the coordinate itself does not define its origin. 26 | struct Coordinate: Equatable, Comparable, Hashable { 27 | /// x coordinate 28 | public let x: Int 29 | /// y coordinate 30 | public let y: Int 31 | /// Create 32 | public init(x: Int, y: Int) { 33 | self.x = x 34 | self.y = y 35 | } 36 | /// Sort from 0,0 to width, height to get consistent ordering 37 | public static func < (lhs: Bitmap.Coordinate, rhs: Bitmap.Coordinate) -> Bool { 38 | if lhs.y < rhs.y { return true } 39 | if lhs.y > rhs.y { return false } 40 | return lhs.x < rhs.x 41 | } 42 | } 43 | 44 | /// A color with a coordinate. 45 | /// 46 | /// Note that the coordinate itself does not define its origin. 47 | struct Pixel: Equatable { 48 | /// The coordinate within the associated bitmap 49 | public let point: Coordinate 50 | /// The color of the pixel 51 | public let color: RGBA 52 | 53 | /// x coordinate 54 | @inlinable public var x: Int { self.point.x } 55 | /// y coordinate 56 | @inlinable public var y: Int { self.point.y } 57 | 58 | /// Create a pixel 59 | /// - Parameters: 60 | /// - x: The x-coordinate 61 | /// - y: The y-coordinate 62 | /// - color: The pixel's RGBA color 63 | public init(x: Int, y: Int, color: RGBA) { 64 | self.point = Coordinate(x: x, y: y) 65 | self.color = color 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+Platform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | #if os(macOS) 24 | import AppKit.NSImage 25 | #else 26 | import UIKit 27 | #endif 28 | 29 | #if os(macOS) 30 | import AppKit 31 | typealias PlatformImage = NSImage 32 | extension NSImage { 33 | public var cgImage: CGImage? { self.cgImage(forProposedRect: nil, context: nil, hints: nil) } 34 | } 35 | #else 36 | import UIKit 37 | typealias PlatformImage = UIImage 38 | #endif 39 | 40 | #if os(macOS) 41 | public extension Bitmap { 42 | /// Create a bitmap using the specified NSImage 43 | convenience init(_ image: NSImage) throws { 44 | guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 45 | throw BitmapError.cannotCreateCGImage 46 | } 47 | try self.init(cgImage) 48 | } 49 | 50 | /// An NSImage representation of the bitmap 51 | var image: NSImage? { 52 | guard let cgImage = self.cgImage else { return nil } 53 | return NSImage(cgImage: cgImage, size: .zero) 54 | } 55 | } 56 | #else 57 | public extension Bitmap { 58 | /// Create a bitmap using the specified UIImage 59 | convenience init(_ image: UIImage) throws { 60 | guard let cgImage = image.cgImage else { throw BitmapError.cannotCreateCGImage } 61 | try self.init(cgImage) 62 | } 63 | 64 | /// An UIImage representation of the bitmap 65 | var image: UIImage? { 66 | guard let cgImage = self.cgImage else { return nil } 67 | return UIImage(cgImage: cgImage) 68 | } 69 | } 70 | #endif 71 | 72 | #if !os(macOS) 73 | public struct NSEdgeInsets { 74 | public let top: CGFloat 75 | public let left: CGFloat 76 | public let bottom: CGFloat 77 | public let right: CGFloat 78 | public init(top: CGFloat, left: CGFloat, bottom: CGFloat, right: CGFloat) { 79 | self.top = top 80 | self.left = left 81 | self.bottom = bottom 82 | self.right = right 83 | } 84 | } 85 | #endif 86 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+RGBA.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | 22 | extension Bitmap { 23 | /// An RGBA pixel representation (32-bit) 24 | public struct RGBA: Equatable, Hashable, Sendable { 25 | /// Red component (0 -> 255) 26 | public let r: UInt8 27 | /// Green component (0 -> 255) 28 | public let g: UInt8 29 | /// Blue component (0 -> 255) 30 | public let b: UInt8 31 | /// Alpha component (0 -> 255) 32 | public let a: UInt8 33 | 34 | /// Fractional red component (0.0 -> 1.0) 35 | @inlinable public var rf: Double { Double(r) / 255.0 } 36 | /// Fractional green component (0.0 -> 1.0) 37 | @inlinable public var gf: Double { Double(g) / 255.0 } 38 | /// Fractional blue component (0.0 -> 1.0) 39 | @inlinable public var bf: Double { Double(b) / 255.0 } 40 | /// Fractional alpha component (0.0 -> 1.0) 41 | @inlinable public var af: Double { Double(a) / 255.0 } 42 | 43 | /// Create an RGBA pixel 44 | public init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 255) { 45 | self.r = r 46 | self.g = g 47 | self.b = b 48 | self.a = a 49 | } 50 | 51 | /// Create an RGBA pixel using fractional components 0.0 -> 1.0 52 | /// 53 | /// Clamps values outside the 0.0 ... 1.0 range 54 | @inlinable public init(rf: Double, gf: Double, bf: Double, af: Double) { 55 | self.init( 56 | r: UInt8(255.0 * min(1, max(0, rf))), 57 | g: UInt8(255.0 * min(1, max(0, gf))), 58 | b: UInt8(255.0 * min(1, max(0, bf))), 59 | a: UInt8(255.0 * min(1, max(0, af))) 60 | ) 61 | } 62 | } 63 | } 64 | 65 | public extension Bitmap.RGBA { 66 | /// Clear color 67 | static let clear = Bitmap.RGBA(r: 0, g: 0, b: 0, a: 0) 68 | /// Black color 69 | static let black = Bitmap.RGBA(r: 0, g: 0, b: 0, a: 255) 70 | /// White color 71 | static let white = Bitmap.RGBA(r: 255, g: 255, b: 255, a: 255) 72 | /// Red color 73 | static let red = Bitmap.RGBA(r: 255, g: 0, b: 0, a: 255) 74 | /// Green color 75 | static let green = Bitmap.RGBA(r: 0, g: 255, b: 0, a: 255) 76 | /// Blue color 77 | static let blue = Bitmap.RGBA(r: 0, g: 0, b: 255, a: 255) 78 | /// Yellow color 79 | static let yellow = Bitmap.RGBA(r: 255, g: 255, b: 0, a: 255) 80 | /// Magenta color 81 | static let magenta = Bitmap.RGBA(r: 255, g: 0, b: 255, a: 255) 82 | /// Cyan color 83 | static let cyan = Bitmap.RGBA(r: 0, g: 255, b: 255, a: 255) 84 | } 85 | 86 | public extension Bitmap.RGBA { 87 | /// Create an RGBA struct from an array of four (4) bytes in the form [R, G, B, A] 88 | init(rgbaByteComponents comps: [UInt8]) { 89 | assert(comps.count == 4) 90 | self = Self.init(r: comps[0], g: comps[1], b: comps[2], a: comps[3]) 91 | } 92 | 93 | /// Create an RGBA struct from an array of four (4) bytes in the form [R, G, B, A] 94 | @inlinable init(slice: ArraySlice) { 95 | assert(slice.count == 4) 96 | let i = slice.startIndex 97 | self = Self.init(r: slice[i], g: slice[i + 1], b: slice[i + 2], a: slice[i + 3]) 98 | } 99 | } 100 | 101 | public extension Bitmap.RGBA { 102 | /// Create an array of RGBA colors from raw bytes. Array count must be a multiple of 4 103 | static func from(rgbaArray: [UInt8]) -> [Bitmap.RGBA] { 104 | assert(MemoryLayout.size == 4) 105 | assert(rgbaArray.count % 4 == 0) 106 | return stride(from: rgbaArray.startIndex, to: rgbaArray.endIndex, by: 4).map { index in 107 | Bitmap.RGBA(r: rgbaArray[index], g: rgbaArray[index + 1], b: rgbaArray[index + 2], a: rgbaArray[index + 3]) 108 | } 109 | } 110 | 111 | /// Create an array of RGBA colors from raw data. Data count must be a multiple of 4 112 | static func from(data: Data) -> [Bitmap.RGBA] { 113 | assert(MemoryLayout.size == 4) 114 | assert(data.count % 4 == 0) 115 | return stride(from: data.startIndex, to: data.endIndex, by: 4).map { index in 116 | Bitmap.RGBA(r: data[index], g: data[index + 1], b: data[index + 2], a: data[index + 3]) 117 | } 118 | } 119 | 120 | /// Create an array of RGBA colors from raw bytes. Count must be a multiple of 4 121 | static func from(slice: ArraySlice) -> [Bitmap.RGBA] { 122 | assert(MemoryLayout.size == 4) 123 | assert(slice.count % 4 == 0) 124 | return stride(from: slice.startIndex, to: slice.endIndex, by: 4).map { index in 125 | Bitmap.RGBA(r: slice[index], g: slice[index + 1], b: slice[index + 2], a: slice[index + 3]) 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+RGBAData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | 22 | public extension Bitmap { 23 | /// Raw RGBA bitmap information 24 | struct RGBAData: Sendable, Equatable { 25 | /// The width of the image in pixels 26 | public let width: Int 27 | /// The height of the image in pixels 28 | public let height: Int 29 | /// The raw RGBA pixel data 30 | public internal(set) var rgbaBytes: [UInt8] 31 | 32 | /// The size of the image (in pixels) 33 | public var size: CGSize { CGSize(width: self.width, height: self.height) } 34 | /// The bounds rectangle for the image 35 | public var bounds: CGRect { CGRect(origin: .zero, size: self.size) } 36 | 37 | /// Create a bitmap data from raw RGBA bytes 38 | /// - Parameters: 39 | /// - width: The bitmap pixel width 40 | /// - height: The bitmap pixel height 41 | /// - rgbaBytes: The raw RGBA bytes array 42 | public init(width: Int, height: Int, rgbaBytes: [UInt8]) { 43 | assert(rgbaBytes.count == width * height * 4) 44 | self.width = width 45 | self.height = height 46 | self.rgbaBytes = rgbaBytes 47 | } 48 | 49 | /// Create an empty bitmap data for a specified width and height 50 | /// - Parameters: 51 | /// - width: The width 52 | /// - height: The height 53 | public init(width: Int, height: Int) { 54 | self.width = width 55 | self.height = height 56 | self.rgbaBytes = [UInt8](repeating: 0, count: Int(width * height * 4)) 57 | } 58 | 59 | /// Create an empty bitmap data for a specified size 60 | /// - Parameter size: The size 61 | @inlinable public init(size: CGSize) { 62 | self.init(width: Int(size.width), height: Int(size.height)) 63 | } 64 | 65 | /// Create bitmap data from an RGBA pixel array 66 | /// - Parameters: 67 | /// - width: The image width 68 | /// - height: The image height 69 | /// - pixelsData: The raw pixels array 70 | public init(width: Int, height: Int, pixelsData: [RGBA]) { 71 | assert(pixelsData.count == width * height) 72 | self.width = width 73 | self.height = height 74 | self.rgbaBytes = pixelsData.withUnsafeBytes { Array($0) } 75 | } 76 | } 77 | } 78 | 79 | public extension Bitmap.RGBAData { 80 | /// sets/gets the image pixel at the given row/column 81 | /// 82 | /// Coordinates start at the bottom left of the image 83 | subscript(x: Int, y: Int) -> Bitmap.RGBA { 84 | get { 85 | self.getPixel(x: x, y: y) 86 | } 87 | set { 88 | let offset = self.byteOffset(x: x, y: y) 89 | self.rgbaBytes[offset] = newValue.r 90 | self.rgbaBytes[offset + 1] = newValue.g 91 | self.rgbaBytes[offset + 2] = newValue.b 92 | self.rgbaBytes[offset + 3] = newValue.a 93 | } 94 | } 95 | 96 | /// Set the RGBA color of the pixel at (x, y) 97 | mutating func setPixel(x: Int, y: Int, color: Bitmap.RGBA) { 98 | assert(x < self.width && y < self.height) 99 | self[x, y] = color 100 | } 101 | 102 | /// Get the RGBA color of the pixel at (x, y) 103 | @inlinable func getPixel(x: Int, y: Int) -> Bitmap.RGBA { 104 | assert(MemoryLayout.size == 4) 105 | assert(y < self.height && x < self.width) 106 | return Bitmap.RGBA(slice: self.getPixelSlice(x: x, y: y)) 107 | } 108 | 109 | /// Returns a 4 byte array slice for the pixel ([R,G,B,A] bytes) 110 | /// 111 | /// Coordinates start at the bottom left of the image 112 | @inlinable func getPixelSlice(x: Int, y: Int) -> ArraySlice { 113 | let offset = self.byteOffset(x: x, y: y) 114 | return self.rgbaBytes[offset ..< offset + 4] 115 | } 116 | 117 | /// Returns the base byte offset for the pixel at (x, y) 118 | @inlinable @inline(__always) internal func byteOffset(x: Int, y: Int) -> Int { 119 | assert(y < self.height && x < self.width) 120 | return ((self.height - 1 - y) * (self.width * 4)) + (x * 4) 121 | } 122 | 123 | /// Returns the row of bytes at the specified row index 124 | /// 125 | /// NOTE: Given that this image is lower-left coordinates, row 0 is the BOTTOM row of the image 126 | @inlinable func rowBytes(at y: Int) -> ArraySlice { 127 | assert(y < self.height) 128 | let offset = self.byteOffset(x: 0, y: y) 129 | return self.rgbaBytes[offset ..< offset + (width * 4)] 130 | } 131 | 132 | /// Returns the row of pixels at the specified row index 133 | /// 134 | /// NOTE: Given that this image is lower-left coordinates, row 0 is the BOTTOM row of the image 135 | @inlinable func rowPixels(at y: Int) -> [Bitmap.RGBA] { 136 | self.rowBytes(at: y).withUnsafeBytes { ptr in 137 | Array(ptr.bindMemory(to: Bitmap.RGBA.self)) 138 | } 139 | } 140 | 141 | /// Returns the column of pixels at the specified column index 142 | /// 143 | /// NOTE: Given that this image is lower-left coordinates, row 0 is the BOTTOM row of the image 144 | @inlinable func columnPixels(at x: Int) -> [Bitmap.RGBA] { 145 | assert(x >= 0 && x < self.width) 146 | var result: [Bitmap.RGBA] = [] 147 | result.reserveCapacity(self.height) 148 | for y in (0 ..< self.height).reversed() { 149 | let offset = ((y * self.width) + x) * 4 150 | let slice = self.rgbaBytes[offset ..< offset + 4] 151 | let pixel = slice.withUnsafeBytes { ptr in 152 | ptr.bindMemory(to: Bitmap.RGBA.self) 153 | } 154 | result += pixel 155 | } 156 | return result 157 | } 158 | 159 | /// Returns the pixels in the image as an array of RGBA pixels 160 | /// 161 | /// (0) is the top left pixel, is the bottom right pixel 162 | @inlinable internal var rawPixels: [Bitmap.RGBA] { 163 | assert(MemoryLayout.size == 4) 164 | return self.rgbaBytes.withUnsafeBytes { ptr in 165 | Array(ptr.bindMemory(to: Bitmap.RGBA.self)) 166 | } 167 | } 168 | 169 | /// Erase the image 170 | mutating func eraseAll() { 171 | // The stored CGContext is (effectively) built with a pointer to the array of bytes 172 | // in order to reduce memory requirements. As a result, we should not just 173 | // nuke `rgbaBytes` with a new array - it may invalidate the CGContext 174 | _ = self.rgbaBytes.withUnsafeMutableBytes { ptr in 175 | ptr.initializeMemory(as: UInt8.self, repeating: 0) 176 | } 177 | } 178 | } 179 | 180 | internal extension Bitmap.RGBAData { 181 | /// Set the raw RGBA bytes for the image. 182 | /// - Parameter bytes: The image bytes in an RGBA format 183 | mutating func setBytes(_ bytes: [UInt8]) { 184 | assert(bytes.count == (self.height * (self.width * 4))) 185 | 186 | // The stored CGContext is (effectively) built with a pointer to the array of bytes 187 | // in order to reduce memory requirements. As a result, we should not just 188 | // nuke `rgbaBytes` with a new array - it may invalidate the CGContext 189 | self.rgbaBytes.withUnsafeMutableBytes { orig in 190 | orig.copyBytes(from: bytes) 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap+RepresentationType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | #if os(macOS) 24 | import AppKit 25 | #else 26 | import UIKit 27 | #endif 28 | 29 | /// Extensions for known image format types (eg. `NSImage`, `UIImage`, `Bitmap` etc) 30 | public protocol ImageRepresentationType { 31 | /// Returns a CGImage representation of the bitmap image format 32 | func imageRepresentation() throws -> CGImage 33 | } 34 | 35 | extension CGImage: ImageRepresentationType { 36 | /// Returns a CGImage representation of the bitmap image format 37 | @inlinable @inline(__always) 38 | public func imageRepresentation() throws -> CGImage { self } 39 | } 40 | 41 | extension Bitmap: ImageRepresentationType { 42 | /// Returns a CGImage representation of the bitmap 43 | @inlinable public func imageRepresentation() throws -> CGImage { 44 | guard let snapshot = self.cgImage else { throw Bitmap.BitmapError.cannotCreateCGImage } 45 | return snapshot 46 | } 47 | } 48 | 49 | #if os(macOS) 50 | import AppKit 51 | extension NSImage: ImageRepresentationType { 52 | /// Returns a CGImage representation of the NSImage 53 | @inlinable public func imageRepresentation() throws -> CGImage { 54 | guard let snapshot = self.cgImage else { throw Bitmap.BitmapError.cannotCreateCGImage } 55 | return snapshot 56 | } 57 | } 58 | #else 59 | import UIKit 60 | extension UIImage: ImageRepresentationType { 61 | /// Returns a CGImage representation of the UIImage 62 | public func imageRepresentation() throws -> CGImage { 63 | guard let snapshot = self.cgImage else { throw Bitmap.BitmapError.cannotCreateCGImage } 64 | return snapshot 65 | } 66 | } 67 | #endif 68 | -------------------------------------------------------------------------------- /Sources/Bitmap/Bitmap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import CoreGraphics 21 | import Foundation 22 | 23 | #if os(macOS) 24 | import AppKit 25 | #else 26 | import UIKit 27 | #endif 28 | 29 | /// A bitmap 30 | /// 31 | /// Coordinates start at the bottom left of the image, always in the sRGB colorspace 32 | public class Bitmap { 33 | /// Raw bitmap information 34 | public internal(set) var bitmapData: Bitmap.RGBAData 35 | /// The raw RGBA byte data for the bitmap 36 | @inlinable @inline(__always) 37 | public var rgbaBytes: [UInt8] { bitmapData.rgbaBytes } 38 | /// The width of the image in pixels 39 | @inlinable public var width: Int { bitmapData.width } 40 | /// The height of the image in pixels 41 | @inlinable public var height: Int { bitmapData.height } 42 | /// The size of the image (in pixels) 43 | public var size: CGSize { bitmapData.size } 44 | /// The bounds rectangle for the image 45 | public var bounds: CGRect { bitmapData.bounds } 46 | 47 | /// Create a bitmap from raw bitmap data 48 | /// - Parameter bitmapData: The bitmap data 49 | public init(_ rgbaBitmapData: Bitmap.RGBAData) throws { 50 | self.bitmapData = rgbaBitmapData 51 | guard 52 | let ctx = CGContext( 53 | data: &bitmapData.rgbaBytes, 54 | width: bitmapData.width, 55 | height: bitmapData.height, 56 | bitsPerComponent: 8, 57 | bytesPerRow: bitmapData.width * 4, 58 | space: Bitmap.colorSpace, 59 | bitmapInfo: Bitmap.bitmapInfo.rawValue 60 | ) 61 | else { 62 | throw BitmapError.invalidContext 63 | } 64 | self.bitmapContext = ctx 65 | } 66 | 67 | /// Create an empty bitmap with transparent background using an RGBA colorspace 68 | /// - Parameters: 69 | /// - width: bitmap width 70 | /// - height: bitmap height 71 | /// - backgroundColor: The background color for the bitmap (defaults to transparent) 72 | public convenience init(width: Int, height: Int, backgroundColor: CGColor? = nil) throws { 73 | let bitmapData = Bitmap.RGBAData(width: width, height: height) 74 | try self.init(bitmapData) 75 | if let backgroundColor = backgroundColor { 76 | self.fill(backgroundColor) 77 | } 78 | } 79 | 80 | /// Create a transparent bitmap 81 | /// - Parameters: 82 | /// - size: The size of the image to create 83 | /// - backgroundColor: The background color for the bitmap (defaults to transparent) 84 | @inlinable public convenience init(size: CGSize, backgroundColor: CGColor? = nil) throws { 85 | try self.init(width: Int(size.width), height: Int(size.height), backgroundColor: backgroundColor) 86 | } 87 | 88 | /// Create a bitmap from raw RGBA bytes 89 | /// - Parameters: 90 | /// - rgbaBytes: Raw rgba data (array of R,G,B,A bytes) 91 | /// - width: The expected width of the resulting bitmap 92 | /// - height: The expected height of the resulting bitmap 93 | public convenience init(rgbaBytes: [UInt8], width: Int, height: Int) throws { 94 | guard rgbaBytes.count == (width * height * 4) else { throw BitmapError.rgbaDataMismatchSize } 95 | let bitmapData = Bitmap.RGBAData(width: width, height: height, rgbaBytes: rgbaBytes) 96 | try self.init(bitmapData) 97 | } 98 | 99 | /// Create a bitmap from raw RGBA bytes 100 | /// - Parameters: 101 | /// - rgbaData: The raw rgba data as R,G,B,A bytes 102 | /// - width: The expected width of the resulting bitmap 103 | /// - height: The expected height of the resulting bitmap 104 | @inlinable public convenience init(rgbaData: Data, width: Int, height: Int) throws { 105 | try self.init(rgbaBytes: Array(rgbaData), width: width, height: height) 106 | } 107 | 108 | /// Create a bitmap containing an image 109 | /// - Parameter image: The image to present in the bitmap 110 | public convenience init(_ image: CGImage) throws { 111 | try self.init(width: image.width, height: image.height) 112 | self.draw { ctx in 113 | ctx.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) 114 | } 115 | } 116 | 117 | /// Create a bitmap of a specified size with a context block for initialization 118 | /// - Parameters: 119 | /// - size: The bitmap size 120 | /// - setupBlock: The block to call when the bitmap has been created 121 | public convenience init(size: CGSize, _ setupBlock: (CGContext) -> Void) throws { 122 | try self.init(width: Int(size.width), height: Int(size.height)) 123 | self.draw(setupBlock) 124 | } 125 | 126 | /// Create a bitmap by copying another bitmap 127 | /// - Parameter bitmap: The bitmap to copy 128 | @inlinable public convenience init(_ bitmap: Bitmap) throws { 129 | try self.init(bitmap.bitmapData) 130 | } 131 | 132 | /// Load a bitmap from an image asset 133 | /// - Parameter name: The name of the image asset 134 | public convenience init(named name: String) throws { 135 | guard let cgi = PlatformImage(named: name)?.cgImage else { throw BitmapError.cannotCreateCGImage } 136 | try self.init(cgi) 137 | } 138 | 139 | /// Make a bitmap from a CGContext 140 | /// - Parameter ctx: The context to generate the bitmap from 141 | public convenience init(_ ctx: CGContext) throws { 142 | ctx.flush() 143 | guard let image = ctx.makeImage() else { throw BitmapError.cannotCreateCGImage } 144 | try self.init(image) 145 | } 146 | 147 | #if !os(watchOS) 148 | /// Create a bitmap from the contents of a CALayer 149 | /// - Parameter layer: The layer 150 | public convenience init(_ layer: CALayer) throws { 151 | let width = Int(layer.bounds.width * layer.contentsScale) 152 | let height = Int(layer.bounds.height * layer.contentsScale) 153 | guard let ctx = CGContext( 154 | data: nil, 155 | width: width, 156 | height: height, 157 | bitsPerComponent: 8, 158 | bytesPerRow: 0, 159 | space: Bitmap.colorSpace, 160 | bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 161 | ) else { 162 | throw BitmapError.cannotCreateCGImage 163 | } 164 | 165 | #if !os(macOS) 166 | // Flip the context to render it the correct orientation. 167 | let flipped = CGAffineTransform(1, 0, 0, -1, 0, CGFloat(height)) 168 | ctx.concatenate(flipped) 169 | #endif 170 | 171 | layer.render(in: ctx) 172 | try self.init(ctx) 173 | } 174 | #endif 175 | 176 | // Private 177 | 178 | private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) 179 | private static let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! 180 | 181 | /// The bitmap's image context 182 | @usableFromInline internal var bitmapContext: CGContext 183 | } 184 | 185 | #if os(macOS) 186 | public extension Bitmap { 187 | /// Create a bitmap from the contents of an NSGraphicsContext. Must be called on the main thread 188 | /// - Parameter ctx: The context 189 | @inlinable convenience init(_ ctx: NSGraphicsContext) throws { 190 | try self.init(ctx.cgContext) 191 | } 192 | 193 | /// Create a bitmap from the contents of an NSView. Must be called on the main thread 194 | /// - Parameter view: The view 195 | @MainActor 196 | convenience init(_ view: NSView) throws { 197 | precondition(Thread.isMainThread) 198 | guard let imageRepresentation = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { 199 | throw BitmapError.cannotCreateCGImage 200 | } 201 | view.cacheDisplay(in: view.bounds, to: imageRepresentation) 202 | guard let cgImage = imageRepresentation.cgImage else { 203 | throw BitmapError.cannotCreateCGImage 204 | } 205 | try self.init(cgImage) 206 | } 207 | } 208 | #elseif !os(watchOS) 209 | public extension Bitmap { 210 | /// Create a bitmap from the contents of a UIView. Must be called on the main thread 211 | /// - Parameter view: The view 212 | @MainActor 213 | convenience init(_ view: UIView) throws { 214 | precondition(Thread.isMainThread) 215 | 216 | let format = UIGraphicsImageRendererFormat() 217 | format.scale = 0 // Use the scaling factor defined in the view 218 | format.opaque = view.isOpaque 219 | let renderer = UIGraphicsImageRenderer(size: view.bounds.size, format: format) 220 | let image = renderer.image { ctx in 221 | if view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) == false { 222 | // view mustn't be attached to a window, or has not yet fully rendered. 223 | // Fall back to the old method 224 | view.layer.render(in: ctx.cgContext) 225 | } 226 | } 227 | try self.init(image) 228 | } 229 | } 230 | #endif 231 | 232 | extension Bitmap: Equatable { 233 | /// Check if two bitmaps are equal 234 | public static func == (lhs: Bitmap, rhs: Bitmap) -> Bool { 235 | lhs.bitmapData == rhs.bitmapData 236 | } 237 | } 238 | 239 | public extension Bitmap { 240 | /// Make a copy of this bitmap 241 | /// - Returns: A new bitmap 242 | @inlinable func copy() throws -> Bitmap { 243 | try Bitmap(self.bitmapData) 244 | } 245 | } 246 | 247 | // MARK: - Retrieving the image 248 | 249 | public extension Bitmap { 250 | /// Returns a CGImage representation of this bitmap 251 | @inlinable var cgImage: CGImage? { self.bitmapContext.makeImage() } 252 | } 253 | 254 | // MARK: - Getting/setting pixels 255 | 256 | public extension Bitmap { 257 | /// sets/gets the image pixel at the given column, row 258 | /// 259 | /// Coordinates start at the bottom left (0, 0) of the image 260 | subscript(x: Int, y: Int) -> RGBA { 261 | get { self.bitmapData[x, y] } 262 | set { self.bitmapData[x, y] = newValue } 263 | } 264 | 265 | /// Set the RGBA color of the pixel at (x, y) 266 | /// 267 | /// Coordinates start at the bottom left (0, 0) of the image 268 | func setPixel(x: Int, y: Int, color: RGBA) { 269 | self.bitmapData.setPixel(x: x, y: y, color: color) 270 | } 271 | 272 | /// Set the RGBA color of the pixel in the bitmap 273 | /// - Parameter pixel: The pixel to set, in bottom left coordinates 274 | @inlinable @inline(__always) func setPixel(_ pixel: Bitmap.Pixel) { 275 | self.setPixel(x: pixel.x, y: pixel.y, color: pixel.color) 276 | } 277 | 278 | /// Set the RGBA color of the pixel at (x, y), assuming (0, 0) is at the bottom left of the bitmap 279 | /// 280 | /// Coordinate is assumed to have its origin at the bottom left 281 | func setPixel(_ coordinate: Bitmap.Coordinate, color: RGBA) { 282 | self.bitmapData.setPixel(x: coordinate.x, y: coordinate.y, color: color) 283 | } 284 | 285 | /// Returns a 4 byte array slice for the pixel ([R,G,B,A] bytes) 286 | /// 287 | /// Coordinates start at the bottom left (0, 0) of the image 288 | @inlinable @inline(__always) func getPixelSlice(x: Int, y: Int) -> ArraySlice { 289 | self.bitmapData.getPixelSlice(x: x, y: y) 290 | } 291 | 292 | /// Returns a 4 byte array slice for the pixel ([R,G,B,A] bytes), assuming (0, 0) is at the bottom left of the bitmap 293 | /// 294 | /// Coordinates start at the bottom left (0, 0) of the image 295 | @inlinable @inline(__always) func getPixelSlice(_ coordinate: Bitmap.Coordinate) -> ArraySlice { 296 | self.bitmapData.getPixelSlice(x: coordinate.x, y: coordinate.y) 297 | } 298 | 299 | /// Get the RGBA color of the pixel at (x, y). 300 | /// 301 | /// Coordinates start at the bottom left (0, 0) of the image 302 | @inlinable @inline(__always) func getPixel(x: Int, y: Int) -> RGBA { 303 | self.bitmapData.getPixel(x: x, y: y) 304 | } 305 | 306 | /// Get the RGBA color of the pixel at (x, y), assuming (0, 0) is at the bottom left of the bitmap 307 | /// 308 | /// Coordinates start at the bottom left (0, 0) of the image 309 | @inlinable @inline(__always) func getPixel(_ coordinate: Bitmap.Coordinate) -> RGBA { 310 | self.bitmapData.getPixel(x: coordinate.x, y: coordinate.y) 311 | } 312 | 313 | /// Returns the pixels in the image as an array of RGBA pixels 314 | /// 315 | /// (0) is the top left pixel, is the bottom right pixel 316 | @inlinable @inline(__always) internal var rawPixels: [RGBA] { 317 | self.bitmapData.rawPixels 318 | } 319 | } 320 | 321 | public extension Bitmap { 322 | /// Return the pixel coordinates exactly matching the specified color, assuming (0, 0) is at the bottom left of the bitmap 323 | /// - Parameter color: The color 324 | /// - Returns: An array of pixel coordinates 325 | func coordinatesMatching(_ color: RGBA) -> [Bitmap.Coordinate] { 326 | assert(MemoryLayout.size == 4) 327 | assert(self.rgbaBytes.count % 4 == 0) 328 | return stride(from: 0, to: self.rgbaBytes.count, by: 4).compactMap { index in 329 | let sl = Array(self.rgbaBytes[index ..< index + 4]) 330 | assert(sl.count == 4) 331 | if sl[0] == color.r, sl[1] == color.g, sl[2] == color.b, sl[3] == color.a { 332 | let pIndex = index / 4 333 | let x: Int = pIndex % self.width 334 | let y: Int = self.height - (pIndex / self.width) - 1 335 | return Coordinate(x: x, y: y) 336 | } 337 | return nil 338 | } 339 | } 340 | } 341 | 342 | public extension Bitmap { 343 | /// Return an array of raw pixels for this bitmap 344 | /// - Parameter topLeft: If true, returns coordinates are returned setting (0, 0) at the bottom left, 345 | /// otherwise pixels are returned using (0, 0) in the top left 346 | /// - Returns: An array of pixels 347 | func pixels(bottomLeft: Bool = true) -> [Bitmap.Pixel] { 348 | assert(MemoryLayout.size == 4) 349 | assert(self.rgbaBytes.count % 4 == 0) 350 | return stride(from: 0, to: self.rgbaBytes.count, by: 4).map { index in 351 | let sl = self.rgbaBytes[index ..< index + 4] 352 | let pIndex = index / 4 353 | let x: Int = pIndex % self.width 354 | let y: Int = bottomLeft ? self.height - (pIndex / self.width) - 1 : pIndex / self.width 355 | return Pixel(x: x, y: y, color: RGBA(slice: sl)) 356 | } 357 | } 358 | 359 | /// Returns the row of pixels at the specified row index 360 | /// 361 | /// NOTE: Given that this image is lower-left coordinates, row 0 is the BOTTOM row of the image 362 | @inlinable func rowPixels(at y: Int) -> [Bitmap.RGBA] { self.bitmapData.rowPixels(at: y) } 363 | 364 | /// Returns the column of pixels at the specified column index 365 | /// 366 | /// NOTE: Given that this image is lower-left coordinates, row 0 is the BOTTOM row of the image 367 | @inlinable func columnPixels(at x: Int) -> [Bitmap.RGBA] { self.bitmapData.columnPixels(at: x) } 368 | } 369 | 370 | // MARK: - Adopting data from another bitmap 371 | 372 | extension Bitmap { 373 | /// Replace the contents of this bitmap with another bitmap 374 | /// - Parameter bitmap: The bitmap to copy 375 | /// - Returns: self 376 | @discardableResult 377 | public func replaceContent(with bitmap: Bitmap) throws -> Bitmap { 378 | if bitmap.size == self.size { 379 | // If the dimensions are the same, just reuse our existing context 380 | self.bitmapData.setBytes(bitmap.rgbaBytes) 381 | } 382 | else { 383 | // Build a new context and map the new data 384 | try self.replaceContents(with: bitmap.bitmapData) 385 | } 386 | return self 387 | } 388 | 389 | /// Replace the contents of this bitmap raw RGBA data 390 | /// - Parameter data: The bitmap data to copy 391 | /// - Returns: self 392 | @discardableResult 393 | public func replaceContents(with data: Bitmap.RGBAData) throws -> Bitmap { 394 | self.bitmapData = data 395 | guard 396 | let ctx = CGContext( 397 | data: &bitmapData.rgbaBytes, 398 | width: bitmapData.width, 399 | height: bitmapData.height, 400 | bitsPerComponent: 8, 401 | bytesPerRow: bitmapData.width * 4, 402 | space: Bitmap.colorSpace, 403 | bitmapInfo: Bitmap.bitmapInfo.rawValue 404 | ) 405 | else { 406 | throw BitmapError.invalidContext 407 | } 408 | self.bitmapContext = ctx 409 | return self 410 | } 411 | } 412 | 413 | // MARK: - Creating copies 414 | 415 | extension Bitmap { 416 | /// Make a copy of this bitmap, and perform a block on that copy 417 | /// - Parameters: 418 | /// - isEmpty: If true, make a new bitmap with the same size without copying the content 419 | /// - block: The block to call after creating the new bitmap 420 | /// - Returns: A copy of the bitmap 421 | internal func makingCopy( 422 | isEmpty: Bool = false, 423 | _ block: (Bitmap) throws -> Void = { _ in } 424 | ) throws -> Bitmap { 425 | let copy = isEmpty ? try Bitmap(size: self.size) : try self.copy() 426 | try block(copy) 427 | return copy 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /Sources/Bitmap/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | NSPrivacyCollectedDataTypes 8 | 9 | NSPrivacyTracking 10 | 11 | NSPrivacyTrackingDomains 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+AdjustColorControls.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | #if canImport(CoreImage) 24 | 25 | import CoreImage 26 | 27 | // MARK: - CoreImage routines 28 | 29 | public extension Bitmap { 30 | /// Adjust saturation, brightness, and contrast values. 31 | /// - Parameters: 32 | /// - saturation: The amount of saturation to apply. The larger the value, the more saturated the result. 33 | /// - brightness: The amount of brightness to apply. The larger the value, the brighter the result. 34 | /// - contrast: The amount of contrast to apply. The larger the value, the more contrast in the resulting image. 35 | @inlinable func adjustColorControls( 36 | saturation: Double = 1.0, 37 | brightness: Double = 0.0, 38 | contrast: Double = 1.0 39 | ) throws { 40 | try self.replaceContent( 41 | with: try self.adjustingColorControls( 42 | saturation: saturation, 43 | brightness: brightness, 44 | contrast: contrast 45 | ) 46 | ) 47 | } 48 | 49 | /// Adjust saturation, brightness, and contrast values. 50 | /// - Parameters: 51 | /// - saturation: The amount of saturation to apply. The larger the value, the more saturated the result. 52 | /// - brightness: The amount of brightness to apply. The larger the value, the brighter the result. 53 | /// - contrast: The amount of contrast to apply. The larger the value, the more contrast in the resulting image. 54 | /// - Returns: A new bitmap 55 | func adjustingColorControls( 56 | saturation: Double = 1.0, 57 | brightness: Double = 0.0, 58 | contrast: Double = 1.0 59 | ) throws -> Bitmap { 60 | guard 61 | let filter = CIFilter( 62 | name: "CIColorControls", 63 | parameters: [ 64 | "inputImage": self.ciImage as Any, 65 | "inputSaturation": 1.0, 66 | "inputBrightness": 0.0, 67 | "inputContrast": 1.0, 68 | ] 69 | ), 70 | let output = filter.outputImage 71 | else { 72 | throw BitmapError.cannotCreateCGImage 73 | } 74 | return try Bitmap(output) 75 | } 76 | } 77 | 78 | #endif // canImport(CoreImage) 79 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+AdjustingSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // MARK: - Adjusting bitmap size 24 | 25 | public extension Bitmap { 26 | /// Changes the bitmap size around the centroid of the current image, cropping or extending 27 | /// the current bitmap as required 28 | /// - Parameter size: The size of the new image 29 | /// - Returns: A new bitmap 30 | func adjustingSize(to size: CGSize) throws -> Bitmap { 31 | let result = try Bitmap(size: size) 32 | let woffset = (size.width - self.size.width) / 2 33 | let hoffset = (size.height - self.size.height) / 2 34 | try result.drawBitmap(self, atPoint: CGPoint(x: woffset, y: hoffset)) 35 | return result 36 | } 37 | 38 | /// Changes the bitmap size around the centroid of the current image, cropping or extending 39 | /// the current bitmap as required 40 | /// - Parameter size: The new size for the bitmap 41 | @inlinable func adjustSize(to size: CGSize) throws { 42 | try self.replaceContent(with: try self.adjustingSize(to: size)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Blending.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import CoreGraphics 21 | import Foundation 22 | 23 | // Blend mode support 24 | 25 | public extension Bitmap { 26 | /// Create a new bitmap by blending an image 27 | /// - Parameters: 28 | /// - image: The image to blend 29 | /// - blendMode: The blending mode 30 | /// - backgroundColor: initial background color for the bitmap 31 | /// - clippingRects: Clipping rects within the bitmap 32 | convenience init( 33 | _ image: ImageRepresentationType, 34 | blendMode: CGBlendMode, 35 | backgroundColor: CGColor? = nil, 36 | clippingRects: [CGRect]? = nil 37 | ) throws { 38 | let image = try image.imageRepresentation() 39 | try self.init(width: image.width, height: image.height, backgroundColor: backgroundColor) 40 | self.draw { ctx in 41 | ctx.clip(to: [self.bounds]) 42 | ctx.setBlendMode(blendMode) 43 | ctx.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) 44 | } 45 | } 46 | } 47 | 48 | // MARK: - Operating on the original bitmap 49 | 50 | public extension Bitmap { 51 | /// Blend an image onto this bitmap 52 | /// - Parameters: 53 | /// - image: The image to blend 54 | /// - blendMode: The blending mode 55 | /// - dest: The rect in which to draw the blended image 56 | /// - clippingRects: Clipping rects within the bitmap 57 | /// - Returns: self 58 | func blend( 59 | _ image: ImageRepresentationType, 60 | blendMode: CGBlendMode, 61 | dest: CGRect? = nil, 62 | clippingRects: [CGRect]? = nil 63 | ) throws { 64 | let image = try image.imageRepresentation() 65 | self.bitmapContext.savingGState { ctx in 66 | ctx.clip(to: clippingRects ?? [self.bounds]) 67 | ctx.setBlendMode(blendMode) 68 | ctx.draw(image, in: dest ?? CGRect(origin: .zero, size: image.size)) 69 | } 70 | } 71 | } 72 | 73 | public extension Bitmap { 74 | /// Blend an image onto this bitmap 75 | /// - Parameters: 76 | /// - image: The image to blend 77 | /// - blendMode: The blending mode 78 | /// - position: The position to draw the image 79 | /// - clippingRects: Clipping rects within the bitmap 80 | /// - Returns: self 81 | func blend( 82 | _ image: ImageRepresentationType, 83 | blendMode: CGBlendMode, 84 | position: CGPoint, 85 | clippingRects: [CGRect]? = nil 86 | ) throws { 87 | let image = try image.imageRepresentation() 88 | let dest = CGRect(origin: position, size: image.size) 89 | try self.blend(image, blendMode: blendMode, dest: dest, clippingRects: clippingRects) 90 | } 91 | } 92 | 93 | // MARK: - Operating on a copy of this bitmap 94 | 95 | public extension Bitmap { 96 | /// Create a new bitmap by blending another image onto this image 97 | /// - Parameters: 98 | /// - image: The image to blend 99 | /// - blendMode: The blending mode 100 | /// - dest: The rect in which to draw the blended image 101 | /// - clippingRects: Rects to clip within the bitmap 102 | /// - Returns: A blended bitmap 103 | func blending( 104 | _ image: ImageRepresentationType, 105 | blendMode: CGBlendMode, 106 | dest: CGRect? = nil, 107 | clippingRects: [CGRect]? = nil 108 | ) throws -> Bitmap { 109 | try self.makingCopy { copy in 110 | try copy.blend(image, blendMode: blendMode, dest: dest, clippingRects: clippingRects) 111 | } 112 | } 113 | 114 | /// Create a new bitmap by blending another image onto this image 115 | /// - Parameters: 116 | /// - image: The image to blend 117 | /// - blendMode: The blending mode 118 | /// - position: The position to draw the image 119 | /// - clippingRects: Rects to clip within the bitmap 120 | /// - Returns: A blended bitmap 121 | func blending( 122 | _ image: ImageRepresentationType, 123 | blendMode: CGBlendMode, 124 | position: CGPoint, 125 | clippingRects: [CGRect]? = nil 126 | ) throws -> Bitmap { 127 | try self.makingCopy { copy in 128 | try copy.blend(image, blendMode: blendMode, position: position, clippingRects: clippingRects) 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Blur.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Accelerate 21 | import CoreGraphics 22 | import Foundation 23 | 24 | public extension Bitmap { 25 | /// Blur this bitmap using a radius 26 | /// - Parameter inputRadius: The blur radius 27 | @inlinable func blur(_ inputRadius: Int32 = 5) throws { 28 | let update = try self.blurring(inputRadius) 29 | try self.replaceContents(with: update.bitmapData) 30 | } 31 | 32 | /// Return a blurred version of this bitmap using a box convolve 33 | /// - Parameter inputRadius: The blur radius 34 | /// - Returns: A blurred bitmap 35 | /// 36 | /// See: [UIImageEffects example code](https://developer.apple.com/library/archive/samplecode/UIImageEffects/Listings/UIImageEffects_UIImageEffects_m.html#//apple_ref/doc/uid/DTS40013396-UIImageEffects_UIImageEffects_m-DontLinkElementID_9) 37 | func blurring(_ inputRadius: Int32 = 5) throws -> Bitmap { 38 | guard let data = self.bitmapContext.data else { 39 | throw BitmapError.invalidContext 40 | } 41 | 42 | var radius = UInt32(floor((Double(inputRadius) * 3 * sqrt(2 * Double.pi) / 4 + 0.5) / 2)) 43 | radius |= 1 // force radius to be odd so that the three box-blur methodology works. 44 | 45 | let bytesPerRow = self.width * 4 46 | let size = MemoryLayout.size * self.width * self.height * 4 47 | let bufferOut = UnsafeMutablePointer.allocate(capacity: size) 48 | defer { bufferOut.deallocate() } 49 | var src = vImage_Buffer( 50 | data: data, 51 | height: vImagePixelCount(self.height), 52 | width: vImagePixelCount(self.width), 53 | rowBytes: bytesPerRow 54 | ) 55 | var dst = vImage_Buffer( 56 | data: bufferOut, 57 | height: vImagePixelCount(self.height), 58 | width: vImagePixelCount(self.width), 59 | rowBytes: bytesPerRow 60 | ) 61 | vImageBoxConvolve_ARGB8888(&src, &dst, nil, 0, 0, radius, radius, nil, vImage_Flags(kvImageCopyInPlace)) 62 | 63 | let result = self 64 | guard let _ = result.bitmapContext.data else { 65 | throw BitmapError.invalidContext 66 | } 67 | result.bitmapContext.data!.copyMemory(from: bufferOut, byteCount: size) 68 | return result 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Border.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Draw a line around the border of this bitmap 25 | /// - Parameter stroke: The type of border line to draw 26 | func drawBorder(stroke: Stroke = Stroke(color: .standard.black, lineWidth: 1)) { 27 | let rect = self.bounds.insetBy(dx: stroke.lineWidth / 2.0, dy: stroke.lineWidth / 2.0) 28 | let path = CGPath(rect: rect, transform: nil) 29 | self.stroke(path, stroke) 30 | } 31 | 32 | /// Create a new bitmap by drawing a line around the border of this bitmap 33 | /// - Parameters: 34 | /// - stroke: The type of border line to draw 35 | /// - expanding: If true, expands the size of the resulting bitmap to include the border width 36 | /// - Returns: A new bitmap 37 | func drawingBorder( 38 | stroke: Stroke = Stroke(color: .standard.black, lineWidth: 1), 39 | expanding: Bool = false 40 | ) throws -> Bitmap { 41 | var copy: Bitmap 42 | if expanding { 43 | let newS = CGSize( 44 | width: self.size.width + (stroke.lineWidth * 2), 45 | height: self.size.height + (stroke.lineWidth * 2) 46 | ) 47 | copy = try self.adjustingSize(to: newS) 48 | } 49 | else { 50 | copy = try self.copy() 51 | } 52 | copy.drawBorder(stroke: stroke) 53 | return copy 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Clip.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Perform a drawing operation clipped to a path 25 | /// - Parameters: 26 | /// - clippingPath: The path to clip to 27 | /// - drawBlock: The drawing operation(s) 28 | @available(*, deprecated, renamed: "clip", message: "clipped had been renamed to clip") 29 | func clipped(to clippingPath: CGPath, _ drawBlock: (CGContext) -> Void) { 30 | self.clip(to: clippingPath, drawBlock) 31 | } 32 | 33 | /// Perform a drawing operation in this bitmap clipped to a path 34 | /// - Parameters: 35 | /// - clippingPath: The path to clip to 36 | /// - drawBlock: The drawing operation(s) 37 | func clip(to clippingPath: CGPath, _ drawBlock: (CGContext) -> Void) { 38 | self.savingGState { ctx in 39 | ctx.addPath(clippingPath) 40 | ctx.clip() 41 | drawBlock(ctx) 42 | } 43 | } 44 | 45 | /// Perform a clipped drawing operation on a copy of this bitmap 46 | /// - Parameters: 47 | /// - clippingPath: The path to clip to 48 | /// - drawBlock: The drawing operation(s) 49 | /// - Returns: A bitmap 50 | @inlinable func clipping(to clippingPath: CGPath, _ drawBlock: (CGContext) -> Void) throws -> Bitmap { 51 | let copy = try self.copy() 52 | copy.clip(to: clippingPath, drawBlock) 53 | return copy 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+ColorInvert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Invert the colors in this bitmap 25 | func invertColors() { 26 | self.bitmapData.rgbaBytes.withUnsafeMutableBytes { buffer in 27 | for p in stride(from: 0, to: buffer.count, by: 4) { 28 | buffer[p] = 255 - buffer[p] 29 | buffer[p + 1] = 255 - buffer[p + 1] 30 | buffer[p + 2] = 255 - buffer[p + 2] 31 | } 32 | } 33 | } 34 | 35 | /// Make a copy of this bitmap by inverting its colors 36 | /// - Returns: A new bitmap 37 | func invertingColors() throws -> Bitmap { 38 | try makingCopy { bitmap in 39 | bitmap.invertColors() 40 | } 41 | } 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+ColorMapping.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // MARK: - Mapping colors 24 | 25 | public extension Bitmap { 26 | /// Map one color in the bitmap to another color 27 | /// - Parameters: 28 | /// - color: The color to replace 29 | /// - replacementColor: The color to replace with 30 | /// - includeTransparencyInCheck: If true, the pixel's alpha is used in the comparison check 31 | /// - Returns: self 32 | @discardableResult 33 | func mapColor( 34 | _ color: Bitmap.RGBA, 35 | to replacementColor: Bitmap.RGBA, 36 | includeTransparencyInCheck: Bool = true 37 | ) -> Bitmap { 38 | self.bitmapData.rgbaBytes.withUnsafeMutableBytes { buffer in 39 | for p in stride(from: 0, to: buffer.count, by: 4) { 40 | if buffer[p] == color.r, 41 | buffer[p + 1] == color.g, 42 | buffer[p + 2] == color.b, 43 | (includeTransparencyInCheck == false || (buffer[p + 3] == color.a)) 44 | { 45 | buffer[p] = replacementColor.r 46 | buffer[p + 1] = replacementColor.g 47 | buffer[p + 2] = replacementColor.b 48 | buffer[p + 3] = replacementColor.a 49 | } 50 | } 51 | } 52 | return self 53 | } 54 | 55 | /// Map one color in the bitmap to another color, returning a new bitmap 56 | /// - Parameters: 57 | /// - color: The color to replace 58 | /// - replacementColor: The color to replace with 59 | /// - includeTransparencyInCheck: If true, the pixel's alpha is used in the comparison check 60 | /// - Returns: self 61 | func mappingColor( 62 | _ color: Bitmap.RGBA, 63 | to replacementColor: Bitmap.RGBA, 64 | includeTransparencyInCheck: Bool = true 65 | ) throws -> Bitmap { 66 | try self.copy() 67 | .mapColor(color, to: replacementColor, includeTransparencyInCheck: includeTransparencyInCheck) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Crop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import CoreGraphics 21 | import Foundation 22 | 23 | // MARK: - Crop 24 | 25 | public extension Bitmap { 26 | /// Create a new bitmap by cropping this bitmap to a rect 27 | /// - Parameter path: The rect to crop 28 | /// - Returns: A new bitmap 29 | /// 30 | /// The coordinate system (0, 0) is at the bitmap lower left 31 | @inlinable func cropping(to rect: CGRect) throws -> Bitmap { 32 | // CGImage cropping has zero coordinate at the top left. We need to flip first 33 | let flipped = rect.flippingY(within: self.bounds) 34 | guard let image = self.cgImage?.cropping(to: flipped) else { throw BitmapError.cannotCreateCGImage } 35 | return try Bitmap(image) 36 | } 37 | 38 | /// Create a new bitmap by cropping this bitmap to the given path 39 | /// - Parameter path: The path 40 | /// - Returns: A new bitmap with the clipping path applied 41 | /// 42 | /// The coordinate system (0, 0) is at the bitmap lower left 43 | func cropping(to path: CGPath) throws -> Bitmap { 44 | // Crop to the path bounds 45 | let newBitmap = try self.cropping(to: path.boundingBoxOfPath) 46 | let newBounds = newBitmap.bounds 47 | 48 | // Take a snapshot of the cropped image so we can mask out the path 49 | guard let orig = newBitmap.cgImage else { throw BitmapError.cannotCreateCGImage } 50 | 51 | // Clear the bitmap - we'll reuse it 52 | newBitmap.eraseAll() 53 | 54 | // Move the path to the origin 55 | let newPath = CGMutablePath() 56 | newPath.addPath(path, transform: CGAffineTransform(translationX: -path.boundingBoxOfPath.origin.x, 57 | y: -path.boundingBoxOfPath.origin.y)) 58 | 59 | // Okay. Now mask the path and re-draw the original image 60 | newBitmap.draw { ctx in 61 | ctx.addPath(newPath) 62 | ctx.clip() 63 | drawImageInContext(ctx, image: orig, rect: newBounds) 64 | } 65 | 66 | return newBitmap 67 | } 68 | } 69 | 70 | public extension Bitmap { 71 | /// Crop this bitmap to the given rect 72 | /// - Parameter path: The rect to crop 73 | /// 74 | /// The coordinate system (0, 0) is at the bitmap lower left 75 | @inlinable func crop(to rect: CGRect) throws { 76 | try self.replaceContent(with: self.cropping(to: rect)) 77 | } 78 | 79 | /// Crop the image to a path 80 | /// - Parameter path: The path to crop 81 | /// 82 | /// The coordinate system (0, 0) is at the bitmap lower left 83 | @inlinable func crop(to path: CGPath) throws { 84 | try self.replaceContent(with: self.cropping(to: path)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Dither.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // https://surma.dev/things/ditherpunk/ 24 | 25 | public extension Bitmap { 26 | /// Types of dithering 27 | enum DitherType { 28 | case atkinson 29 | case floydSteinberg 30 | case jarvisJudiceNinke 31 | } 32 | 33 | /// Black/white dither the image 34 | /// - Parameter ditherType: The type of dithering to apply 35 | /// - Returns: New bitmap 36 | func dithering(_ ditherType: DitherType) throws -> Bitmap { 37 | var gray = try self.grayscaling() 38 | 39 | for y in (0 ..< self.height) { 40 | for x in (0 ..< self.width) { 41 | // The existing grayscale color at the pixel 42 | let oldPixel = gray[x, y].r 43 | 44 | // Determine new pixel value (0 or 255) 45 | let newPixel: Int = oldPixel < 128 ? 0 : 255 46 | 47 | // Calculate error 48 | let error = Int(oldPixel) - Int(newPixel) 49 | 50 | gray[x, y] = RGBA(r: UInt8(newPixel), g: UInt8(newPixel), b: UInt8(newPixel)) 51 | 52 | switch ditherType { 53 | case .atkinson: 54 | atkinsonDither(x: x, y: y, error: error, bitmap: &gray) 55 | case .floydSteinberg: 56 | floydSteinbergDither(x: x, y: y, error: error, bitmap: &gray) 57 | case .jarvisJudiceNinke: 58 | jarvisJudiceNinkeDither(x: x, y: y, error: error, bitmap: &gray) 59 | } 60 | } 61 | } 62 | return gray 63 | } 64 | } 65 | 66 | public extension Bitmap { 67 | /// Black/white dither this image 68 | /// - Parameter ditherType: The type of dithering to apply 69 | @inlinable func dither(_ ditherType: DitherType) throws { 70 | try self.replaceContent(with: try self.dithering(ditherType)) 71 | } 72 | } 73 | 74 | // MARK: - Implementation 75 | 76 | private func atkinsonDither(x: Int, y: Int, error: Int, bitmap: inout Bitmap) { 77 | // Atkinson distribution matrix - distribute 3/8ths of error to surrounding pixels 78 | // 1/8 error goes to each of these neighbors: (x+1,y), (x+2,y), (x-1,y+1), (x,y+1), (x+1,y+1), (x,y+2) 79 | let errorFraction = error / 8 80 | 81 | // Distribute the error to neighboring pixels 82 | spreadError(x: x + 1, y: y, error: errorFraction, bitmap: &bitmap) 83 | spreadError(x: x + 2, y: y, error: errorFraction, bitmap: &bitmap) 84 | spreadError(x: x - 1, y: y + 1, error: errorFraction, bitmap: &bitmap) 85 | spreadError(x: x, y: y + 1, error: errorFraction, bitmap: &bitmap) 86 | spreadError(x: x + 1, y: y + 1, error: errorFraction, bitmap: &bitmap) 87 | spreadError(x: x, y: y + 2, error: errorFraction, bitmap: &bitmap) 88 | } 89 | 90 | private func floydSteinbergDither(x: Int, y: Int, error: Int, bitmap: inout Bitmap) { 91 | // Atkinson distribution matrix - distribute 3/8ths of error to surrounding pixels 92 | // 1/8 error goes to each of these neighbors: (x+1,y), (x+2,y), (x-1,y+1), (x,y+1), (x+1,y+1), (x,y+2) 93 | let errorFraction = error / 16 94 | 95 | // Distribute the error to neighboring pixels 96 | spreadError(x: x + 1, y: y, error: 7 * errorFraction, bitmap: &bitmap) 97 | spreadError(x: x - 1, y: y + 1, error: 3 * errorFraction, bitmap: &bitmap) 98 | spreadError(x: x, y: y + 1 , error: 5 * errorFraction, bitmap: &bitmap) 99 | spreadError(x: x + 1, y: y + 1 , error: 1 * errorFraction, bitmap: &bitmap) 100 | } 101 | 102 | private func jarvisJudiceNinkeDither(x: Int, y: Int, error: Int, bitmap: inout Bitmap) { 103 | // Atkinson distribution matrix - distribute 3/8ths of error to surrounding pixels 104 | // 1/8 error goes to each of these neighbors: (x+1,y), (x+2,y), (x-1,y+1), (x,y+1), (x+1,y+1), (x,y+2) 105 | let errorFraction = error / 48 106 | 107 | // Distribute the error to neighboring pixels 108 | spreadError(x: x + 1, y: y, error: 7 * errorFraction, bitmap: &bitmap) 109 | spreadError(x: x + 2, y: y, error: 5 * errorFraction, bitmap: &bitmap) 110 | 111 | spreadError(x: x - 2, y: y + 1, error: 3 * errorFraction, bitmap: &bitmap) 112 | spreadError(x: x - 1, y: y + 1, error: 5 * errorFraction, bitmap: &bitmap) 113 | spreadError(x: x , y: y + 1, error: 7 * errorFraction, bitmap: &bitmap) 114 | spreadError(x: x + 1, y: y + 1, error: 5 * errorFraction, bitmap: &bitmap) 115 | spreadError(x: x + 2, y: y + 1, error: 3 * errorFraction, bitmap: &bitmap) 116 | 117 | spreadError(x: x - 2, y: y + 2, error: 1 * errorFraction, bitmap: &bitmap) 118 | spreadError(x: x - 1, y: y + 2, error: 3 * errorFraction, bitmap: &bitmap) 119 | spreadError(x: x , y: y + 2, error: 5 * errorFraction, bitmap: &bitmap) 120 | spreadError(x: x + 1, y: y + 2, error: 3 * errorFraction, bitmap: &bitmap) 121 | spreadError(x: x + 2, y: y + 2, error: 1 * errorFraction, bitmap: &bitmap) 122 | } 123 | 124 | /// Helper function to distribute error to a specific pixel 125 | private func spreadError(x: Int, y: Int, error: Int, bitmap: inout Bitmap) { 126 | // Check boundaries 127 | guard x >= 0 && x < bitmap.width && y >= 0 && y < bitmap.height else { return } 128 | 129 | let og = Int(bitmap[x, y].r) 130 | 131 | // Add error and clip to valid range 132 | let ng = UInt8((og + error).clamped(to: 0 ... 255)) 133 | bitmap[x, y] = Bitmap.RGBA(r: ng, g: ng, b: ng) 134 | } 135 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Erase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Erase the bitmap (set the image content to all transparent) 25 | func eraseAll() { 26 | self.bitmapData.eraseAll() 27 | } 28 | 29 | /// Erase the content of the path 30 | /// - Parameters: 31 | /// - path: The path to erase 32 | /// - backgroundColor: The background color for the erased path 33 | func erase(_ path: CGPath, backgroundColor: CGColor? = nil) throws { 34 | try self.replaceContent(with: try self.erasing(path)) 35 | } 36 | 37 | /// Create a new bitmap by erasing the content of the path 38 | /// - Parameters: 39 | /// - path: The path to erase 40 | /// - backgroundColor: The background color for the erased path 41 | /// - Returns: The new bitmap 42 | func erasing(_ path: CGPath, backgroundColor: CGColor? = nil) throws -> Bitmap { 43 | guard let cgImage = self.cgImage else { throw BitmapError.cannotCreateCGImage } 44 | let copy = try Bitmap(size: self.size) 45 | let bounds = copy.bounds 46 | copy.draw { ctx in 47 | ctx.addRect(bounds) 48 | ctx.addPath(path) 49 | ctx.clip(using: .evenOdd) 50 | ctx.draw(cgImage, in: bounds) 51 | } 52 | 53 | if let backgroundColor = backgroundColor { 54 | copy.fill(path, backgroundColor) 55 | } 56 | return copy 57 | } 58 | } 59 | 60 | public extension Bitmap { 61 | /// Erase the content of the rect 62 | /// - Parameters: 63 | /// - rect: The rect to erase 64 | /// - backgroundColor: The background color for the erased path 65 | func erase(_ rect: CGRect, backgroundColor: CGColor? = nil) throws { 66 | try self.erase(rect.path, backgroundColor: backgroundColor) 67 | } 68 | 69 | /// Create a new bitmap by erasing the content of the rect 70 | /// - Parameters: 71 | /// - rect: The rect to erase 72 | /// - backgroundColor: The background color for the erased path 73 | /// - Returns: The new bitmap 74 | func erasing(_ rect: CGRect, backgroundColor: CGColor? = nil) throws -> Bitmap { 75 | try self.erasing(rect.path, backgroundColor: backgroundColor) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Extract.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Extract the content of the path into a new Bitmap 25 | /// - Parameters: 26 | /// - path: The clip path 27 | /// - clipToPath: If true, resize the resulting image to fit the clip path bounds 28 | /// - Returns: A new bitmap containing the contents of the clip path 29 | func extracting(_ path: CGPath, clipToPath: Bool = false) throws -> Bitmap { 30 | guard let cgImage = self.cgImage else { throw BitmapError.cannotCreateCGImage } 31 | let result = try Bitmap(size: self.size) 32 | result.clip(to: path) { ctx in 33 | ctx.draw(cgImage, in: self.bounds) 34 | } 35 | 36 | if clipToPath { 37 | try result.crop(to: path.boundingBoxOfPath) 38 | } 39 | 40 | return result 41 | } 42 | 43 | /// Extract the content of a rect into a new Bitmap 44 | /// - Parameters: 45 | /// - rect: The clip rect 46 | /// - clipToPath: If true, resize the resulting image to fit the clip path bounds 47 | /// - Returns: A new bitmap containing the contents of the clip path 48 | @inlinable func extracting(_ rect: CGRect, clipToPath: Bool = false) throws -> Bitmap { 49 | try self.extracting(CGPath(rect: rect, transform: nil), clipToPath: clipToPath) 50 | } 51 | 52 | /// Extract the content of the path into a new Bitmap 53 | /// - Parameters: 54 | /// - path: The clip path 55 | /// - clipToPath: If true, resize the resulting image to fit the clip path bounds 56 | @inlinable func extract(_ path: CGPath, clipToPath: Bool = false) throws { 57 | try self.replaceContent(with: self.extracting(path, clipToPath: clipToPath)) 58 | } 59 | 60 | /// Extract the content of a rect into a new Bitmap 61 | /// - Parameters: 62 | /// - rect: The clip rect 63 | /// - clipToPath: If true, resize the resulting image to fit the clip path bounds 64 | @inlinable func extract(_ rect: CGRect, clipToPath: Bool = false) throws { 65 | try self.replaceContent(with: self.extracting(rect, clipToPath: clipToPath)) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+FillStroke.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Stroke drawing properties 25 | struct Stroke { 26 | /// Stroke dash 27 | public struct Dash { 28 | public let lengths: [CGFloat] 29 | public let phase: CGFloat 30 | public init(lengths: [CGFloat], phase: CGFloat = 0.0) { 31 | self.lengths = lengths 32 | self.phase = phase 33 | } 34 | } 35 | 36 | public let color: CGColor 37 | public let lineWidth: Double 38 | public let dash: Dash? 39 | public init(color: CGColor = .standard.black, lineWidth: Double = 1.0, dash: Dash? = nil) { 40 | self.color = color 41 | self.lineWidth = lineWidth 42 | self.dash = dash 43 | } 44 | } 45 | 46 | /// Fill/stroke a path on a copy of this image 47 | /// - Parameters: 48 | /// - path: The path to fill/stroke 49 | /// - fillColor: The fill color 50 | /// - stroke: The stroke style 51 | /// - Returns: A new bitmap 52 | func drawingPath(_ path: CGPath, fillColor: CGColor? = nil, stroke: Stroke? = nil) throws -> Bitmap { 53 | let copy = try self.copy() 54 | copy.drawPath(path, fillColor: fillColor, stroke: stroke) 55 | return copy 56 | } 57 | 58 | /// Draw on the current image 59 | /// - Parameters: 60 | /// - path: The path to fill/stroke 61 | /// - fillColor: The fill color 62 | /// - stroke: The stroke style 63 | @inlinable func drawPath(_ path: CGPath, fillColor: CGColor? = nil, stroke: Stroke? = nil) { 64 | self.drawPaths([path], fillColor: fillColor, stroke: stroke) 65 | } 66 | 67 | /// Draw on the current image 68 | /// - Parameters: 69 | /// - rect: The rect to fill/stroke 70 | /// - fillColor: The fill color 71 | /// - stroke: The stroke style 72 | @inlinable func drawRect(_ rect: CGRect, fillColor: CGColor? = nil, stroke: Stroke? = nil) { 73 | self.drawPaths([CGPath(rect: rect, transform: nil)], fillColor: fillColor, stroke: stroke) 74 | } 75 | 76 | /// Draw on the current image 77 | /// - Parameters: 78 | /// - paths: The path(s) to fill/stroke 79 | /// - fillColor: The fill color 80 | /// - stroke: The stroke style 81 | @inlinable func drawPaths(_ paths: [CGPath], fillColor: CGColor?, stroke: Stroke?) { 82 | self.draw { ctx in 83 | paths.forEach { 84 | if let c = fillColor { 85 | ctx.setFillColor(c) 86 | ctx.addPath($0) 87 | ctx.fillPath() 88 | } 89 | if let s = stroke { 90 | ctx.setStrokeColor(s.color) 91 | if let dash = s.dash { 92 | ctx.setLineDash(phase: dash.phase, lengths: dash.lengths) 93 | } 94 | ctx.setLineWidth(s.lineWidth) 95 | paths.forEach { 96 | ctx.addPath($0) 97 | ctx.strokePath() 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | public extension Bitmap { 106 | /// Fill the entire bitmap with a color 107 | /// - Parameter fillColor: The color to fill 108 | @inlinable func fill(_ fillColor: CGColor) { 109 | self.fill(self.bounds.path, fillColor) 110 | } 111 | 112 | /// Create a new bitmap by filling this bitmap with a color 113 | /// - Parameter fillColor: The color to fill 114 | /// - Returns: A new bitmap 115 | @inlinable func filling(with fillColor: CGColor) throws -> Bitmap { 116 | let copy = try self.copy() 117 | copy.fill(self.bounds.path, fillColor) 118 | return copy 119 | } 120 | } 121 | 122 | public extension Bitmap { 123 | /// Fill a rect in this bitmap 124 | /// - Parameters: 125 | /// - path: The path to fill 126 | /// - fillColor: The color to fill 127 | @inlinable func fill(_ rect: CGRect, _ fillColor: CGColor) { 128 | self.fill([rect], fillColor) 129 | } 130 | 131 | /// Fill rects in this bitmap 132 | /// - Parameters: 133 | /// - path: The path to fill 134 | /// - fillColor: The color to fill 135 | func fill(_ rects: [CGRect], _ fillColor: CGColor) { 136 | self.draw { ctx in 137 | ctx.setFillColor(fillColor) 138 | ctx.fill(rects) 139 | } 140 | } 141 | 142 | /// Fill a path in this bitmap 143 | /// - Parameters: 144 | /// - path: The path to fill 145 | /// - fillColor: The color to fill 146 | func fill(_ path: CGPath, _ fillColor: CGColor) { 147 | self.draw { ctx in 148 | ctx.addPath(path) 149 | ctx.setFillColor(fillColor) 150 | ctx.fillPath() 151 | } 152 | } 153 | 154 | /// Create a new bitmap by filling a path in this bitmap with a color 155 | /// - Parameters: 156 | /// - path: The path to fill 157 | /// - fillColor: The color to fill 158 | /// - Returns: A new bitmap with the path filled 159 | func filling(_ path: CGPath, _ fillColor: CGColor) throws -> Bitmap { 160 | let copy = try self.copy() 161 | copy.fill(path, fillColor) 162 | return copy 163 | } 164 | } 165 | 166 | public extension Bitmap { 167 | /// Stroke a path in the bitmap 168 | /// - Parameters: 169 | /// - path: The path to stroke 170 | /// - stroke: Stroke parameters 171 | func stroke(_ path: CGPath, _ stroke: Stroke) { 172 | self.savingGState { ctx in 173 | ctx.addPath(path) 174 | ctx.stroke(stroke) 175 | } 176 | } 177 | 178 | /// Stroke a path in the bitmap 179 | /// - Parameters: 180 | /// - path: The path to stroke 181 | /// - stroke: Stroke parameters 182 | /// - Returns: A new bitmap with the path stroked 183 | func stroking(_ path: CGPath, _ stroke: Stroke) throws -> Bitmap { 184 | let copy = try self.copy() 185 | copy.stroke(path, stroke) 186 | return copy 187 | } 188 | 189 | /// Return a new bitmap by stroking a rect on this bitmap 190 | /// - Parameters: 191 | /// - rect: The rect to stroke 192 | /// - stroke: Stroke style 193 | /// - Returns: A new bitmap 194 | @inlinable func stroking(_ rect: CGRect, _ stroke: Stroke) throws -> Bitmap { 195 | try self.stroking(rect.path, stroke) 196 | } 197 | } 198 | 199 | public extension Bitmap { 200 | /// Stroke multiple rects within the bitmap 201 | /// - Parameters: 202 | /// - rects: The rects to stroke 203 | /// - stroke: Stroke parameters 204 | func stroke(_ rects: [CGRect], _ stroke: Stroke) { 205 | self.savingGState { ctx in 206 | ctx.addRects(rects) 207 | ctx.stroke(stroke) 208 | } 209 | } 210 | 211 | /// Stroke a single rect within the bitmap 212 | /// - Parameters: 213 | /// - rect: The rect to stroke 214 | /// - stroke: Stroke parameters 215 | @inlinable func stroke(_ rect: CGRect, _ stroke: Stroke) { 216 | self.stroke([rect], stroke) 217 | } 218 | } 219 | 220 | public extension Bitmap { 221 | /// Fill and stroke a path on this bitmap 222 | /// - Parameters: 223 | /// - path: The path 224 | /// - fillColor: Fill color 225 | /// - stroke: Stroke style 226 | /// - Returns: A new bitmap 227 | func fillStroke(_ path: CGPath, fillColor: CGColor, stroke: Stroke) { 228 | self.savingGState { ctx in 229 | ctx.addPath(path) 230 | ctx.setFillColor(fillColor) 231 | ctx.fillPath() 232 | 233 | self.stroke(path, stroke) 234 | } 235 | } 236 | 237 | /// Return a new bitmap by filling and stroking a path on this bitmap 238 | /// - Parameters: 239 | /// - path: The path 240 | /// - fillColor: Fill color 241 | /// - stroke: Stroke style 242 | /// - Returns: A new bitmap 243 | func fillingStroking(_ path: CGPath, fillColor: CGColor, stroke: Stroke) throws -> Bitmap { 244 | let copy = try self.copy() 245 | copy.fillStroke(path, fillColor: fillColor, stroke: stroke) 246 | return copy 247 | } 248 | } 249 | 250 | public extension Bitmap { 251 | /// Draw a simple line on the bitmap 252 | /// - Parameters: 253 | /// - from: The start position of the line 254 | /// - to: The end position of the line 255 | /// - stroke: The stroke style 256 | func drawLine(from: CGPoint, to: CGPoint, stroke: Stroke) { 257 | let pth = CGMutablePath() 258 | pth.move(to: from) 259 | pth.addLine(to: to) 260 | pth.closeSubpath() 261 | self.stroke(pth, stroke) 262 | } 263 | 264 | /// Draw a simple line on the bitmap 265 | @inlinable func drawLine(x1: CGFloat, y1: CGFloat, x2: CGFloat, y2: CGFloat, stroke: Stroke) { 266 | self.drawLine(from: CGPoint(x: x1, y: y1), to: CGPoint(x: x2, y: y2), stroke: stroke) 267 | } 268 | } 269 | 270 | public extension CGContext { 271 | /// Stroke using the specified stroke style 272 | func stroke(_ stokeStyle: Bitmap.Stroke) { 273 | self.setStrokeColor(stokeStyle.color) 274 | self.setLineWidth(stokeStyle.lineWidth) 275 | if let dash = stokeStyle.dash { 276 | self.setLineDash(phase: dash.phase, lengths: dash.lengths) 277 | } 278 | self.strokePath() 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Flip.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import CoreGraphics 21 | import Foundation 22 | 23 | // MARK: - Flip 24 | 25 | public extension Bitmap { 26 | /// Flip types 27 | enum FlipType { 28 | /// Flip horizontally 29 | case horizontally 30 | /// Flip vertically 31 | case vertically 32 | /// Flip across both axes 33 | case both 34 | } 35 | 36 | /// Flip this bitmap 37 | /// - Parameter flipType: The type of flipping to apply 38 | func flip(_ flipType: FlipType) throws { 39 | guard let cgImage = self.cgImage else { throw BitmapError.cannotCreateCGImage } 40 | self.eraseAll() 41 | 42 | self.savingGState { ctx in 43 | // Draw the flipped image 44 | switch flipType { 45 | case .horizontally: 46 | ctx.scaleBy(x: 1, y: -1) 47 | ctx.translateBy(x: 0, y: Double(-height)) 48 | case .vertically: 49 | ctx.scaleBy(x: -1, y: 1) 50 | ctx.translateBy(x: Double(-width), y: 0) 51 | case .both: 52 | ctx.scaleBy(x: -1, y: -1) 53 | ctx.translateBy(x: Double(-width), y: Double(-height)) 54 | } 55 | ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) 56 | } 57 | } 58 | 59 | /// Create a new bitmap by flipping this bitmap 60 | /// - Parameter flipType: The type of flipping to apply 61 | /// - Returns: A new image with the original image flipped 62 | func flipping(_ flipType: FlipType) throws -> Bitmap { 63 | try self.makingCopy { copy in 64 | try copy.flip(flipType) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Gamma.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | #if canImport(CoreImage) 24 | import CoreImage 25 | #endif 26 | 27 | public extension Bitmap { 28 | /// Adjusts midtone brightness 29 | /// - Parameter power: The input power. The larger the value, the darker the result 30 | @inlinable func adjustGamma(power: Double) throws { 31 | try self.replaceContent(with: try self.adjustingGamma(power: power)) 32 | } 33 | } 34 | 35 | #if canImport(CoreImage) 36 | 37 | public extension Bitmap { 38 | /// Adjusts midtone brightness 39 | /// - Parameter power: The input power. The larger the value, the darker the result 40 | /// - Returns: A new bitmap 41 | func adjustingGamma(power: Double) throws -> Bitmap { 42 | guard 43 | let filter = CIFilter( 44 | name: "CIGammaAdjust", 45 | parameters: [ 46 | "inputImage": self.ciImage as Any, 47 | "inputPower": power, 48 | ] 49 | ), 50 | let output = filter.outputImage 51 | else { 52 | throw BitmapError.cannotCreateCGImage 53 | } 54 | return try Bitmap(output) 55 | } 56 | } 57 | 58 | #else 59 | 60 | public extension Bitmap { 61 | func adjustingGamma(power: Double) throws -> Bitmap { 62 | throw BitmapError.notImplemented 63 | } 64 | } 65 | 66 | #endif 67 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Generator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | #if canImport(CoreImage) 21 | 22 | import Foundation 23 | import CoreGraphics 24 | import CoreImage 25 | 26 | public extension Bitmap { 27 | /// Create a bitmap containing a checkerboard pattern 28 | /// - Parameters: 29 | /// - width: The bitmap width 30 | /// - height: The bitmap height 31 | /// - center: The center point for the pattern 32 | /// - checkSize: The size of the check 33 | /// - color0: odd color 34 | /// - color1: even color 35 | /// - Returns: A bitmap containing a checkerboard pattern 36 | static func Checkerboard( 37 | width: Int, 38 | height: Int, 39 | center: CGPoint? = nil, 40 | checkSize: CGFloat = 20, 41 | color0: CGColor = .standard.black, 42 | color1: CGColor = .standard.white 43 | ) throws -> Bitmap { 44 | let bitmap = try Bitmap(width: width, height: height) 45 | let center = center ?? CGPoint(x: 150.0, y: 150.0) 46 | guard 47 | let filter = CIFilter( 48 | name: "CICheckerboardGenerator", 49 | parameters: [ 50 | "inputCenter": CIVector(cgPoint: center), 51 | "inputColor0": CIColor(cgColor: color0), 52 | "inputColor1": CIColor(cgColor: color1), 53 | "inputWidth": checkSize, 54 | "inputSharpness": 1.0, 55 | ] 56 | ), 57 | let check = filter.outputImage, 58 | let cgImage = CIContext().createCGImage(check, from: bitmap.bounds) 59 | else { 60 | throw BitmapError.cannotFilter 61 | } 62 | return try bitmap.drawBitmap(cgImage, atPoint: .zero) 63 | } 64 | } 65 | 66 | public extension Bitmap { 67 | /// Generate diagonal lines 68 | /// - Parameters: 69 | /// - width: The width for the resulting bitmap 70 | /// - height: The height for the resulting bitmap 71 | /// - lineWidth: The line width for the dialog lines 72 | /// - angle: The line angle 73 | /// - color0: color 0 74 | /// - color1: color 1 75 | /// - Returns: A bitmap containing diagonal lines 76 | static func DiagonalLines( 77 | width: Int, 78 | height: Int, 79 | lineWidth: CGFloat, 80 | angle: Angle2D, 81 | color0: CGColor = .standard.black, 82 | color1: CGColor = .standard.white 83 | ) throws -> Bitmap { 84 | let bitmap = try Bitmap(width: width, height: height) 85 | let center = CIVector(x: 0.0, y: 0.0) 86 | let filterSize = CGRect(origin: .zero, size: CGSize(width: width * 2, height: height * 2)) 87 | guard 88 | let filter = CIFilter( 89 | name: "CIStripesGenerator", 90 | parameters: [ 91 | "inputCenter": center, 92 | "inputColor0": CIColor(cgColor: color0), 93 | "inputColor1": CIColor(cgColor: color1), 94 | "inputWidth": lineWidth * 2, 95 | "inputSharpness": 1.0, 96 | ] 97 | ), 98 | let lines = filter.outputImage?.transformed(by: .init(rotationAngle: CGFloat(angle.radians))), 99 | let cgImage = CIContext().createCGImage(lines, from: filterSize) 100 | else { 101 | throw BitmapError.cannotFilter 102 | } 103 | 104 | // Scale the 2x generated image down into the bitmap 105 | try bitmap.drawBitmap(cgImage, in: .init(origin: .zero, size: bitmap.bounds.size) ) 106 | return bitmap 107 | } 108 | } 109 | 110 | #else 111 | 112 | import Foundation 113 | import CoreGraphics 114 | 115 | public extension Bitmap { 116 | /// Create a bitmap containing a checkerboard pattern 117 | /// - Parameters: 118 | /// - width: The bitmap width 119 | /// - height: The bitmap height 120 | /// - checkSize: The size of the check 121 | /// - color0: odd color 122 | /// - color1: even color 123 | /// - Returns: A bitmap containing a checkerboard pattern 124 | static func Checkerboard( 125 | width: Int, 126 | height: Int, 127 | checkSize: CGFloat = 20, 128 | color0: CGColor = .standard.black, 129 | color1: CGColor = .standard.white 130 | ) throws -> Bitmap { 131 | let bitmap = try Bitmap(width: width, height: height) 132 | 133 | var isEvenRow = true 134 | for y in stride(from: CGFloat(0), through: CGFloat(height), by: checkSize) { 135 | var isEven = isEvenRow 136 | for x in stride(from: CGFloat(0), through: CGFloat(width), by: checkSize) { 137 | let r = CGRect(x: x, y: y, width: checkSize, height: checkSize) 138 | bitmap.fill(CGPath(rect: r, transform: nil), isEven ? color0 : color1) 139 | isEven.toggle() 140 | } 141 | isEvenRow.toggle() 142 | } 143 | 144 | return bitmap 145 | } 146 | } 147 | 148 | 149 | #endif 150 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Grayscale.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | #if canImport(CoreImage) 24 | import CoreImage 25 | #endif 26 | 27 | public extension Bitmap { 28 | /// Apply a grayscale filter to this bitmap 29 | @inlinable func grayscale() throws { 30 | try self.replaceContent(with: try self.grayscaling()) 31 | } 32 | } 33 | 34 | #if canImport(CoreImage) 35 | 36 | public extension Bitmap { 37 | /// Return a grayscale representation of this bitmap 38 | /// - Returns: A grayscale bitmap 39 | func grayscaling() throws -> Bitmap { 40 | guard 41 | let filter = CIFilter( 42 | name: "CIPhotoEffectMono", 43 | parameters: [ 44 | "inputImage": self.ciImage as Any, 45 | ] 46 | ), 47 | let output = filter.outputImage 48 | else { 49 | throw BitmapError.cannotCreateCGImage 50 | } 51 | return try Bitmap(output) 52 | } 53 | } 54 | 55 | #else 56 | 57 | // MARK: - Non CoreImage routines 58 | 59 | public extension Bitmap { 60 | /// Return a grayscale representation of this bitmap 61 | /// - Returns: A grayscale bitmap 62 | func grayscaling() throws -> Bitmap { 63 | guard let cgImage = self.cgImage else { throw BitmapError.cannotCreateCGImage } 64 | 65 | // Create a grayscale context 66 | guard let ctx = CGContext( 67 | data: nil, 68 | width: width, 69 | height: height, 70 | bitsPerComponent: 8, 71 | bytesPerRow: 0, 72 | space: CGColorSpaceCreateDeviceGray(), 73 | bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue 74 | ) 75 | else { 76 | throw BitmapError.invalidContext 77 | } 78 | 79 | /// Draw the image into the new context 80 | let imageRect = CGRect(origin: .zero, size: size) 81 | 82 | /// Draw the image 83 | ctx.draw(cgImage, in: imageRect) 84 | 85 | ctx.setBlendMode(.destinationIn) 86 | ctx.clip(to: imageRect, mask: cgImage) 87 | 88 | guard let image = ctx.makeImage() else { 89 | throw BitmapError.cannotCreateCGImage 90 | } 91 | return try Bitmap(image) 92 | } 93 | } 94 | 95 | #endif 96 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Image.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import CoreGraphics 21 | import Foundation 22 | 23 | // MARK: - Drawing 24 | 25 | public extension Bitmap { 26 | /// Create a new bitmap by drawing an image on this bitmap 27 | /// - Parameters: 28 | /// - bitmap: The image to draw 29 | /// - rect: The destination rect 30 | /// - scaling: The scaling method for scaling the image up/down to fit/fill the rect 31 | /// - Returns: A copy of this bitmap with the new bitmap drawn on 32 | @inlinable func drawingBitmap( 33 | _ image: ImageRepresentationType, 34 | in rect: CGRect, 35 | scaling: ScalingType = .axesIndependent 36 | ) throws -> Bitmap { 37 | try self.copy() 38 | .drawBitmap(image, in: rect, scaling: scaling) 39 | } 40 | 41 | /// Draw an image onto this image, scaling to fit within the specified rect 42 | /// - Parameters: 43 | /// - bitmap: The image to draw 44 | /// - rect: The destination rect 45 | /// - scaling: The scaling method for scaling the image up/down to fit/fill the rect 46 | /// - Returns: self 47 | @discardableResult 48 | func drawBitmap( 49 | _ image: ImageRepresentationType, 50 | in rect: CGRect, 51 | scaling: ScalingType = .axesIndependent 52 | ) throws -> Bitmap { 53 | let image = try image.imageRepresentation() 54 | drawImageInContext(self.bitmapContext, image: image, rect: rect, scalingType: scaling) 55 | return self 56 | } 57 | } 58 | 59 | public extension Bitmap { 60 | /// Draw an image at a point within this bitmap 61 | /// - Parameters: 62 | /// - image: The image to draw 63 | /// - point: The point at which to draw the image 64 | /// - Returns: self 65 | @discardableResult 66 | func drawBitmap(_ image: ImageRepresentationType, atPoint point: CGPoint = .zero) throws -> Bitmap { 67 | let image = try image.imageRepresentation() 68 | let bounds = self.bounds 69 | let dest = CGRect(origin: point, size: image.size) 70 | self.draw { ctx in 71 | ctx.clip(to: [bounds]) 72 | ctx.draw(image, in: dest) 73 | } 74 | return self 75 | } 76 | 77 | /// Draw an image at a point within this bitmap, cropping to the bounds of the original image 78 | /// - Parameters: 79 | /// - image: The image to draw 80 | /// - point: The point at which to draw the image 81 | /// - Returns: A new bitmap 82 | @inlinable func drawingBitmap(_ image: ImageRepresentationType, atPoint point: CGPoint = .zero) throws -> Bitmap { 83 | try self.copy() 84 | .drawBitmap(image, atPoint: point) 85 | } 86 | } 87 | 88 | // MARK: - Global implementations 89 | 90 | internal func drawImageInContext(_ ctx: CGContext, image: CGImage, rect: CGRect, scalingType: Bitmap.ScalingType = .axesIndependent) { 91 | switch scalingType { 92 | case .axesIndependent: 93 | ctx.draw(image, in: rect) 94 | case .aspectFill: 95 | drawImageToFill(in: ctx, image: image, rect: rect) 96 | case .aspectFit: 97 | drawImageToFit(in: ctx, image: image, rect: rect) 98 | } 99 | } 100 | 101 | internal func drawImageToFit(in ctx: CGContext, image: CGImage, rect: CGRect) { 102 | let origSize = image.size 103 | 104 | // Keep aspect ratio 105 | var destWidth: CGFloat = 0 106 | var destHeight: CGFloat = 0 107 | let widthFloat = origSize.width 108 | let heightFloat = origSize.height 109 | if origSize.width > origSize.height { 110 | destWidth = rect.width 111 | destHeight = heightFloat * rect.width / widthFloat 112 | } 113 | else { 114 | destHeight = rect.height 115 | destWidth = widthFloat * rect.height / heightFloat 116 | } 117 | 118 | if destWidth > rect.width { 119 | destWidth = rect.width 120 | destHeight = heightFloat * rect.width / widthFloat 121 | } 122 | 123 | if destHeight > rect.height { 124 | destHeight = rect.height 125 | destWidth = widthFloat * rect.height / heightFloat 126 | } 127 | 128 | ctx.draw( 129 | image, 130 | in: CGRect( 131 | x: rect.minX + ((rect.width - destWidth) / 2), 132 | y: rect.minY + ((rect.height - destHeight) / 2), 133 | width: destWidth, 134 | height: destHeight 135 | ) 136 | ) 137 | } 138 | 139 | internal func drawImageToFill(in ctx: CGContext, image: CGImage, rect: CGRect) { 140 | let imageSize = image.size 141 | 142 | ctx.saveGState() 143 | defer { ctx.restoreGState() } 144 | 145 | // Set up a clipping rect 146 | ctx.clip(to: [rect]) 147 | 148 | var destWidth: CGFloat = 0 149 | var destHeight: CGFloat = 0 150 | let widthRatio = rect.width / imageSize.width 151 | let heightRatio = rect.height / imageSize.height 152 | 153 | // Keep aspect ratio 154 | if heightRatio > widthRatio { 155 | 156 | // The width needs to fit exactly the width of the rect 157 | destHeight = rect.height 158 | 159 | // Scale the height 160 | destWidth = imageSize.width * heightRatio // (rect.height / imageSize.height) 161 | } 162 | else { 163 | // The height needs to fit exactly the width of the rect 164 | destHeight = rect.height 165 | 166 | // Scale the width 167 | destWidth = imageSize.width * (rect.width / imageSize.width) 168 | } 169 | 170 | ctx.clip(to: [rect]) 171 | ctx.draw( 172 | image, 173 | in: CGRect( 174 | x: rect.minX + ((rect.width - destWidth) / 2), 175 | y: rect.minY + ((rect.height - destHeight) / 2), 176 | width: destWidth, 177 | height: destHeight 178 | ) 179 | ) 180 | } 181 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+InnerShadow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // MARK: - Inner shadows 24 | 25 | public extension Bitmap { 26 | /// Draw a path using an inner shadow 27 | /// - Parameters: 28 | /// - path: The path 29 | /// - fillColor: The color to fill the path, or nil for no color 30 | /// - shadow: The shadow definition 31 | func drawInnerShadow(_ path: CGPath, fillColor: CGColor? = nil, shadow: Bitmap.Shadow) { 32 | self.draw { ctx in 33 | if let fillColor = fillColor { 34 | ctx.setFillColor(fillColor) 35 | ctx.addPath(path) 36 | ctx.fillPath() 37 | } 38 | ctx.drawInnerShadow(in: path, shadowColor: shadow.color, offset: shadow.offset, blurRadius: shadow.blur) 39 | } 40 | } 41 | 42 | /// Create a new bitmap by drawing a path using an inner shadow 43 | /// - Parameters: 44 | /// - path: The path 45 | /// - fillColor: The color to fill the path, or nil for no color 46 | /// - shadow: The shadow definition 47 | /// - Returns: A new bitmap 48 | func drawingInnerShadow(_ path: CGPath, fillColor: CGColor? = nil, shadow: Bitmap.Shadow) throws -> Bitmap { 49 | try self.makingCopy { copy in 50 | copy.drawInnerShadow(path, fillColor: fillColor, shadow: shadow) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Inset.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // MARK: - Inset 24 | 25 | public extension Bitmap { 26 | /// Inset this bitmap 27 | /// - Parameter value: The amount to inset the bitmap 28 | /// 29 | /// The original bitmap size is not affected. 30 | @inlinable func inset(by value: Double, backgroundColor: CGColor? = nil) throws { 31 | try self.replaceContent(with: try self.insetting(by: value, backgroundColor: backgroundColor)) 32 | } 33 | 34 | /// Inset this bitmap 35 | /// - Parameter edgeInsets: Edge insets to apply 36 | /// 37 | /// The original bitmap size is not affected 38 | @inlinable func inset(by edgeInsets: NSEdgeInsets, backgroundColor: CGColor? = nil) throws { 39 | try self.replaceContent(with: try self.insetting(by: edgeInsets, backgroundColor: backgroundColor)) 40 | } 41 | 42 | /// Create a new bitmap by applying insets to this bitmap 43 | /// - Parameter value: The amount to inset 44 | /// - Returns: A copy of this bitmap with the inset applied 45 | @inlinable func insetting(by value: Double, backgroundColor: CGColor? = nil) throws -> Bitmap { 46 | try self.insetting( 47 | by: NSEdgeInsets(top: value, left: value, bottom: value, right: value), 48 | backgroundColor: backgroundColor 49 | ) 50 | } 51 | 52 | /// Create a new bitmap by applying insets to this bitmap 53 | /// - Parameters: 54 | /// - edgeInsets: the padding to apply to each edge 55 | /// - backgroundColor: The background color to use for the inset areas, or nil for clear 56 | /// - Returns: A copy of this bitmap with the inset applied 57 | func insetting(by edgeInsets: NSEdgeInsets, backgroundColor: CGColor? = nil) throws -> Bitmap { 58 | guard 59 | edgeInsets.top >= 0, 60 | edgeInsets.bottom >= 0, 61 | edgeInsets.left >= 0, 62 | edgeInsets.right >= 0 63 | else { 64 | throw BitmapError.paddingOrInsetMustBePositiveValue 65 | } 66 | 67 | guard let cgi = self.cgImage else { 68 | throw BitmapError.cannotCreateCGImage 69 | } 70 | 71 | // New bitmap will be the same pixel size as the original 72 | let newImage = try Bitmap(width: self.width, height: self.height) 73 | 74 | let nw = Double(self.width) - (edgeInsets.left + edgeInsets.right) 75 | let nh = Double(self.height) - (edgeInsets.top + edgeInsets.bottom) 76 | let imageDestination = CGRect( 77 | origin: CGPoint(x: edgeInsets.left, y: edgeInsets.bottom), 78 | size: CGSize(width: nw, height: nh) 79 | ) 80 | 81 | if let backgroundColor = backgroundColor { 82 | let bounds = newImage.bounds 83 | newImage.draw { ctx in 84 | // Clip out the image's destination out of the background fill 85 | ctx.addRect(bounds) 86 | ctx.addRect(imageDestination) 87 | ctx.clip(using: .evenOdd) 88 | ctx.setFillColor(backgroundColor) 89 | ctx.fill(bounds) 90 | } 91 | } 92 | 93 | return try newImage.drawBitmap(cgi, in: imageDestination) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Masking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // MARK: - Mask using an image 24 | 25 | public extension Bitmap { 26 | /// Mask this bitmap using a mask bitmap 27 | /// - Parameter maskImage: The mask image 28 | /// - Returns: self 29 | @discardableResult func mask(using maskImage: ImageRepresentationType) throws -> Bitmap { 30 | try self.replaceContent(with: try self.masking(using: try maskImage.imageRepresentation())) 31 | return self 32 | } 33 | 34 | /// Create a new bitmap by masking this image using an image mask 35 | /// - Parameter maskImage: The mask bitmap 36 | /// - Returns: A new bitmap 37 | func masking(using maskImage: ImageRepresentationType) throws -> Bitmap { 38 | guard let origImage = self.cgImage else { throw BitmapError.cannotCreateCGImage } 39 | let maskImage = try maskImage.imageRepresentation() 40 | let origRect = self.bounds 41 | let resultBitmap = try Bitmap(size: origRect.size) 42 | resultBitmap.draw { ctx in 43 | ctx.clip(to: origRect, mask: maskImage) 44 | ctx.draw(origImage, in: origRect) 45 | } 46 | return resultBitmap 47 | } 48 | } 49 | 50 | // MARK: - Mask using a path 51 | 52 | public extension Bitmap { 53 | /// Mask out the part of the bitmap contained within the image 54 | /// - Parameter path: The mask path 55 | func mask(using path: CGPath) throws { 56 | let cropped = try self.cropping(to: path) 57 | self.eraseAll() 58 | try self.drawBitmap(cropped, atPoint: path.boundingBoxOfPath.origin) 59 | } 60 | 61 | /// Create a new bitmap by masking this bitmap with a path 62 | /// - Parameter path: The path to mask 63 | /// - Returns: A new bitmap 64 | func masking(using path: CGPath) throws -> Bitmap { 65 | try self.makingCopy { copy in 66 | try copy.mask(using: path) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Padding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // MARK: - Padding 24 | 25 | public extension Bitmap { 26 | /// Pad the current image by adding pixels on all edges (so the resulting image will be larger) 27 | /// - Parameters: 28 | /// - value: The amount to pad the edges of the bitmap 29 | /// - backgroundColor: The color to use for the extended edges, or `nil` for transparent 30 | @discardableResult @inlinable 31 | func pad(by value: CGFloat, backgroundColor: CGColor? = nil) throws -> Bitmap { 32 | try self.replaceContent(with: try self.padding(by: value, backgroundColor: backgroundColor)) 33 | return self 34 | } 35 | 36 | /// Pad the current image by adding pixels on all edges (so the image will be larger) 37 | /// - Parameters: 38 | /// - padding: The padding to apply to each edge of the bitmap 39 | /// - backgroundColor: The color to use for the extended edges, or `nil` for transparent 40 | @discardableResult @inlinable 41 | func pad(by padding: NSEdgeInsets, backgroundColor: CGColor? = nil) throws -> Bitmap { 42 | try self.replaceContent(with: try self.padding(by: padding, backgroundColor: backgroundColor)) 43 | return self 44 | } 45 | 46 | /// Create a new bitmap by padding the edges of the image with additional pixels 47 | /// - Parameters: 48 | /// - value: The amount to pad the edges of the bitmap 49 | /// - backgroundColor: The color to use for the extended edges, or `nil` for transparent 50 | /// - Returns: A new bitmap 51 | @inlinable func padding(by value: Double, backgroundColor: CGColor? = nil) throws -> Bitmap { 52 | try padding( 53 | by: NSEdgeInsets(top: value, left: value, bottom: value, right: value), 54 | backgroundColor: backgroundColor 55 | ) 56 | } 57 | 58 | /// Create a new bitmap by padding the edges of the image with transparent pixels 59 | /// - Parameters: 60 | /// - edgeInsets: the padding to apply to each edge 61 | /// - backgroundColor: The background color to use for the padded areas, or nil for clear 62 | /// - Returns: A new bitmap 63 | func padding(by edgeInsets: NSEdgeInsets, backgroundColor: CGColor? = nil) throws -> Bitmap { 64 | guard 65 | edgeInsets.top >= 0, 66 | edgeInsets.bottom >= 0, 67 | edgeInsets.left >= 0, 68 | edgeInsets.right >= 0 69 | else { 70 | throw BitmapError.paddingOrInsetMustBePositiveValue 71 | } 72 | 73 | guard let cgi = self.cgImage else { 74 | throw BitmapError.cannotCreateCGImage 75 | } 76 | 77 | let nw = Double(self.width) + edgeInsets.left + edgeInsets.right 78 | let nh = Double(self.height) + edgeInsets.top + edgeInsets.bottom 79 | 80 | let imageDestination = CGRect( 81 | origin: CGPoint(x: edgeInsets.left, y: edgeInsets.bottom), 82 | size: CGSize(width: self.width, height: self.height) 83 | ) 84 | 85 | let newImage = try Bitmap(width: Int(nw), height: Int(nh)) 86 | if let backgroundColor = backgroundColor { 87 | let bounds = newImage.bounds 88 | newImage.draw { ctx in 89 | // Clip out the image's destination out of the background fill 90 | ctx.addRect(bounds) 91 | ctx.addRect(imageDestination) 92 | ctx.clip(using: .evenOdd) 93 | ctx.setFillColor(backgroundColor) 94 | ctx.fill(bounds) 95 | } 96 | } 97 | 98 | return try newImage.drawBitmap(cgi, in: imageDestination) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Quantize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Return a new bitmap by quanitzing to a black white image 25 | /// - Parameter midPointValue: The midpoint value (the gray value that defines the midpoint between black and white) 26 | /// - Returns: A new bitmap 27 | func quantizingBlackWhite(_ midPointValue: UInt8) throws -> Bitmap { 28 | let gray = try self.grayscaling() 29 | for y in (0 ..< self.height) { 30 | for x in (0 ..< self.width) { 31 | let value = gray[x, y].r > midPointValue ? 1.0 : 0.0 32 | gray[x, y] = RGBA(rf: value, gf: value, bf: value, af: 1) 33 | } 34 | } 35 | return gray 36 | } 37 | 38 | /// Quanitze to a black white image by setting a midpoint value 39 | /// - Parameter midPointValue: The midpoint value (the gray value that defines the midpoint between black and white) 40 | @inlinable 41 | func quantizeBlackWhite(_ midPointValue: UInt8) throws { 42 | try self.replaceContent(with: try self.quantizingBlackWhite(midPointValue)) 43 | } 44 | } 45 | 46 | public extension Bitmap { 47 | /// Quantize the bitmap to a fixed number of (equally sized) gray buckets 48 | /// - Parameter bucketCount: The number of gray buckets 49 | /// - Returns: A new bitmap 50 | func quantizingGray(bucketCount: UInt8) throws -> Bitmap { 51 | precondition(bucketCount > 1) 52 | // Convert to gray 53 | let gray = try self.grayscaling() 54 | 55 | let bucketSize = UInt8(255.0 / Double(bucketCount)) 56 | 57 | for y in (0 ..< self.height) { 58 | for x in (0 ..< self.width) { 59 | let orig = gray[x, y].r 60 | let which = (orig / bucketSize) * bucketSize 61 | gray[x, y] = RGBA(r: which, g: which, b: which, a: 255) 62 | } 63 | } 64 | return gray 65 | } 66 | 67 | /// Quantize the bitmap to a fixed number of (equally sized) gray buckets 68 | /// - Parameter bucketCount: The number of gray buckets 69 | @inlinable 70 | func quantizeGray(bucketCount: UInt8) throws { 71 | try self.replaceContent(with: try self.quantizingGray(bucketCount: bucketCount)) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Rotation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Rotate this bitmap clockwise around its center 25 | /// - Parameters: 26 | /// - angle: The rotation angle 27 | @inlinable func rotate(by angle: Angle2D) throws { 28 | let update = try self.rotating(by: angle) 29 | try self.replaceContents(with: update.bitmapData) 30 | } 31 | 32 | /// Create a new bitmap by rotating this bitmap clockwise round its center 33 | /// - Parameters: 34 | /// - angle: The rotation angle 35 | /// - Returns: The rotated bitmap 36 | func rotating(by angle: Angle2D) throws -> Bitmap { 37 | guard let cgImage = self.cgImage else { 38 | throw BitmapError.cannotCreateCGImage 39 | } 40 | 41 | // We only ever want to deal with radian values 42 | let rotationAngle = CGFloat(angle.radians) 43 | 44 | let origWidth = CGFloat(width) 45 | let origHeight = CGFloat(height) 46 | let origRect = CGRect(origin: .zero, size: CGSize(width: origWidth, height: origHeight)) 47 | let rotatedRect = origRect.applying(CGAffineTransform(rotationAngle: rotationAngle)) 48 | 49 | let n = try Bitmap(width: Int(rotatedRect.width), height: Int(rotatedRect.height)) 50 | n.draw { ctx in 51 | ctx.translateBy(x: rotatedRect.size.width * 0.5, y: rotatedRect.size.height * 0.5) 52 | ctx.rotate(by: -rotationAngle) 53 | ctx.draw( 54 | cgImage, 55 | in: CGRect( 56 | x: -origWidth * 0.5, 57 | y: -origHeight * 0.5, 58 | width: origWidth, 59 | height: origHeight 60 | ) 61 | ) 62 | } 63 | return n 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Scale.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // MARK: [Scaling] 24 | 25 | public extension Bitmap { 26 | 27 | /// The type of scaling to apply to an image 28 | enum ScalingType { 29 | /// Scale the X and Y axes independently when resizing the image 30 | case axesIndependent 31 | /// Scale the X and Y axes equally so that the entire image fills the specified size 32 | case aspectFill 33 | /// Sclae the X and Y axes equally so that the entire image fits within the specified size 34 | case aspectFit 35 | } 36 | 37 | /// Scale this bitmap 38 | /// - Parameters: 39 | /// - image: The image to scale 40 | /// - scalingType: The type of scaling to perform 41 | /// - targetSize: The target size for the image 42 | /// - Returns: The scaled image, or nil if an error occurred 43 | @inlinable func scaleImage( 44 | scalingType: ScalingType = .axesIndependent, 45 | to targetSize: CGSize 46 | ) throws { 47 | try self.replaceContent(with: try self.scalingImage(scalingType: scalingType, to: targetSize)) 48 | } 49 | 50 | /// Create a bitmap by scaling to fit a target size 51 | /// - Parameters: 52 | /// - image: The image to scale 53 | /// - scalingType: The type of scaling to perform 54 | /// - targetSize: The target size for the image 55 | /// - Returns: The scaled image, or nil if an error occurred 56 | @inlinable func scalingImage( 57 | scalingType: ScalingType = .axesIndependent, 58 | to targetSize: CGSize 59 | ) throws -> Bitmap { 60 | switch scalingType { 61 | case .axesIndependent: 62 | return try self.scaling(axesIndependent: targetSize) 63 | case .aspectFill: 64 | return try self.scaling(aspectFill: targetSize) 65 | case .aspectFit: 66 | return try self.scaling(aspectFit: targetSize) 67 | } 68 | } 69 | 70 | /// Create a bitmap by scaling to fit a target size 71 | /// - Parameters: 72 | /// - targetSize: The target size for the image 73 | /// - Returns: The scaled image 74 | func scaling(axesIndependent targetSize: CGSize) throws -> Bitmap { 75 | guard let image = self.cgImage else { throw BitmapError.cannotCreateCGImage } 76 | return try Bitmap(size: targetSize) { ctx in 77 | ctx.draw(image, in: CGRect(origin: .zero, size: targetSize)) 78 | } 79 | } 80 | 81 | /// Create an bitmap by scaling this bitmap to fit the target size 82 | /// - Parameters: 83 | /// - targetSize: The target size for the image 84 | /// - Returns: The scaled image, or nil if an error occurred 85 | func scaling(aspectFit targetSize: CGSize) throws -> Bitmap { 86 | guard let image = self.cgImage else { throw BitmapError.cannotCreateCGImage } 87 | return try Bitmap(size: targetSize) { ctx in 88 | drawImageToFit(in: ctx, image: image, rect: CGRect(origin: .zero, size: targetSize)) 89 | } 90 | } 91 | 92 | /// Create an bitmap by scaling this bitmap to fill the target size 93 | /// - Parameters: 94 | /// - targetSize: The target size for the image 95 | /// - Returns: The scaled image, or nil if an error occurred 96 | func scaling(aspectFill targetSize: CGSize) throws -> Bitmap { 97 | guard let image = self.cgImage else { throw BitmapError.cannotCreateCGImage } 98 | return try Bitmap(size: targetSize) { ctx in 99 | drawImageToFill(in: ctx, image: image, rect: CGRect(origin: .zero, size: targetSize)) 100 | } 101 | } 102 | 103 | /// Create a new bitmap by scaling this bitmap by a scaling factor 104 | /// - Parameter scale: The scale fraction 105 | /// - Returns: A new bitmap 106 | func scaling(scale: CGFloat) throws -> Bitmap { 107 | assert(scale > 0) 108 | let newSize = CGSize(width: CGFloat(width) * scale, height: CGFloat(height) * scale) 109 | return try self.scaling(axesIndependent: newSize) 110 | } 111 | 112 | /// Scale this image by a multiplier value without interpolation (so the resulting image is pixelly) 113 | /// - Parameter multiplier: The multiplier value 114 | /// - Returns: A new bitmap 115 | func scaling(multiplier: CGFloat) throws -> Bitmap { 116 | assert(multiplier > 0) 117 | guard let image = self.cgImage else { throw BitmapError.cannotCreateCGImage } 118 | let targetSize = CGSize(width: CGFloat(width) * multiplier, height: CGFloat(height) * multiplier) 119 | return try Bitmap(size: targetSize) { ctx in 120 | ctx.savingGState { context in 121 | context.interpolationQuality = .none 122 | drawImageToFill(in: context, image: image, rect: CGRect(origin: .zero, size: targetSize)) 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Scroll.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | /// Based on work by [BenedictSt](https://github.com/BenedictSt) 21 | /// See PR [here](https://github.com/dagronf/Bitmap/pull/1) 22 | 23 | import Foundation 24 | 25 | // MARK: - Scroll 26 | 27 | public extension Bitmap { 28 | /// Scroll directions 29 | enum ScrollDirection { 30 | /// Scroll down 31 | case down 32 | /// Scroll up 33 | case up 34 | /// Scroll left 35 | case left 36 | /// Scroll right 37 | case right 38 | } 39 | 40 | /// Scroll the bitmap, wrapping the content around the boundary 41 | /// - Parameters: 42 | /// - direction: The direction of scrolling to apply 43 | /// - count: The number of pixels to scroll 44 | /// - wrapsContent: Should the content wrap when scrolled? 45 | func scroll(direction: ScrollDirection, count: Int = 1, wrapsContent: Bool = true) { 46 | // If the row count to scroll is zero, return early 47 | if count == 0 { return } 48 | 49 | switch direction { 50 | case .down: 51 | self.scrollVertically(isScrollingDown: true, count: count, wrapsContent: wrapsContent) 52 | case .up: 53 | self.scrollVertically(isScrollingDown: false, count: count, wrapsContent: wrapsContent) 54 | case .left: 55 | self.scrollHorizonally(isScrollingRight: false, count: count, wrapsContent: wrapsContent) 56 | case .right: 57 | self.scrollHorizonally(isScrollingRight: true, count: count, wrapsContent: wrapsContent) 58 | } 59 | } 60 | 61 | /// Create a new bitmap by scrolling the bitmap, wrapping the content around the boundary 62 | /// - Parameters: 63 | /// - direction: The direction of scrolling to apply 64 | /// - count: The number of rows or columns to scroll by 65 | /// - wrapsContent: Should the content wrap when scrolled? 66 | /// - Returns: A new image with the original image scrolled 67 | func scrolling(direction: ScrollDirection, count: Int = 1, wrapsContent: Bool = true) throws -> Bitmap { 68 | try self.makingCopy { copy in 69 | copy.scroll(direction: direction, count: count, wrapsContent: wrapsContent) 70 | } 71 | } 72 | } 73 | 74 | // MARK: - Zero point 75 | 76 | public extension Bitmap { 77 | /// Reorient the bitmap around the new coordinate 78 | /// - Parameters: 79 | /// - x: The new x-coordinate to reorient to x=0 80 | /// - y: The new y-coordinate to reorient to y=0 81 | func zeroPoint(x: Int, y: Int) { 82 | assert(x >= 0 && x < self.width) 83 | assert(y >= 0 && y < self.height) 84 | self.scroll(direction: .left, count: x) 85 | self.scroll(direction: .down, count: y) 86 | } 87 | 88 | /// Create a new bitmap by reorienting this bitmap around the new coordinate 89 | /// - Parameters: 90 | /// - x: The new x-coordinate to reorient to x=0 91 | /// - y: The new y-coordinate to reorient to y=0 92 | func zeroingPoint(x: Int, y: Int) throws -> Bitmap { 93 | let copy = try self.copy() 94 | copy.zeroPoint(x: x, y: y) 95 | return copy 96 | } 97 | } 98 | 99 | private extension Bitmap { 100 | func scrollVertically(isScrollingDown: Bool, count: Int, wrapsContent: Bool) { 101 | assert(count >= 1) 102 | assert(count < self.height) 103 | 104 | let splitSize = self.width * 4 * count 105 | let splitPosition: Int 106 | if isScrollingDown { 107 | splitPosition = self.rgbaBytes.count - splitSize 108 | } 109 | else { 110 | splitPosition = splitSize 111 | } 112 | 113 | let topSlice = (!wrapsContent && !isScrollingDown) ? Array(repeating: 0, count: splitPosition) : Array(self.rgbaBytes[.. 131 | let right: ArraySlice 132 | if isScrollingRight { 133 | left = rowSlice[rowSlice.startIndex ..< rowSlice.endIndex - (count * 4)] 134 | right = !wrapsContent ? zeroedSlice[0 ..< (count * 4)] : rowSlice[rowSlice.endIndex - (count * 4) ..< rowSlice.endIndex] 135 | } 136 | else { // direction == .left 137 | left = !wrapsContent ? zeroedSlice[0 ..< (count * 4)] : rowSlice[rowSlice.startIndex ..< rowSlice.startIndex + (count * 4)] 138 | right = rowSlice[rowSlice.startIndex + (count * 4) ..< rowSlice.endIndex] 139 | } 140 | 141 | result.append(contentsOf: right) 142 | result.append(contentsOf: left) 143 | } 144 | self.bitmapData.setBytes(result) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Shadow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Shadow definition 25 | struct Shadow { 26 | /// Specifies a translation in base-space 27 | public let offset: CGSize 28 | /// A non-negative number specifying the amount of blur. 29 | public let blur: Double 30 | /// Specifies the color of the shadow, which may contain a non-opaque alpha value. If NULL, then shadowing is disabled. 31 | public let color: CGColor 32 | /// Create a shadow style 33 | public init(offset: CGSize = .init(width: 3, height: -3), blur: Double = 5, color: CGColor = .standard.black) { 34 | self.offset = offset 35 | self.blur = blur 36 | self.color = color 37 | } 38 | } 39 | } 40 | 41 | // MARK: - Shadows 42 | 43 | public extension Bitmap { 44 | /// Apply a shadow to a drawing block 45 | /// - Parameters: 46 | /// - shadow: The shadow style 47 | /// - draw: The drawing to apply the shadow to 48 | func applyingShadow(_ shadow: Shadow, _ draw: (Bitmap) -> Void) { 49 | self.savingGState { ctx in 50 | ctx.setShadow(offset: shadow.offset, blur: shadow.blur, color: shadow.color) 51 | draw(self) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Text.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreText 22 | 23 | #if os(macOS) 24 | import AppKit 25 | #else 26 | import UIKit 27 | #endif 28 | 29 | public extension Bitmap { 30 | /// The minimum size to contain the string 31 | /// - Parameter string: The string 32 | /// - Returns: The size required to contain the string 33 | @inlinable func requiredBounds(for string: String) -> CGSize { 34 | self.requiredBounds(for: NSAttributedString(string: string)) 35 | } 36 | 37 | /// The minimum size to contain the string 38 | /// - Parameter attributedString: The string 39 | /// - Returns: The size required to contain the string 40 | func requiredBounds(for attributedString: NSAttributedString) -> CGSize { 41 | var ascent: CGFloat = 0 42 | var descent: CGFloat = 0 43 | var leading: CGFloat = 0 44 | let line = CTLineCreateWithAttributedString(attributedString as CFAttributedString) 45 | let width = CTLineGetTypographicBounds(line, &ascent, &descent, &leading) 46 | return CGSize(width: width, height: ascent + descent) 47 | } 48 | 49 | /// Draw text inside the given path 50 | /// - Parameters: 51 | /// - string: The string to draw 52 | /// - color: The text color 53 | /// - path: The position to draw the text 54 | @inlinable func drawText(_ string: String, color: CGColor = .standard.black, position: CGPoint) { 55 | let astr = NSAttributedString(string: string, attributes: [NSAttributedString.Key.foregroundColor: color]) 56 | self.drawText(astr, position: position) 57 | } 58 | 59 | /// Draw text inside the given path 60 | /// - Parameters: 61 | /// - string: The string to draw 62 | /// - color: The text color 63 | /// - path: The path containing the text, or nil for the entire image 64 | @inlinable func drawText(_ string: String, color: CGColor = .standard.black, path: CGPath? = nil) { 65 | let astr = NSAttributedString(string: string, attributes: [NSAttributedString.Key.foregroundColor: color]) 66 | self.drawText(astr, path: path) 67 | } 68 | 69 | /// Draw text at a position within the bitmap 70 | /// - Parameters: 71 | /// - attributedString: The attributed string to draw 72 | /// - position: The position to draw the text 73 | func drawText(_ attributedString: NSAttributedString, position: CGPoint) { 74 | self.savingGState { ctx in 75 | let line = CTLineCreateWithAttributedString(attributedString as CFAttributedString) 76 | ctx.textPosition = position 77 | CTLineDraw(line, ctx) 78 | } 79 | } 80 | 81 | /// Draw text inside the given path 82 | /// - Parameters: 83 | /// - attributedString: The string to draw 84 | /// - path: The path containing the text, or nil for the entire image 85 | func drawText(_ attributedString: NSAttributedString, path: CGPath? = nil) { 86 | let w = self.width 87 | let h = self.height 88 | self.savingGState { ctx in 89 | let frameSetter = CTFramesetterCreateWithAttributedString(attributedString) 90 | let path = path ?? CGPath(rect: CGRect(x: 0, y: 0, width: w, height: h), transform: nil) 91 | let frameRef = CTFramesetterCreateFrame(frameSetter, CFRange(location: 0, length: 0), path, nil) 92 | CTFrameDraw(frameRef, ctx) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Tint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | #if canImport(CoreImage) 24 | import CoreImage 25 | #endif 26 | 27 | public extension Bitmap { 28 | /// Tint this bitmap with a color 29 | /// - Parameters: 30 | /// - color: The tinting color 31 | /// - intensity: The tinting intensity (0 -> 1) 32 | @inlinable func tint(with color: CGColor, intensity: CGFloat = 1.0) throws { 33 | try self.replaceContent(with: try self.tinting(with: color, intensity: intensity)) 34 | } 35 | } 36 | 37 | public extension Bitmap { 38 | /// Tint an area within a bitmap with a specific color 39 | /// - Parameters: 40 | /// - color: The tint color 41 | /// - rect: The rect within the bitmap to tint 42 | /// - intensity: The color intensity (0.0 -> 1.0) 43 | func tint(with color: CGColor, in rect: CGRect, intensity: CGFloat = 1.0) throws { 44 | // Crop out the part to be tinted 45 | let component = try self.cropping(to: rect) 46 | 47 | // Tint this component 48 | let tinted = try component.tinting(with: color, intensity: intensity) 49 | guard let ti = tinted.cgImage else { throw BitmapError.cannotCreateCGImage } 50 | 51 | // And draw the tinted part back into the original image 52 | try self.drawBitmap(ti, in: rect) 53 | } 54 | 55 | /// Tint an area within a bitmap with a specific color 56 | /// - Parameters: 57 | /// - color: The tint color 58 | /// - rect: The rect within the bitmap to tint 59 | /// - intensity: The color intensity (0.0 -> 1.0) 60 | /// - Returns: A new bitmap 61 | func tinting(with color: CGColor, in rect: CGRect, intensity: CGFloat = 1.0) throws -> Bitmap { 62 | try makingCopy { copy in 63 | try copy.tint(with: color, in: rect, intensity: intensity) 64 | } 65 | } 66 | } 67 | 68 | #if canImport(CoreImage) 69 | 70 | public extension Bitmap { 71 | /// Returns a new image tinted with a color 72 | /// - Parameters: 73 | /// - color: The tint color 74 | /// - intensity: The tinting intensity (0 -> 1) 75 | /// - Returns: A new bitmap tinted with the specified color 76 | func tinting(with color: CGColor, intensity: CGFloat = 1.0) throws -> Bitmap { 77 | guard 78 | let filter = CIFilter( 79 | name: "CIColorMonochrome", 80 | parameters: [ 81 | "inputImage": self.ciImage as Any, 82 | "inputColor": CIColor(cgColor: color), 83 | "inputIntensity": 1.0, 84 | ] 85 | ), 86 | let output = filter.outputImage 87 | else { 88 | throw BitmapError.cannotCreateCGImage 89 | } 90 | 91 | return try Bitmap(output) 92 | } 93 | } 94 | 95 | #else 96 | 97 | public extension Bitmap { 98 | 99 | /// Returns a new image tinted with a color 100 | /// - Parameters: 101 | /// - color: The tint color 102 | /// - intensity: The tinting intensity (0 -> 1) 103 | /// - Returns: A new bitmap tinted with the specified color 104 | func tinting(with color: CGColor, intensity: CGFloat = 1.0) throws -> Bitmap { 105 | guard let cgImage = self.cgImage else { throw BitmapError.cannotCreateCGImage } 106 | let rect = CGRect(origin: .zero, size: size) 107 | return try Bitmap(size: size) { ctx in 108 | // draw black background to preserve color of transparent pixels 109 | ctx.setBlendMode(.normal) 110 | ctx.setFillColor(.standard.black) 111 | ctx.fill([rect]) 112 | 113 | // Draw the image 114 | ctx.setBlendMode(.normal) 115 | ctx.draw(cgImage, in: rect) 116 | 117 | // tint image (losing alpha) - the luminosity of the original image is preserved 118 | ctx.setBlendMode(.color) 119 | ctx.setFillColor(color) 120 | ctx.fill([rect]) 121 | 122 | // mask by alpha values of original image 123 | ctx.setBlendMode(.destinationIn) 124 | ctx.draw(cgImage, in: rect) 125 | } 126 | } 127 | } 128 | 129 | #endif 130 | -------------------------------------------------------------------------------- /Sources/Bitmap/drawing/Bitmap+Transparency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | public extension Bitmap { 24 | /// Remove transparency from this image 25 | /// - Parameter backgroundColor: The background color 26 | @inlinable func removeTransparency(backgroundColor: CGColor = .standard.black) throws { 27 | try self.replaceContent(with: try self.removingTransparency(backgroundColor: backgroundColor)) 28 | } 29 | 30 | /// Remove transparency from this image 31 | /// - Parameter backgroundColor: The background color 32 | /// - Returns: A new bitmap with the transparency removed 33 | @inlinable func removeTransparency(backgroundColor: Bitmap.RGBA = .black) throws { 34 | try self.replaceContent(with: try self.removingTransparency(backgroundColor: backgroundColor)) 35 | } 36 | 37 | /// Create a new image by removing the transparency information from this image 38 | /// - Parameter backgroundColor: The background color 39 | /// - Returns: A new bitmap with the transparency removed 40 | @inlinable func removingTransparency(backgroundColor: Bitmap.RGBA = .black) throws -> Bitmap { 41 | try self.removingTransparency(backgroundColor: backgroundColor.cgColor) 42 | } 43 | 44 | /// Create a new image by removing the transparency information from this image 45 | /// - Parameter backgroundColor: The background color 46 | /// - Returns: A new bitmap with transparency removed 47 | func removingTransparency(backgroundColor: CGColor = .standard.black) throws -> Bitmap { 48 | let newBitmap = try Bitmap(size: self.size, backgroundColor: backgroundColor) 49 | try newBitmap.drawBitmap(self, atPoint: .zero) 50 | return newBitmap 51 | } 52 | } 53 | 54 | public extension Bitmap { 55 | /// Map a specific color in this bitmap to transparency 56 | /// - Parameters: 57 | /// - color: The color to map to transparency 58 | /// - includeTransparencyInCheck: If true, include the alpha value in the comparison check 59 | @inlinable @inline(__always) func mapColorToTransparency( 60 | _ color: Bitmap.RGBA, 61 | includeTransparencyInCheck: Bool = false 62 | ) { 63 | self.mapColor(color, to: .clear, includeTransparencyInCheck: includeTransparencyInCheck) 64 | } 65 | 66 | /// Create a new bitmap by mapping a specific color in this bitmap to transparency 67 | /// - Parameters: 68 | /// - color: The color to map to transparency 69 | /// - includeTransparencyInCheck: If true, include the alpha value in the comparison check 70 | @inlinable @inline(__always) func mappingColorToTransparency( 71 | _ color: Bitmap.RGBA, 72 | includeTransparencyInCheck: Bool = false 73 | ) throws -> Bitmap { 74 | try self.mappingColor(color, to: .clear, includeTransparencyInCheck: includeTransparencyInCheck) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/Bitmap/utils/Angle2D.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | 22 | /// A 2D angle 23 | /// 24 | /// An angle value represents either a radians or a degrees angle, with functions to easily convert between the two 25 | public enum Angle2D: Sendable { 26 | /// Radians angle value 27 | case radians(FloatingPointType) 28 | /// Degrees angle value 29 | case degrees(FloatingPointType) 30 | 31 | /// The radians value for the angle 32 | @inlinable public var radians: FloatingPointType { 33 | switch self { 34 | case let .radians(value): return value 35 | case let .degrees(value): return value * 0.01745329251 // precomputed -> pi / 180 36 | } 37 | } 38 | 39 | /// The degrees value for the angle 40 | @inlinable public var degrees: FloatingPointType { 41 | switch self { 42 | case let .radians(value): return value * 57.2957795131 // precomputed -> 180 / pi 43 | case let .degrees(value): return value 44 | } 45 | } 46 | 47 | /// Return a guaranteed radians angle representation 48 | @inlinable public var asRadians: Angle2D { .radians(self.radians) } 49 | /// Return a guaranteed degrees angle representation 50 | @inlinable public var asDegrees: Angle2D { .degrees(self.degrees) } 51 | } 52 | 53 | @usableFromInline let _doublePI: Angle2D = .radians(Double.pi) 54 | public extension Angle2D where FloatingPointType == Double { 55 | /// The mathematical constant pi (π), approximately equal to 3.14159 56 | @inlinable static var pi: Angle2D { _doublePI } 57 | } 58 | 59 | @usableFromInline let _floatPI: Angle2D = .radians(Float.pi) 60 | public extension Angle2D where FloatingPointType == Float { 61 | /// The mathematical constant pi (π), approximately equal to 3.14159 62 | @inlinable static var pi: Angle2D { _floatPI } 63 | } 64 | 65 | @usableFromInline let _cgfloatPI: Angle2D = .radians(CGFloat.pi) 66 | public extension Angle2D where FloatingPointType == CGFloat { 67 | /// The mathematical constant pi (π), approximately equal to 3.14159 68 | @inlinable static var pi: Angle2D { _cgfloatPI } 69 | } 70 | 71 | // MARK: - Additive conformance 72 | 73 | extension Angle2D: AdditiveArithmetic { 74 | /// The zero value 75 | @inlinable public static var zero: Angle2D { .radians(0.0) } 76 | 77 | /// Returns the given number unchanged. 78 | @inlinable public static prefix func + (x: Angle2D) -> Angle2D { x } 79 | /// Adds two values and stores the result in the left-hand-side variable. 80 | @inlinable public static func += (lhs: inout Angle2D, rhs: Angle2D) { 81 | switch lhs { 82 | case let .radians(value): lhs = .radians(value + rhs.radians) 83 | case let .degrees(value): lhs = .degrees(value + rhs.degrees) 84 | } 85 | } 86 | 87 | /// Adds two values and produces their sum. 88 | @inlinable public static func + (lhs: Angle2D, rhs: Angle2D) -> Angle2D { 89 | switch lhs { 90 | case let .radians(value): .radians(value + rhs.radians) 91 | case let .degrees(value): .degrees(value + rhs.degrees) 92 | } 93 | } 94 | 95 | /// Returns the additive inverse of the given angle. 96 | public static prefix func - (angle: Angle2D) -> Angle2D { 97 | switch angle { 98 | case let .radians(value): .radians(-value) 99 | case let .degrees(value): .degrees(-value) 100 | } 101 | } 102 | 103 | /// Subtracts one value from another and produces their difference. 104 | @inlinable public static func - (_ lhs: Angle2D, _ rhs: Angle2D) -> Angle2D { 105 | switch lhs { 106 | case let .radians(value): .radians(value - rhs.radians) 107 | case let .degrees(value): .degrees(value - rhs.degrees) 108 | } 109 | } 110 | 111 | /// Subtracts the second value from the first and stores the difference in the left-hand-side variable. 112 | @inlinable public static func -= (lhs: inout Angle2D, rhs: Angle2D) { 113 | switch lhs { 114 | case let .radians(value): lhs = .radians(value - rhs.radians) 115 | case let .degrees(value): lhs = .degrees(value - rhs.degrees) 116 | } 117 | } 118 | } 119 | 120 | // MARK: - Hashable conformance 121 | 122 | extension Angle2D: Hashable { 123 | public func hash(into hasher: inout Hasher) { 124 | hasher.combine(self.radians) 125 | } 126 | } 127 | 128 | // MARK: - Equatable conformance 129 | 130 | extension Angle2D: Equatable { 131 | /// Returns a Boolean value indicating whether two values are equal. 132 | @inlinable public static func == (lhs: Angle2D, rhs: Angle2D) -> Bool { 133 | switch lhs { 134 | case let .radians(value): return value == rhs.radians 135 | case let .degrees(value): return value == rhs.degrees 136 | } 137 | } 138 | } 139 | 140 | // MARK: - Comparable conformance 141 | 142 | extension Angle2D: Comparable { 143 | /// Returns a Boolean value indicating whether the value of the first argument is less than that of the second argument. 144 | @inlinable public static func < (lhs: Angle2D, rhs: Angle2D) -> Bool { 145 | switch lhs { 146 | case let .radians(value): return value < rhs.radians 147 | case let .degrees(value): return value < rhs.degrees 148 | } 149 | } 150 | } 151 | 152 | // MARK: - CustomDebugStringConvertible conformance 153 | 154 | extension Angle2D: CustomDebugStringConvertible { 155 | public var debugDescription: String { 156 | switch self { 157 | case let .radians(value): return "(rad=\(value), •deg=\(self.degrees))" 158 | case let .degrees(value): return "(deg=\(value), •rad=\(self.radians))" 159 | } 160 | } 161 | } 162 | 163 | // MARK: - Codable conformance 164 | 165 | private let _radiansString = "radians" 166 | private let _degreesString = "degrees" 167 | 168 | extension Angle2D: Codable { 169 | private enum CodingKeys: String, CodingKey { 170 | case type 171 | case value 172 | } 173 | 174 | public init(from decoder: any Decoder) throws { 175 | let data = try decoder.container(keyedBy: CodingKeys.self) 176 | let type = try data.decode(String.self, forKey: .type) 177 | let value = try data.decode(FloatingPointType.self, forKey: .value) 178 | switch type { 179 | case _radiansString: 180 | self = .radians(value) 181 | case _degreesString: 182 | self = .degrees(value) 183 | default: 184 | throw DecodingError.dataCorruptedError( 185 | forKey: .type, 186 | in: data, 187 | debugDescription: "Unknown angle type '\(type)'. Expected 'radians' or 'degrees'" 188 | ) 189 | } 190 | } 191 | 192 | public func encode(to encoder: any Encoder) throws { 193 | var container = encoder.container(keyedBy: CodingKeys.self) 194 | switch self { 195 | case let .radians(value): 196 | try container.encode(_radiansString, forKey: .type) 197 | try container.encode(value, forKey: .value) 198 | 199 | case let .degrees(value): 200 | try container.encode(_degreesString, forKey: .type) 201 | try container.encode(value, forKey: .value) 202 | } 203 | } 204 | } 205 | 206 | // MARK: - Basic math operations 207 | 208 | @inlinable @inline(__always) func cos(_ value: Angle2D) -> Double { Foundation.cos(value.radians) } 209 | @inlinable @inline(__always) func cos(_ value: Angle2D) -> Float { Foundation.cos(value.radians) } 210 | @inlinable @inline(__always) func cos(_ value: Angle2D) -> CGFloat { Foundation.cos(value.radians) } 211 | 212 | @inlinable @inline(__always) func cosh(_ value: Angle2D) -> Double { Foundation.cosh(value.radians) } 213 | @inlinable @inline(__always) func cosh(_ value: Angle2D) -> Float { Foundation.cosh(value.radians) } 214 | @inlinable @inline(__always) func cosh(_ value: Angle2D) -> CGFloat { Foundation.cosh(value.radians) } 215 | 216 | @inlinable @inline(__always) func sin(_ value: Angle2D) -> Double { Foundation.sin(value.radians) } 217 | @inlinable @inline(__always) func sin(_ value: Angle2D) -> Float { Foundation.sin(value.radians) } 218 | @inlinable @inline(__always) func sin(_ value: Angle2D) -> CGFloat { Foundation.sin(value.radians) } 219 | 220 | @inlinable @inline(__always) func sinh(_ value: Angle2D) -> Double { Foundation.sinh(value.radians) } 221 | @inlinable @inline(__always) func sinh(_ value: Angle2D) -> Float { Foundation.sinh(value.radians) } 222 | @inlinable @inline(__always) func sinh(_ value: Angle2D) -> CGFloat { Foundation.sinh(value.radians) } 223 | 224 | @inlinable @inline(__always) func tan(_ value: Angle2D) -> Double { Foundation.tan(value.radians) } 225 | @inlinable @inline(__always) func tan(_ value: Angle2D) -> Float { Foundation.tan(value.radians) } 226 | @inlinable @inline(__always) func tan(_ value: Angle2D) -> CGFloat { Foundation.tan(value.radians) } 227 | 228 | @inlinable @inline(__always) func tanh(_ value: Angle2D) -> Double { Foundation.tanh(value.radians) } 229 | @inlinable @inline(__always) func tanh(_ value: Angle2D) -> Float { Foundation.tanh(value.radians) } 230 | @inlinable @inline(__always) func tanh(_ value: Angle2D) -> CGFloat { Foundation.tanh(value.radians) } 231 | -------------------------------------------------------------------------------- /Sources/Bitmap/utils/CGColor+extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | extension CGColorSpace { 24 | static let csRGB = CGColorSpace(name: CGColorSpace.sRGB)! 25 | } 26 | 27 | public extension Bitmap.RGBA { 28 | /// Create an RGBA color from a cgColor, converting the colorspace if necessary 29 | /// - Parameter cgColor: The color 30 | init(_ cgColor: CGColor) throws { 31 | var color = cgColor 32 | if cgColor.colorSpace?.name != CGColorSpace.sRGB { 33 | guard let c = color.converted(to: CGColorSpace.csRGB, intent: .defaultIntent, options: nil) else { 34 | throw Bitmap.BitmapError.cannotConvertColorSpace 35 | } 36 | color = c 37 | } 38 | guard 39 | let components = color.components, 40 | components.count == 4 41 | else { throw Bitmap.BitmapError.cannotConvertColorSpace } 42 | self.init(rf: components[0], gf: components[1], bf: components[2], af: components[3]) 43 | } 44 | 45 | /// Returns a CGColor representation of the RGBA color using our standard colorspace 46 | var cgColor: CGColor { 47 | CGColor(colorSpace: .csRGB, components: [self.rf, self.gf, self.bf, self.af])! 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Bitmap/utils/CGColor+standard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | // Standard cross-platform color definitions 24 | 25 | public extension CGColor { 26 | struct StandardColors { 27 | static public let clear = CGColor(colorSpace: CGColorSpace.csRGB, components: [0.0, 0.0, 0.0, 0.0])! 28 | static public let black = CGColor(colorSpace: CGColorSpace.csRGB, components: [0.0, 0.0, 0.0, 1.0])! 29 | static public let white = CGColor(colorSpace: CGColorSpace.csRGB, components: [1.0, 1.0, 1.0, 1.0])! 30 | 31 | static public let red = CGColor(colorSpace: CGColorSpace.csRGB, components: [1.0, 0.0, 0.0, 1.0])! 32 | static public let green = CGColor(colorSpace: CGColorSpace.csRGB, components: [0.0, 1.0, 0.0, 1.0])! 33 | static public let blue = CGColor(colorSpace: CGColorSpace.csRGB, components: [0.0, 0.0, 1.0, 1.0])! 34 | static public let yellow = CGColor(colorSpace: CGColorSpace.csRGB, components: [1.0, 1.0, 0.0, 1.0])! 35 | static public let magenta = CGColor(colorSpace: CGColorSpace.csRGB, components: [1.0, 0.0, 1.0, 1.0])! 36 | static public let cyan = CGColor(colorSpace: CGColorSpace.csRGB, components: [0.0, 1.0, 1.0, 1.0])! 37 | } 38 | 39 | /// Standard color definition accessor 40 | static var standard: StandardColors.Type { StandardColors.self } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Bitmap/utils/CGContext+extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | extension CGContext { 24 | /// Block-based graphics state saving 25 | @inlinable func savingGState(_ drawBlock: (CGContext) throws -> Void ) rethrows -> Void { 26 | self.saveGState() 27 | defer { self.restoreGState() } 28 | try drawBlock(self) 29 | } 30 | 31 | /// Wrap the drawing commands in `block` within a transparency layer 32 | @inlinable func usingTransparencyLayer(auxiliaryInfo: CFDictionary? = nil, _ block: () -> Void) { 33 | self.beginTransparencyLayer(auxiliaryInfo: auxiliaryInfo) 34 | defer { self.endTransparencyLayer() } 35 | block() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Bitmap/utils/CGContext+innerShadow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT License 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy 7 | // of this software and associated documentation files (the "Software"), to deal 8 | // in the Software without restriction, including without limitation the rights 9 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | // copies of the Software, and to permit persons to whom the Software is 11 | // furnished to do so, subject to the following conditions: 12 | // 13 | // The above copyright notice and this permission notice shall be included in all 14 | // copies or substantial portions of the Software. 15 | // 16 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | // SOFTWARE. 23 | 24 | import CoreGraphics 25 | import Foundation 26 | 27 | public extension CGContext { 28 | /// Draw an inner shadow in the path 29 | /// - Parameters: 30 | /// - path: The path to apply the inner shadow to 31 | /// - shadowColor: Specifies the color of the shadow, which may contain a non-opaque alpha value. If NULL, then shadowing is disabled. 32 | /// - offset: Specifies a translation in base-space. 33 | /// - blurRadius: A non-negative number specifying the amount of blur. 34 | /// 35 | /// **Inner Shadows in Quartz: Helftone** 36 | /// [Blog Article](https://blog.helftone.com/demystifying-inner-shadows-in-quartz/) 37 | /// [(Archived)](https://web.archive.org/web/20221206132428/https://blog.helftone.com/demystifying-inner-shadows-in-quartz/) 38 | func drawInnerShadow(in path: CGPath, shadowColor: CGColor?, offset: CGSize, blurRadius: CGFloat) { 39 | guard 40 | let shadowColor = shadowColor, 41 | let opaqueShadowColor = shadowColor.copy(alpha: 1.0) 42 | else { 43 | // No shadow color specified, therefore no shadow. 44 | return 45 | } 46 | 47 | self.savingGState { ctx in 48 | ctx.addPath(path) 49 | ctx.clip() 50 | ctx.setAlpha(shadowColor.alpha) 51 | ctx.usingTransparencyLayer { 52 | ctx.setShadow(offset: offset, blur: blurRadius, color: opaqueShadowColor) 53 | ctx.setBlendMode(.sourceOut) 54 | ctx.setFillColor(opaqueShadowColor) 55 | ctx.addPath(path) 56 | ctx.fillPath() 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Bitmap/utils/CGImage+extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | extension CGImage { 24 | /// The size of the image 25 | var size: CGSize { CGSize(width: self.width, height: self.height) } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Bitmap/utils/CGRect+extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | extension CGRect { 24 | /// A basic path representation for the rect 25 | @inlinable var path: CGPath { CGPath(rect: self, transform: nil) } 26 | /// A basic ellipse path representation within the bounds of the rect 27 | @inlinable var ellipsePath: CGPath { CGPath(ellipseIn: self, transform: nil) } 28 | 29 | /// Flip the y axis within a bounds rect 30 | @inlinable func flippingY(within bounds: CGRect) -> CGRect { 31 | var b = self 32 | b.origin.y = bounds.height - (b.origin.y + b.size.height) 33 | return b 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Bitmap/utils/Clamp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | 22 | extension Numeric where Self: Comparable { 23 | @inlinable @inline(__always) 24 | func clamped(to range: ClosedRange) -> Self { 25 | min(max(self, range.lowerBound), range.upperBound) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/16-squares.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/16-squares.png -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/apple-logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/apple-logo-dark.png -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/apple-logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/apple-logo-white.png -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/cat-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/cat-icon.png -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/cmyk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/cmyk.jpg -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/dog.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/dog.jpeg -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/food.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/food.jpg -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/gps-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/gps-image.jpg -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/p3test.ppm: -------------------------------------------------------------------------------- 1 | P3 2 | # feep.ppm 3 | 4 4 4 | 15 5 | 0 0 0 0 0 0 0 0 0 15 0 15 6 | 0 0 0 0 15 7 0 0 0 0 0 0 7 | 0 0 0 0 0 0 0 15 7 0 0 0 8 | 15 0 15 0 0 0 0 0 0 0 0 0 9 | -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/p6test.ppm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/p6test.ppm -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/qrcode.png -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/sf-bb.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/sf-bb.jpeg -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/sf-ggb.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/sf-ggb.jpeg -------------------------------------------------------------------------------- /Tests/BitmapTests/resources/viking.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dagronf/Bitmap/c998028004035c34dbd969e7e89027ce358bbb33/Tests/BitmapTests/resources/viking.jpg -------------------------------------------------------------------------------- /Tests/BitmapTests/utils/MarkdownGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | import CoreGraphics 22 | 23 | import Bitmap 24 | 25 | class MarkdownGenerator { 26 | var content: String = "" 27 | 28 | var images: [(filename: String, imageData: Data)] = [] 29 | 30 | func write(to destination: URL) throws { 31 | let file = FileWrapper(directoryWithFileWrappers: [:]) 32 | let imageFolder = FileWrapper(directoryWithFileWrappers: [:]) 33 | imageFolder.preferredFilename = "images" 34 | file.addFileWrapper(imageFolder) 35 | 36 | images.forEach { image in 37 | //let data = try WCGImage.pngData(image: image.1, compression: 0.7) 38 | let imageFile = FileWrapper(regularFileWithContents: image.imageData) 39 | imageFile.preferredFilename = image.filename 40 | imageFolder.addFileWrapper(imageFile) 41 | } 42 | 43 | let markdownFile = FileWrapper(regularFileWithContents: content.data(using: .utf8)!) 44 | markdownFile.preferredFilename = "index.md" 45 | file.addFileWrapper(markdownFile) 46 | 47 | try file.write(to: destination, originalContentsURL: nil) 48 | } 49 | 50 | /// Add a header 1 51 | @discardableResult func h1(_ text: String, _ block: ((MarkdownGenerator) throws -> Void)? = nil) rethrows -> MarkdownGenerator { 52 | content += "# \(text)\n\n" 53 | if let _ = block { try block?(self) } 54 | self.br() 55 | return self 56 | } 57 | 58 | /// Add a header 2 59 | @discardableResult func h2(_ text: String, _ block: ((MarkdownGenerator) throws -> Void)? = nil) rethrows -> MarkdownGenerator { 60 | content += "## \(text)\n\n" 61 | if let _ = block { try block?(self) } 62 | self.br() 63 | return self 64 | } 65 | 66 | /// Add a header 3 67 | @discardableResult func h3(_ text: String, _ block: ((MarkdownGenerator) throws -> Void)? = nil) rethrows -> MarkdownGenerator { 68 | content += "### \(text)\n\n" 69 | if let _ = block { try block?(self) } 70 | self.br() 71 | return self 72 | } 73 | 74 | /// Add a header 4 75 | @discardableResult func h4(_ text: String, _ block: ((MarkdownGenerator) throws -> Void)? = nil) rethrows -> MarkdownGenerator { 76 | content += "#### \(text)\n\n" 77 | if let _ = block { try block?(self) } 78 | self.br() 79 | return self 80 | } 81 | 82 | /// Add text content 83 | @discardableResult func text(_ text: String) -> MarkdownGenerator { 84 | content += "\(text)\n" 85 | return self 86 | } 87 | 88 | /// Add raw text (doesn't attempt to interpret the text) 89 | @discardableResult func raw(_ text: String) -> MarkdownGenerator { 90 | content += text 91 | return self 92 | } 93 | 94 | /// Add a line break 95 | @discardableResult func br() -> MarkdownGenerator { 96 | content += "\n\n" 97 | return self 98 | } 99 | 100 | /// Add a bullet list 101 | @discardableResult func bulleted(_ rawBulletStrings: [String]) -> MarkdownGenerator { 102 | content += "\n" 103 | 104 | content += rawBulletStrings.reduce("") { partialResult, content in 105 | partialResult + "* \(content)\n" 106 | } 107 | 108 | content += "\n" 109 | return self 110 | } 111 | 112 | @discardableResult func image(_ image: CGImage, width: CGFloat? = nil, height: CGFloat? = nil, linked: Bool = true) throws -> MarkdownGenerator { 113 | try self.image(try Bitmap(image), width: width, height: height, linked: linked) 114 | } 115 | 116 | /// Add an image to the markdown 117 | @discardableResult func image(_ image: Bitmap, width: CGFloat? = nil, height: CGFloat? = nil, linked: Bool = true) throws -> MarkdownGenerator { 118 | let identifier = "\(UUID().uuidString).png" 119 | 120 | let data = try image.representation!.png() 121 | images.append((identifier, data)) 122 | 123 | if linked { 124 | content += "" 125 | } 126 | 127 | do { 128 | content += "" 129 | if let width = width { 130 | content += " width=\"\(width)\"" 131 | } 132 | if let height = height { 133 | content += " height=\"\(height)\"" 134 | } 135 | content += " /> " 136 | } 137 | 138 | if linked { 139 | content += " " 140 | } 141 | 142 | return self 143 | } 144 | 145 | /// Add an image with optional sizes and clickability 146 | @discardableResult func imageData(_ data: Data, extn: String, width: CGFloat? = nil, height: CGFloat? = nil, linked: Bool = true) throws -> MarkdownGenerator { 147 | let identifier = "\(UUID().uuidString).\(extn)" 148 | images.append((identifier, data)) 149 | 150 | if linked { 151 | content += "" 152 | } 153 | 154 | do { 155 | content += "" 156 | if let width = width { 157 | content += " width=\"\(width)\"" 158 | } 159 | if let height = height { 160 | content += " height=\"\(height)\"" 161 | } 162 | content += " /> " 163 | } 164 | 165 | if linked { 166 | content += "" 167 | } 168 | 169 | return self 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Tests/BitmapTests/utils/TestUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Darren Ford. All rights reserved. 3 | // 4 | // MIT license 5 | // 6 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 8 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all copies or substantial 12 | // portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 15 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 16 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 17 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 18 | // 19 | 20 | import Foundation 21 | 22 | // Note: DateFormatter is thread safe 23 | // See https://developer.apple.com/documentation/foundation/dateformatter#1680059 24 | private let iso8601Formatter: DateFormatter = { 25 | let dateFormatter = DateFormatter() 26 | dateFormatter.locale = Locale(identifier: "en_US_POSIX") // set locale to reliable US_POSIX ISO8601 27 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HHmmssSSSZ" 28 | return dateFormatter 29 | }() 30 | 31 | class TestOutputContainer { 32 | let _root = FileManager.default.temporaryDirectory 33 | let _container: URL 34 | 35 | init(title: String) { 36 | _container = _root 37 | .appendingPathComponent(title) 38 | .appendingPathComponent(iso8601Formatter.string(from: Date())) 39 | try! FileManager.default.createDirectory(at: _container, withIntermediateDirectories: true) 40 | 41 | // Create a symbolic link for the latest results 42 | let latest = _root.appendingPathComponent(title).appendingPathComponent("_latest") 43 | try? FileManager.default.removeItem(at: latest) 44 | try! FileManager.default.createSymbolicLink(at: latest, withDestinationURL: _container) 45 | Swift.print("Temp files at: \(_container)") 46 | } 47 | 48 | func testFilenameWithName(_ name: String) throws -> URL { 49 | _container.appendingPathComponent(name) 50 | } 51 | } 52 | 53 | let OperatingSystemVersion: String = { ProcessInfo.processInfo.operatingSystemVersionString }() 54 | 55 | #if os(macOS) 56 | 57 | let DeviceModel: String = { 58 | var size = 0 59 | sysctlbyname("hw.model", nil, &size, nil, 0) 60 | var machine = [CChar](repeating: 0, count: size) 61 | sysctlbyname("hw.model", &machine, &size, nil, 0) 62 | return String(cString: machine) 63 | }() 64 | 65 | //let CurrentMacModel: String? = { 66 | // let service = IOServiceGetMatchingService( 67 | // kIOMainPortDefault, 68 | // IOServiceMatching("IOPlatformExpertDevice") 69 | // ) 70 | // defer { IOObjectRelease(service) } 71 | // if let modelData = IORegistryEntryCreateCFProperty(service, "model" as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? Data { 72 | // if let modelIdentifierCString = String(data: modelData, encoding: .utf8)?.cString(using: .utf8) { 73 | // return String(cString: modelIdentifierCString) 74 | // } 75 | // } 76 | // return nil 77 | //}() 78 | 79 | #endif 80 | --------------------------------------------------------------------------------