Files
i7t5--edmund/Sources/EdmundCore/Rendering/EditorTextView+ImageRendering.swift
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

212 lines
10 KiB
Swift

import AppKit
// MARK: - Image Rendering
//
// `![alt](path)` renders the referenced image inline when the cursor is outside
// the token, and shows the raw, editable markdown when the cursor is inside it
// (the `.image` branch of `styleBlock`). A loaded image is drawn by a
// `FragmentOverlay` anchored on the leading `!` — the same mechanism math and
// list markers use — with the rest of the markdown hidden and the line height
// reserved for the picture. An image that can't be shown (outside the token)
// gets the same overlay treatment, but with a small icon + reason in place of
// the picture, so the user knows *why* — not just that nothing rendered.
//
// Resolution: absolute paths, `~`-paths, and `file:` URLs load directly;
// relative paths resolve against the document's directory. A remote `https`
// image loads only when `allowRemoteImages` is on (mirrors Read mode's
// `allowRemoteImages`/`blockExternalImages`), and asynchronously — loading it
// synchronously on the styling path would block the main thread. `http` never
// loads: App Transport Security refuses the insecure connection outright,
// regardless of the setting (same reasoning as Read mode's DocumentHTML).
// Loaded images are cached by resolved absolute path (local) or URL string
// (remote), so a recompose doesn't re-read/re-fetch them. NSCache is
// internally thread-safe.
nonisolated(unsafe) private let imageCache = NSCache<NSString, NSImage>()
// Remote URLs currently being fetched, so a burst of re-styles (scrolling,
// cursor moves near the image) doesn't kick off duplicate downloads. Mutated
// only on the main actor: inserted synchronously from `loadRemoteImage`
// (called while styling, always on the main thread) and removed inside the
// fetch completion's `@MainActor` hop.
nonisolated(unsafe) private var inFlightRemoteImages = Set<String>()
// Remote URLs that were fetched and turned out not to decode as an image, so
// repeated re-styles show "Not an image" instead of re-fetching forever.
nonisolated(unsafe) private var undecodableRemoteImages = Set<String>()
/// Why an `![alt](destination)` couldn't be shown — the short label a
/// blocked-image placeholder draws next to its icon. Shared by Edit mode
/// (this file) and Read mode/export (`DocumentHTML`) so the two report the
/// same reason, in the same words, for the same failure.
enum ImageLoadFailure {
case httpUnsupported
case blockedBySetting
case notAnImage
case notFound
var label: String {
switch self {
case .httpUnsupported: return "HTTP connection not supported"
case .blockedBySetting: return "External images blocked"
case .notAnImage: return "Not an image"
case .notFound: return "Image not found"
}
}
}
extension EditorTextView {
/// What `styleBlock` should show for an image token when the cursor is
/// outside it.
enum ImageDisplay {
/// The image loaded; draw it.
case image(NSImage)
/// It can't be shown; draw an icon + `failure.label` in its place.
case blocked(ImageLoadFailure)
/// A remote fetch is in flight — transient, not an error; the caller
/// falls back to plain alt text until a recompose picks up the result.
case pending
}
/// Resolves and (for local files) loads the image referenced by
/// `destination`, classifying why it can't be shown when it can't.
func imageDisplay(destination: String) -> ImageDisplay {
let dest = destination.trimmingCharacters(in: .whitespacesAndNewlines)
guard !dest.isEmpty else { return .blocked(.notFound) }
if let scheme = URL(string: dest)?.scheme?.lowercased(), scheme == "http" || scheme == "https" {
guard scheme == "https" else { return .blocked(.httpUnsupported) }
guard allowRemoteImages else { return .blocked(.blockedBySetting) }
return loadRemoteImage(dest)
}
guard let url = resolveImageURL(dest) else { return .blocked(.notFound) }
let key = url.path as NSString
if let cached = imageCache.object(forKey: key) { return .image(cached) }
// `resolveImageURL` builds a URL from the path string alone (it doesn't
// check existence), so a missing file and an undecodable one both fail
// `NSImage(contentsOf:)` the same way — check existence first so the two
// get distinct, accurate messages.
guard FileManager.default.fileExists(atPath: url.path) else { return .blocked(.notFound) }
guard let image = NSImage(contentsOf: url) else { return .blocked(.notAnImage) }
imageCache.setObject(image, forKey: key)
return .image(image)
}
/// Returns the cached/decoded outcome for a remote `urlString`; otherwise
/// starts an async fetch (once per URL, while one is already in flight)
/// and returns `.pending`. The completion caches the image (or remembers a
/// decode failure) and re-styles the document so the result appears —
/// without blocking the main thread on network I/O.
private func loadRemoteImage(_ urlString: String) -> ImageDisplay {
let key = urlString as NSString
if let cached = imageCache.object(forKey: key) { return .image(cached) }
if undecodableRemoteImages.contains(urlString) { return .blocked(.notAnImage) }
guard !inFlightRemoteImages.contains(urlString), let url = URL(string: urlString) else { return .pending }
inFlightRemoteImages.insert(urlString)
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
let image = data.flatMap { NSImage(data: $0) }
Task { @MainActor in
inFlightRemoteImages.remove(urlString)
if let image {
imageCache.setObject(image, forKey: urlString as NSString)
} else {
undecodableRemoteImages.insert(urlString)
}
self?.recomposeAllDirty()
}
}.resume()
return .pending
}
/// Resolves a destination string to a local file URL. Returns nil for a
/// remote URL (handled separately, before this is reached) or when a
/// relative path can't be anchored.
private func resolveImageURL(_ destination: String) -> URL? {
let dest = destination.trimmingCharacters(in: .whitespacesAndNewlines)
guard !dest.isEmpty else { return nil }
if let url = URL(string: dest), let scheme = url.scheme {
return scheme == "file" ? url : nil
}
if dest.hasPrefix("/") { return URL(fileURLWithPath: dest) }
if dest.hasPrefix("~") {
return URL(fileURLWithPath: (dest as NSString).expandingTildeInPath)
}
// Relative to the document's directory.
if let docDir = document?.fileURL?.deletingLastPathComponent() {
return docDir.appendingPathComponent(dest)
}
return nil
}
/// A `FragmentOverlay` for `destination`'s image or placeholder, or nil
/// while a remote fetch is pending (the caller then shows plain alt text).
/// `width`/`height` are declared pixel dimensions from an HTML `<img>` tag.
func imageOverlay(destination: String, width: Int? = nil, height: Int? = nil) -> FragmentOverlay? {
switch imageDisplay(destination: destination) {
case .image(let image):
return scaledOverlay(image: image, width: width, height: height)
case .blocked(let failure):
return placeholderOverlay(failure: failure)
case .pending:
return nil
}
}
/// Scales `image` down to fit the text width while keeping its aspect
/// ratio. `bounds.minY == 0` sits the image bottom on the text baseline
/// (the reserved line height makes room above it). Declared `width`/
/// `height` override the natural size first (one alone scales the other
/// proportionally); the max-width clamp still applies after.
private func scaledOverlay(image: NSImage, width: Int? = nil, height: Int? = nil) -> FragmentOverlay? {
var size = image.size
guard size.width > 0, size.height > 0 else { return nil }
switch (width, height) {
case let (w?, h?): size = NSSize(width: CGFloat(w), height: CGFloat(h))
case let (w?, nil): size = NSSize(width: CGFloat(w),
height: size.height * CGFloat(w) / size.width)
case let (nil, h?): size = NSSize(width: size.width * CGFloat(h) / size.height,
height: CGFloat(h))
case (nil, nil): break
}
let maxWidth = availableContentWidth
if maxWidth > 0, size.width > maxWidth {
size = NSSize(width: maxWidth, height: size.height * (maxWidth / size.width))
}
return FragmentOverlay(image: image,
bounds: CGRect(x: 0, y: 0, width: size.width, height: size.height))
}
/// Draws "icon reason" into one image (same technique as the callout
/// header: `LucideIcons.image` tinted to match the muted text), so a
/// blocked/missing/undecodable image reads at a glance instead of just
/// showing nothing.
private func placeholderOverlay(failure: ImageLoadFailure) -> FragmentOverlay? {
let pointSize = bodyFont.pointSize
guard let icon = LucideIcons.image("image-off", color: .secondaryLabelColor, pointSize: pointSize)
else { return nil }
let labelAttrs: [NSAttributedString.Key: Any] = [.font: bodyFont, .foregroundColor: NSColor.secondaryLabelColor]
let label = NSAttributedString(string: failure.label, attributes: labelAttrs)
let labelSize = label.size()
let gap = pointSize * 0.3
let iconW = icon.size.width, iconH = icon.size.height
let height = ceil(max(iconH, labelSize.height))
let width = ceil(iconW + gap + labelSize.width)
let image = NSImage(size: NSSize(width: width, height: height), flipped: false) { _ in
icon.draw(in: NSRect(x: 0, y: (height - iconH) / 2, width: iconW, height: iconH))
label.draw(at: NSPoint(x: iconW + gap, y: (height - labelSize.height) / 2))
return true
}
image.cacheMode = .never // re-rasterize at the screen's backing scale, like the callout header
return FragmentOverlay(image: image, bounds: CGRect(x: 0, y: 0, width: width, height: height))
}
}