// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit /// Produces styled QR codes containing aesthetic features such as an overlaid /// Signal logo and rounded "pixels". They are scaled up so as to appropriately /// render the rounded shapes. struct QRCodeGenerator { enum StylingMode { case brandedWithLogo case brandedWithoutLogo } init() {} // MARK: - /// Generates a styled, Signal-branded QR code image encoding the given URL. func generateQRCode(url: URL, stylingMode: StylingMode = .brandedWithLogo) -> UIImage? { let urlData = Data(url.absoluteString.utf8) guard let unstyledQRCode = generateUnstyledQRCode(data: urlData) else { return nil } return QRCodeStyler().styleQRCode( unstyledQRCode: unstyledQRCode, stylingMode: stylingMode ) } /// Generate an un-styled QR code image encoding the given data. func generateUnstyledQRCode(data: Data) -> UIImage? { guard let filter = CIFilter(name: "CIQRCodeGenerator") else { owsFailDebug("Failed to get QR code generation filer!") return nil } filter.setValue("H", forKey: "inputCorrectionLevel") filter.setValue(data, forKey: "inputMessage") guard let ciImage = filter.outputImage else { owsFailDebug("Failed to get CI image!") return nil } // The unstyled images will always be black-over-clear, and callers can // apply tint colors as desired. let colorParameters = [ "inputColor0": CIColor(color: .black), "inputColor1": CIColor(color: .clear) ] let recoloredCIImage = ciImage.applyingFilter("CIFalseColor", parameters: colorParameters) // UIImages backed by a CIImage won't render without antialiasing, so we convert the backing // image to a CGImage, which can be scaled crisply. let context = CIContext(options: nil) guard let cgImage = context.createCGImage(recoloredCIImage, from: recoloredCIImage.extent) else { owsFailDebug("Failed to create CG image!") return nil } return UIImage(cgImage: cgImage) } } // MARK: - private struct QRCodeStyler { /// For the following constants: /// - "Point" refers to a coordinate ("pixel") in the QR code. /// - "Pixel" refers to a pixel in the image returned by the generator. private enum Constants { /// The number of pixels used to draw each QR code point. static let imagePixelsPerQRCodePoint: Int = 30 /// The desired percentage of QR code points that should be occluded by /// the deadzone. Note that the deadzone size will be calculated in /// points (rounded down), as it sits in the context of the QR code /// image. static let deadzoneSizePointsPercentage: CGFloat = 64 / 184 /// A number of points by which to pad the deadzone around the drawn /// circle. static let deadzonePaddingPoints: Int = 1 /// The percentage of the deadzone to be dedicated to the circle stroke. static let deadzoneCircleStrokePercentage: CGFloat = 4 / 64 /// The percentage of the deadzone in each dimension that will be /// occupied by the logo. Note that this will be exact, as inside the /// deadzone we do not need to use points. static let deadzoneLogoSizePercentage: CGFloat = 38 / 64 } typealias StylingMode = QRCodeGenerator.StylingMode init() {} func styleQRCode( unstyledQRCode: UIImage, stylingMode: StylingMode ) -> UIImage { guard let cgQRCode = unstyledQRCode.cgImage, let qrCodeBitmap = Bitmaps.Image(cgImage: cgQRCode) else { owsFailDebug("Failed to get bitmap!") return unstyledQRCode } let deadzone: Bitmaps.Rect? = switch stylingMode { case .brandedWithLogo: // Specify a centered deadzone in the QR code. qrCodeBitmap.centeredDeadzone( dimensionPercentage: Constants.deadzoneSizePointsPercentage, paddingPoints: Constants.deadzonePaddingPoints ) case .brandedWithoutLogo: nil } // Make a grid drawing of the QR code. let qrCodeGridDrawing = qrCodeBitmap.gridDrawingByMergingAdjacentPixels( deadzone: deadzone ) // Paint the grid drawing into a CGContext. let styledQRCodeContext: CGContext = .drawing( gridDrawing: qrCodeGridDrawing, scaledBy: Constants.imagePixelsPerQRCodePoint, foregroundColor: UIColor.black.cgColor, backgroundColor: UIColor.clear.cgColor ) if let deadzone { // Draw a circle into the deadzone, inside the padding. When drawing, // inset by half the stroke so the circle draws entirely inside the rect // instead of straddling the edges (the CoreGraphics behavior). let circleRect = deadzone.cgRect( scaledBy: CGFloat(Constants.imagePixelsPerQRCodePoint), insetBy: CGFloat(Constants.deadzonePaddingPoints) ) let circleStroke = circleRect.width * Constants.deadzoneCircleStrokePercentage styledQRCodeContext.setLineWidth(circleStroke) styledQRCodeContext.strokeEllipse(in: circleRect.insetBy( dx: circleStroke / 2, dy: circleStroke / 2 )) // Draw the logo inside the circle in the deadzone. let logo = UIImage(named: "signal-logo-40")! let logoRect = circleRect.scaled(toPercentage: Constants.deadzoneLogoSizePercentage) styledQRCodeContext.draw(logo.cgImage!, in: logoRect) } guard let styledQRCodeImage = styledQRCodeContext.makeImage() else { owsFailDebug("Failed to make styled image!") return unstyledQRCode } return UIImage(cgImage: styledQRCodeImage) } } extension Bitmaps.Image { /// Compute a zone occupying the center of the image, with dimensions /// the given percentage of each dimension of the image (rounded down), plus /// the given padding on each side. func centeredDeadzone( dimensionPercentage percentage: CGFloat, paddingPoints: Int ) -> Bitmaps.Rect { owsPrecondition( percentage < 0.5, // Roughly the dimension percentage for deadzoning 30% of the surface area "Deadzoning too much of a QR code means it might not scan!" ) let widthRange = centeredRange( percentage: percentage, ofInt: width, padding: paddingPoints ) let heightRange = centeredRange( percentage: percentage, ofInt: height, padding: paddingPoints ) return Bitmaps.Rect( x: widthRange.lowerBound, y: heightRange.lowerBound, width: widthRange.upperBound - widthRange.lowerBound, height: heightRange.upperBound - heightRange.lowerBound ) } private func centeredRange( percentage: CGFloat, ofInt int: Int, padding: Int ) -> ClosedRange { var rangeLength = Int(CGFloat(int) * percentage) + padding * 2 var remainder = int - rangeLength // Ensure we have an even remainder so we can split it evenly. if remainder % 2 == 1 { rangeLength += 1 remainder -= 1 } let rangeStart = remainder / 2 return rangeStart...(rangeStart + rangeLength) } } private extension CGRect { func scaled(toPercentage percentage: CGFloat) -> CGRect { let dx = size.width * (1 - percentage) / 2 let dy = size.height * (1 - percentage) / 2 return insetBy(dx: dx, dy: dy) } }