226e7b2 un-flipped the canvas but left destinationRect anchored at
pageRect.minY — the page's BOTTOM edge in bottom-up space — so
sub-page targets printed at the foot of the sheet and oversize
targets clipped their top rows. Anchor at pageRect.maxY - height,
restoring the spec'd top-left anchor (docs/14 §6, D9).
Adds the missing regression coverage: the geometry test now asserts
the top anchor with a sub-page raster, and the PDF harness gains a
banded (red-over-blue) fixture that fails on either a mirrored draw
or a bottom anchor — solid fills could catch neither.
146 lines
5.4 KiB
Swift
146 lines
5.4 KiB
Swift
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).
|
|
/// `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
|
|
) -> 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
|
|
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
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
let data = Data(bytes)
|
|
|
|
let space: CGColorSpace
|
|
switch components {
|
|
case 1: space = CGColorSpaceCreateDeviceGray()
|
|
case 3: space = CGColorSpaceCreateDeviceRGB()
|
|
case 4: space = CGColorSpaceCreateDeviceCMYK()
|
|
default: return nil
|
|
}
|
|
var info = CGBitmapInfo()
|
|
if bitsPerComponent == 16 {
|
|
info.insert(.byteOrder16Big)
|
|
}
|
|
if alpha {
|
|
info = CGBitmapInfo(
|
|
rawValue: info.rawValue | CGImageAlphaInfo.last.rawValue)
|
|
}
|
|
guard let provider = CGDataProvider(data: data as CFData)
|
|
else { return nil }
|
|
return CGImage(
|
|
width: width, height: height,
|
|
bitsPerComponent: bitsPerComponent,
|
|
bitsPerPixel: bitsPerComponent * samples,
|
|
bytesPerRow: bytesPerRow,
|
|
space: space,
|
|
bitmapInfo: info,
|
|
provider: provider,
|
|
decode: nil,
|
|
shouldInterpolate: false,
|
|
intent: .defaultIntent)
|
|
}
|
|
|
|
/// 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?,
|
|
components: Int = 3,
|
|
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)
|
|
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
|
|
}
|
|
}
|