Files
TUIkit/Sources/TUIkitImage/ImageLoader.swift
T
phranck a945ae3b36 Fix: Add FoundationNetworking import for Linux compatibility
- Import FoundationNetworking conditionally on Linux where URLRequest,
  URLSession and URLSessionDataTask are not part of Foundation
2026-02-14 14:17:05 +01:00

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
}
}