Compare commits

..
Author SHA1 Message Date
gronod 273c319152 Merge pull request 'test(print): flip canvas coordinate space to bottom-up for NSPrintOperation compatibility' (#212) from bugfix/211-print-output-flip into develop
macOS CI / build-and-test (push) Failing after 3m41s
macOS CI / package (push) Skipped
Reviewed-on: #212
2026-09-17 21:18:36 +01:00
4 changed files with 57 additions and 200 deletions
@@ -27,10 +27,8 @@ final class TargetPageCanvasView: NSView {
fatalError("TargetPageCanvasView is code-only") fatalError("TargetPageCanvasView is code-only")
} }
/// Bottom-up CoreGraphics space: `CGContext.draw(_:in:)` renders /// Flipped: the CoreGraphics coordinate space is correctly oriented
/// the raster upright — a flipped view prints mirrored (#211). /// top-down, so page 1 is the TOP rect.
/// Page 1 is the BOTTOM band of the frame, the non-flipped
/// pagination convention.
override var isFlipped: Bool { false } override var isFlipped: Bool { false }
override var isOpaque: Bool { true } override var isOpaque: Bool { true }
@@ -39,7 +37,7 @@ final class TargetPageCanvasView: NSView {
return true return true
} }
/// 1-based page → its paper-sized rect, stacked bottom-up. /// 1-based page → its paper-sized rect, stacked top-down.
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,
@@ -55,7 +53,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.maxY - size.height), y: Self.snap(pageRect.minY),
width: size.width, width: size.width,
height: size.height) height: size.height)
} }
+37 -142
View File
@@ -40,8 +40,21 @@ 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")
let info = makeSaveInfo(pdfURL: pdfURL) info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL
let canvas = TargetPageCanvasView( let canvas = TargetPageCanvasView(
pages: [raster, raster], paperSize: paperSize) pages: [raster, raster], paperSize: paperSize)
@@ -75,7 +88,29 @@ 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 renderPageToBitmap(page, mediaBox: mediaBox) 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) { func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
let color = try XCTUnwrap( let color = try XCTUnwrap(
@@ -126,144 +161,4 @@ 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` bottom-up page stacking, /// Issue #201 Phase 3 — `TargetPageCanvasView` top-down 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,9 +35,7 @@ final class TargetCanvasGeometryTests: XCTestCase {
// MARK: - Page stacking // MARK: - Page stacking
/// Bottom-up space: page 1 is the BOTTOM band of the frame — the func testRectForPageStacksTopDown() {
/// 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)
@@ -70,33 +68,18 @@ 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.03, accuracy: 0.001) XCTAssertEqual(first.origin.y, 0, 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, second.origin.y, paperSize.height, accuracy: 0.001)
2 * paperSize.height - first.height, accuracy: 0.001)
XCTAssertEqual(second.size, first.size) XCTAssertEqual(second.size, first.size)
// A raster smaller than the paper hangs from the page's TOP for rect in [first, second] {
// 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,16 +27,12 @@ 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)
@@ -44,31 +40,19 @@ 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
func buildRow(_ pattern: [UInt8]) -> [UInt8] { let pattern = pixelBytes
?? 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( for offset in stride(from: 0, to: bytesPerRow, by: pattern.count) {
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 rowIndex in 0..<height { for _ in 0..<height {
bytes.append( bytes.append(contentsOf: row)
contentsOf: rowIndex < height / 2 ? topRow : bottomRow)
} }
let data = Data(bytes) let data = Data(bytes)
@@ -104,7 +88,6 @@ 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?,
@@ -112,14 +95,12 @@ 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")