Files
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
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 🖥 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
}
}