import CoreGraphics import Foundation import ImageIO import UniformTypeIdentifiers import os // MARK: - PastedImage /// **A picture on the pasteboard, as a file this app could write** (04-interactions.md ▸ Clipboard, /// the image-data branch ruled 2026-08-09) — which flavor to take, what the landed file is called, /// and whether the bytes travel verbatim or are re-encoded on the way. /// /// ### Why this is a pure rule with no pasteboard in it /// /// `NewCardTarget` and `PasteTarget`'s reason, one layer over: every clause below is a *decision* /// about a list of type identifiers, and a decision that can be a value function should be one — the /// menu item's `disabled`, the paste's own refusal, and the tests all read the same answer instead of /// three hand-kept-in-sync conditions. The bytes are fetched by whoever owns the pasteboard seam /// (`ClipboardStore`); nothing here touches `NSPasteboard`. /// /// ### The precedence, which is the whole of the classification /// /// 1. **The app's own clipboard type wins outright.** A Lanework copy on the pasteboard is a board /// payload and pastes as cards or lanes exactly as it always did — an image flavor riding beside /// it (there is none today, but a future manifest could carry a preview) must never divert ⌘V. /// 2. **File URLs are somebody else's branch.** A Finder copy puts `public.file-url` down, sometimes /// with an image flavor beside it, and "the pasteboard's payload is IMAGE DATA (no file URL)" is /// the ruling's own parenthesis. A file URL is a *reference* to something the user already has /// filed; taking a second copy of it into `attachments/` behind their back would be a different /// gesture with a different answer, so this branch declines outright — and `ClipboardStore`'s own /// file-URL branch (`pasteFiles`, ruled 2026-08-09) takes the reference at its word instead, /// importing the file itself rather than a re-encoded copy of its bytes. A Finder-copied image /// file therefore lands as the file it is, never as a second "Pasted Image.png" beside it. /// 3. **Raw image data is the fallback**, which is the screenshot (⌃⇧⌘4), the browser's Copy Image, /// and Preview's ⌘C. /// /// ### The format rule /// /// **A file-shaped flavor is kept byte for byte** — PNG, JPEG, GIF, HEIC, WebP. These are the /// encodings a file on disk is already written in, so re-encoding one would cost either fidelity (a /// JPEG round-tripped through PNG is bigger *and* still carries the original's artefacts) or the /// picture itself (a GIF's animation does not survive a single-frame decode). The extension is that /// type's own preferred one, so the landed file opens in Preview and QuickLooks in the sidebar with /// no ceremony. /// /// **Everything else is re-encoded to PNG** — in practice `public.tiff`, AppKit's lossless /// interchange flavor, which is what a screenshot and a Preview copy put down beside their PNG and /// what a great many apps offer *instead* of one. TIFF is an interchange encoding rather than a file /// people want sitting in a card's folder: a 5K screenshot is tens of megabytes as TIFF and about a /// tenth of that as PNG, both lossless. `.bmp` rides the same branch for the same reason. /// /// The preference order is PNG first and TIFF last, so the overwhelmingly common paste — a screenshot /// offering `public.png` and `public.tiff` together — lands as the PNG it already is, with no decode /// and no re-encode at all. public enum PastedImage { // MARK: The name /// **The name a pasted image lands under** — Finder's own shape for a file that arrives with no /// name of its own ("Pasted Image.png", then "Pasted Image 2.png", …). /// /// Only the stem is here: the extension is the flavor's (`Flavor.fileExtension`), and the /// collision ladder is `BoardWriter.freshName`'s, reached by handing the import path a temporary /// file with this name on it. Nothing in this app climbs a second ladder — "Finder-style rename /// on collision is one rule wherever the app has to find a free name". public static let baseName = "Pasted Image" /// **The board backdrop's own stem** — the same rule one level up (03-board-ui.md § Styling ▸ /// Capabilities), so a pasted background is as recognizable in a board folder as a pasted /// attachment is in a card's. /// /// Its own constant rather than `baseName` reused: the two files land in different folders for /// different reasons, and a board folder holding something called "Pasted Image.png" would say /// nothing about what it is for. `FacetsGenerator.fileName` is the sibling this is modeled on. public static let backgroundBaseName = "Pasted Background" // MARK: The flavor /// One readable image payload on the pasteboard: where to read it from, what to write, and what /// to call it. public struct Flavor: Equatable, Sendable { /// The pasteboard type identifier the bytes come from. public let type: String /// The landed file's extension — the flavor's own for a verbatim write, `png` for a /// converted one. public let fileExtension: String /// Whether the bytes are re-encoded on the way (see the type comment's format rule). public let convertsToPNG: Bool /// The file name a paste of this flavor mints, before the Finder ladder ever sees it. public var fileName: String { "\(PastedImage.baseName).\(fileExtension)" } /// The board-backdrop file name for the same flavor. public var backgroundFileName: String { "\(PastedImage.backgroundBaseName).\(fileExtension)" } } // MARK: Classification /// The flavors kept verbatim, **in this app's preference order** — not the pasteboard's, which /// is the *owner's* ranking of what it thinks a taker wants and has no idea a file is about to /// be written. /// /// PNG leads because it is lossless, universally readable, and the flavor a screenshot already /// carries. JPEG follows so a photograph copied out of a browser lands as the JPEG it is rather /// than as a PNG several times its size. GIF, HEIC and WebP are here so that a source offering /// only one of them is still a paste rather than a refusal. public static let verbatimTypes: [UTType] = [.png, .jpeg, .gif, .heic, .webP] /// The flavors re-encoded to PNG — the interchange bitmaps (see the type comment). public static let convertedTypes: [UTType] = [.tiff, .bmp] /// What this pasteboard offers the image branch, or `nil` when the branch does not apply. /// /// - Parameter hasBoardItems: whether the app's own clipboard type is present and readable — /// clause 1 of the precedence. Passed in rather than read here because deciding *that* is /// `ClipboardManifest`'s job and this type has no pasteboard. /// - Parameter types: every type identifier the pasteboard currently carries. public static func flavor(hasBoardItems: Bool, types: [String]) -> Flavor? { guard !hasBoardItems, !carriesFileURL(types) else { return nil } let offered = Set(types) for type in verbatimTypes where offered.contains(type.identifier) { // A registered type with no preferred extension is not something a file can be named // after; skipping it lets the ladder fall through to the converted branch rather than // minting "Pasted Image." with nothing after the dot. guard let ext = type.preferredFilenameExtension else { continue } return Flavor(type: type.identifier, fileExtension: ext, convertsToPNG: false) } for type in convertedTypes where offered.contains(type.identifier) { return Flavor(type: type.identifier, fileExtension: "png", convertsToPNG: true) } return nil } /// Whether the pasteboard is carrying a file reference — clause 2 of the precedence. /// /// Conformance rather than equality with `public.file-url`, for `FinderDrop.isDirectory /// (typeIdentifiers:)`'s reason: a source is free to declare a subtype of it, and the rule is /// about what the value *is*. A type the system does not know is not a file URL, which is the /// same optimistic reading a drag's unknown types get. public static func carriesFileURL(_ types: [String]) -> Bool { types.contains { UTType($0)?.conforms(to: .fileURL) ?? false } } // MARK: The bytes private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "paste-image") /// The bytes this flavor writes, given what the pasteboard handed over — `raw` itself for a /// verbatim flavor, a PNG re-encoding for a converted one, and `nil` when the payload turns out /// not to be a decodable image at all. /// /// A `nil` here is the honest refusal: the pasteboard *declared* a type it cannot back up, and /// writing an unreadable file into somebody's card folder is worse than doing nothing. The /// caller treats it exactly as it treats an empty pasteboard. public static func encode(_ raw: Data, as flavor: Flavor) -> Data? { guard flavor.convertsToPNG else { return raw.isEmpty ? nil : raw } return pngData(from: raw) } /// A bitmap payload re-encoded as PNG, through ImageIO. /// /// **ImageIO rather than `NSBitmapImageRep`**, which is the same call `BoardBackdrop.decode` /// makes and for the same reasons: it is the framework that actually owns the codecs, it is /// `Sendable`-clean and main-actor-free, and it needs no AppKit image cache in the middle. The /// full image is decoded rather than a thumbnail — this is a *conversion*, and downsampling a /// picture the user pasted would silently cost them resolution they never agreed to lose. /// /// Alpha survives, because a PNG destination writing a CGImage with an alpha channel keeps it — /// which matters for exactly the payload this branch sees most, a screenshot of a rounded window. public static func pngData(from data: Data) -> Data? { guard let source = CGImageSourceCreateWithData(data as CFData, nil), let image = CGImageSourceCreateImageAtIndex( source, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary ) else { logger.debug("pasteboard bitmap could not be decoded") return nil } let output = NSMutableData() guard let destination = CGImageDestinationCreateWithData( output, UTType.png.identifier as CFString, 1, nil ) else { return nil } CGImageDestinationAddImage(destination, image, nil) guard CGImageDestinationFinalize(destination) else { logger.debug("pasteboard bitmap could not be re-encoded as PNG") return nil } return output as Data } // MARK: Which attachments can be a hero /// Whether an attachment named `name` is one **"Set as Hero" may point at** — an image, by its /// name's own extension (05-card-window.md ▸ Attachments; 03-board-ui.md § Card face ▸ Hero /// image). /// /// **By extension rather than by opening the file**, deliberately. The context menu is built /// while the pointer is going down on a row, and the sidebar may be showing a hundred of them; /// a per-row `CGImageSourceCreateWithURL` to decide whether a menu row is offered is exactly the /// kind of disk touch the card window keeps out of a body evaluation. The cost of being wrong is /// nothing either way: a name whose extension lies renders as no band at all (`CardHeroImage`'s /// structural degrade), and an image the system does not recognize by extension is simply not /// offered the row — the `hero` key is still hand-writable, which is what it was born as. public static func isImageName(_ name: String) -> Bool { let ext = (name as NSString).pathExtension guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return false } return type.conforms(to: .image) } }