diff --git a/Sources/ICCery/Print/TargetPageCanvasView.swift b/Sources/ICCery/Print/TargetPageCanvasView.swift index 52d638c..f63ae8d 100644 --- a/Sources/ICCery/Print/TargetPageCanvasView.swift +++ b/Sources/ICCery/Print/TargetPageCanvasView.swift @@ -27,9 +27,11 @@ final class TargetPageCanvasView: NSView { 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 } + /// Bottom-up CoreGraphics space: `CGContext.draw(_:in:)` renders + /// the raster upright — a flipped view prints mirrored (#211). + /// Page 1 is the BOTTOM band of the frame, the non-flipped + /// pagination convention. + override var isFlipped: Bool { false } override var isOpaque: Bool { true } override func knowsPageRange(_ range: NSRangePointer) -> Bool { @@ -37,7 +39,7 @@ final class TargetPageCanvasView: NSView { return true } - /// 1-based page → its paper-sized rect, stacked top-down. + /// 1-based page → its paper-sized rect, stacked bottom-up. override func rectForPage(_ page: Int) -> NSRect { NSRect(x: 0, y: CGFloat(page - 1) * paperSize.height, @@ -53,7 +55,7 @@ final class TargetPageCanvasView: NSView { let pageRect = rectForPage(page) let size = pages[page - 1].pointSize return NSRect(x: Self.snap(pageRect.minX), - y: Self.snap(pageRect.minY), + y: Self.snap(pageRect.maxY - size.height), width: size.width, height: size.height) } diff --git a/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift b/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift index 51984ae..4e2b7c5 100644 --- a/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift +++ b/Tests/ICCeryCoreTests/NativeSpoolPDFTests.swift @@ -40,21 +40,8 @@ final class NativeSpoolPDFTests: XCTestCase { 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 info = makeSaveInfo(pdfURL: pdfURL) let canvas = TargetPageCanvasView( pages: [raster, raster], paperSize: paperSize) @@ -88,29 +75,7 @@ final class NativeSpoolPDFTests: XCTestCase { ) 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() + let rep = try renderPageToBitmap(page, mediaBox: mediaBox) func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) { let color = try XCTUnwrap( @@ -161,4 +126,144 @@ final class NativeSpoolPDFTests: XCTestCase { XCTAssertEqual(outside.1, 1, accuracy: 0.04) XCTAssertEqual(outside.2, 1, accuracy: 0.04) } + + /// #211 — a top/bottom banded block proves the page is drawn + /// upright AND anchored at the paper's top edge: a mirrored draw + /// swaps the bands; a bottom-anchored draw leaves white above the + /// block instead of below. + func testSavePDFDrawsPageUprightTopAnchored() throws { + try XCTSkipIf( + ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"] + == "1", + "ICCERY_SKIP_PRINT_PDF=1 — print system unavailable") + + // 144×144 px @ 72 dpi → a 144×144 pt block, top half red, + // bottom half blue. + let topBytes: [UInt8] = [230, 40, 50] + let bottomBytes: [UInt8] = [40, 50, 230] + let tiff = try TargetTestFixtures.makeTIFF( + px: CGSize(width: 144, height: 144), dpi: 72, + components: 3, bitsPerComponent: 8, + pixelBytes: topBytes, + bottomHalfPixelBytes: bottomBytes, in: tempRoot) + let raster = try TargetRasterLoader.load(tiff: tiff) + + let pdfURL = tempRoot.appendingPathComponent("bands.pdf") + let info = makeSaveInfo(pdfURL: pdfURL) + let canvas = TargetPageCanvasView( + pages: [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") + + let document = try XCTUnwrap( + CGPDFDocument(pdfURL as CFURL)) + let page = try XCTUnwrap(document.page(at: 1)) + let mediaBox = page.getBoxRect(.mediaBox) + let rep = try renderPageToBitmap(page, mediaBox: mediaBox) + + 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 matches( + _ p: (Double, Double, Double), _ rgb: [UInt8] + ) -> Bool { + abs(p.0 - Double(rgb[0]) / 255) < 0.04 + && abs(p.1 - Double(rgb[1]) / 255) < 0.04 + && abs(p.2 - Double(rgb[2]) / 255) < 0.04 + } + + // Painted region = the page fill inset by the queue's + // unprintable margin (R10); minimum opaque y is its top edge + // in the rendered bitmap (same convention as the 1:1 test). + let width = Int(ceil(mediaBox.width)) + let height = Int(ceil(mediaBox.height)) + var topX = width, topY = height + for y in 0.. 0.5 { + topX = min(topX, x) + topY = min(topY, y) + } + } + XCTAssertLessThan(topY, height, "PDF page rendered empty") + + // The 144 pt block hangs from the page's top edge: top-band + // colour for the first 72 pt, bottom-band colour for the + // next 72 pt, then the white page fill. + XCTAssertTrue( + try matches(pixel(topX + 10, topY + 10), topBytes), + "block top is not the top-band colour — mirrored draw?") + XCTAssertTrue( + try matches(pixel(topX + 10, topY + 82), bottomBytes), + "block bottom is not the bottom-band colour — mirrored draw?") + XCTAssertTrue( + try matches(pixel(topX + 10, topY + 134), bottomBytes), + "block does not reach 144 pt below the painted top edge") + let below = try pixel(topX + 10, topY + 160) + XCTAssertEqual(below.0, 1, accuracy: 0.04) + XCTAssertEqual(below.1, 1, accuracy: 0.04) + XCTAssertEqual(below.2, 1, accuracy: 0.04) + } + + /// Print settings shared by the save-PDF harness tests: A4, zero + /// margins, `.clip` pagination, 1:1, no centring, `.save` to + /// `pdfURL`. + private func makeSaveInfo(pdfURL: URL) -> NSPrintInfo { + 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 + info.dictionary()[ + NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL + return info + } + + /// Render a saved-PDF page into a bitmap at 1 px/pt. + private func renderPageToBitmap( + _ page: CGPDFPage, mediaBox: CGRect + ) throws -> NSBitmapImageRep { + 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() + return rep + } } diff --git a/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift index ce58e6f..0bdf832 100644 --- a/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift +++ b/Tests/ICCeryCoreTests/TargetCanvasGeometryTests.swift @@ -4,7 +4,7 @@ import Foundation import XCTest @testable import ICCery -/// Issue #201 Phase 3 — `TargetPageCanvasView` top-down page stacking, +/// Issue #201 Phase 3 — `TargetPageCanvasView` bottom-up page stacking, /// snapped top-left anchoring, and the draw-time interpolation / /// antialias contract asserted via `drawProbe`. @MainActor @@ -35,11 +35,13 @@ final class TargetCanvasGeometryTests: XCTestCase { // MARK: - Page stacking - func testRectForPageStacksTopDown() { + /// Bottom-up space: page 1 is the BOTTOM band of the frame — the + /// non-flipped pagination convention (#211). + func testRectForPageStacksBottomUp() { let view = TargetPageCanvasView( pages: [raster(), raster(), raster()], paperSize: paperSize) - XCTAssertTrue(view.isFlipped) + XCTAssertFalse(view.isFlipped) let page1 = view.rectForPage(1) let page3 = view.rectForPage(3) @@ -68,18 +70,33 @@ final class TargetCanvasGeometryTests: XCTestCase { pages: [raster(), raster()], paperSize: paperSize) + // Near-full-page raster: in bottom-up space the page's top + // edge is `maxY`, so the top-anchored origin is ~0.03 pt + // negative — the overflow clips at the BOTTOM of the sheet. let first = view.destinationRect(forPage: 1) XCTAssertEqual(first.origin.x, 0, accuracy: 0.001) - XCTAssertEqual(first.origin.y, 0, accuracy: 0.001) + XCTAssertEqual(first.origin.y, -0.03, 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) + second.origin.y, + 2 * paperSize.height - first.height, accuracy: 0.001) XCTAssertEqual(second.size, first.size) - for rect in [first, second] { + // A raster smaller than the paper hangs from the page's TOP + // edge — minY would anchor it at the bottom (#211 follow-up). + let small = TargetPageCanvasView( + pages: [raster(pixelWidth: 72, pixelHeight: 72, dpi: 72)], + paperSize: paperSize) + let smallRect = small.destinationRect(forPage: 1) + XCTAssertEqual(smallRect.origin.x, 0, accuracy: 0.001) + XCTAssertEqual( + smallRect.origin.y, paperSize.height - 72, accuracy: 0.001) + XCTAssertEqual(smallRect.size, CGSize(width: 72, height: 72)) + + for rect in [first, second, smallRect] { XCTAssertEqual( rect.origin.x * 1000, (rect.origin.x * 1000).rounded(), diff --git a/Tests/ICCeryCoreTests/TargetRasterFixtures.swift b/Tests/ICCeryCoreTests/TargetRasterFixtures.swift index 00d97f8..36241ca 100644 --- a/Tests/ICCeryCoreTests/TargetRasterFixtures.swift +++ b/Tests/ICCeryCoreTests/TargetRasterFixtures.swift @@ -27,12 +27,16 @@ enum TargetTestFixtures { /// 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). + /// `bottomHalfPixelBytes`, when set, is the pattern for the bottom + /// half of the rows — a vertical asymmetry that lets tests catch a + /// mirrored draw (#211). static func makeImage( px: CGSize, components: Int, bitsPerComponent: Int, alpha: Bool = false, - pixelBytes: [UInt8]? = nil + pixelBytes: [UInt8]? = nil, + bottomHalfPixelBytes: [UInt8]? = nil ) -> CGImage? { let width = Int(px.width) let height = Int(px.height) @@ -40,19 +44,31 @@ enum TargetTestFixtures { let samples = components + (alpha ? 1 : 0) let bytesPerPixel = samples * bytesPerComponent let bytesPerRow = width * bytesPerPixel - let pattern = pixelBytes - ?? Array(0.. [UInt8] { + var row = [UInt8](repeating: 0, count: bytesPerRow) + for offset in stride( + from: 0, to: bytesPerRow, by: pattern.count) { + for (index, byte) in pattern.enumerated() + where offset + index < bytesPerRow { + row[offset + index] = byte + } } + return row } + let topRow = buildRow( + pixelBytes + ?? Array(0.. URL { guard let image = makeImage( px: px, components: components, bitsPerComponent: bitsPerComponent, - alpha: alpha, pixelBytes: pixelBytes) + alpha: alpha, pixelBytes: pixelBytes, + bottomHalfPixelBytes: bottomHalfPixelBytes) else { throw FixtureError.imageNotCreated } let url = directory.appendingPathComponent( "fixture-\(UUID().uuidString).tiff")