[Bug/Critical] Print output mirrored + bottom-anchored target canvas (#211) #213

Merged
gronod merged 2 commits from bugfix/211-print-output-flip into develop 2026-09-17 21:59:47 +01:00
4 changed files with 202 additions and 59 deletions
@@ -27,9 +27,11 @@ final class TargetPageCanvasView: NSView {
fatalError("TargetPageCanvasView is code-only") fatalError("TargetPageCanvasView is code-only")
} }
/// Flipped: the CoreGraphics coordinate space is correctly oriented /// Bottom-up CoreGraphics space: `CGContext.draw(_:in:)` renders
/// top-down, so page 1 is the TOP rect. /// the raster upright — a flipped view prints mirrored (#211).
override var isFlipped: Bool { true } /// 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 var isOpaque: Bool { true }
override func knowsPageRange(_ range: NSRangePointer) -> Bool { override func knowsPageRange(_ range: NSRangePointer) -> Bool {
@@ -37,7 +39,7 @@ final class TargetPageCanvasView: NSView {
return true 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 { override func rectForPage(_ page: Int) -> NSRect {
NSRect(x: 0, NSRect(x: 0,
y: CGFloat(page - 1) * paperSize.height, y: CGFloat(page - 1) * paperSize.height,
@@ -53,7 +55,7 @@ final class TargetPageCanvasView: NSView {
let pageRect = rectForPage(page) let pageRect = rectForPage(page)
let size = pages[page - 1].pointSize let size = pages[page - 1].pointSize
return NSRect(x: Self.snap(pageRect.minX), return NSRect(x: Self.snap(pageRect.minX),
y: Self.snap(pageRect.minY), y: Self.snap(pageRect.maxY - size.height),
width: size.width, width: size.width,
height: size.height) height: size.height)
} }
+142 -37
View File
@@ -40,21 +40,8 @@ final class NativeSpoolPDFTests: XCTestCase {
pixelBytes: patchBytes, in: tempRoot) pixelBytes: patchBytes, in: tempRoot)
let raster = try TargetRasterLoader.load(tiff: tiff) 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") let pdfURL = tempRoot.appendingPathComponent("spool.pdf")
info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL let info = makeSaveInfo(pdfURL: pdfURL)
let canvas = TargetPageCanvasView( let canvas = TargetPageCanvasView(
pages: [raster, raster], paperSize: paperSize) pages: [raster, raster], paperSize: paperSize)
@@ -88,29 +75,7 @@ final class NativeSpoolPDFTests: XCTestCase {
) throws { ) throws {
let width = Int(ceil(mediaBox.width)) let width = Int(ceil(mediaBox.width))
let height = Int(ceil(mediaBox.height)) let height = Int(ceil(mediaBox.height))
let rep = try XCTUnwrap(NSBitmapImageRep( let rep = try renderPageToBitmap(page, mediaBox: mediaBox)
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) { func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
let color = try XCTUnwrap( let color = try XCTUnwrap(
@@ -161,4 +126,144 @@ final class NativeSpoolPDFTests: XCTestCase {
XCTAssertEqual(outside.1, 1, accuracy: 0.04) XCTAssertEqual(outside.1, 1, accuracy: 0.04)
XCTAssertEqual(outside.2, 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..<height {
for x in 0..<width
where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 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
}
} }
@@ -4,7 +4,7 @@ import Foundation
import XCTest import XCTest
@testable import ICCery @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 / /// snapped top-left anchoring, and the draw-time interpolation /
/// antialias contract asserted via `drawProbe`. /// antialias contract asserted via `drawProbe`.
@MainActor @MainActor
@@ -35,11 +35,13 @@ final class TargetCanvasGeometryTests: XCTestCase {
// MARK: - Page stacking // 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( let view = TargetPageCanvasView(
pages: [raster(), raster(), raster()], pages: [raster(), raster(), raster()],
paperSize: paperSize) paperSize: paperSize)
XCTAssertTrue(view.isFlipped) XCTAssertFalse(view.isFlipped)
let page1 = view.rectForPage(1) let page1 = view.rectForPage(1)
let page3 = view.rectForPage(3) let page3 = view.rectForPage(3)
@@ -68,18 +70,33 @@ final class TargetCanvasGeometryTests: XCTestCase {
pages: [raster(), raster()], pages: [raster(), raster()],
paperSize: paperSize) 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) let first = view.destinationRect(forPage: 1)
XCTAssertEqual(first.origin.x, 0, accuracy: 0.001) 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.width, 595.2, accuracy: 0.01)
XCTAssertEqual(first.height, 841.92, accuracy: 0.01) XCTAssertEqual(first.height, 841.92, accuracy: 0.01)
let second = view.destinationRect(forPage: 2) let second = view.destinationRect(forPage: 2)
XCTAssertEqual( 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) 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( XCTAssertEqual(
rect.origin.x * 1000, rect.origin.x * 1000,
(rect.origin.x * 1000).rounded(), (rect.origin.x * 1000).rounded(),
@@ -27,12 +27,16 @@ enum TargetTestFixtures {
/// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true` /// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true`
/// appends one alpha sample per pixel (`last`). `pixelBytes` is the /// appends one alpha sample per pixel (`last`). `pixelBytes` is the
/// repeating per-pixel pattern (big-endian for 16 bpc). /// 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( static func makeImage(
px: CGSize, px: CGSize,
components: Int, components: Int,
bitsPerComponent: Int, bitsPerComponent: Int,
alpha: Bool = false, alpha: Bool = false,
pixelBytes: [UInt8]? = nil pixelBytes: [UInt8]? = nil,
bottomHalfPixelBytes: [UInt8]? = nil
) -> CGImage? { ) -> CGImage? {
let width = Int(px.width) let width = Int(px.width)
let height = Int(px.height) let height = Int(px.height)
@@ -40,19 +44,31 @@ enum TargetTestFixtures {
let samples = components + (alpha ? 1 : 0) let samples = components + (alpha ? 1 : 0)
let bytesPerPixel = samples * bytesPerComponent let bytesPerPixel = samples * bytesPerComponent
let bytesPerRow = width * bytesPerPixel let bytesPerRow = width * bytesPerPixel
let pattern = pixelBytes func buildRow(_ pattern: [UInt8]) -> [UInt8] {
?? Array(0..<bytesPerPixel).map { UInt8(($0 * 37 + 11) & 0xff) }
var row = [UInt8](repeating: 0, count: bytesPerRow) var row = [UInt8](repeating: 0, count: bytesPerRow)
for offset in stride(from: 0, to: bytesPerRow, by: pattern.count) { for offset in stride(
from: 0, to: bytesPerRow, by: pattern.count) {
for (index, byte) in pattern.enumerated() for (index, byte) in pattern.enumerated()
where offset + index < bytesPerRow { where offset + index < bytesPerRow {
row[offset + index] = byte row[offset + index] = byte
} }
} }
return row
}
let topRow = buildRow(
pixelBytes
?? Array(0..<bytesPerPixel).map {
UInt8(($0 * 37 + 11) & 0xff)
})
let bottomRow = buildRow(bottomHalfPixelBytes ?? pixelBytes
?? Array(0..<bytesPerPixel).map {
UInt8(($0 * 37 + 11) & 0xff)
})
var bytes = [UInt8]() var bytes = [UInt8]()
bytes.reserveCapacity(bytesPerRow * height) bytes.reserveCapacity(bytesPerRow * height)
for _ in 0..<height { for rowIndex in 0..<height {
bytes.append(contentsOf: row) bytes.append(
contentsOf: rowIndex < height / 2 ? topRow : bottomRow)
} }
let data = Data(bytes) let data = Data(bytes)
@@ -88,6 +104,7 @@ enum TargetTestFixtures {
/// Writes `makeImage` output to a single-page TIFF. `dpi` nil /// Writes `makeImage` output to a single-page TIFF. `dpi` nil
/// produces a TIFF with no resolution tags (the 72-fallback path). /// produces a TIFF with no resolution tags (the 72-fallback path).
/// `bottomHalfPixelBytes` passes through to `makeImage` (#211).
static func makeTIFF( static func makeTIFF(
px: CGSize, px: CGSize,
dpi: Double?, dpi: Double?,
@@ -95,12 +112,14 @@ enum TargetTestFixtures {
bitsPerComponent: Int = 8, bitsPerComponent: Int = 8,
alpha: Bool = false, alpha: Bool = false,
pixelBytes: [UInt8]? = nil, pixelBytes: [UInt8]? = nil,
bottomHalfPixelBytes: [UInt8]? = nil,
in directory: URL in directory: URL
) throws -> URL { ) throws -> URL {
guard let image = makeImage( guard let image = makeImage(
px: px, components: components, px: px, components: components,
bitsPerComponent: bitsPerComponent, bitsPerComponent: bitsPerComponent,
alpha: alpha, pixelBytes: pixelBytes) alpha: alpha, pixelBytes: pixelBytes,
bottomHalfPixelBytes: bottomHalfPixelBytes)
else { throw FixtureError.imageNotCreated } else { throw FixtureError.imageNotCreated }
let url = directory.appendingPathComponent( let url = directory.appendingPathComponent(
"fixture-\(UUID().uuidString).tiff") "fixture-\(UUID().uuidString).tiff")