mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
a945ae3b36
- Import FoundationNetworking conditionally on Linux where URLRequest, URLSession and URLSessionDataTask are not part of Foundation
283 lines
9.3 KiB
Swift
283 lines
9.3 KiB
Swift
// 🖥️ TUIKit — Terminal UI Kit for Swift
|
||
// ImageLoader.swift
|
||
//
|
||
// Created by LAYERED.work
|
||
// License: MIT
|
||
|
||
import CSTBImage
|
||
import Foundation
|
||
|
||
#if canImport(FoundationNetworking)
|
||
import FoundationNetworking
|
||
#endif
|
||
|
||
// MARK: - ImageLoader Protocol
|
||
|
||
/// Loads images from file paths or raw data and converts them to `RGBAImage`.
|
||
///
|
||
/// Uses stb_image (bundled C library) on all platforms for consistent behavior.
|
||
/// Supported formats: PNG, JPEG, GIF, BMP, TGA, HDR, PSD, PNM.
|
||
public protocol ImageLoader: Sendable {
|
||
/// Loads an image from a file path.
|
||
///
|
||
/// - Parameter path: The absolute file path to the image.
|
||
/// - Returns: The decoded image as `RGBAImage`.
|
||
/// - Throws: `ImageLoadError` if the file cannot be read or decoded.
|
||
func loadImage(from path: String) throws -> RGBAImage
|
||
|
||
/// Loads an image from raw data.
|
||
///
|
||
/// - Parameter data: The image file data.
|
||
/// - Returns: The decoded image as `RGBAImage`.
|
||
/// - Throws: `ImageLoadError` if the data cannot be decoded.
|
||
func loadImage(from data: Data) throws -> RGBAImage
|
||
}
|
||
|
||
// MARK: - ImageLoadError
|
||
|
||
/// Errors that can occur during image loading.
|
||
public enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
|
||
/// The file was not found at the given path.
|
||
case fileNotFound(String)
|
||
|
||
/// The image format is not supported.
|
||
case unsupportedFormat(String)
|
||
|
||
/// The image data could not be decoded.
|
||
case decodingFailed(String)
|
||
|
||
/// A URL download failed.
|
||
case downloadFailed(String)
|
||
|
||
/// The image exceeds the maximum allowed pixel count.
|
||
case imageTooLarge(pixelCount: Int, limit: Int)
|
||
|
||
public var description: String {
|
||
switch self {
|
||
case .fileNotFound(let path):
|
||
return "Image file not found: \(path)"
|
||
case .unsupportedFormat(let format):
|
||
return "Unsupported image format: \(format)"
|
||
case .decodingFailed(let reason):
|
||
return "Image decoding failed: \(reason)"
|
||
case .downloadFailed(let reason):
|
||
return "Image download failed: \(reason)"
|
||
case .imageTooLarge(let pixelCount, let limit):
|
||
return "Image too large: \(pixelCount) pixels (limit: \(limit))"
|
||
}
|
||
}
|
||
|
||
public var errorDescription: String? { description }
|
||
}
|
||
|
||
// MARK: - Platform Image Loader
|
||
|
||
/// Cross-platform image loader using stb_image.
|
||
///
|
||
/// Supports PNG, JPEG, GIF, BMP, TGA, HDR, PSD, and PNM formats
|
||
/// on both macOS and Linux. stb_image is a public-domain single-header
|
||
/// C library bundled as a local `CSTBImage` target.
|
||
public struct PlatformImageLoader: ImageLoader {
|
||
|
||
public init() {}
|
||
|
||
public func loadImage(from path: String) throws -> RGBAImage {
|
||
try loadImage(from: path, maxPixelCount: nil)
|
||
}
|
||
|
||
public func loadImage(from data: Data) throws -> RGBAImage {
|
||
try loadImage(from: data, maxPixelCount: nil)
|
||
}
|
||
|
||
/// Loads an image from a file path with an optional pixel count limit.
|
||
///
|
||
/// - Parameters:
|
||
/// - path: The absolute file path to the image.
|
||
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
|
||
/// - Returns: The decoded image as `RGBAImage`.
|
||
/// - Throws: `ImageLoadError` if the file cannot be read, decoded, or exceeds the limit.
|
||
public func loadImage(from path: String, maxPixelCount: Int?) throws -> RGBAImage {
|
||
guard FileManager.default.fileExists(atPath: path) else {
|
||
throw ImageLoadError.fileNotFound(path)
|
||
}
|
||
|
||
var width: Int32 = 0
|
||
var height: Int32 = 0
|
||
var channels: Int32 = 0
|
||
|
||
guard let rawPixels = stbi_load(path, &width, &height, &channels, 4) else {
|
||
let reason = String(cString: stbi_failure_reason())
|
||
throw ImageLoadError.decodingFailed("stb_image: \(reason)")
|
||
}
|
||
defer { stbi_image_free(rawPixels) }
|
||
|
||
let pixelCount = Int(width) * Int(height)
|
||
if let limit = maxPixelCount, pixelCount > limit {
|
||
throw ImageLoadError.imageTooLarge(pixelCount: pixelCount, limit: limit)
|
||
}
|
||
|
||
return pixelsFromRaw(rawPixels, width: Int(width), height: Int(height))
|
||
}
|
||
|
||
/// Loads an image from raw data with an optional pixel count limit.
|
||
///
|
||
/// - Parameters:
|
||
/// - data: The image file data.
|
||
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
|
||
/// - Returns: The decoded image as `RGBAImage`.
|
||
/// - Throws: `ImageLoadError` if the data cannot be decoded or exceeds the limit.
|
||
public func loadImage(from data: Data, maxPixelCount: Int?) throws -> RGBAImage {
|
||
var width: Int32 = 0
|
||
var height: Int32 = 0
|
||
var channels: Int32 = 0
|
||
|
||
let rawPixels: UnsafeMutablePointer<UInt8>? = data.withUnsafeBytes { buffer in
|
||
guard let baseAddress = buffer.baseAddress else { return nil }
|
||
return stbi_load_from_memory(
|
||
baseAddress.assumingMemoryBound(to: UInt8.self),
|
||
Int32(data.count),
|
||
&width,
|
||
&height,
|
||
&channels,
|
||
4
|
||
)
|
||
}
|
||
|
||
guard let pixels = rawPixels else {
|
||
let reason = String(cString: stbi_failure_reason())
|
||
throw ImageLoadError.decodingFailed("stb_image: \(reason)")
|
||
}
|
||
defer { stbi_image_free(pixels) }
|
||
|
||
let pixelCount = Int(width) * Int(height)
|
||
if let limit = maxPixelCount, pixelCount > limit {
|
||
throw ImageLoadError.imageTooLarge(pixelCount: pixelCount, limit: limit)
|
||
}
|
||
|
||
return pixelsFromRaw(pixels, width: Int(width), height: Int(height))
|
||
}
|
||
}
|
||
|
||
// MARK: - Private Helpers
|
||
|
||
extension PlatformImageLoader {
|
||
|
||
/// Converts raw stb_image RGBA output to an `RGBAImage`.
|
||
private func pixelsFromRaw(
|
||
_ rawPixels: UnsafeMutablePointer<UInt8>,
|
||
width: Int,
|
||
height: Int
|
||
) -> RGBAImage {
|
||
let count = width * height
|
||
var pixels = [RGBA](repeating: RGBA(r: 0, g: 0, b: 0), count: count)
|
||
|
||
for pixelIndex in 0..<count {
|
||
let offset = pixelIndex * 4
|
||
pixels[pixelIndex] = RGBA(
|
||
r: rawPixels[offset],
|
||
g: rawPixels[offset + 1],
|
||
b: rawPixels[offset + 2],
|
||
a: rawPixels[offset + 3]
|
||
)
|
||
}
|
||
|
||
return RGBAImage(width: width, height: height, pixels: pixels)
|
||
}
|
||
}
|
||
|
||
// MARK: - URL Image Cache
|
||
|
||
/// A session-scoped cache for images downloaded from URLs.
|
||
///
|
||
/// Cached entries persist for the lifetime of the application.
|
||
/// Thread-safe via an internal lock.
|
||
public final class URLImageCache: @unchecked Sendable {
|
||
/// Shared session cache.
|
||
public static let shared = URLImageCache()
|
||
|
||
private var cache: [String: RGBAImage] = [:]
|
||
private let lock = NSLock()
|
||
|
||
private init() {}
|
||
|
||
/// Returns a cached image for the given URL string, or nil.
|
||
public func get(_ urlString: String) -> RGBAImage? {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
return cache[urlString]
|
||
}
|
||
|
||
/// Stores an image in the cache for the given URL string.
|
||
public func set(_ urlString: String, image: RGBAImage) {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
cache[urlString] = image
|
||
}
|
||
}
|
||
|
||
// MARK: - URL Image Loading
|
||
|
||
extension PlatformImageLoader {
|
||
|
||
/// Loads an image from a URL, using the session cache.
|
||
///
|
||
/// On first access the image is downloaded synchronously and cached.
|
||
/// Subsequent calls for the same URL return the cached copy.
|
||
///
|
||
/// - Parameters:
|
||
/// - urlString: The URL to download.
|
||
/// - cache: The image cache to use.
|
||
/// - timeout: The download timeout in seconds (default: 30).
|
||
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
|
||
/// - Returns: The decoded image.
|
||
/// - Throws: `ImageLoadError` on network or decoding failure, or if image exceeds size limit.
|
||
public func loadImage(
|
||
from urlString: String,
|
||
cache: URLImageCache = .shared,
|
||
timeout: TimeInterval = 30,
|
||
maxPixelCount: Int? = nil
|
||
) throws -> RGBAImage {
|
||
if let cached = cache.get(urlString) {
|
||
return cached
|
||
}
|
||
|
||
guard let url = URL(string: urlString) else {
|
||
throw ImageLoadError.downloadFailed("Invalid URL: \(urlString)")
|
||
}
|
||
|
||
let data: Data
|
||
do {
|
||
var request = URLRequest(url: url)
|
||
request.timeoutInterval = timeout
|
||
|
||
nonisolated(unsafe) var responseData: Data?
|
||
nonisolated(unsafe) var responseError: Error?
|
||
let semaphore = DispatchSemaphore(value: 0)
|
||
|
||
let task = URLSession.shared.dataTask(with: request) { d, _, error in
|
||
responseData = d
|
||
responseError = error
|
||
semaphore.signal()
|
||
}
|
||
task.resume()
|
||
semaphore.wait()
|
||
|
||
if let error = responseError {
|
||
throw error
|
||
}
|
||
guard let downloaded = responseData else {
|
||
throw ImageLoadError.downloadFailed("No data received")
|
||
}
|
||
data = downloaded
|
||
} catch let error as ImageLoadError {
|
||
throw error
|
||
} catch {
|
||
throw ImageLoadError.downloadFailed(error.localizedDescription)
|
||
}
|
||
|
||
let image = try loadImage(from: data, maxPixelCount: maxPixelCount)
|
||
cache.set(urlString, image: image)
|
||
return image
|
||
}
|
||
}
|