Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
273c319152 |
@@ -27,10 +27,8 @@ final class TargetPageCanvasView: NSView {
|
||||
fatalError("TargetPageCanvasView is code-only")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Flipped: the CoreGraphics coordinate space is correctly oriented
|
||||
/// top-down, so page 1 is the TOP rect.
|
||||
override var isFlipped: Bool { false }
|
||||
override var isOpaque: Bool { true }
|
||||
|
||||
@@ -39,7 +37,7 @@ final class TargetPageCanvasView: NSView {
|
||||
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 {
|
||||
NSRect(x: 0,
|
||||
y: CGFloat(page - 1) * paperSize.height,
|
||||
@@ -55,7 +53,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.maxY - size.height),
|
||||
y: Self.snap(pageRect.minY),
|
||||
width: size.width,
|
||||
height: size.height)
|
||||
}
|
||||
|
||||
@@ -40,8 +40,21 @@ 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")
|
||||
let info = makeSaveInfo(pdfURL: pdfURL)
|
||||
info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL
|
||||
|
||||
let canvas = TargetPageCanvasView(
|
||||
pages: [raster, raster], paperSize: paperSize)
|
||||
@@ -75,7 +88,29 @@ final class NativeSpoolPDFTests: XCTestCase {
|
||||
) throws {
|
||||
let width = Int(ceil(mediaBox.width))
|
||||
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) {
|
||||
let color = try XCTUnwrap(
|
||||
@@ -126,144 +161,4 @@ 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..<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
|
||||
@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 /
|
||||
/// antialias contract asserted via `drawProbe`.
|
||||
@MainActor
|
||||
@@ -35,9 +35,7 @@ final class TargetCanvasGeometryTests: XCTestCase {
|
||||
|
||||
// MARK: - Page stacking
|
||||
|
||||
/// Bottom-up space: page 1 is the BOTTOM band of the frame — the
|
||||
/// non-flipped pagination convention (#211).
|
||||
func testRectForPageStacksBottomUp() {
|
||||
func testRectForPageStacksTopDown() {
|
||||
let view = TargetPageCanvasView(
|
||||
pages: [raster(), raster(), raster()],
|
||||
paperSize: paperSize)
|
||||
@@ -70,33 +68,18 @@ 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.03, 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,
|
||||
2 * paperSize.height - first.height, accuracy: 0.001)
|
||||
second.origin.y, paperSize.height, accuracy: 0.001)
|
||||
XCTAssertEqual(second.size, first.size)
|
||||
|
||||
// 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] {
|
||||
for rect in [first, second] {
|
||||
XCTAssertEqual(
|
||||
rect.origin.x * 1000,
|
||||
(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`
|
||||
/// 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,
|
||||
bottomHalfPixelBytes: [UInt8]? = nil
|
||||
pixelBytes: [UInt8]? = nil
|
||||
) -> CGImage? {
|
||||
let width = Int(px.width)
|
||||
let height = Int(px.height)
|
||||
@@ -44,31 +40,19 @@ enum TargetTestFixtures {
|
||||
let samples = components + (alpha ? 1 : 0)
|
||||
let bytesPerPixel = samples * bytesPerComponent
|
||||
let bytesPerRow = width * bytesPerPixel
|
||||
func buildRow(_ pattern: [UInt8]) -> [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
|
||||
}
|
||||
let pattern = pixelBytes
|
||||
?? Array(0..<bytesPerPixel).map { UInt8(($0 * 37 + 11) & 0xff) }
|
||||
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..<bytesPerPixel).map {
|
||||
UInt8(($0 * 37 + 11) & 0xff)
|
||||
})
|
||||
let bottomRow = buildRow(bottomHalfPixelBytes ?? pixelBytes
|
||||
?? Array(0..<bytesPerPixel).map {
|
||||
UInt8(($0 * 37 + 11) & 0xff)
|
||||
})
|
||||
var bytes = [UInt8]()
|
||||
bytes.reserveCapacity(bytesPerRow * height)
|
||||
for rowIndex in 0..<height {
|
||||
bytes.append(
|
||||
contentsOf: rowIndex < height / 2 ? topRow : bottomRow)
|
||||
for _ in 0..<height {
|
||||
bytes.append(contentsOf: row)
|
||||
}
|
||||
let data = Data(bytes)
|
||||
|
||||
@@ -104,7 +88,6 @@ enum TargetTestFixtures {
|
||||
|
||||
/// Writes `makeImage` output to a single-page TIFF. `dpi` nil
|
||||
/// produces a TIFF with no resolution tags (the 72-fallback path).
|
||||
/// `bottomHalfPixelBytes` passes through to `makeImage` (#211).
|
||||
static func makeTIFF(
|
||||
px: CGSize,
|
||||
dpi: Double?,
|
||||
@@ -112,14 +95,12 @@ enum TargetTestFixtures {
|
||||
bitsPerComponent: Int = 8,
|
||||
alpha: Bool = false,
|
||||
pixelBytes: [UInt8]? = nil,
|
||||
bottomHalfPixelBytes: [UInt8]? = nil,
|
||||
in directory: URL
|
||||
) throws -> URL {
|
||||
guard let image = makeImage(
|
||||
px: px, components: components,
|
||||
bitsPerComponent: bitsPerComponent,
|
||||
alpha: alpha, pixelBytes: pixelBytes,
|
||||
bottomHalfPixelBytes: bottomHalfPixelBytes)
|
||||
alpha: alpha, pixelBytes: pixelBytes)
|
||||
else { throw FixtureError.imageNotCreated }
|
||||
let url = directory.appendingPathComponent(
|
||||
"fixture-\(UUID().uuidString).tiff")
|
||||
|
||||
Reference in New Issue
Block a user