import Foundation import ImageIO import Observation import RastrilloApple #if os(macOS) import AppKit typealias DocsPlatformImage = NSImage #else import UIKit typealias DocsPlatformImage = UIImage #endif private final class ImageRedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable { func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { guard let url = request.url, ["http", "https"].contains(url.scheme ?? ""), url.user == nil, url.password == nil else { completionHandler(nil); return } completionHandler(request) } } /// Document image bytes live only in this screen's memory. External hosts receive /// no app credentials; same-origin protected assets use the ordinary client. @MainActor @Observable final class DocsImageStore { private(set) var images: [String: DocsPlatformImage] = [:] private var generation = UUID() private var documentURL: URL? private let session: URLSession = { let config = URLSessionConfiguration.ephemeral config.httpShouldSetCookies = false config.urlCache = nil config.timeoutIntervalForRequest = 20 return URLSession(configuration: config, delegate: ImageRedirectGuard(), delegateQueue: nil) }() static func sources(in projection: CRDTProjection) -> [String] { var found = Set() func visit(_ node: CRDTNode) { if node.type == "image", let source = node.attrs?["src"] { found.insert(source) } for child in node.children ?? [] { visit(child) } } for node in projection.children { visit(node) } return found.sorted() } func images(forDocumentPath path: String, client: NativeHTTP) -> [String: DocsPlatformImage] { documentURL == URL(string: path, relativeTo: client.origin)?.absoluteURL ? images : [:] } static func resolvedURL(source: String, documentPath: String, origin: URL) -> URL? { guard let document = URL(string: documentPath, relativeTo: origin)?.absoluteURL else { return nil } return URL(string: source, relativeTo: document)?.absoluteURL } func load(sources: [String], client: NativeHTTP, documentPath: String = "/") async { let current = UUID(); generation = current let document = URL(string: documentPath, relativeTo: client.origin)?.absoluteURL if documentURL != document { images.removeAll(); documentURL = document } images = images.filter { sources.contains($0.key) } for source in sources where images[source] == nil { guard !Task.isCancelled, generation == current else { return } do { let data: Data if source.hasPrefix("data:image/"), let separator = source.firstIndex(of: ","), source[.. DocsPlatformImage? { guard let source = CGImageSourceCreateWithData(data as CFData, nil), let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int, width > 0, height > 0, width <= 16_384, height <= 16_384, width * height <= 40_000_000, let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceThumbnailMaxPixelSize: 2048, kCGImageSourceCreateThumbnailWithTransform: true, ] as CFDictionary) else { return nil } #if os(macOS) return NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height)) #else return UIImage(cgImage: image) #endif } static func image(_ image: DocsPlatformImage?, alt: String?, maxWidth: CGFloat = 440) -> NSAttributedString { let attachment = NSTextAttachment() attachment.image = image if let image { let size = image.size let scale = min(1, maxWidth / max(size.width, 1)) attachment.bounds = CGRect(x: 0, y: 0, width: size.width * scale, height: size.height * scale) } else { #if os(macOS) attachment.image = NSImage(systemSymbolName: "photo", accessibilityDescription: alt) #else attachment.image = UIImage(systemName: "photo") #endif attachment.bounds = CGRect(x: 0, y: 0, width: 24, height: 24) } #if os(macOS) attachment.image?.accessibilityDescription = alt #endif return NSAttributedString(attachment: attachment) } static func rule(width: CGFloat = 440) -> NSAttributedString { let attachment = NSTextAttachment() #if os(macOS) attachment.image = NSImage(size: NSSize(width: width, height: 1), flipped: false) { rect in NSColor.separatorColor.setFill(); rect.fill(); return true } #else attachment.image = UIGraphicsImageRenderer(size: CGSize(width: width, height: 1)).image { context in UIColor.separator.setFill(); context.fill(CGRect(x: 0, y: 0, width: width, height: 1)) } #endif attachment.bounds = CGRect(x: 0, y: 4, width: width, height: 1) return NSAttributedString(attachment: attachment) } }