Merge pull request 'WP-2: PDF composer + PageLayout (two-pass, atomic write, zero annotations)' (#3) from wp2/pdf-composer-20260831 into feat/shotdeck-20260830
This commit was merged in pull request #3.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import CoreText
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import os
|
||||
|
||||
private let pdfLog = Logger(subsystem: "ai.flowmaster.shotdeck", category: "PDF")
|
||||
|
||||
public struct PDFComposer: Sendable {
|
||||
public init() {}
|
||||
|
||||
@discardableResult
|
||||
public func compose(
|
||||
session: CaptureSession,
|
||||
imageURL: (Capture) -> URL,
|
||||
title: String,
|
||||
to outputURL: URL
|
||||
) throws -> Int {
|
||||
if session.isEmpty {
|
||||
throw ShotdeckError.pdfCompositionFailed(reason: "session has no captures")
|
||||
}
|
||||
|
||||
var loaded: [(Capture, CGImage)] = []
|
||||
loaded.reserveCapacity(session.captures.count)
|
||||
for capture in session.captures {
|
||||
let url = imageURL(capture)
|
||||
if let image = Self.loadCGImage(from: url) {
|
||||
loaded.append((capture, image))
|
||||
} else {
|
||||
pdfLog.error(
|
||||
"skipping capture \(capture.id.uuidString, privacy: .public) seq \(capture.sequence, privacy: .public): image unreadable at \(url.path, privacy: .public)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
guard !loaded.isEmpty else {
|
||||
throw ShotdeckError.pdfCompositionFailed(reason: "no readable images in session")
|
||||
}
|
||||
|
||||
let pageCount = loaded.count
|
||||
let tmpURL = outputURL
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(".tmp-\(UUID().uuidString)-\(outputURL.lastPathComponent)")
|
||||
|
||||
do {
|
||||
try Self.writePDF(
|
||||
loaded: loaded,
|
||||
pageCount: pageCount,
|
||||
title: title,
|
||||
sessionID: session.id,
|
||||
to: tmpURL
|
||||
)
|
||||
|
||||
let handle = try FileHandle(forWritingTo: tmpURL)
|
||||
try handle.synchronize()
|
||||
try handle.close()
|
||||
|
||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||
try FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tmpURL, to: outputURL)
|
||||
return pageCount
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: tmpURL)
|
||||
if let shotdeck = error as? ShotdeckError {
|
||||
throw shotdeck
|
||||
}
|
||||
throw ShotdeckError.pdfCompositionFailed(reason: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public static func fileName(for session: CaptureSession) -> String {
|
||||
"Shotdeck-\(DubaiTime.fileStamp(session.createdAt)).pdf"
|
||||
}
|
||||
|
||||
private static func writePDF(
|
||||
loaded: [(Capture, CGImage)],
|
||||
pageCount: Int,
|
||||
title: String,
|
||||
sessionID: UUID,
|
||||
to tmpURL: URL
|
||||
) throws {
|
||||
guard let consumer = CGDataConsumer(url: tmpURL as CFURL) else {
|
||||
throw ShotdeckError.pdfCompositionFailed(reason: "cannot open output location")
|
||||
}
|
||||
|
||||
let auxiliaryInfo: [String: Any] = [
|
||||
kCGPDFContextCreator as String: "Shotdeck",
|
||||
kCGPDFContextTitle as String: title,
|
||||
kCGPDFContextSubject as String: sessionID.uuidString.lowercased(),
|
||||
]
|
||||
|
||||
guard let context = CGContext(
|
||||
consumer: consumer,
|
||||
mediaBox: nil,
|
||||
auxiliaryInfo as CFDictionary
|
||||
) else {
|
||||
throw ShotdeckError.pdfCompositionFailed(reason: "cannot open output location")
|
||||
}
|
||||
|
||||
let lightGrey = CGColor(gray: 0.75, alpha: 1)
|
||||
let black = CGColor(gray: 0, alpha: 1)
|
||||
let timestampColor = CGColor(gray: 0.45, alpha: 1)
|
||||
|
||||
for pageIndex in 1...pageCount {
|
||||
let (capture, cgImage) = loaded[pageIndex - 1]
|
||||
let layout = PageLayout(capture: capture, pageIndex: pageIndex, pageCount: pageCount)
|
||||
|
||||
context.beginPDFPage(Self.pageInfo(mediaBox: layout.pageRect))
|
||||
|
||||
context.draw(cgImage, in: layout.imageRect)
|
||||
|
||||
context.setStrokeColor(lightGrey)
|
||||
context.setLineWidth(0.5)
|
||||
context.move(to: CGPoint(x: layout.headerRect.minX, y: layout.headerRect.minY))
|
||||
context.addLine(to: CGPoint(x: layout.headerRect.maxX, y: layout.headerRect.minY))
|
||||
context.strokePath()
|
||||
|
||||
drawText(
|
||||
layout.pageNumberText,
|
||||
font: layout.pageNumberFont,
|
||||
color: black,
|
||||
at: layout.pageNumberOrigin,
|
||||
in: context
|
||||
)
|
||||
drawText(
|
||||
layout.timestampText,
|
||||
font: layout.timestampFont,
|
||||
color: timestampColor,
|
||||
at: layout.timestampOrigin,
|
||||
in: context
|
||||
)
|
||||
drawText(
|
||||
"PASS",
|
||||
font: layout.tickLabelFont,
|
||||
color: black,
|
||||
at: layout.passLabelOrigin,
|
||||
in: context
|
||||
)
|
||||
drawText(
|
||||
"FAIL",
|
||||
font: layout.tickLabelFont,
|
||||
color: black,
|
||||
at: layout.failLabelOrigin,
|
||||
in: context
|
||||
)
|
||||
|
||||
strokeTickBox(layout.passTickBox, in: context, color: black)
|
||||
strokeTickBox(layout.failTickBox, in: context, color: black)
|
||||
|
||||
context.endPDFPage()
|
||||
}
|
||||
|
||||
context.closePDF()
|
||||
}
|
||||
|
||||
private static func pageInfo(mediaBox: CGRect) -> CFDictionary {
|
||||
var box = mediaBox
|
||||
let data = Data(bytes: &box, count: MemoryLayout<CGRect>.size)
|
||||
return [kCGPDFContextMediaBox as String: data] as CFDictionary
|
||||
}
|
||||
|
||||
private static func drawText(
|
||||
_ string: String,
|
||||
font: NSFont,
|
||||
color: CGColor,
|
||||
at origin: CGPoint,
|
||||
in context: CGContext
|
||||
) {
|
||||
let attributes: [CFString: Any] = [
|
||||
kCTFontAttributeName: font,
|
||||
kCTForegroundColorAttributeName: color,
|
||||
]
|
||||
guard let attributed = CFAttributedStringCreate(
|
||||
nil,
|
||||
string as CFString,
|
||||
attributes as CFDictionary
|
||||
) else {
|
||||
return
|
||||
}
|
||||
let line = CTLineCreateWithAttributedString(attributed)
|
||||
context.textMatrix = .identity
|
||||
context.textPosition = origin
|
||||
CTLineDraw(line, context)
|
||||
}
|
||||
|
||||
private static func strokeTickBox(_ box: CGRect, in context: CGContext, color: CGColor) {
|
||||
let path = CGPath(
|
||||
roundedRect: box,
|
||||
cornerWidth: 2,
|
||||
cornerHeight: 2,
|
||||
transform: nil
|
||||
)
|
||||
context.addPath(path)
|
||||
context.setStrokeColor(color)
|
||||
context.setLineWidth(1.0)
|
||||
context.strokePath()
|
||||
}
|
||||
|
||||
private static func loadCGImage(from url: URL) -> CGImage? {
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||
CGImageSourceGetCount(source) > 0
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return CGImageSourceCreateImageAtIndex(source, 0, nil)
|
||||
}
|
||||
}
|
||||
@@ -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