From a865b779cf2826524196901dc57b083a82782322 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Sun, 6 Oct 2024 15:53:38 -0700 Subject: [PATCH] [graphics] add CGContext extensions --- Package.resolved | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../CoreGraphics/CGContext+Extensions.swift | 113 +++++++ .../CGPath+Extensions.swift | 0 .../CGPath+Transform.swift | 0 .../CGPathElement.Element.swift | 0 .../CGContext+ExtensionsTests.swift | 277 ++++++++++++++++++ .../CGPath+ExtensionsTests.swift | 0 .../CGPath+TransformTests.swift | 0 .../CGPathElement.Element+TestHelpers.swift | 0 .../CGPathElement.ElementTests.swift | 0 11 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 Sources/ChouTiUI/Universal/CoreGraphics/CGContext+Extensions.swift rename Sources/ChouTiUI/Universal/{Graphics => CoreGraphics}/CGPath+Extensions.swift (100%) rename Sources/ChouTiUI/Universal/{Graphics => CoreGraphics}/CGPath+Transform.swift (100%) rename Sources/ChouTiUI/Universal/{Graphics => CoreGraphics}/CGPathElement.Element.swift (100%) create mode 100644 Tests/ChouTiUITests/Universal/CoreGraphics/CGContext+ExtensionsTests.swift rename Tests/ChouTiUITests/Universal/{Graphics => CoreGraphics}/CGPath+ExtensionsTests.swift (100%) rename Tests/ChouTiUITests/Universal/{Graphics => CoreGraphics}/CGPath+TransformTests.swift (100%) rename Tests/ChouTiUITests/Universal/{Graphics => CoreGraphics}/CGPathElement.Element+TestHelpers.swift (100%) rename Tests/ChouTiUITests/Universal/{Graphics => CoreGraphics}/CGPathElement.ElementTests.swift (100%) diff --git a/Package.resolved b/Package.resolved index 3d002d8..96cb778 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "location" : "https://github.com/honghaoz/ChouTi", "state" : { "branch" : "develop", - "revision" : "db4e47dbe69db57806049b7a99284809a03b573a" + "revision" : "43251518cd40350ffa0ac60c3a14c1e5cf0b9858" } } ], diff --git a/Project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3d002d8..96cb778 100644 --- a/Project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "location" : "https://github.com/honghaoz/ChouTi", "state" : { "branch" : "develop", - "revision" : "db4e47dbe69db57806049b7a99284809a03b573a" + "revision" : "43251518cd40350ffa0ac60c3a14c1e5cf0b9858" } } ], diff --git a/Sources/ChouTiUI/Universal/CoreGraphics/CGContext+Extensions.swift b/Sources/ChouTiUI/Universal/CoreGraphics/CGContext+Extensions.swift new file mode 100644 index 0000000..ee6fccb --- /dev/null +++ b/Sources/ChouTiUI/Universal/CoreGraphics/CGContext+Extensions.swift @@ -0,0 +1,113 @@ +// +// CGContext+Extensions.swift +// ChouTiUI +// +// Created by Honghao Zhang on 1/14/22. +// Copyright © 2020 Honghao Zhang. +// +// MIT License +// +// Copyright (c) 2020 Honghao Zhang (github.com/honghaoz) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// + +#if canImport(AppKit) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +import CoreGraphics + +public extension CGContext { + + /// Returns the current drawing context. + @inlinable + @inline(__always) + static var current: CGContext? { + #if os(macOS) + return NSGraphicsContext.current?.cgContext + #else + return UIGraphicsGetCurrentContext() + #endif + + /// https://github.com/shaps80/GraphicsRenderer/blob/master/GraphicsRenderer/Classes/Platforms.swift + } + + /// Flips the context's coordinate system vertically. + /// + /// - Parameter height: The height of the context to flip. + func flipCoordinatesVertically(height: CGFloat) { + translateBy(x: 0.0, y: height) + scaleBy(x: 1.0, y: -1.0) + + /// https://stackoverflow.com/questions/506622/cgcontextdrawimage-draws-image-upside-down-when-passed-uiimage-cgimage + /// + /// Useful when drawing CGImage on context. + } + + // Deprecated: The below implementation is not reliable. + // + /// Get the height of the context. + // private func getContextHeight() -> CGFloat { + // let contextHeight = self.height + // if contextHeight > 0 { + // return CGFloat(contextHeight) + // } else { + // // got zero height, the context is not a bitmap context + // // fallback to use the clip path to get the height + // saveGState() + // + // resetClip() + // let height = boundingBoxOfClipPath.size.height + // + // restoreGState() + // + // return height + // } + // } + + /// Executes drawing operations within a saved graphics state. + /// + /// Example: + /// ```swift + /// context.onPushedGraphicsState { context in + /// context.setFillColor(UIColor.red.cgColor) + /// context.fill(CGRect(x: 0, y: 0, width: 100, height: 100)) + /// } + /// ``` + /// + /// - Parameter draw: A closure that performs drawing operations on the context. + @inlinable + @inline(__always) + func onPushedGraphicsState(_ draw: (_ context: CGContext) throws -> Void) rethrows { + saveGState() + try draw(self) + restoreGState() + } +} + +/// NSView Drawing Issue on macOS Big Sur.md +/// https://gist.github.com/lukaskubanek/9a61ac71dc0db8bb04db2028f2635779 + +/// Cocoa Drawing Guide +/// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CocoaDrawingGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40003290-CH201-SW1 diff --git a/Sources/ChouTiUI/Universal/Graphics/CGPath+Extensions.swift b/Sources/ChouTiUI/Universal/CoreGraphics/CGPath+Extensions.swift similarity index 100% rename from Sources/ChouTiUI/Universal/Graphics/CGPath+Extensions.swift rename to Sources/ChouTiUI/Universal/CoreGraphics/CGPath+Extensions.swift diff --git a/Sources/ChouTiUI/Universal/Graphics/CGPath+Transform.swift b/Sources/ChouTiUI/Universal/CoreGraphics/CGPath+Transform.swift similarity index 100% rename from Sources/ChouTiUI/Universal/Graphics/CGPath+Transform.swift rename to Sources/ChouTiUI/Universal/CoreGraphics/CGPath+Transform.swift diff --git a/Sources/ChouTiUI/Universal/Graphics/CGPathElement.Element.swift b/Sources/ChouTiUI/Universal/CoreGraphics/CGPathElement.Element.swift similarity index 100% rename from Sources/ChouTiUI/Universal/Graphics/CGPathElement.Element.swift rename to Sources/ChouTiUI/Universal/CoreGraphics/CGPathElement.Element.swift diff --git a/Tests/ChouTiUITests/Universal/CoreGraphics/CGContext+ExtensionsTests.swift b/Tests/ChouTiUITests/Universal/CoreGraphics/CGContext+ExtensionsTests.swift new file mode 100644 index 0000000..4f0f0f3 --- /dev/null +++ b/Tests/ChouTiUITests/Universal/CoreGraphics/CGContext+ExtensionsTests.swift @@ -0,0 +1,277 @@ +// +// CGContext+ExtensionsTests.swift +// ChouTiUI +// +// Created by Honghao Zhang on 10/6/24. +// Copyright © 2020 Honghao Zhang. +// +// MIT License +// +// Copyright (c) 2020 Honghao Zhang (github.com/honghaoz) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// + +#if canImport(AppKit) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +import CoreGraphics + +import ChouTiTest + +import ChouTiUI + +final class CGContext_ExtensionsTests: XCTestCase { + + func testFlipCoordinatesVertically() throws { + // matching height + do { + let context = try CGContext( + data: nil, + width: 200, + height: 100, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ).unwrap() + expect(context.ctm) == CGAffineTransform.identity + context.flipCoordinatesVertically(height: 100) + expect(context.ctm) == CGAffineTransform(a: 1.0, b: 0, c: 0, d: -1, tx: 0, ty: 100.0) + } + + // smaller height + do { + let context = try CGContext( + data: nil, + width: 200, + height: 100, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ).unwrap() + expect(context.ctm) == CGAffineTransform.identity + context.flipCoordinatesVertically(height: 30) + expect(context.ctm) == CGAffineTransform(a: 1.0, b: 0.0, c: -0.0, d: -1.0, tx: 0.0, ty: 30.0) + } + } + + func testFlipCoordinatesVertically_usingLayer() { + let expectation = self.expectation(description: "draw") + + class TestLayer: CALayer { + + var expectation: XCTestExpectation? + + override func draw(in context: CGContext) { + let scale = contentsScale + + #if os(macOS) + context.flipCoordinatesVertically(height: bounds.height) + #endif + + expect(context.ctm) == CGAffineTransform(a: scale, b: 0, c: 0, d: -scale, tx: 0, ty: bounds.height * scale) + context.flipCoordinatesVertically(height: bounds.height) + expect(context.ctm) == CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: 0, ty: 0) + + super.draw(in: context) + + expectation?.fulfill() + } + } + + let layer = TestLayer() + layer.contentsScale = 2.0 + layer.frame = CGRect(x: 0, y: 0, width: 200, height: 100) + layer.expectation = expectation + + // trigger draw + layer.setNeedsDisplay() + layer.displayIfNeeded() + + waitForExpectations(timeout: 1) + } + + func testFlipCoordinatesVertically_usingView() { + let expectation = self.expectation(description: "draw") + + class TestView: View { + + var expectation: XCTestExpectation? + + #if os(macOS) + // isFlipped has no effect on the drawing context + // override var isFlipped: Bool { true } + + override init(frame: CGRect) { + super.init(frame: frame) + + wantsLayer = true + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + // swiftlint:disable:next fatal_error + fatalError("init(coder:) is unavailable") + } + #endif + + override func draw(_ rect: CGRect) { + super.draw(rect) + guard let context = CGContext.current else { + fail("Failed to get current CGContext") + return + } + + #if os(macOS) + context.flipCoordinatesVertically(height: bounds.height) + #endif + + let scale = self.unsafeLayer.contentsScale + expect(context.ctm) == CGAffineTransform(a: scale, b: 0, c: 0, d: -scale, tx: 0, ty: bounds.height * scale) + context.flipCoordinatesVertically(height: bounds.height) + expect(context.ctm) == CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: 0, ty: 0) + + expectation?.fulfill() + } + } + + let view = TestView(frame: CGRect(x: 0, y: 0, width: 200, height: 100)) + view.expectation = expectation + #if os(macOS) + view.setNeedsDisplay(.zero) + view.display() + #else + view.layer()?.setNeedsDisplay() + view.layer()?.displayIfNeeded() + #endif + + waitForExpectations(timeout: 1) + } + + func testOnPushedGraphicsState() throws { + let context = try CGContext( + data: nil, + width: 100, + height: 100, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ).unwrap() + + func drawLine(y: CGFloat, color: CGColor) { + context.setStrokeColor(color) + + context.beginPath() + context.move(to: CGPoint(x: 0, y: y)) + context.addLine(to: CGPoint(x: 100, y: y)) + context.strokePath() + } + + func measureHorizontalLineWidth(in image: CGImage, + lineColor: CGColor, + file: StaticString = #filePath, + line: UInt = #line) -> CGFloat + { + guard let data = image.dataProvider?.data, + let pointer = CFDataGetBytePtr(data) + else { + fail("Failed to get image data", file: file, line: line) + return 0 + } + + let bytesPerRow = image.bytesPerRow + let bytesPerPixel = image.bitsPerPixel / 8 + let midX = image.width / 2 + + expect(image.alphaInfo, file: file, line: line) == .premultipliedLast + + let components = lineColor.components ?? [0, 0, 0, 1] + let expectedRed = UInt8(components[0] * 255) + let expectedGreen = UInt8(components[1] * 255) + let expectedBlue = UInt8(components[2] * 255) + let expectedAlpha = UInt8(components[3] * 255) + + var startY: Int? + var endY: Int? + + for y in 0 ..< image.height { + let offset = y * bytesPerRow + midX * bytesPerPixel + let red = pointer[offset] + let green = pointer[offset + 1] + let blue = pointer[offset + 2] + let alpha = pointer[offset + 3] + + // Check if the pixel is the line color and not transparent (255 alpha) + if red == expectedRed, green == expectedGreen, blue == expectedBlue, alpha == expectedAlpha { + if startY == nil { + startY = y + } + endY = y + } else if startY != nil { + // we've found the end of the line + break + } + } + + guard let start = startY, let end = endY else { + fail("Failed to find line in image", file: file, line: line) + return 0 + } + + return CGFloat(end - start + 1) + } + + // set line width to 10 + let lineWidth: CGFloat = 10 + context.setLineWidth(lineWidth) + + // verify line width is 10 + do { + let lineColor = CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 1) + drawLine(y: 20, color: lineColor) + expect(try measureHorizontalLineWidth(in: context.makeImage().unwrap(), lineColor: lineColor)) == lineWidth + } + + // set line width to 20 on a pushed graphics state + try context.onPushedGraphicsState { context in + let lineWidth: CGFloat = 20 + context.setLineWidth(lineWidth) + + // verify line width is 20 + let lineColor = CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 1) + drawLine(y: 50, color: lineColor) + expect(try measureHorizontalLineWidth(in: context.makeImage().unwrap(), lineColor: lineColor)) == lineWidth + } + + // verify line width is still 10 + do { + let lineColor = CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 1) + drawLine(y: 80, color: lineColor) + expect(try measureHorizontalLineWidth(in: context.makeImage().unwrap(), lineColor: lineColor)) == lineWidth + } + } +} diff --git a/Tests/ChouTiUITests/Universal/Graphics/CGPath+ExtensionsTests.swift b/Tests/ChouTiUITests/Universal/CoreGraphics/CGPath+ExtensionsTests.swift similarity index 100% rename from Tests/ChouTiUITests/Universal/Graphics/CGPath+ExtensionsTests.swift rename to Tests/ChouTiUITests/Universal/CoreGraphics/CGPath+ExtensionsTests.swift diff --git a/Tests/ChouTiUITests/Universal/Graphics/CGPath+TransformTests.swift b/Tests/ChouTiUITests/Universal/CoreGraphics/CGPath+TransformTests.swift similarity index 100% rename from Tests/ChouTiUITests/Universal/Graphics/CGPath+TransformTests.swift rename to Tests/ChouTiUITests/Universal/CoreGraphics/CGPath+TransformTests.swift diff --git a/Tests/ChouTiUITests/Universal/Graphics/CGPathElement.Element+TestHelpers.swift b/Tests/ChouTiUITests/Universal/CoreGraphics/CGPathElement.Element+TestHelpers.swift similarity index 100% rename from Tests/ChouTiUITests/Universal/Graphics/CGPathElement.Element+TestHelpers.swift rename to Tests/ChouTiUITests/Universal/CoreGraphics/CGPathElement.Element+TestHelpers.swift diff --git a/Tests/ChouTiUITests/Universal/Graphics/CGPathElement.ElementTests.swift b/Tests/ChouTiUITests/Universal/CoreGraphics/CGPathElement.ElementTests.swift similarity index 100% rename from Tests/ChouTiUITests/Universal/Graphics/CGPathElement.ElementTests.swift rename to Tests/ChouTiUITests/Universal/CoreGraphics/CGPathElement.ElementTests.swift