155 lines
5.8 KiB
Swift
155 lines
5.8 KiB
Swift
import Foundation
|
|
import ImageIO
|
|
import CoreGraphics
|
|
import AppKit
|
|
|
|
/// One raster page. Loaded with ImageIO (SPEC §6.1) and held as a Device-RGB
|
|
/// `CGImage` whose pixel values match the TIFF (SPEC §6.3).
|
|
|
|
final class TargetPage {
|
|
let url: URL
|
|
let name: String
|
|
let pixelWidth: Int
|
|
let pixelHeight: Int
|
|
let dpiX: CGFloat
|
|
let dpiY: CGFloat
|
|
/// Device RGB, `shouldInterpolate = false`. Used for both preview and print.
|
|
let cgImage: CGImage
|
|
/// Low-resolution thumbnail for the sidebar (SPEC §9).
|
|
let thumbnail: NSImage
|
|
|
|
var physicalWidthIn: CGFloat {
|
|
Geometry.physicalInches(pixels: CGFloat(pixelWidth), dpi: dpiX)
|
|
}
|
|
|
|
var physicalHeightIn: CGFloat {
|
|
Geometry.physicalInches(pixels: CGFloat(pixelHeight), dpi: dpiY)
|
|
}
|
|
|
|
init(url: URL, name: String, pixelWidth: Int, pixelHeight: Int,
|
|
dpiX: CGFloat, dpiY: CGFloat, cgImage: CGImage, thumbnail: NSImage) {
|
|
self.url = url
|
|
self.name = name
|
|
self.pixelWidth = pixelWidth
|
|
self.pixelHeight = pixelHeight
|
|
self.dpiX = dpiX
|
|
self.dpiY = dpiY
|
|
self.cgImage = cgImage
|
|
self.thumbnail = thumbnail
|
|
}
|
|
|
|
static func load(url: URL) throws -> [TargetPage] {
|
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
|
throw AppError.unreadableTarget(url.path)
|
|
}
|
|
let count = CGImageSourceGetCount(source)
|
|
guard count > 0 else { throw AppError.unreadableTarget(url.path) }
|
|
var pages: [TargetPage] = []
|
|
for index in 0..<count {
|
|
pages.append(try load(source: source, url: url, index: index, total: count))
|
|
}
|
|
return pages
|
|
}
|
|
|
|
private static func load(source: CGImageSource, url: URL, index: Int, total: Int) throws -> TargetPage {
|
|
let options: [CFString: Any] = [
|
|
kCGImageSourceShouldCache: true,
|
|
kCGImageSourceShouldAllowFloat: false,
|
|
kCGImageSourceCreateThumbnailFromImageIfAbsent: false,
|
|
]
|
|
guard let image = CGImageSourceCreateImageAtIndex(source, index, options as CFDictionary) else {
|
|
throw AppError.unreadableTarget("\(url.path) frame \(index)")
|
|
}
|
|
|
|
var dpiX: CGFloat = 72
|
|
var dpiY: CGFloat = 72
|
|
if let props = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any] {
|
|
if let x = props[kCGImagePropertyDPIWidth] as? CGFloat, x > 0 { dpiX = x }
|
|
else if let x = props[kCGImagePropertyDPIWidth] as? NSNumber, x.doubleValue > 0 { dpiX = CGFloat(x.doubleValue) }
|
|
if let y = props[kCGImagePropertyDPIHeight] as? CGFloat, y > 0 { dpiY = y }
|
|
else if let y = props[kCGImagePropertyDPIHeight] as? NSNumber, y.doubleValue > 0 { dpiY = CGFloat(y.doubleValue) }
|
|
}
|
|
|
|
guard let unmanaged = makeUnmanagedDeviceRGB(image) else {
|
|
throw AppError.unreadableTarget("unable to wrap \(url.lastPathComponent) as Device RGB")
|
|
}
|
|
|
|
let suffix = total > 1 ? " [\(index + 1)]" : ""
|
|
let name = url.lastPathComponent + suffix
|
|
let thumb = makeThumbnail(unmanaged)
|
|
return TargetPage(
|
|
url: url,
|
|
name: name,
|
|
pixelWidth: unmanaged.width,
|
|
pixelHeight: unmanaged.height,
|
|
dpiX: dpiX,
|
|
dpiY: dpiY,
|
|
cgImage: unmanaged,
|
|
thumbnail: thumb
|
|
)
|
|
}
|
|
|
|
/// Reinterpret the source bitmap as Device RGB without a ColorSync transform.
|
|
/// SPEC §6.3: do **not** call `CGImageCreateCopyWithColorSpace` with a calibrated space.
|
|
static func makeUnmanagedDeviceRGB(_ image: CGImage) -> CGImage? {
|
|
let width = image.width
|
|
let height = image.height
|
|
let bpc = 8
|
|
let spp = 4
|
|
let bpr = width * spp
|
|
let device = CGColorSpaceCreateDeviceRGB()
|
|
guard let buffer = NSMutableData(length: bpr * height) else { return nil }
|
|
let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.noneSkipLast.rawValue
|
|
guard let ctx = CGContext(
|
|
data: buffer.mutableBytes,
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: bpc,
|
|
bytesPerRow: bpr,
|
|
space: image.colorSpace ?? device,
|
|
bitmapInfo: bitmapInfo
|
|
) else { return nil }
|
|
ctx.interpolationQuality = .none
|
|
ctx.setShouldAntialias(false)
|
|
ctx.setAllowsAntialiasing(false)
|
|
ctx.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
|
|
guard let provider = CGDataProvider(data: buffer) else { return nil }
|
|
return CGImage(
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: bpc,
|
|
bitsPerPixel: 32,
|
|
bytesPerRow: bpr,
|
|
space: device,
|
|
bitmapInfo: CGBitmapInfo(rawValue: bitmapInfo),
|
|
provider: provider,
|
|
decode: nil,
|
|
shouldInterpolate: false,
|
|
intent: .defaultIntent
|
|
)
|
|
}
|
|
|
|
private static func makeThumbnail(_ image: CGImage) -> NSImage {
|
|
let maxEdge: CGFloat = 128
|
|
let w = CGFloat(image.width)
|
|
let h = CGFloat(image.height)
|
|
let scale = min(maxEdge / max(w, 1), maxEdge / max(h, 1), 1)
|
|
let tw = max(1, (w * scale).rounded())
|
|
let th = max(1, (h * scale).rounded())
|
|
let img = NSImage(size: NSSize(width: tw, height: th))
|
|
img.lockFocus()
|
|
if let gc = NSGraphicsContext.current {
|
|
gc.imageInterpolation = .medium
|
|
gc.shouldAntialias = true
|
|
}
|
|
let ns = NSImage(cgImage: image, size: NSSize(width: w, height: h))
|
|
ns.draw(in: NSRect(x: 0, y: 0, width: tw, height: th),
|
|
from: NSRect(x: 0, y: 0, width: w, height: h),
|
|
operation: .copy,
|
|
fraction: 1)
|
|
img.unlockFocus()
|
|
return img
|
|
}
|
|
}
|