diff --git a/Sources/ICCery/Print/TargetPageCanvasView.swift b/Sources/ICCery/Print/TargetPageCanvasView.swift new file mode 100644 index 0000000..52d638c --- /dev/null +++ b/Sources/ICCery/Print/TargetPageCanvasView.swift @@ -0,0 +1,86 @@ +import AppKit + +/// Headless print canvas (#201, docs/14 §6). Never installed in a +/// window; `NSPrintOperation` is its only client. +@MainActor +final class TargetPageCanvasView: NSView { + + private let pages: [TargetPageRaster] + private let paperSize: NSSize + + /// Test probe — invoked once per drawn page with the live context + /// so the interpolation/antialias contract is assertable without a + /// printer. + var drawProbe: ((NSGraphicsContext, Int, NSRect) -> Void)? + + init(pages: [TargetPageRaster], paperSize: NSSize) { + self.pages = pages + self.paperSize = paperSize + super.init(frame: NSRect( + x: 0, y: 0, + width: paperSize.width, + height: paperSize.height * CGFloat(pages.count))) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("TargetPageCanvasView is code-only") + } + + /// Flipped: the CoreGraphics coordinate space is correctly oriented + /// top-down, so page 1 is the TOP rect. + override var isFlipped: Bool { true } + override var isOpaque: Bool { true } + + override func knowsPageRange(_ range: NSRangePointer) -> Bool { + range.pointee = NSRange(location: 1, length: pages.count) + return true + } + + /// 1-based page → its paper-sized rect, stacked top-down. + override func rectForPage(_ page: Int) -> NSRect { + NSRect(x: 0, + y: CGFloat(page - 1) * paperSize.height, + width: paperSize.width, + height: paperSize.height) + } + + /// Where page `page`'s image lands: top-left anchored inside + /// `rectForPage`, sized `raster.pointSize`, origin snapped to + /// 0.001 pt (docs/14 §6) so a fractional origin cannot trigger + /// device resampling. `internal` for unit tests. + func destinationRect(forPage page: Int) -> NSRect { + let pageRect = rectForPage(page) + let size = pages[page - 1].pointSize + return NSRect(x: Self.snap(pageRect.minX), + y: Self.snap(pageRect.minY), + width: size.width, + height: size.height) + } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current else { return } + ctx.imageInterpolation = .none + ctx.shouldAntialias = false + let cg = ctx.cgContext + cg.interpolationQuality = .none + cg.setShouldAntialias(false) + cg.setAllowsAntialiasing(false) + cg.setShouldSmoothFonts(false) + for index in pages.indices { + let pageRect = rectForPage(index + 1) + guard pageRect.intersects(dirtyRect) else { continue } + cg.saveGState() // page N never inherits N-1's CTM + NSColor.white.setFill() + pageRect.fill() + cg.draw(pages[index].cgImage, + in: destinationRect(forPage: index + 1)) + cg.restoreGState() + drawProbe?(ctx, index + 1, destinationRect(forPage: index + 1)) + } + } + + private static func snap(_ value: CGFloat) -> CGFloat { + (value * 1000).rounded() / 1000 + } +} diff --git a/Sources/ICCery/Print/TargetRaster.swift b/Sources/ICCery/Print/TargetRaster.swift new file mode 100644 index 0000000..5c3d32f --- /dev/null +++ b/Sources/ICCery/Print/TargetRaster.swift @@ -0,0 +1,221 @@ +import CoreGraphics +import Foundation +import ICCeryCore +import ImageIO + +/// One decoded target page ready to draw 1:1 (#201, docs/14 §6). +struct TargetPageRaster: Equatable { + let sourceURL: URL + /// Re-tagged into a **device** colour space — the image Quartz draws. + let cgImage: CGImage + let pixelWidth: Int + let pixelHeight: Int + let dpiX: Double + let dpiY: Double + let componentCount: Int + let bitsPerComponent: Int + /// Expected physical size from the printtarg manifest, when known. + let expectedWidthMm: Double? + let expectedHeightMm: Double? + + /// `pixels / dpi * 72`. The ONLY size the canvas ever draws at. + /// 72 pt = 1 in. **Never** `backingScaleFactor` (docs/14 §6). + var pointSize: CGSize { + CGSize(width: Double(pixelWidth) / dpiX * 72, + height: Double(pixelHeight) / dpiY * 72) + } + + /// `pixels / dpi * 25.4` — the physical size the DPI implies. + var physicalMm: CGSize { + CGSize(width: Double(pixelWidth) / dpiX * 25.4, + height: Double(pixelHeight) / dpiY * 25.4) + } + + /// `nil` when within 0.5 mm of the manifest, else the drift + /// message (D9 — warn + spool, never rescale). + var manifestDrift: String? { + var parts: [String] = [] + if let expectedWidthMm, + abs(physicalMm.width - expectedWidthMm) > 0.5 { + parts.append(String( + format: "width %.2f mm vs manifest %.2f mm", + physicalMm.width, expectedWidthMm)) + } + if let expectedHeightMm, + abs(physicalMm.height - expectedHeightMm) > 0.5 { + parts.append(String( + format: "height %.2f mm vs manifest %.2f mm", + physicalMm.height, expectedHeightMm)) + } + guard !parts.isEmpty else { return nil } + return "TIFF DPI disagrees with the printtarg manifest — " + + parts.joined(separator: "; ") + } + + static func == (lhs: TargetPageRaster, rhs: TargetPageRaster) -> Bool { + lhs.sourceURL == rhs.sourceURL + && lhs.cgImage === rhs.cgImage + && lhs.pixelWidth == rhs.pixelWidth + && lhs.pixelHeight == rhs.pixelHeight + && lhs.dpiX == rhs.dpiX + && lhs.dpiY == rhs.dpiY + && lhs.componentCount == rhs.componentCount + && lhs.bitsPerComponent == rhs.bitsPerComponent + && lhs.expectedWidthMm == rhs.expectedWidthMm + && lhs.expectedHeightMm == rhs.expectedHeightMm + } +} + +enum TargetRasterError: LocalizedError, Equatable { + case tiffMissing(String) + case undecodable(String) + case unsupportedLayout(path: String, components: Int, + bitsPerComponent: Int, hasAlpha: Bool) + + var errorDescription: String? { + switch self { + case .tiffMissing(let path): + return "Target TIFF is missing: \(path)" + case .undecodable(let path): + return "Target TIFF could not be decoded: \(path)" + case .unsupportedLayout(let path, let components, + let bitsPerComponent, let hasAlpha): + return "Target TIFF has an unsupported layout " + + "(\(components) components, \(bitsPerComponent) bpc" + + "\(hasAlpha ? ", alpha" : "")): \(path)" + } + } +} + +enum TargetRasterLoader { + /// Absent DPI metadata defaults to 72 (docs/14 §6) — and logs `warn`. + static let fallbackDPI: Double = 72 + + static func load( + tiff url: URL, + expectedWidthMm: Double? = nil, + expectedHeightMm: Double? = nil + ) throws -> TargetPageRaster { + guard FileManager.default.fileExists(atPath: url.path) else { + throw TargetRasterError.tiffMissing(url.path) + } + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) + else { + throw TargetRasterError.undecodable(url.path) + } + let options: [CFString: Any] = [ + kCGImageSourceShouldCache: true, + kCGImageSourceShouldAllowFloat: false, + ] + guard let image = CGImageSourceCreateImageAtIndex( + source, 0, options as CFDictionary) + else { + throw TargetRasterError.undecodable(url.path) + } + + // DPI lives in the container metadata, not the CGImage. + var dpiX = fallbackDPI + var dpiY = fallbackDPI + var foundX = false + var foundY = false + if let properties = CGImageSourceCopyPropertiesAtIndex( + source, 0, nil) as? [CFString: Any] { + if let x = (properties[kCGImagePropertyDPIWidth] + as? NSNumber)?.doubleValue, x > 0 { + dpiX = x + foundX = true + } + if let y = (properties[kCGImagePropertyDPIHeight] + as? NSNumber)?.doubleValue, y > 0 { + dpiY = y + foundY = true + } + } + if !foundX || !foundY { + AppLogger.shared.warn( + "TargetRaster: '\(url.lastPathComponent)' carries no " + + "usable DPI metadata — assuming \(fallbackDPI) dpi") + } + + let components = image.colorSpace?.numberOfComponents ?? 0 + guard !Self.hasRealAlpha(image), let tagged = deviceTagged(image) + else { + throw TargetRasterError.unsupportedLayout( + path: url.path, + components: components, + bitsPerComponent: image.bitsPerComponent, + hasAlpha: Self.hasRealAlpha(image)) + } + + return TargetPageRaster( + sourceURL: url, + cgImage: tagged, + pixelWidth: image.width, + pixelHeight: image.height, + dpiX: dpiX, + dpiY: dpiY, + componentCount: components, + bitsPerComponent: image.bitsPerComponent, + expectedWidthMm: expectedWidthMm, + expectedHeightMm: expectedHeightMm) + } + + /// Re-tag into `DeviceGray` (1 comp) / `DeviceRGB` (3) / + /// `DeviceCMYK` (4) by reusing the decoded image's own + /// `dataProvider`, `bitsPerComponent`, `bitsPerPixel`, `bytesPerRow` + /// and `bitmapInfo` — **no resample, no bit-depth change, no + /// `CGImageCreateCopyWithColorSpace` into a calibrated space** + /// (docs/14 §6.3). `shouldInterpolate: false`, + /// `intent: .defaultIntent`. + /// + /// Returns `nil` (caller throws `.unsupportedLayout`) for alpha, + /// indexed, float, or >4-component images — a profiling target + /// never has those, and guessing would corrupt patches. + static func deviceTagged(_ image: CGImage) -> CGImage? { + guard !hasRealAlpha(image), + !image.bitmapInfo.contains(.floatComponents), + let colorSpace = image.colorSpace, + colorSpace.model != .indexed, + colorSpace.model != .pattern, + let space = deviceSpace(for: colorSpace.numberOfComponents), + let provider = image.dataProvider + else { return nil } + return CGImage( + width: image.width, + height: image.height, + bitsPerComponent: image.bitsPerComponent, + bitsPerPixel: image.bitsPerPixel, + bytesPerRow: image.bytesPerRow, + space: space, + bitmapInfo: image.bitmapInfo, + provider: provider, + decode: image.decode, + shouldInterpolate: false, + intent: .defaultIntent) + } + + /// Skip-only padding (`noneSkipLast`/`noneSkipFirst`) is **not** + /// alpha — ImageIO pads opaque RGB TIFFs to 32 bpp that way, so + /// accepting it is required for real printtarg files. True coverage + /// alpha (`last`/`first`/`premultiplied*`/`only`) is rejected. + static func hasRealAlpha(_ image: CGImage) -> Bool { + switch image.alphaInfo { + case .none, .noneSkipLast, .noneSkipFirst: + return false + default: + return true + } + } + + /// The device colour space for a component count — `nil` for + /// layouts a profiling target can never legally be (2 channels, + /// DeviceN, >4 components). + static func deviceSpace(for componentCount: Int) -> CGColorSpace? { + switch componentCount { + case 1: return CGColorSpaceCreateDeviceGray() + case 3: return CGColorSpaceCreateDeviceRGB() + case 4: return CGColorSpaceCreateDeviceCMYK() + default: return nil + } + } +} diff --git a/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift b/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift new file mode 100644 index 0000000..51984ae --- /dev/null +++ b/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift @@ -0,0 +1,164 @@ +import AppKit +import CoreGraphics +import Foundation +import XCTest +@testable import ICCery + +/// Issue #201 Phase 3 — real `NSPrintOperation` with +/// `jobDisposition = .save`: proves the canvas paginates at paper size, +/// draws the raster 1:1 at the top-left, and survives end-to-end +/// without a printer. Skippable via `ICCERY_SKIP_PRINT_PDF=1` for +/// hosts where the print system cannot run (R8). +@MainActor +final class NativeSpoolPDFTests: XCTestCase { + + private var tempRoot: URL! + + /// A4 in points. + private let paperSize = NSSize(width: 595.28, height: 841.89) + /// Known top-left patch colour — solid fill across the fixture. + private let patchBytes: [UInt8] = [230, 40, 50] + + override func setUpWithError() throws { + tempRoot = try TargetTestFixtures.makeTempDirectory() + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempRoot) + } + + func testSavePDFDrawsPagesOneToOne() throws { + try XCTSkipIf( + ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"] + == "1", + "ICCERY_SKIP_PRINT_PDF=1 — print system unavailable") + + // 72×72 px @ 72 dpi → a 72×72 pt solid block at the top-left. + let tiff = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 72, height: 72), dpi: 72, + components: 3, bitsPerComponent: 8, + pixelBytes: patchBytes, in: tempRoot) + let raster = try TargetRasterLoader.load(tiff: tiff) + + let info = NSPrintInfo() + info.paperSize = paperSize + info.orientation = .portrait + info.topMargin = 0 + info.bottomMargin = 0 + info.leftMargin = 0 + info.rightMargin = 0 + info.horizontalPagination = .clip + info.verticalPagination = .clip + info.scalingFactor = 1.0 + info.isHorizontallyCentered = false + info.isVerticallyCentered = false + info.jobDisposition = .save + let pdfURL = tempRoot.appendingPathComponent("spool.pdf") + info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL + + let canvas = TargetPageCanvasView( + pages: [raster, raster], paperSize: paperSize) + let operation = NSPrintOperation(view: canvas, printInfo: info) + operation.showsPrintPanel = false + operation.showsProgressPanel = false + operation.canSpawnSeparateThread = false + + XCTAssertTrue(operation.run(), + "NSPrintOperation.save failed") + XCTAssertTrue( + FileManager.default.fileExists(atPath: pdfURL.path)) + + let document = try XCTUnwrap( + CGPDFDocument(pdfURL as CFURL)) + XCTAssertEqual(document.numberOfPages, 2) + + let page = try XCTUnwrap(document.page(at: 1)) + let mediaBox = page.getBoxRect(.mediaBox) + XCTAssertEqual(mediaBox.width, paperSize.width, accuracy: 0.5) + XCTAssertEqual(mediaBox.height, paperSize.height, accuracy: 0.5) + + try assertTopLeftPatch(on: page, mediaBox: mediaBox) + } + + /// Render the page at 1 px/pt and check the fixture's solid block + /// landed at the top-left at its point size — the coarse 1:1 + + /// no-scaling check. + private func assertTopLeftPatch( + on page: CGPDFPage, mediaBox: CGRect + ) throws { + let width = Int(ceil(mediaBox.width)) + let height = Int(ceil(mediaBox.height)) + let rep = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try XCTUnwrap( + NSGraphicsContext(bitmapImageRep: rep)) + let cg = context.cgContext + let transform = page.getDrawingTransform( + .mediaBox, + rect: CGRect(x: 0, y: 0, width: width, height: height), + rotate: 0, preserveAspectRatio: true) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + cg.concatenate(transform) + cg.drawPDFPage(page) + NSGraphicsContext.restoreGraphicsState() + + func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) { + let color = try XCTUnwrap( + rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB)) + return (color.redComponent, + color.greenComponent, + color.blueComponent) + } + func isPatch(_ p: (Double, Double, Double)) -> Bool { + abs(p.0 - Double(patchBytes[0]) / 255) < 0.04 + && abs(p.1 - Double(patchBytes[1]) / 255) < 0.04 + && abs(p.2 - Double(patchBytes[2]) / 255) < 0.04 + } + + // The print system insets the view by the queue's unprintable + // margin (R10) — host-dependent, ~18 pt on this machine — so + // the block's anchor is the painted region's top-left, not + // (0,0). Find the painted origin by the first opaque pixel. + var originX = width, originY = height + for y in 0.. 0.5 { + originX = min(originX, x) + originY = min(originY, y) + } + } + XCTAssertLessThan(originX, width, "PDF page rendered empty") + XCTAssertLessThan(originY, height, "PDF page rendered empty") + + // 10 px inside the 72 pt block on each axis → patch colour. + XCTAssertTrue(try isPatch(pixel(originX + 10, originY + 10)), + "top-left block is not the fixture colour") + + // Measure the block's painted width on a row through it: + // 72 pt at 1 px/pt — the 1:1 / no-scaling assertion. + var blockWidth = 0 + var x = originX + while x < width, + isPatch(try pixel(x, originY + 10)) { + blockWidth += 1 + x += 1 + } + XCTAssertEqual(blockWidth, 72, accuracy: 2) + + // Past the block edge the canvas's white page fill shows. + let outside = try pixel(originX + 80, originY + 80) + XCTAssertEqual(outside.0, 1, accuracy: 0.04) + XCTAssertEqual(outside.1, 1, accuracy: 0.04) + XCTAssertEqual(outside.2, 1, accuracy: 0.04) + } +} diff --git a/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift new file mode 100644 index 0000000..ce58e6f --- /dev/null +++ b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift @@ -0,0 +1,170 @@ +import AppKit +import CoreGraphics +import Foundation +import XCTest +@testable import ICCery + +/// Issue #201 Phase 3 — `TargetPageCanvasView` top-down page stacking, +/// snapped top-left anchoring, and the draw-time interpolation / +/// antialias contract asserted via `drawProbe`. +@MainActor +final class TargetCanvasGeometryTests: XCTestCase { + + /// A4-ish paper, points. + private let paperSize = NSSize(width: 595.28, height: 841.89) + + private func raster( + pixelWidth: Int = 2480, + pixelHeight: Int = 3508, + dpi: Double = 300 + ) -> TargetPageRaster { + let image = TargetTestFixtures.makeImage( + px: CGSize(width: 8, height: 8), + components: 3, bitsPerComponent: 8)! + return TargetPageRaster( + sourceURL: URL(fileURLWithPath: "/fixture.tiff"), + cgImage: image, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + dpiX: dpi, dpiY: dpi, + componentCount: 3, + bitsPerComponent: 8, + expectedWidthMm: nil, + expectedHeightMm: nil) + } + + // MARK: - Page stacking + + func testRectForPageStacksTopDown() { + let view = TargetPageCanvasView( + pages: [raster(), raster(), raster()], + paperSize: paperSize) + XCTAssertTrue(view.isFlipped) + + let page1 = view.rectForPage(1) + let page3 = view.rectForPage(3) + XCTAssertEqual(page1.minY, 0, accuracy: 0.001) + XCTAssertEqual(page3.minY, 2 * paperSize.height, accuracy: 0.001) + XCTAssertEqual(page1.size, paperSize) + XCTAssertEqual(page3.size, paperSize) + } + + func testKnowsPageRangeAndFrame() { + let view = TargetPageCanvasView( + pages: [raster(), raster()], + paperSize: paperSize) + var range = NSRange() + XCTAssertTrue(view.knowsPageRange(&range)) + XCTAssertEqual(range, NSRange(location: 1, length: 2)) + XCTAssertEqual( + view.frame.height, 2 * paperSize.height, accuracy: 0.001) + XCTAssertEqual(view.frame.width, paperSize.width, accuracy: 0.001) + } + + // MARK: - Destination rect + + func testDestinationRectAnchorsTopLeftAndSnaps() { + let view = TargetPageCanvasView( + pages: [raster(), raster()], + paperSize: paperSize) + + let first = view.destinationRect(forPage: 1) + XCTAssertEqual(first.origin.x, 0, accuracy: 0.001) + XCTAssertEqual(first.origin.y, 0, accuracy: 0.001) + XCTAssertEqual(first.width, 595.2, accuracy: 0.01) + XCTAssertEqual(first.height, 841.92, accuracy: 0.01) + + let second = view.destinationRect(forPage: 2) + XCTAssertEqual( + second.origin.y, paperSize.height, accuracy: 0.001) + XCTAssertEqual(second.size, first.size) + + for rect in [first, second] { + XCTAssertEqual( + rect.origin.x * 1000, + (rect.origin.x * 1000).rounded(), + accuracy: 1e-9) + XCTAssertEqual( + rect.origin.y * 1000, + (rect.origin.y * 1000).rounded(), + accuracy: 1e-9) + } + } + + // MARK: - Draw flags + + /// Render the canvas into an offscreen bitmap context and assert — + /// from inside `drawProbe` — that interpolation and antialiasing + /// are off and the drawn rect is `destinationRect(forPage:)`. + func testDrawDisablesInterpolationAndAntialias() throws { + let pages = [raster(), raster()] + let view = TargetPageCanvasView( + pages: pages, paperSize: paperSize) + + let rep = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(ceil(view.frame.width)), + pixelsHigh: Int(ceil(view.frame.height)), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try XCTUnwrap( + NSGraphicsContext(bitmapImageRep: rep)) + + var probed: [(page: Int, rect: NSRect)] = [] + view.drawProbe = { ctx, page, rect in + XCTAssertEqual(ctx.imageInterpolation, .none, + "page \(page): interpolation not .none") + XCTAssertFalse(ctx.shouldAntialias, + "page \(page): antialiasing still on") + probed.append((page, rect)) + } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + view.draw(view.bounds) + NSGraphicsContext.restoreGraphicsState() + + XCTAssertEqual(probed.count, pages.count) + for (page, rect) in probed { + XCTAssertEqual( + rect, view.destinationRect(forPage: page), + "page \(page): probe rect mismatch") + } + } + + /// A dirty rect covering only page 2's band must draw only page 2. + func testDrawSkipsPagesOutsideDirtyRect() throws { + let view = TargetPageCanvasView( + pages: [raster(), raster(), raster()], + paperSize: paperSize) + + let rep = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(ceil(view.frame.width)), + pixelsHigh: Int(ceil(view.frame.height)), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try XCTUnwrap( + NSGraphicsContext(bitmapImageRep: rep)) + + var probedPages: [Int] = [] + view.drawProbe = { _, page, _ in probedPages.append(page) } + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + view.draw(view.rectForPage(2)) + NSGraphicsContext.restoreGraphicsState() + + XCTAssertEqual(probedPages, [2]) + } +} diff --git a/Tests/ICCeryCoreTests/TargetRasterFixtures.swift b/Tests/ICCeryCoreTests/TargetRasterFixtures.swift new file mode 100644 index 0000000..00d97f8 --- /dev/null +++ b/Tests/ICCeryCoreTests/TargetRasterFixtures.swift @@ -0,0 +1,126 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Shared TIFF fixture builder for the #201 raster / canvas / PDF +/// harness tests. Writes real TIFFs via `CGImageDestination` so the +/// loader exercises the same decode path as production. +enum TargetTestFixtures { + + enum FixtureError: Error { + case imageNotCreated + case destinationNotCreated + case finalizeFailed + } + + /// Fresh per-test directory under the temp root. + static func makeTempDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-raster-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: url, withIntermediateDirectories: true) + return url + } + + /// A solid-fill (or repeating-pattern) `CGImage` with a device + /// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true` + /// appends one alpha sample per pixel (`last`). `pixelBytes` is the + /// repeating per-pixel pattern (big-endian for 16 bpc). + static func makeImage( + px: CGSize, + components: Int, + bitsPerComponent: Int, + alpha: Bool = false, + pixelBytes: [UInt8]? = nil + ) -> CGImage? { + let width = Int(px.width) + let height = Int(px.height) + let bytesPerComponent = bitsPerComponent / 8 + let samples = components + (alpha ? 1 : 0) + let bytesPerPixel = samples * bytesPerComponent + let bytesPerRow = width * bytesPerPixel + let pattern = pixelBytes + ?? Array(0.. URL { + guard let image = makeImage( + px: px, components: components, + bitsPerComponent: bitsPerComponent, + alpha: alpha, pixelBytes: pixelBytes) + else { throw FixtureError.imageNotCreated } + let url = directory.appendingPathComponent( + "fixture-\(UUID().uuidString).tiff") + guard let destination = CGImageDestinationCreateWithURL( + url as CFURL, UTType.tiff.identifier as CFString, 1, nil) + else { throw FixtureError.destinationNotCreated } + var properties: [CFString: Any] = [:] + if let dpi { + properties[kCGImagePropertyDPIWidth] = dpi + properties[kCGImagePropertyDPIHeight] = dpi + properties[kCGImagePropertyTIFFDictionary] = [ + kCGImagePropertyTIFFXResolution: dpi, + kCGImagePropertyTIFFYResolution: dpi, + kCGImagePropertyTIFFResolutionUnit: 2, // inches + ] + } + CGImageDestinationAddImage( + destination, image, properties as CFDictionary) + guard CGImageDestinationFinalize(destination) + else { throw FixtureError.finalizeFailed } + return url + } +} diff --git a/Tests/ICCeryCoreTests/TargetRasterTests.swift b/Tests/ICCeryCoreTests/TargetRasterTests.swift new file mode 100644 index 0000000..7bbbd3c --- /dev/null +++ b/Tests/ICCeryCoreTests/TargetRasterTests.swift @@ -0,0 +1,241 @@ +import CoreGraphics +import Foundation +import ImageIO +import XCTest +@testable import ICCery + +/// Issue #201 Phase 3 — `TargetRasterLoader` DPI handling, device +/// re-tag (D10), unsupported-layout rejection, and manifest drift (D9). +final class TargetRasterTests: XCTestCase { + + private var tempRoot: URL! + + override func setUpWithError() throws { + tempRoot = try TargetTestFixtures.makeTempDirectory() + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempRoot) + } + + private func load(_ url: URL) throws -> TargetPageRaster { + try TargetRasterLoader.load(tiff: url) + } + + /// Decode index 0 the same way the loader does, for provider-byte + /// comparisons that prove the re-tag did not resample. + private func decoded(_ url: URL) throws -> CGImage { + let source = try XCTUnwrap( + CGImageSourceCreateWithURL(url as CFURL, nil)) + return try XCTUnwrap( + CGImageSourceCreateImageAtIndex(source, 0, nil)) + } + + // MARK: - pointSize / physicalMm + + func testPointSizeFromDPI() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 2480, height: 3508), dpi: 300, + in: tempRoot) + let raster = try load(url) + XCTAssertEqual(raster.pointSize.width, 595.2, accuracy: 0.01) + XCTAssertEqual(raster.pointSize.height, 841.92, accuracy: 0.01) + XCTAssertEqual(raster.physicalMm.width, 209.97, accuracy: 0.01) + XCTAssertEqual(raster.physicalMm.height, 297.01, accuracy: 0.01) + } + + func testPointSizeAt600dpi() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 1200, height: 1800), dpi: 600, + in: tempRoot) + let raster = try load(url) + XCTAssertEqual(raster.pointSize.width, 144, accuracy: 0.01) + XCTAssertEqual(raster.pointSize.height, 216, accuracy: 0.01) + } + + func testMissingDPIFallsBackTo72() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 100, height: 80), dpi: nil, + in: tempRoot) + let raster = try load(url) + XCTAssertEqual(raster.dpiX, TargetRasterLoader.fallbackDPI) + XCTAssertEqual(raster.dpiY, TargetRasterLoader.fallbackDPI) + XCTAssertEqual(raster.pointSize.width, 100, accuracy: 0.01) + XCTAssertEqual(raster.pointSize.height, 80, accuracy: 0.01) + } + + func testMissingFileThrows() { + let url = tempRoot.appendingPathComponent("nope.tiff") + XCTAssertThrowsError(try load(url)) { error in + XCTAssertEqual( + error as? TargetRasterError, + .tiffMissing(url.path)) + } + } + + func testGarbageFileThrowsUndecodable() throws { + let url = tempRoot.appendingPathComponent("junk.tiff") + try Data([0x00, 0x01, 0x02, 0x03]).write(to: url) + XCTAssertThrowsError(try load(url)) { error in + XCTAssertEqual( + error as? TargetRasterError, + .undecodable(url.path)) + } + } + + // MARK: - deviceTagged (D10) + + func testDeviceTaggedRGB8() throws { + let source = try XCTUnwrap(TargetTestFixtures.makeImage( + px: CGSize(width: 16, height: 16), + components: 3, bitsPerComponent: 8)) + let tagged = try XCTUnwrap( + TargetRasterLoader.deviceTagged(source)) + XCTAssertEqual(tagged.colorSpace?.model, .rgb) + XCTAssertEqual(tagged.bitsPerComponent, 8) + XCTAssertFalse(tagged.shouldInterpolate) + XCTAssertEqual( + tagged.dataProvider?.data as? Data, + source.dataProvider?.data as? Data) + } + + func testDeviceTaggedCMYK8() throws { + let source = try XCTUnwrap(TargetTestFixtures.makeImage( + px: CGSize(width: 16, height: 16), + components: 4, bitsPerComponent: 8)) + let tagged = try XCTUnwrap( + TargetRasterLoader.deviceTagged(source)) + XCTAssertEqual(tagged.colorSpace?.model, .cmyk) + XCTAssertEqual(tagged.bitsPerComponent, 8) + XCTAssertEqual( + tagged.dataProvider?.data as? Data, + source.dataProvider?.data as? Data) + } + + func testDeviceTaggedGray8() throws { + let source = try XCTUnwrap(TargetTestFixtures.makeImage( + px: CGSize(width: 16, height: 16), + components: 1, bitsPerComponent: 8)) + let tagged = try XCTUnwrap( + TargetRasterLoader.deviceTagged(source)) + XCTAssertEqual(tagged.colorSpace?.model, .monochrome) + XCTAssertEqual(tagged.bitsPerComponent, 8) + XCTAssertEqual( + tagged.dataProvider?.data as? Data, + source.dataProvider?.data as? Data) + } + + func testDeviceTaggedRGB16KeepsBitDepth() throws { + let source = try XCTUnwrap(TargetTestFixtures.makeImage( + px: CGSize(width: 16, height: 16), + components: 3, bitsPerComponent: 16)) + let tagged = try XCTUnwrap( + TargetRasterLoader.deviceTagged(source)) + XCTAssertEqual(tagged.colorSpace?.model, .rgb) + XCTAssertEqual(tagged.bitsPerComponent, 16) + XCTAssertEqual( + tagged.dataProvider?.data as? Data, + source.dataProvider?.data as? Data) + } + + /// The loaded raster reuses the decoder's provider verbatim — byte + /// equality is the no-resample guarantee (R5). + func testLoadedRasterSharesDecodedBytes() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 32, height: 24), dpi: 300, + in: tempRoot) + let raster = try load(url) + let decoded = try decoded(url) + XCTAssertEqual( + raster.cgImage.dataProvider?.data as? Data, + decoded.dataProvider?.data as? Data) + XCTAssertEqual(raster.cgImage.colorSpace?.model, .rgb) + } + + // MARK: - Rejections + + func testAlphaTIFFThrowsUnsupportedLayout() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 16, height: 16), dpi: 300, + components: 3, alpha: true, in: tempRoot) + XCTAssertThrowsError(try load(url)) { error in + guard case .unsupportedLayout(_, _, _, let hasAlpha) = + error as? TargetRasterError + else { + return XCTFail("expected unsupportedLayout, got \(error)") + } + XCTAssertTrue(hasAlpha) + } + } + + func testDeviceTaggedRejectsFloat() throws { + var info = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + info.insert(.floatComponents) + info.insert(.byteOrder32Little) + var bytes = [Float](repeating: 0.5, count: 4 * 4 * 3) + let floatImage = bytes.withUnsafeMutableBytes { buffer -> CGImage? in + CGImage( + width: 4, height: 4, + bitsPerComponent: 32, bitsPerPixel: 96, + bytesPerRow: 4 * 3 * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: info, + provider: CGDataProvider(data: Data(buffer) as CFData)!, + decode: nil, shouldInterpolate: false, + intent: .defaultIntent) + } + bytes = [] + let image = try XCTUnwrap(floatImage) + XCTAssertNil(TargetRasterLoader.deviceTagged(image)) + } + + func testDeviceTaggedRejectsIndexed() throws { + var table = [UInt8](repeating: 0, count: 256 * 3) + let indexed = try XCTUnwrap(CGColorSpace( + indexedBaseSpace: CGColorSpaceCreateDeviceRGB(), + last: 255, colorTable: &table)) + let image = try XCTUnwrap(CGImage( + width: 8, height: 8, + bitsPerComponent: 8, bitsPerPixel: 8, + bytesPerRow: 8, + space: indexed, + bitmapInfo: CGBitmapInfo(), + provider: CGDataProvider( + data: Data(repeating: 7, count: 64) as CFData)!, + decode: nil, shouldInterpolate: false, + intent: .defaultIntent)) + XCTAssertNil(TargetRasterLoader.deviceTagged(image)) + } + + /// No 2-component `CGImage` is fabricatable through public API — + /// the rejection lives in `deviceSpace(for:)`, asserted here. + func testDeviceSpaceRejectsTwoAndFiveComponents() { + XCTAssertNil(TargetRasterLoader.deviceSpace(for: 2)) + XCTAssertNil(TargetRasterLoader.deviceSpace(for: 0)) + XCTAssertNil(TargetRasterLoader.deviceSpace(for: 5)) + XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 1)) + XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 3)) + XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 4)) + } + + // MARK: - Manifest drift (D9) + + func testManifestDriftWhenMismatch() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 2480, height: 3508), dpi: 300, + in: tempRoot) + let raster = try TargetRasterLoader.load( + tiff: url, expectedWidthMm: 200, expectedHeightMm: 297) + XCTAssertNotNil(raster.manifestDrift) + } + + func testManifestDriftNilWhenMatching() throws { + let url = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 2480, height: 3508), dpi: 300, + in: tempRoot) + // 2480 px @300 dpi = 209.97 mm — inside the 0.5 mm band. + let raster = try TargetRasterLoader.load( + tiff: url, expectedWidthMm: 210, expectedHeightMm: 297) + XCTAssertNil(raster.manifestDrift) + } +}