WP-2: PDF composer + PageLayout per SPEC-A1b (two-pass, atomic write, zero annotations)
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user