Files
projet_gestion_commande/frontend-admin/node_modules/expo-image-picker/ios/ImageUtils.swift
T
2026-02-07 22:33:02 +01:00

393 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2024-present 650 Industries. All rights reserved.
import CoreGraphics
import ExpoModulesCore
import ImageIO
import Photos
import UniformTypeIdentifiers
extension UTType {
static var avif: UTType {
UTType(importedAs: "public.avif")
}
}
internal struct ImageUtils {
static func readImageFrom(mediaInfo: MediaInfo, shouldReadCroppedImage: Bool) -> UIImage? {
// ---------------------------------------------------------------------------
// 1. Fast-path (allowsEditing == true)
// ---------------------------------------------------------------------------
// When the user confirms a crop in the system UI, UIKit puts the final bitmap
// with the crop *and* the correct visual orientation already baked in
// under `UIImagePickerController.InfoKey.editedImage`.
// Re-using that image means we don't have to:
// • translate `cropRect` into the sensor's coordinate space, nor
// • worry about EXIF orientation flags that differ across formats.
// This single line therefore handles the vast majority of cases safely.
if shouldReadCroppedImage, let systemCropped = mediaInfo[.editedImage] as? UIImage {
return systemCropped.fixOrientation()
}
// ---------------------------------------------------------------------------
// 2. Manual path (no .editedImage available)
// ---------------------------------------------------------------------------
// Some edge-cases (older iOS versions, multi-selection via PHPicker, or
// editing disabled) don't supply `.editedImage`. In those cases we:
// a) pull `.originalImage`,
// b) apply `cropRect` *before* touching orientation, and
// c) call `fixOrientation()` once at the end to normalize the bitmap.
guard let originalImage = mediaInfo[.originalImage] as? UIImage else {
return nil
}
if shouldReadCroppedImage,
let cropRect = mediaInfo[.cropRect] as? CGRect,
let cropped = ImageUtils.crop(image: originalImage, to: cropRect) {
// Crop first (rect is defined in the original pixel space), then rotate.
return cropped.fixOrientation()
}
// No editing only remove any EXIF orientation so JavaScript consumers see
// an upright image.
return originalImage.fixOrientation()
}
static func crop(image: UIImage, to: CGRect) -> UIImage? {
guard let cgImage = image.cgImage?.cropping(to: to) else {
return nil
}
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
}
static func readDataAndFileExtension(
image: UIImage,
mediaInfo: MediaInfo,
options: ImagePickerOptions
) throws -> (imageData: Data?, fileExtension: String) {
// nil when an image is picked from camera
let referenceUrl = mediaInfo[.referenceURL] as? URL
switch referenceUrl?.absoluteString {
case .some(let s) where s.contains("ext=PNG"):
let data = image.pngData()
return (data, ".png")
case .some(let s) where s.contains("ext=WEBP"):
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (nil, ".webp")
case .some(let s) where s.contains("ext=BMP"):
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (nil, ".bmp")
case .some(let s) where s.contains("ext=GIF"):
var rawData: Data?
if let imgUrl = mediaInfo[.imageURL] as? URL {
rawData = try? Data(contentsOf: imgUrl)
}
let inputData = rawData ?? image.jpegData(compressionQuality: options.quality)
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
let cropRect = options.allowsEditing ? mediaInfo[.cropRect] as? CGRect : nil
let gifData = try processGifData(
inputData: inputData,
compressionQuality: options.quality,
initialMetadata: metadata,
cropRect: cropRect
)
return (gifData, ".gif")
default:
let data = image.jpegData(compressionQuality: options.quality)
return (data, ".jpg")
}
}
static func readDataAndFileExtension(
image: UIImage,
rawData: Data,
itemProvider: NSItemProvider,
options: ImagePickerOptions
) throws -> (imageData: Data?, fileExtension: String) {
let preferredFormat = itemProvider.registeredTypeIdentifiers.first
switch preferredFormat {
case UTType.bmp.identifier:
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (rawData, ".bmp")
case UTType.png.identifier:
let data = image.pngData()
return (data, ".png")
case UTType.webP.identifier:
if options.allowsEditing {
// switch to png if editing
let data = image.pngData()
return (data, ".png")
}
return (rawData, ".webp")
case UTType.gif.identifier:
let gifData = try processGifData(
inputData: rawData,
compressionQuality: options.quality,
initialMetadata: nil
)
return (gifData, ".gif")
case UTType.heic.identifier:
return (rawData, ".heic")
case UTType.tiff.identifier:
return (rawData, ".tiff")
case UTType.avif.identifier:
return (rawData, ".avif")
default:
if options.quality >= 1.0 {
return (rawData, ".jpg")
}
let data = image.jpegData(compressionQuality: options.quality)
return (data, ".jpg")
}
}
static func write(imageData: Data?, to: URL) throws {
do {
try imageData?.write(to: to, options: [.atomic])
} catch {
throw FailedToWriteImageException()
.causedBy(error)
}
}
/**
@returns `true` upon copying success and `false` otherwise
*/
static func tryCopyingOriginalImageFrom(mediaInfo: MediaInfo, to: URL) -> Bool {
guard let from = mediaInfo[.imageURL] as? URL else {
return false
}
do {
try FileManager.default.copyItem(atPath: from.path, toPath: to.path)
return true
} catch {
return false
}
}
/**
Reads base64 representation of the image data. If the data is `nil` fallbacks to reading the data from the url.
*/
static func readBase64From(imageData: Data?, orImageFileUrl url: URL, tryReadingFile: Bool) throws
-> String? {
if tryReadingFile {
do {
let data = try Data(contentsOf: url)
return data.base64EncodedString()
} catch {
throw FailedToReadImageDataException()
.causedBy(error)
}
}
guard let data = imageData else {
throw FailedToReadImageDataForBase64Exception()
}
return data.base64EncodedString()
}
static func readExifFrom(mediaInfo: MediaInfo) async -> ExifInfo? {
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
if let metadata {
return ImageUtils.readExifFrom(imageMetadata: metadata)
}
guard let imageUrl = mediaInfo[.referenceURL] as? URL else {
log.error("Could not fetch metadata for image")
return nil
}
let assets = PHAsset.fetchAssets(withALAssetURLs: [imageUrl], options: nil)
guard let asset = assets.firstObject else {
log.error("Could not fetch metadata for image '\(imageUrl.absoluteString)'.")
return nil
}
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
return await withCheckedContinuation { continuation in
asset.requestContentEditingInput(with: options) { input, _ in
guard let imageUrl = input?.fullSizeImageURL,
let properties = CIImage(contentsOf: imageUrl)?.properties
else {
log.error("Could not fetch metadata for '\(imageUrl.absoluteString)'.")
return continuation.resume(returning: nil)
}
let exif = ImageUtils.readExifFrom(imageMetadata: properties)
return continuation.resume(returning: exif)
}
}
}
static func optionallyReadExifFrom(
mediaInfo: MediaInfo,
shouldReadExif: Bool,
completion: @escaping (_ result: ExifInfo?) -> Void
) {
if !shouldReadExif {
return completion(nil)
}
let metadata = mediaInfo[.mediaMetadata] as? [String: Any]
if let metadata {
let exif = ImageUtils.readExifFrom(imageMetadata: metadata)
return completion(exif)
}
guard let imageUrl = mediaInfo[.referenceURL] as? URL else {
log.error("Could not fetch metadata for image")
return completion(nil)
}
let assets = PHAsset.fetchAssets(withALAssetURLs: [imageUrl], options: nil)
guard let asset = assets.firstObject else {
log.error("Could not fetch metadata for image '\(imageUrl.absoluteString)'.")
return completion(nil)
}
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
asset.requestContentEditingInput(with: options) { input, _ in
guard let imageUrl = input?.fullSizeImageURL,
let properties = CIImage(contentsOf: imageUrl)?.properties
else {
log.error("Could not fetch metadata for '\(imageUrl.absoluteString)'.")
return completion(nil)
}
let exif = ImageUtils.readExifFrom(imageMetadata: properties)
return completion(exif)
}
}
static func readExifFrom(data: Data) -> ExifInfo? {
if let cgImageSource = CGImageSourceCreateWithData(data as CFData, nil) {
if let properties = CGImageSourceCopyPropertiesAtIndex(cgImageSource, 0, nil)
as? [String: Any] {
return ImageUtils.readExifFrom(imageMetadata: properties)
}
}
return nil
}
static func readExifFrom(imageMetadata: [String: Any]) -> ExifInfo {
var exif: ExifInfo = imageMetadata[kCGImagePropertyExifDictionary as String] as? ExifInfo ?? [:]
// Copy ["{GPS}"]["<tag>"] to ["GPS<tag>"]
if let gps = imageMetadata[kCGImagePropertyGPSDictionary as String] as? [String: Any] {
gps.forEach { key, value in
exif["GPS\(key)"] = value
}
}
if let tiff = imageMetadata[kCGImagePropertyTIFFDictionary as String] as? [String: Any] {
// Inject tiff data (make, model, resolution...)
exif.merge(tiff) { current, _ in current }
}
return exif
}
static func processGifData(
inputData: Data?,
compressionQuality: Double?,
initialMetadata: [String: Any]?,
cropRect: CGRect? = nil
) throws -> Data? {
let quality = compressionQuality ?? MAXIMUM_QUALITY
// for uncropped, maximum quality image we can just pass through the raw data
if cropRect == nil && quality >= MAXIMUM_QUALITY {
return inputData
}
guard let sourceData = inputData,
let imageSource = CGImageSourceCreateWithData(sourceData as CFData, nil)
else {
throw FailedToReadImageException()
}
let gifProperties = CGImageSourceCopyProperties(imageSource, nil) as? [String: Any]
let frameCount = CGImageSourceGetCount(imageSource)
let destinationData = NSMutableData()
guard let imageDestination = CGImageDestinationCreateWithData(destinationData, UTType.gif.identifier as CFString, frameCount, nil) else {
throw FailedToCreateGifException()
}
let gifMetadata = initialMetadata ?? gifProperties
CGImageDestinationSetProperties(imageDestination, gifMetadata as CFDictionary?)
for frameIndex in 0..<frameCount {
guard var cgImage = CGImageSourceCreateImageAtIndex(imageSource, frameIndex, nil) else {
throw FailedToCreateGifException()
}
var frameProperties =
CGImageSourceCopyPropertiesAtIndex(imageSource, frameIndex, nil) as? [String: Any] ?? [:]
if let cropRect {
cgImage = cgImage.cropping(to: cropRect) ?? cgImage
}
frameProperties[kCGImageDestinationLossyCompressionQuality as String] = quality
CGImageDestinationAddImage(imageDestination, cgImage, frameProperties as CFDictionary)
}
if !CGImageDestinationFinalize(imageDestination) {
throw FailedToExportGifException()
}
return destinationData as Data
}
/*
* Extracts the visual dimensions from an image file, accounting for EXIF orientation.
* This ensures portrait images return portrait dimensions (height > width).
* @param url The file URL of the image to analyze
* @return A CGSize containing the visual width and height, or nil if the dimensions cannot be determined
*/
static func readVisualSizeFrom(url: URL) -> CGSize? {
// Try to fetch the dimensions from the image metadata as this is the fastest way
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? CGFloat,
let height = properties[kCGImagePropertyPixelHeight] as? CGFloat else {
// Fallback: minimally decode the image using UIImage when metadata is not available
if let img = UIImage(contentsOfFile: url.path) {
return CGSize(width: img.size.width, height: img.size.height)
}
return nil
}
// Check EXIF orientation to determine if dimensions should be swapped
let orientation = properties[kCGImagePropertyOrientation] as? Int ?? 1
// Orientations 5,6,7,8 (left/right rotated) need dimension swapping
if orientation >= 5 && orientation <= 8 {
return CGSize(width: height, height: width) // Swap for portrait
}
// Keep as is for landscape
return CGSize(width: width, height: height)
}
}