745 lines
23 KiB
Swift
745 lines
23 KiB
Swift
import Flutter
|
|
import ImageIO
|
|
import Photos
|
|
import UniformTypeIdentifiers
|
|
import UIKit
|
|
|
|
class SceneDelegate: FlutterSceneDelegate {
|
|
private var imageBridge: NativeImageBridge?
|
|
|
|
override func scene(
|
|
_ scene: UIScene,
|
|
willConnectTo session: UISceneSession,
|
|
options connectionOptions: UIScene.ConnectionOptions
|
|
) {
|
|
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
|
|
|
if let controller = window?.rootViewController as? FlutterViewController {
|
|
let bridge = NativeImageBridge(controller: controller)
|
|
imageBridge = bridge
|
|
bridge.handleOpenURLContexts(connectionOptions.urlContexts, notifyFlutter: false)
|
|
}
|
|
}
|
|
|
|
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
|
super.scene(scene, openURLContexts: URLContexts)
|
|
imageBridge?.handleOpenURLContexts(URLContexts, notifyFlutter: true)
|
|
}
|
|
}
|
|
|
|
private final class NativeImageBridge: NSObject, UIDocumentPickerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
private enum PendingOperation {
|
|
case pickFile(FlutterResult)
|
|
case pickAlbum(FlutterResult)
|
|
}
|
|
|
|
private weak var controller: FlutterViewController?
|
|
private var channel: FlutterMethodChannel?
|
|
private var pendingOperation: PendingOperation?
|
|
private var pendingShareResult: FlutterResult?
|
|
private var pendingSharedImages: [[String: Any]] = []
|
|
private var sources: [String: URL] = [:]
|
|
|
|
init(controller: FlutterViewController) {
|
|
self.controller = controller
|
|
super.init()
|
|
|
|
let channel = FlutterMethodChannel(
|
|
name: "luban_imager/native_images",
|
|
binaryMessenger: controller.binaryMessenger
|
|
)
|
|
self.channel = channel
|
|
channel.setMethodCallHandler { [weak self] call, result in
|
|
self?.handle(call: call, result: result)
|
|
}
|
|
}
|
|
|
|
private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
switch call.method {
|
|
case "pickImages":
|
|
pickImages(result: result)
|
|
case "pickAlbumImage":
|
|
pickAlbumImage(result: result)
|
|
case "takeSharedImages":
|
|
takeSharedImages(result: result)
|
|
case "compressImage":
|
|
compressImage(arguments: call.arguments, result: result)
|
|
case "overwriteOriginal":
|
|
overwriteOriginal(arguments: call.arguments, result: result)
|
|
case "saveToGallery":
|
|
saveToGallery(arguments: call.arguments, result: result)
|
|
case "shareImage":
|
|
shareImage(arguments: call.arguments, result: result)
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
func handleOpenURLContexts(
|
|
_ URLContexts: Set<UIOpenURLContext>,
|
|
notifyFlutter: Bool
|
|
) {
|
|
let payload = URLContexts.compactMap { context -> [String: Any]? in
|
|
do {
|
|
return try buildSharedImage(sourceURL: context.url)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
guard !payload.isEmpty else {
|
|
return
|
|
}
|
|
|
|
pendingSharedImages = payload
|
|
if notifyFlutter {
|
|
channel?.invokeMethod("sharedImages", arguments: payload)
|
|
}
|
|
}
|
|
|
|
private func takeSharedImages(result: @escaping FlutterResult) {
|
|
let images = pendingSharedImages
|
|
pendingSharedImages.removeAll()
|
|
result(images)
|
|
}
|
|
|
|
private func pickImages(result: @escaping FlutterResult) {
|
|
guard pendingOperation == nil else {
|
|
result(FlutterError(code: "busy", message: "正在选择图片", details: nil))
|
|
return
|
|
}
|
|
|
|
let picker: UIDocumentPickerViewController
|
|
if #available(iOS 14.0, *) {
|
|
picker = UIDocumentPickerViewController(
|
|
forOpeningContentTypes: [UTType.image],
|
|
asCopy: false
|
|
)
|
|
} else {
|
|
picker = UIDocumentPickerViewController(documentTypes: ["public.image"], in: .open)
|
|
}
|
|
picker.allowsMultipleSelection = false
|
|
picker.delegate = self
|
|
pendingOperation = .pickFile(result)
|
|
controller?.present(picker, animated: true)
|
|
}
|
|
|
|
private func pickAlbumImage(result: @escaping FlutterResult) {
|
|
guard pendingOperation == nil else {
|
|
result(FlutterError(code: "busy", message: "正在选择图片", details: nil))
|
|
return
|
|
}
|
|
guard let controller = controller else {
|
|
result(FlutterError(code: "picker_unavailable", message: "无法打开相册", details: nil))
|
|
return
|
|
}
|
|
guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else {
|
|
result(FlutterError(code: "picker_unavailable", message: "相册不可用", details: nil))
|
|
return
|
|
}
|
|
|
|
let picker = UIImagePickerController()
|
|
picker.sourceType = .photoLibrary
|
|
picker.mediaTypes = ["public.image"]
|
|
picker.allowsEditing = false
|
|
picker.delegate = self
|
|
pendingOperation = .pickAlbum(result)
|
|
controller.present(picker, animated: true)
|
|
}
|
|
|
|
private func compressImage(arguments: Any?, result: @escaping FlutterResult) {
|
|
guard
|
|
let args = arguments as? [String: Any],
|
|
let sourceHandle = args["sourceHandle"] as? String,
|
|
let sourceURL = sources[sourceHandle]
|
|
else {
|
|
result(FlutterError(code: "bad_args", message: "缺少 sourceHandle", details: nil))
|
|
return
|
|
}
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let payload = try self.compress(sourceURL: sourceURL)
|
|
DispatchQueue.main.async { result(payload) }
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
result(
|
|
FlutterError(
|
|
code: "compress_failed",
|
|
message: error.localizedDescription,
|
|
details: nil
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func overwriteOriginal(arguments: Any?, result: @escaping FlutterResult) {
|
|
guard
|
|
let args = arguments as? [String: Any],
|
|
let sourceHandle = args["sourceHandle"] as? String,
|
|
let compressedPath = args["compressedPath"] as? String,
|
|
let sourceURL = sources[sourceHandle]
|
|
else {
|
|
result(FlutterError(code: "bad_args", message: "缺少覆盖参数", details: nil))
|
|
return
|
|
}
|
|
|
|
let compressedURL = URL(fileURLWithPath: compressedPath)
|
|
do {
|
|
let data = try Data(contentsOf: compressedURL)
|
|
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if scoped {
|
|
sourceURL.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
try data.write(to: sourceURL)
|
|
result(["overwritten": true, "target": sourceHandle])
|
|
} catch {
|
|
result(
|
|
FlutterError(
|
|
code: "overwrite_failed",
|
|
message: error.localizedDescription,
|
|
details: nil
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func shareImage(arguments: Any?, result: @escaping FlutterResult) {
|
|
guard
|
|
let args = arguments as? [String: Any],
|
|
let compressedPath = args["compressedPath"] as? String
|
|
else {
|
|
result(FlutterError(code: "bad_args", message: "缺少 compressedPath", details: nil))
|
|
return
|
|
}
|
|
guard pendingShareResult == nil else {
|
|
result(FlutterError(code: "busy", message: "正在分享", details: nil))
|
|
return
|
|
}
|
|
guard let controller = controller else {
|
|
result(FlutterError(code: "share_unavailable", message: "无法打开分享面板", details: nil))
|
|
return
|
|
}
|
|
|
|
do {
|
|
let sourceURL = URL(fileURLWithPath: compressedPath)
|
|
let suggestedName = args["suggestedName"] as? String ?? sourceURL.lastPathComponent
|
|
let exportURL = try makeExportCopy(sourceURL: sourceURL, suggestedName: suggestedName)
|
|
|
|
let activityController = UIActivityViewController(
|
|
activityItems: [exportURL],
|
|
applicationActivities: nil
|
|
)
|
|
if let popover = activityController.popoverPresentationController,
|
|
let view = controller.view
|
|
{
|
|
popover.sourceView = view
|
|
popover.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 0, height: 0)
|
|
popover.permittedArrowDirections = []
|
|
}
|
|
pendingShareResult = result
|
|
activityController.completionWithItemsHandler = { [weak self] _, completed, _, error in
|
|
guard let self = self else {
|
|
return
|
|
}
|
|
let pendingResult = self.pendingShareResult
|
|
self.pendingShareResult = nil
|
|
if let error = error {
|
|
pendingResult?(
|
|
FlutterError(code: "share_failed", message: error.localizedDescription, details: nil)
|
|
)
|
|
} else {
|
|
pendingResult?(["shared": completed])
|
|
}
|
|
}
|
|
controller.present(activityController, animated: true)
|
|
} catch {
|
|
result(
|
|
FlutterError(code: "share_failed", message: error.localizedDescription, details: nil)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func saveToGallery(arguments: Any?, result: @escaping FlutterResult) {
|
|
guard
|
|
let args = arguments as? [String: Any],
|
|
let compressedPath = args["compressedPath"] as? String
|
|
else {
|
|
result(FlutterError(code: "bad_args", message: "缺少 compressedPath", details: nil))
|
|
return
|
|
}
|
|
|
|
let sourceURL = URL(fileURLWithPath: compressedPath)
|
|
saveImageToPhotoLibrary(sourceURL: sourceURL, result: result)
|
|
}
|
|
|
|
private func saveImageToPhotoLibrary(
|
|
sourceURL: URL,
|
|
result: @escaping FlutterResult
|
|
) {
|
|
let saveChanges = {
|
|
PHPhotoLibrary.shared().performChanges({
|
|
_ = PHAssetCreationRequest.creationRequestForAssetFromImage(atFileURL: sourceURL)
|
|
}) { success, error in
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
result(FlutterError(code: "save_failed", message: error.localizedDescription, details: nil))
|
|
} else if success {
|
|
result(["saved": true])
|
|
} else {
|
|
result(FlutterError(code: "save_failed", message: "保存到相册失败", details: nil))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if #available(iOS 14.0, *) {
|
|
switch PHPhotoLibrary.authorizationStatus(for: .addOnly) {
|
|
case .authorized, .limited:
|
|
saveChanges()
|
|
case .notDetermined:
|
|
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
|
if status == .authorized || status == .limited {
|
|
saveChanges()
|
|
} else {
|
|
DispatchQueue.main.async {
|
|
result(FlutterError(code: "photo_denied", message: "没有相册保存权限", details: nil))
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
result(FlutterError(code: "photo_denied", message: "没有相册保存权限", details: nil))
|
|
}
|
|
} else {
|
|
switch PHPhotoLibrary.authorizationStatus() {
|
|
case .authorized:
|
|
saveChanges()
|
|
case .notDetermined:
|
|
PHPhotoLibrary.requestAuthorization { status in
|
|
if status == .authorized {
|
|
saveChanges()
|
|
} else {
|
|
DispatchQueue.main.async {
|
|
result(FlutterError(code: "photo_denied", message: "没有相册保存权限", details: nil))
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
result(FlutterError(code: "photo_denied", message: "没有相册保存权限", details: nil))
|
|
}
|
|
}
|
|
}
|
|
|
|
func documentPicker(
|
|
_ controller: UIDocumentPickerViewController,
|
|
didPickDocumentsAt urls: [URL]
|
|
) {
|
|
guard let operation = pendingOperation else {
|
|
return
|
|
}
|
|
pendingOperation = nil
|
|
|
|
switch operation {
|
|
case .pickFile(let result):
|
|
do {
|
|
if let url = urls.first {
|
|
result([try buildPickedImage(sourceURL: url)])
|
|
} else {
|
|
result([])
|
|
}
|
|
} catch {
|
|
result(
|
|
FlutterError(code: "pick_failed", message: error.localizedDescription, details: nil)
|
|
)
|
|
}
|
|
case .pickAlbum(let result):
|
|
result([])
|
|
}
|
|
}
|
|
|
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
|
guard let operation = pendingOperation else {
|
|
return
|
|
}
|
|
pendingOperation = nil
|
|
|
|
switch operation {
|
|
case .pickFile(let result), .pickAlbum(let result):
|
|
result([])
|
|
}
|
|
}
|
|
|
|
func imagePickerController(
|
|
_ picker: UIImagePickerController,
|
|
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
|
) {
|
|
picker.dismiss(animated: true)
|
|
|
|
guard let operation = pendingOperation else {
|
|
return
|
|
}
|
|
pendingOperation = nil
|
|
|
|
guard case .pickAlbum(let result) = operation else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
if let url = info[.imageURL] as? URL {
|
|
result([try buildGalleryImage(sourceURL: url)])
|
|
return
|
|
}
|
|
if let image = info[.originalImage] as? UIImage {
|
|
result([try buildGalleryImage(image: image)])
|
|
return
|
|
}
|
|
result([])
|
|
} catch {
|
|
result(FlutterError(code: "gallery_pick_failed", message: error.localizedDescription, details: nil))
|
|
}
|
|
}
|
|
|
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
|
picker.dismiss(animated: true)
|
|
|
|
guard let operation = pendingOperation else {
|
|
return
|
|
}
|
|
pendingOperation = nil
|
|
|
|
switch operation {
|
|
case .pickFile(let result), .pickAlbum(let result):
|
|
result([])
|
|
}
|
|
}
|
|
|
|
private func buildPickedImage(sourceURL: URL) throws -> [String: Any] {
|
|
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if scoped {
|
|
sourceURL.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
|
|
let id = UUID().uuidString
|
|
sources[id] = sourceURL
|
|
|
|
let previewURL = try copyToCache(
|
|
sourceURL: sourceURL,
|
|
child: "originals",
|
|
preferredName: sourceURL.lastPathComponent
|
|
)
|
|
let dimensions = imageDimensions(for: sourceURL) ?? imageDimensions(for: previewURL)
|
|
let size = fileSize(sourceURL) ?? fileSize(previewURL) ?? 0
|
|
|
|
return [
|
|
"sourceHandle": id,
|
|
"displayName": sourceURL.lastPathComponent.isEmpty
|
|
? "image"
|
|
: sourceURL.lastPathComponent,
|
|
"previewPath": previewURL.path,
|
|
"originalSize": size,
|
|
"width": dimensions?.width ?? 0,
|
|
"height": dimensions?.height ?? 0,
|
|
"canOverwrite": FileManager.default.isWritableFile(atPath: sourceURL.path),
|
|
]
|
|
}
|
|
|
|
private func buildSharedImage(sourceURL: URL) throws -> [String: Any] {
|
|
return try buildCachedReadOnlyImage(sourceURL: sourceURL, child: "shared-originals")
|
|
}
|
|
|
|
private func buildGalleryImage(sourceURL: URL) throws -> [String: Any] {
|
|
return try buildCachedReadOnlyImage(sourceURL: sourceURL, child: "gallery-originals")
|
|
}
|
|
|
|
private func buildGalleryImage(image: UIImage) throws -> [String: Any] {
|
|
let hasAlpha = Self.hasAlpha(image)
|
|
let displayName = hasAlpha ? "album-image.png" : "album-image.jpg"
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
"gallery-picker",
|
|
isDirectory: true
|
|
)
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let targetURL = directory.appendingPathComponent("\(UUID().uuidString)-\(displayName)")
|
|
let data = hasAlpha ? image.pngData() : image.jpegData(compressionQuality: 1)
|
|
guard let data else {
|
|
throw NativeImageError.message("无法读取相册图片")
|
|
}
|
|
try data.write(to: targetURL, options: .atomic)
|
|
return try buildGalleryImage(sourceURL: targetURL)
|
|
}
|
|
|
|
private func buildCachedReadOnlyImage(sourceURL: URL, child: String) throws -> [String: Any] {
|
|
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if scoped {
|
|
sourceURL.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
|
|
let displayName = sourceURL.lastPathComponent.isEmpty
|
|
? "image"
|
|
: sourceURL.lastPathComponent
|
|
let previewURL = try copyToCache(
|
|
sourceURL: sourceURL,
|
|
child: child,
|
|
preferredName: displayName
|
|
)
|
|
let id = UUID().uuidString
|
|
sources[id] = previewURL
|
|
let dimensions = imageDimensions(for: previewURL)
|
|
let size = fileSize(previewURL) ?? 0
|
|
|
|
return [
|
|
"sourceHandle": id,
|
|
"displayName": displayName,
|
|
"previewPath": previewURL.path,
|
|
"originalSize": size,
|
|
"width": dimensions?.width ?? 0,
|
|
"height": dimensions?.height ?? 0,
|
|
"canOverwrite": false,
|
|
]
|
|
}
|
|
|
|
private func compress(sourceURL: URL) throws -> [String: Any] {
|
|
let scoped = sourceURL.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if scoped {
|
|
sourceURL.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
|
|
let originalData = try Data(contentsOf: sourceURL)
|
|
guard let image = UIImage(data: originalData) else {
|
|
throw NativeImageError.message("无法解码图片")
|
|
}
|
|
|
|
let originalDimensions = imageDimensions(for: sourceURL)
|
|
?? PixelSize(
|
|
width: max(1, Int(image.size.width * image.scale)),
|
|
height: max(1, Int(image.size.height * image.scale))
|
|
)
|
|
let targetDimensions = Self.targetSize(for: originalDimensions)
|
|
let hasAlpha = Self.hasAlpha(image)
|
|
let rendered = Self.render(image: image, target: targetDimensions, opaque: !hasAlpha)
|
|
let preservePng = hasAlpha && originalData.count <= 1_500_000
|
|
let quality = Self.jpegQuality(for: targetDimensions)
|
|
|
|
let encodedData = preservePng
|
|
? rendered.pngData()
|
|
: rendered.jpegData(compressionQuality: quality)
|
|
guard let encoded = encodedData else {
|
|
throw NativeImageError.message("无法生成压缩图片")
|
|
}
|
|
|
|
var outputData = encoded
|
|
var outputDimensions = targetDimensions
|
|
var outputExtension = preservePng ? "png" : "jpg"
|
|
var passthrough = false
|
|
|
|
if encoded.count >= originalData.count {
|
|
outputData = originalData
|
|
outputDimensions = originalDimensions
|
|
outputExtension = sourceURL.pathExtension.isEmpty ? outputExtension : sourceURL.pathExtension
|
|
passthrough = true
|
|
}
|
|
|
|
let outputURL = try makeCompressedURL(
|
|
originalName: sourceURL.deletingPathExtension().lastPathComponent,
|
|
extensionName: outputExtension
|
|
)
|
|
try outputData.write(to: outputURL, options: .atomic)
|
|
|
|
return [
|
|
"path": outputURL.path,
|
|
"outputSize": outputData.count,
|
|
"width": outputDimensions.width,
|
|
"height": outputDimensions.height,
|
|
"passthrough": passthrough,
|
|
]
|
|
}
|
|
|
|
private func copyToCache(
|
|
sourceURL: URL,
|
|
child: String,
|
|
preferredName: String
|
|
) throws -> URL {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
child,
|
|
isDirectory: true
|
|
)
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let targetURL = directory.appendingPathComponent(
|
|
"\(UUID().uuidString)-\(Self.sanitizeFileName(preferredName))"
|
|
)
|
|
|
|
do {
|
|
try FileManager.default.copyItem(at: sourceURL, to: targetURL)
|
|
} catch {
|
|
let data = try Data(contentsOf: sourceURL)
|
|
try data.write(to: targetURL, options: .atomic)
|
|
}
|
|
return targetURL
|
|
}
|
|
|
|
private func makeCompressedURL(originalName: String, extensionName: String) throws -> URL {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
"compressed",
|
|
isDirectory: true
|
|
)
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let safeName = Self.sanitizeFileName(originalName.isEmpty ? "image" : originalName)
|
|
return directory.appendingPathComponent("\(UUID().uuidString)-\(safeName).\(extensionName)")
|
|
}
|
|
|
|
private func makeExportCopy(sourceURL: URL, suggestedName: String) throws -> URL {
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
"exports",
|
|
isDirectory: true
|
|
)
|
|
try FileManager.default.createDirectory(
|
|
at: directory,
|
|
withIntermediateDirectories: true
|
|
)
|
|
let targetURL = directory.appendingPathComponent(Self.sanitizeFileName(suggestedName))
|
|
if FileManager.default.fileExists(atPath: targetURL.path) {
|
|
try FileManager.default.removeItem(at: targetURL)
|
|
}
|
|
try FileManager.default.copyItem(at: sourceURL, to: targetURL)
|
|
return targetURL
|
|
}
|
|
|
|
private func imageDimensions(for url: URL) -> PixelSize? {
|
|
guard
|
|
let source = CGImageSourceCreateWithURL(url as CFURL, [
|
|
kCGImageSourceShouldCache: false
|
|
] as CFDictionary),
|
|
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
|
let width = properties[kCGImagePropertyPixelWidth] as? NSNumber,
|
|
let height = properties[kCGImagePropertyPixelHeight] as? NSNumber
|
|
else {
|
|
return nil
|
|
}
|
|
return PixelSize(width: width.intValue, height: height.intValue)
|
|
}
|
|
|
|
private func fileSize(_ url: URL) -> Int? {
|
|
if let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
|
|
let size = values.fileSize
|
|
{
|
|
return size
|
|
}
|
|
if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
let size = attrs[.size] as? NSNumber
|
|
{
|
|
return size.intValue
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func targetSize(for source: PixelSize) -> PixelSize {
|
|
let width = max(1, source.width)
|
|
let height = max(1, source.height)
|
|
let longSide = max(width, height)
|
|
let shortSide = min(width, height)
|
|
let aspect = Double(shortSide) / Double(longSide)
|
|
var scale = 1.0
|
|
|
|
if longSide > 10_800 && aspect > 0.25 {
|
|
scale = min(scale, 1440.0 / Double(longSide))
|
|
} else if shortSide > 1440 {
|
|
scale = min(scale, 1440.0 / Double(shortSide))
|
|
}
|
|
|
|
let sourcePixels = Double(width) * Double(height)
|
|
if sourcePixels > 40_960_000 {
|
|
scale = min(scale, 0.5)
|
|
}
|
|
|
|
let scaledPixels = sourcePixels * scale * scale
|
|
if scaledPixels > 10_240_000 {
|
|
scale = min(scale, sqrt(10_240_000 / sourcePixels))
|
|
}
|
|
|
|
return PixelSize(
|
|
width: max(1, Int((Double(width) * scale).rounded(.down))),
|
|
height: max(1, Int((Double(height) * scale).rounded(.down)))
|
|
)
|
|
}
|
|
|
|
private static func jpegQuality(for size: PixelSize) -> CGFloat {
|
|
let megapixels = Double(size.width * size.height) / 1_000_000
|
|
if megapixels < 0.5 {
|
|
return 0.92
|
|
}
|
|
if megapixels < 1.0 {
|
|
return 0.88
|
|
}
|
|
if megapixels < 3.0 {
|
|
return 0.8
|
|
}
|
|
return 0.72
|
|
}
|
|
|
|
private static func render(image: UIImage, target: PixelSize, opaque: Bool) -> UIImage {
|
|
let size = CGSize(width: CGFloat(target.width), height: CGFloat(target.height))
|
|
let format = UIGraphicsImageRendererFormat.default()
|
|
format.scale = 1
|
|
format.opaque = opaque
|
|
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
|
|
image.draw(in: CGRect(origin: .zero, size: size))
|
|
}
|
|
}
|
|
|
|
private static func hasAlpha(_ image: UIImage) -> Bool {
|
|
guard let alpha = image.cgImage?.alphaInfo else {
|
|
return false
|
|
}
|
|
switch alpha {
|
|
case .first, .last, .premultipliedFirst, .premultipliedLast:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func sanitizeFileName(_ name: String) -> String {
|
|
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
|
|
let sanitized = name.unicodeScalars
|
|
.map { allowed.contains($0) ? String($0) : "_" }
|
|
.joined()
|
|
return sanitized.isEmpty ? "image.jpg" : sanitized
|
|
}
|
|
}
|
|
|
|
private struct PixelSize {
|
|
let width: Int
|
|
let height: Int
|
|
}
|
|
|
|
private enum NativeImageError: LocalizedError {
|
|
case message(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .message(let message):
|
|
return message
|
|
}
|
|
}
|
|
}
|