150 lines
6.0 KiB
Swift
150 lines
6.0 KiB
Swift
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))
|
|
}
|
|
}
|