import AppKit import CoreGraphics import CoreText import Foundation public struct PageLayout: Sendable, Equatable { public let isLandscape: Bool public let pageRect: CGRect public let headerRect: CGRect public let imageBoxRect: CGRect public let imageRect: CGRect public let pageNumberText: String public let pageNumberOrigin: CGPoint public let timestampText: String public let timestampOrigin: CGPoint public let passLabelOrigin: CGPoint public let failLabelOrigin: CGPoint public let passTickBox: CGRect public let failTickBox: CGRect /// Constructed at access time so `PageLayout` stays Sendable under Swift 6 /// (`NSFont` is not Sendable). Same `NSFont.systemFont` values used for measurement. public var pageNumberFont: NSFont { NSFont.systemFont(ofSize: 9, weight: .semibold) } public var timestampFont: NSFont { NSFont.systemFont(ofSize: 9, weight: .regular) } public var tickLabelFont: NSFont { NSFont.systemFont(ofSize: 8, weight: .semibold) } private static let marginAll: CGFloat = 18 private static let headerHeight: CGFloat = 26 private static let headerImageGap: CGFloat = 10 private static let tickBoxSize: CGFloat = 13 private static let tickGroupGap: CGFloat = 8 private static let portraitPage = CGSize(width: 595, height: 842) private static let landscapePage = CGSize(width: 842, height: 595) /// `pageIndex` and `pageCount` are 1-based / total, over ACTUALLY-WRITTEN pages /// (see SPEC decision D-D), not `capture.sequence` / `session.captures.count`. public init(capture: Capture, pageIndex: Int, pageCount: Int) { let pageNumberFont = NSFont.systemFont(ofSize: 9, weight: .semibold) let timestampFont = NSFont.systemFont(ofSize: 9, weight: .regular) let tickLabelFont = NSFont.systemFont(ofSize: 8, weight: .semibold) isLandscape = capture.isLandscape pageRect = CGRect(origin: .zero, size: isLandscape ? Self.landscapePage : Self.portraitPage) let W = pageRect.width let H = pageRect.height let contentMinX = Self.marginAll let contentMaxX = W - Self.marginAll let contentMinY = Self.marginAll let contentMaxY = H - Self.marginAll let contentWidth = contentMaxX - contentMinX headerRect = CGRect( x: contentMinX, y: contentMaxY - Self.headerHeight, width: contentWidth, height: Self.headerHeight ) imageBoxRect = CGRect( x: contentMinX, y: contentMinY, width: contentWidth, height: headerRect.minY - Self.headerImageGap - contentMinY ) let pointSize = CGSize( width: CGFloat(capture.pixelWidth) / capture.scale, height: CGFloat(capture.pixelHeight) / capture.scale ) let fitScale = min( imageBoxRect.width / pointSize.width, imageBoxRect.height / pointSize.height, 1.0 ) let drawnSize = CGSize( width: pointSize.width * fitScale, height: pointSize.height * fitScale ) imageRect = CGRect( x: imageBoxRect.midX - drawnSize.width / 2, y: imageBoxRect.midY - drawnSize.height / 2, width: drawnSize.width, height: drawnSize.height ) let metricsFont = timestampFont as CTFont let ascent = CTFontGetAscent(metricsFont) let descent = CTFontGetDescent(metricsFont) let baselineY = headerRect.minY + (headerRect.height - (ascent + descent)) / 2 + descent pageNumberText = "\(pageIndex) / \(pageCount)" pageNumberOrigin = CGPoint(x: headerRect.minX, y: baselineY) timestampText = DubaiTime.stamp(capture.capturedAt) let pageNumberWidth = Self.measuredWidth(pageNumberText, font: pageNumberFont) timestampOrigin = CGPoint(x: headerRect.minX + pageNumberWidth + 10, y: baselineY) let groupRightX = headerRect.maxX let failBoxMinX = groupRightX - Self.tickBoxSize let tickBoxY = headerRect.midY - Self.tickBoxSize / 2 failTickBox = CGRect( x: failBoxMinX, y: tickBoxY, width: Self.tickBoxSize, height: Self.tickBoxSize ) let failLabelWidth = Self.measuredWidth("FAIL", font: tickLabelFont) let failLabelMaxX = failTickBox.minX - Self.tickGroupGap failLabelOrigin = CGPoint(x: failLabelMaxX - failLabelWidth, y: baselineY) let passBoxMaxX = failLabelOrigin.x - Self.tickGroupGap passTickBox = CGRect( x: passBoxMaxX - Self.tickBoxSize, y: tickBoxY, width: Self.tickBoxSize, height: Self.tickBoxSize ) let passLabelWidth = Self.measuredWidth("PASS", font: tickLabelFont) let passLabelMaxX = passTickBox.minX - Self.tickGroupGap passLabelOrigin = CGPoint(x: passLabelMaxX - passLabelWidth, y: baselineY) } public static func == (lhs: PageLayout, rhs: PageLayout) -> Bool { lhs.isLandscape == rhs.isLandscape && lhs.pageRect == rhs.pageRect && lhs.headerRect == rhs.headerRect && lhs.imageBoxRect == rhs.imageBoxRect && lhs.imageRect == rhs.imageRect && lhs.pageNumberText == rhs.pageNumberText && lhs.pageNumberOrigin == rhs.pageNumberOrigin && lhs.timestampText == rhs.timestampText && lhs.timestampOrigin == rhs.timestampOrigin && lhs.passLabelOrigin == rhs.passLabelOrigin && lhs.failLabelOrigin == rhs.failLabelOrigin && lhs.passTickBox == rhs.passTickBox && lhs.failTickBox == rhs.failTickBox } private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { let attributed = NSAttributedString(string: text, attributes: [.font: font]) let line = CTLineCreateWithAttributedString(attributed) return CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) } }