176 lines
6.4 KiB
Swift
176 lines
6.4 KiB
Swift
import Foundation
|
||
import CoreGraphics
|
||
|
||
/// SPEC §6.2 — macOS printing uses 72 PostScript points = 1 inch.
|
||
/// All layout uses these values. Never multiply by `backingScaleFactor`.
|
||
|
||
enum Geometry {
|
||
static let pointsPerInch: CGFloat = 72
|
||
static let mmPerInch: CGFloat = 25.4
|
||
/// Typical hardware margin used when `imageablePageBounds` is unavailable.
|
||
static let defaultMarginMM: CGFloat = 12.7
|
||
/// G1 pass band: ±0.1 mm on the physical print.
|
||
static let geometricToleranceMM: CGFloat = 0.1
|
||
|
||
struct Size {
|
||
var width: CGFloat
|
||
var height: CGFloat
|
||
}
|
||
|
||
struct Paper {
|
||
var name: String
|
||
var widthPt: CGFloat
|
||
var heightPt: CGFloat
|
||
|
||
var size: CGSize { CGSize(width: widthPt, height: heightPt) }
|
||
|
||
func oriented(landscape: Bool) -> Paper {
|
||
if !landscape { return self }
|
||
return Paper(name: name + " landscape", widthPt: heightPt, heightPt: widthPt)
|
||
}
|
||
}
|
||
|
||
struct Report: Equatable {
|
||
var physicalWidthIn: CGFloat
|
||
var physicalHeightIn: CGFloat
|
||
var pointWidth: CGFloat
|
||
var pointHeight: CGFloat
|
||
var dest: CGRect
|
||
var paper: CGRect
|
||
var imageable: CGRect
|
||
var overflow: Bool
|
||
}
|
||
|
||
static let papers: [Paper] = [
|
||
Paper(name: "A4", widthPt: mmToPoints(210), heightPt: mmToPoints(297)),
|
||
Paper(name: "A3", widthPt: mmToPoints(297), heightPt: mmToPoints(420)),
|
||
Paper(name: "A5", widthPt: mmToPoints(148), heightPt: mmToPoints(210)),
|
||
Paper(name: "Letter", widthPt: inchesToPoints(8.5), heightPt: inchesToPoints(11)),
|
||
Paper(name: "Legal", widthPt: inchesToPoints(8.5), heightPt: inchesToPoints(14)),
|
||
Paper(name: "Tabloid", widthPt: inchesToPoints(11), heightPt: inchesToPoints(17)),
|
||
Paper(name: "4x6", widthPt: inchesToPoints(4), heightPt: inchesToPoints(6)),
|
||
Paper(name: "5x7", widthPt: inchesToPoints(5), heightPt: inchesToPoints(7)),
|
||
]
|
||
|
||
static func paper(named name: String) -> Paper {
|
||
let key = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if let match = papers.first(where: { $0.name.caseInsensitiveCompare(key) == .orderedSame }) {
|
||
return match
|
||
}
|
||
// PPD PageSize names often look like "A4.Borderless" or "Letter"
|
||
if let match = papers.first(where: { key.localizedCaseInsensitiveContains($0.name) }) {
|
||
return match
|
||
}
|
||
return papers[0]
|
||
}
|
||
|
||
static func inchesToPoints(_ inches: CGFloat) -> CGFloat { inches * pointsPerInch }
|
||
static func pointsToInches(_ pt: CGFloat) -> CGFloat { pt / pointsPerInch }
|
||
static func mmToPoints(_ mm: CGFloat) -> CGFloat { (mm / mmPerInch) * pointsPerInch }
|
||
static func pointsToMm(_ pt: CGFloat) -> CGFloat { (pt / pointsPerInch) * mmPerInch }
|
||
static func mmToInches(_ mm: CGFloat) -> CGFloat { mm / mmPerInch }
|
||
|
||
static func physicalInches(pixels: CGFloat, dpi: CGFloat) -> CGFloat {
|
||
let safe = dpi > 0 ? dpi : 72
|
||
return pixels / safe
|
||
}
|
||
|
||
static func pointSize(pixels: CGFloat, dpi: CGFloat, scaling: CGFloat) -> CGFloat {
|
||
physicalInches(pixels: pixels, dpi: dpi) * pointsPerInch * scaling
|
||
}
|
||
|
||
static func imageableBounds(paper: Paper, marginMM: CGFloat = defaultMarginMM) -> CGRect {
|
||
let m = mmToPoints(marginMM)
|
||
return CGRect(
|
||
x: m,
|
||
y: m,
|
||
width: max(0, paper.widthPt - m * 2),
|
||
height: max(0, paper.heightPt - m * 2)
|
||
)
|
||
}
|
||
|
||
/// Destination rectangle in PostScript points on the page.
|
||
/// Size is the exact physical size (pixels/dpi × 72 × scaling). Origin is
|
||
/// centred in the imageable rect when `centered` is true, otherwise top-left
|
||
/// of the imageable area (view is flipped: y grows down).
|
||
static func calculateDestinationRect(
|
||
pixelWidth: CGFloat,
|
||
pixelHeight: CGFloat,
|
||
dpiX: CGFloat,
|
||
dpiY: CGFloat,
|
||
paper: Paper,
|
||
imageable: CGRect,
|
||
centered: Bool,
|
||
scaling: CGFloat
|
||
) -> CGRect {
|
||
let w = pointSize(pixels: pixelWidth, dpi: dpiX, scaling: scaling)
|
||
let h = pointSize(pixels: pixelHeight, dpi: dpiY, scaling: scaling)
|
||
var x: CGFloat
|
||
var y: CGFloat
|
||
if centered {
|
||
x = imageable.midX - w / 2
|
||
y = imageable.midY - h / 2
|
||
} else {
|
||
x = imageable.minX
|
||
y = imageable.minY
|
||
}
|
||
// Keep exact size. Round origin to 1/1000 pt so the print pipeline is
|
||
// not asked to sample on a fractional device pixel, without violating
|
||
// the G1 ±0.1 mm band (0.1 mm ≈ 0.28 pt).
|
||
x = (x * 1000).rounded() / 1000
|
||
y = (y * 1000).rounded() / 1000
|
||
return CGRect(x: x, y: y, width: w, height: h)
|
||
}
|
||
|
||
static func report(
|
||
pixelWidth: CGFloat,
|
||
pixelHeight: CGFloat,
|
||
dpiX: CGFloat,
|
||
dpiY: CGFloat,
|
||
paper: Paper,
|
||
imageable: CGRect,
|
||
centered: Bool,
|
||
scaling: CGFloat
|
||
) -> Report {
|
||
let physW = physicalInches(pixels: pixelWidth, dpi: dpiX)
|
||
let physH = physicalInches(pixels: pixelHeight, dpi: dpiY)
|
||
let dest = calculateDestinationRect(
|
||
pixelWidth: pixelWidth,
|
||
pixelHeight: pixelHeight,
|
||
dpiX: dpiX,
|
||
dpiY: dpiY,
|
||
paper: paper,
|
||
imageable: imageable,
|
||
centered: centered,
|
||
scaling: scaling
|
||
)
|
||
let paperRect = CGRect(x: 0, y: 0, width: paper.widthPt, height: paper.heightPt)
|
||
let overflow = dest.minX < imageable.minX - 0.01
|
||
|| dest.minY < imageable.minY - 0.01
|
||
|| dest.maxX > imageable.maxX + 0.01
|
||
|| dest.maxY > imageable.maxY + 0.01
|
||
return Report(
|
||
physicalWidthIn: physW,
|
||
physicalHeightIn: physH,
|
||
pointWidth: dest.width,
|
||
pointHeight: dest.height,
|
||
dest: dest,
|
||
paper: paperRect,
|
||
imageable: imageable,
|
||
overflow: overflow
|
||
)
|
||
}
|
||
|
||
/// G1: expected physical size matches actual within ±0.1 mm.
|
||
static func geometricAccuracyPass(
|
||
expectedWidthIn: CGFloat,
|
||
expectedHeightIn: CGFloat,
|
||
actualWidthIn: CGFloat,
|
||
actualHeightIn: CGFloat
|
||
) -> Bool {
|
||
let tol = mmToInches(geometricToleranceMM)
|
||
return abs(actualWidthIn - expectedWidthIn) <= tol
|
||
&& abs(actualHeightIn - expectedHeightIn) <= tol
|
||
}
|
||
}
|