From 1b5816aa387ce8597570379f6d9867b7da739176 Mon Sep 17 00:00:00 2001 From: kua-agent Date: Mon, 31 Aug 2026 14:42:00 +0400 Subject: [PATCH] WP-2: PDF composer + PageLayout per SPEC-A1b (two-pass, atomic write, zero annotations) --- Sources/ShotdeckCore/PDF/PDFComposer.swift | 209 +++++++ Sources/ShotdeckCore/PDF/PageLayout.swift | 149 +++++ .../ShotdeckCoreTests/PDFComposerTests.swift | 579 ++++++++++++++++++ 3 files changed, 937 insertions(+) create mode 100644 Sources/ShotdeckCore/PDF/PDFComposer.swift create mode 100644 Sources/ShotdeckCore/PDF/PageLayout.swift create mode 100644 Tests/ShotdeckCoreTests/PDFComposerTests.swift diff --git a/Sources/ShotdeckCore/PDF/PDFComposer.swift b/Sources/ShotdeckCore/PDF/PDFComposer.swift new file mode 100644 index 0000000..0e5e79c --- /dev/null +++ b/Sources/ShotdeckCore/PDF/PDFComposer.swift @@ -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.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) + } +} diff --git a/Sources/ShotdeckCore/PDF/PageLayout.swift b/Sources/ShotdeckCore/PDF/PageLayout.swift new file mode 100644 index 0000000..7cd7dfe --- /dev/null +++ b/Sources/ShotdeckCore/PDF/PageLayout.swift @@ -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)) + } +} diff --git a/Tests/ShotdeckCoreTests/PDFComposerTests.swift b/Tests/ShotdeckCoreTests/PDFComposerTests.swift new file mode 100644 index 0000000..2842daf --- /dev/null +++ b/Tests/ShotdeckCoreTests/PDFComposerTests.swift @@ -0,0 +1,579 @@ +import CoreGraphics +import Foundation +import ImageIO +import PDFKit +import ShotdeckCore +import Testing +import UniformTypeIdentifiers + +enum TestFixtureError: Error { case cannotCreateBitmap, cannotCreatePNG, cannotFinalizePNG } + +func writeSolidPNG( + pixelWidth: Int, + pixelHeight: Int, + red: UInt8, + green: UInt8, + blue: UInt8, + to url: URL +) throws { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: pixelWidth * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { throw TestFixtureError.cannotCreateBitmap } + context.setFillColor(CGColor( + red: CGFloat(red) / 255, + green: CGFloat(green) / 255, + blue: CGFloat(blue) / 255, + alpha: 1 + )) + context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight)) + guard let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) + else { throw TestFixtureError.cannotCreatePNG } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { throw TestFixtureError.cannotFinalizePNG } +} + +private let fixtureDate = Date(timeIntervalSince1970: 1_777_482_180) + +private func makeCapture( + sequence: Int, + pixelWidth: Int, + pixelHeight: Int, + scale: CGFloat = 1, + capturedAt: Date = fixtureDate, + id: UUID = UUID(), + fileName: String? = nil +) -> Capture { + Capture( + id: id, + sequence: sequence, + fileName: fileName ?? String(format: "%03d.png", sequence), + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + scale: scale, + capturedAt: capturedAt + ) +} + +private func makeSession( + captures: [Capture], + id: UUID = UUID(), + createdAt: Date = fixtureDate +) -> CaptureSession { + CaptureSession( + id: id, + createdAt: createdAt, + state: .open, + captures: captures, + pdfFileName: nil + ) +} + +private func makeScratchDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("shotdeck-pdf-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + +private func writePNG( + for capture: Capture, + red: UInt8, + green: UInt8, + blue: UInt8, + in directory: URL +) throws -> URL { + let url = directory.appendingPathComponent(capture.fileName) + try writeSolidPNG( + pixelWidth: capture.pixelWidth, + pixelHeight: capture.pixelHeight, + red: red, + green: green, + blue: blue, + to: url + ) + return url +} + +private func imageURLMap(_ urls: [UUID: URL]) -> (Capture) -> URL { + { capture in + urls[capture.id] ?? URL(fileURLWithPath: "/nonexistent/\(capture.id.uuidString).png") + } +} + +private func openDocument(_ url: URL) throws -> PDFDocument { + let document = try #require(PDFDocument(url: url)) + return document +} + +private func pdftoppm(pdf: URL, prefix: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/pdftoppm") + process.arguments = ["-png", "-r", "110", pdf.path, prefix.path] + let err = Pipe() + process.standardError = err + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + throw ShotdeckError.pdfCompositionFailed(reason: "pdftoppm failed: \(message)") + } +} + +private struct RGBABitmap { + let width: Int + let height: Int + let bytesPerRow: Int + let data: Data + + func pixel(x: Int, y: Int) -> (UInt8, UInt8, UInt8)? { + guard x >= 0, y >= 0, x < width, y < height else { return nil } + let offset = y * bytesPerRow + x * 4 + guard offset + 2 < data.count else { return nil } + return (data[offset], data[offset + 1], data[offset + 2]) + } + + func containsNonWhite(x0: Int, y0: Int, x1: Int, y1: Int) -> Bool { + let xStart = max(0, x0) + let yStart = max(0, y0) + let xEnd = min(width, x1) + let yEnd = min(height, y1) + guard xStart < xEnd, yStart < yEnd else { return false } + for y in yStart.. RGBABitmap { + let colorSpace = CGColorSpaceCreateDeviceRGB() + let source = try #require(CGImageSourceCreateWithURL(url as CFURL, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + let width = image.width + let height = image.height + let bytesPerRow = width * 4 + var data = Data(count: bytesPerRow * height) + try data.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { + throw TestFixtureError.cannotCreateBitmap + } + guard let context = CGContext( + data: base, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw TestFixtureError.cannotCreateBitmap + } + context.interpolationQuality = .none + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + } + return RGBABitmap(width: width, height: height, bytesPerRow: bytesPerRow, data: data) + } +} + +private func pixelWindow( + for rect: CGRect, + pageHeightPt: CGFloat, + scale k: CGFloat +) -> (x0: Int, y0: Int, x1: Int, y1: Int) { + let pixelX0 = Int(floor(rect.minX * k)) + let pixelX1 = Int(ceil(rect.maxX * k)) + let pixelY0 = Int(floor((pageHeightPt - rect.maxY) * k)) + let pixelY1 = Int(ceil((pageHeightPt - rect.minY) * k)) + return (pixelX0, pixelY0, pixelX1, pixelY1) +} + +@Test("Three-page basic compose") +func threePageBasicCompose() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let c1 = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let c2 = makeCapture(sequence: 2, pixelWidth: 900, pixelHeight: 1600) + let c3 = makeCapture(sequence: 3, pixelWidth: 1200, pixelHeight: 800) + let urls: [UUID: URL] = [ + c1.id: try writePNG(for: c1, red: 200, green: 20, blue: 20, in: directory), + c2.id: try writePNG(for: c2, red: 20, green: 200, blue: 20, in: directory), + c3.id: try writePNG(for: c3, red: 20, green: 20, blue: 200, in: directory), + ] + let session = makeSession(captures: [c1, c2, c3]) + let output = directory.appendingPathComponent("three.pdf") + let pageCount = try PDFComposer().compose( + session: session, + imageURL: imageURLMap(urls), + title: "Three page", + to: output + ) + #expect(pageCount == 3) + let document = try openDocument(output) + #expect(document.pageCount == 3) + let p0 = try #require(document.page(at: 0)) + let p1 = try #require(document.page(at: 1)) + let p2 = try #require(document.page(at: 2)) + #expect(p0.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) + #expect(p1.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) + #expect(p2.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) +} + +@Test("Wide/tall page geometry exact") +func wideTallPageGeometryExact() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let wide = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let tall = makeCapture(sequence: 1, pixelWidth: 900, pixelHeight: 1600) + let wideURL = try writePNG(for: wide, red: 10, green: 10, blue: 10, in: directory) + let tallURL = try writePNG(for: tall, red: 10, green: 10, blue: 10, in: directory) + + let wideOut = directory.appendingPathComponent("wide.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [wide]), + imageURL: { _ in wideURL }, + title: "Wide", + to: wideOut + ) + let tallOut = directory.appendingPathComponent("tall.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [tall]), + imageURL: { _ in tallURL }, + title: "Tall", + to: tallOut + ) + + let widePage = try #require(openDocument(wideOut).page(at: 0)) + let tallPage = try #require(openDocument(tallOut).page(at: 0)) + #expect(widePage.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 842, height: 595)) + #expect(tallPage.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) +} + +@Test("Square resolves to portrait (Capture.swift:39-42 isLandscape strict >)") +func squareResolvesToPortrait() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let square = makeCapture(sequence: 1, pixelWidth: 1000, pixelHeight: 1000) + let png = try writePNG(for: square, red: 80, green: 80, blue: 80, in: directory) + let output = directory.appendingPathComponent("square.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [square]), + imageURL: { _ in png }, + title: "Square", + to: output + ) + let page = try #require(openDocument(output).page(at: 0)) + #expect(page.bounds(for: .mediaBox) == CGRect(x: 0, y: 0, width: 595, height: 842)) +} + +@Test("Retina capture uses point size, not pixel size") +func retinaCaptureUsesPointSize() throws { + let capture = makeCapture(sequence: 1, pixelWidth: 2880, pixelHeight: 1620, scale: 2.0) + let layout = PageLayout(capture: capture, pageIndex: 1, pageCount: 1) + let expectedScale = min(806.0 / 1440.0, 523.0 / 810.0, 1.0) + let expected = CGSize(width: 1440.0 * expectedScale, height: 810.0 * expectedScale) + #expect(abs(layout.imageRect.width - expected.width) < 0.01) + #expect(abs(layout.imageRect.height - expected.height) < 0.01) + #expect(layout.imageRect.width < 2000) + #expect(layout.isLandscape) +} + +@Test("Zero PDFAnnotation objects on every page") +func zeroPDFAnnotationObjects() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let c1 = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 600) + let c2 = makeCapture(sequence: 2, pixelWidth: 600, pixelHeight: 800) + let c3 = makeCapture(sequence: 3, pixelWidth: 1000, pixelHeight: 1000) + let urls: [UUID: URL] = [ + c1.id: try writePNG(for: c1, red: 30, green: 30, blue: 30, in: directory), + c2.id: try writePNG(for: c2, red: 40, green: 40, blue: 40, in: directory), + c3.id: try writePNG(for: c3, red: 50, green: 50, blue: 50, in: directory), + ] + let output = directory.appendingPathComponent("no-annots.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [c1, c2, c3]), + imageURL: imageURLMap(urls), + title: "No annotations", + to: output + ) + let document = try openDocument(output) + #expect(document.pageCount == 3) + for index in 0.. 150 && c1.1 < 80 && c1.2 < 80) + #expect(c2.2 > 150 && c2.0 < 80 && c2.1 < 80) +} + +@Test("An unreadable (corrupt) PNG is treated exactly like a missing one (D-A)") +func unreadablePNGIsSkippedLikeMissing() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let good = makeCapture(sequence: 1, pixelWidth: 800, pixelHeight: 500) + let bad = makeCapture(sequence: 2, pixelWidth: 800, pixelHeight: 500) + let goodURL = try writePNG(for: good, red: 30, green: 140, blue: 30, in: directory) + let badURL = directory.appendingPathComponent("corrupt.png") + try Data([0x00, 0x01, 0x02, 0xFF, 0xD8, 0x00]).write(to: badURL) + let urls: [UUID: URL] = [good.id: goodURL, bad.id: badURL] + let output = directory.appendingPathComponent("skip-corrupt.pdf") + let pageCount = try PDFComposer().compose( + session: makeSession(captures: [good, bad]), + imageURL: imageURLMap(urls), + title: "Skip corrupt", + to: output + ) + #expect(pageCount == 1) + let document = try openDocument(output) + #expect(document.pageCount == 1) +} + +@Test("Document attributes round-trip exactly") +func documentAttributesRoundTrip() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let sessionID = try #require(UUID(uuidString: "deadbeef-dead-4eef-8ead-deadbeef0001")) + let capture = makeCapture(sequence: 1, pixelWidth: 640, pixelHeight: 480) + let png = try writePNG(for: capture, red: 40, green: 40, blue: 40, in: directory) + let output = directory.appendingPathComponent("attrs.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [capture], id: sessionID), + imageURL: { _ in png }, + title: "Ben review — 2026-08-30", + to: output + ) + let document = try openDocument(output) + let attributes = try #require(document.documentAttributes) + #expect(attributes[PDFDocumentAttribute.creatorAttribute] as? String == "Shotdeck") + #expect(attributes[PDFDocumentAttribute.titleAttribute] as? String == "Ben review — 2026-08-30") + #expect(attributes[PDFDocumentAttribute.subjectAttribute] as? String == "deadbeef-dead-4eef-8ead-deadbeef0001") + #expect(document.pageCount == 1) + #expect(try #require(document.page(at: 0)).annotations.isEmpty) +} + +@Test("Rendered page is not blank (pdftoppm, pinned renderer + rounding)") +func renderedPageIsNotBlank() throws { + let directory = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let capture = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let png = try writePNG(for: capture, red: 128, green: 128, blue: 128, in: directory) + let output = directory.appendingPathComponent("not-blank.pdf") + _ = try PDFComposer().compose( + session: makeSession(captures: [capture]), + imageURL: { _ in png }, + title: "Not blank", + to: output + ) + let prefix = directory.appendingPathComponent("not-blank") + try pdftoppm(pdf: output, prefix: prefix) + let rendered = directory.appendingPathComponent("not-blank-1.png") + let bitmap = try RGBABitmap.loadPNG(at: rendered) + let layout = PageLayout(capture: capture, pageIndex: 1, pageCount: 1) + let k: CGFloat = 110.0 / 72.0 + let imageWin = pixelWindow(for: layout.imageRect, pageHeightPt: layout.pageRect.height, scale: k) + let headerWin = pixelWindow(for: layout.headerRect, pageHeightPt: layout.pageRect.height, scale: k) + #expect(bitmap.containsNonWhite(x0: imageWin.x0, y0: imageWin.y0, x1: imageWin.x1, y1: imageWin.y1)) + #expect(bitmap.containsNonWhite(x0: headerWin.x0, y0: headerWin.y0, x1: headerWin.x1, y1: headerWin.y1)) +} + +@Test("Right-edge tick group geometry matches D-12 exactly, both orientations") +func rightEdgeTickGroupGeometry() throws { + let landscape = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let portrait = makeCapture(sequence: 1, pixelWidth: 900, pixelHeight: 1600) + let landscapeLayout = PageLayout(capture: landscape, pageIndex: 1, pageCount: 1) + let portraitLayout = PageLayout(capture: portrait, pageIndex: 1, pageCount: 1) + + #expect(landscapeLayout.failTickBox.maxX == 824) + #expect(landscapeLayout.headerRect.maxX == 824) + #expect(portraitLayout.failTickBox.maxX == 577) + #expect(portraitLayout.headerRect.maxX == 577) + #expect(landscapeLayout.failTickBox.width == 13) + #expect(landscapeLayout.failTickBox.height == 13) + #expect(portraitLayout.failTickBox.width == 13) + #expect(portraitLayout.failTickBox.height == 13) + #expect(landscapeLayout.passTickBox.maxX == landscapeLayout.failLabelOrigin.x - 8) + #expect(portraitLayout.passTickBox.maxX == portraitLayout.failLabelOrigin.x - 8) +} + +@Test("Writes visual sample for review") +func writesVisualSampleForReview() throws { + let verifyDir = URL(fileURLWithPath: "/Users/benjaminhippler/mmd-projects/multi-agent-runs/shotdeck-20260830/verify") + try FileManager.default.createDirectory(at: verifyDir, withIntermediateDirectories: true) + let scratch = try makeScratchDirectory() + defer { try? FileManager.default.removeItem(at: scratch) } + + let wide = makeCapture(sequence: 1, pixelWidth: 1600, pixelHeight: 900) + let tall = makeCapture(sequence: 2, pixelWidth: 900, pixelHeight: 1600) + let retina = makeCapture(sequence: 3, pixelWidth: 2880, pixelHeight: 1620, scale: 2.0) + let urls: [UUID: URL] = [ + wide.id: try writePNG(for: wide, red: 196, green: 42, blue: 42, in: scratch), + tall.id: try writePNG(for: tall, red: 32, green: 96, blue: 176, in: scratch), + retina.id: try writePNG(for: retina, red: 36, green: 148, blue: 84, in: scratch), + ] + let output = verifyDir.appendingPathComponent("wp2-sample.pdf") + let pageCount = try PDFComposer().compose( + session: makeSession(captures: [wide, tall, retina]), + imageURL: imageURLMap(urls), + title: "WP-2 visual sample", + to: output + ) + #expect(pageCount == 3) + let prefix = verifyDir.appendingPathComponent("wp2-sample") + try pdftoppm(pdf: output, prefix: prefix) +} -- 2.54.0