// // CVFilledPreviewPageView.swift // App for Indeed // // Full-screen preview of a profile merged into the selected CV template. // import Cocoa import UniformTypeIdentifiers private final class CVPreviewFlippedDocumentView: NSView { override var isFlipped: Bool { true } } /// Same metrics as job cards’ **Apply** (`DashboardView` `JobPayloadButton`): 13pt semibold, 32pt tall, 8pt corners. private final class CVPreviewPrimaryCTAButton: NSButton { private static let fill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) private static let fillHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1) /// Slightly wider than default title metrics so the label is not flush to the pill edges. private static let horizontalOutset: CGFloat = 20 private var trackingAreaRef: NSTrackingArea? private var didPushCursor = false init(title: String, target: Any?, action: Selector?) { super.init(frame: .zero) self.title = title self.target = target as AnyObject? self.action = action translatesAutoresizingMaskIntoConstraints = false isBordered = false bezelStyle = .rounded font = .systemFont(ofSize: 13, weight: .semibold) wantsLayer = true layer?.cornerRadius = 8 layer?.backgroundColor = Self.fill.cgColor contentTintColor = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) focusRingType = .none setContentHuggingPriority(.required, for: .horizontal) setContentCompressionResistancePriority(.required, for: .horizontal) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var intrinsicContentSize: NSSize { let s = super.intrinsicContentSize guard s.width != NSView.noIntrinsicMetric, s.width >= 1 else { return s } return NSSize(width: s.width + Self.horizontalOutset, height: s.height) } override func updateTrackingAreas() { super.updateTrackingAreas() if let ta = trackingAreaRef { removeTrackingArea(ta) } let ta = NSTrackingArea( rect: bounds, options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil ) addTrackingArea(ta) trackingAreaRef = ta } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) layer?.backgroundColor = Self.fillHover.cgColor if !didPushCursor { NSCursor.pointingHand.push() didPushCursor = true } } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) layer?.backgroundColor = Self.fill.cgColor if didPushCursor { NSCursor.pop() didPushCursor = false } } override func viewWillMove(toWindow newWindow: NSWindow?) { super.viewWillMove(toWindow: newWindow) if newWindow == nil, didPushCursor { NSCursor.pop() didPushCursor = false } } } /// Hosts a scrollable `CVProfileDocumentView` with a simple chrome header and back navigation. final class CVFilledPreviewPageView: NSView { var onDismiss: (() -> Void)? private let backButton = NSButton(title: "← Profiles", target: nil, action: nil) private let titleLabel = NSTextField(labelWithString: "CV preview") private let exportButton = CVPreviewPrimaryCTAButton(title: "Export PDF…", target: nil, action: nil) private let scrollView = NSScrollView() private let documentView = CVPreviewFlippedDocumentView() private let contentStack = NSStackView() private weak var profileDocumentView: CVProfileDocumentView? private var lastProfile: SavedProfile? private var lastTemplate: CVTemplate? private static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) private static let secondaryText = NSColor(srgbRed: 100 / 255, green: 116 / 255, blue: 139 / 255, alpha: 1) private static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1) override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true layer?.backgroundColor = Self.pageBackground.cgColor userInterfaceLayoutDirection = .leftToRight backButton.translatesAutoresizingMaskIntoConstraints = false backButton.bezelStyle = .rounded backButton.isBordered = false backButton.font = .systemFont(ofSize: 13, weight: .semibold) backButton.contentTintColor = Self.brandBlue backButton.target = self backButton.action = #selector(didTapBack) titleLabel.font = .systemFont(ofSize: 18, weight: .semibold) titleLabel.textColor = NSColor(srgbRed: 31 / 255, green: 41 / 255, blue: 55 / 255, alpha: 1) exportButton.target = self exportButton.action = #selector(didTapExportPDF) let subtitle = NSTextField(wrappingLabelWithString: "Layout matches the CV Maker thumbnail for this template. Export a PDF that matches what you see here (fonts, columns, colours, and rules).") subtitle.font = .systemFont(ofSize: 12, weight: .regular) subtitle.textColor = Self.secondaryText subtitle.maximumNumberOfLines = 0 let headerCol = NSStackView(views: [backButton, titleLabel, subtitle, exportButton]) headerCol.orientation = .vertical headerCol.alignment = .leading headerCol.spacing = 6 headerCol.setCustomSpacing(14, after: backButton) headerCol.setCustomSpacing(10, after: subtitle) headerCol.translatesAutoresizingMaskIntoConstraints = false contentStack.orientation = .vertical contentStack.alignment = .leading contentStack.spacing = 20 contentStack.translatesAutoresizingMaskIntoConstraints = false documentView.translatesAutoresizingMaskIntoConstraints = false documentView.addSubview(contentStack) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.drawsBackground = false scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = true scrollView.borderType = .noBorder scrollView.documentView = documentView addSubview(headerCol) addSubview(scrollView) NSLayoutConstraint.activate([ headerCol.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 32), headerCol.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -32), headerCol.topAnchor.constraint(equalTo: topAnchor, constant: 16), exportButton.heightAnchor.constraint(equalToConstant: 32), exportButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 96), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), scrollView.topAnchor.constraint(equalTo: headerCol.bottomAnchor, constant: 16), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 40), contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32), contentStack.trailingAnchor.constraint(lessThanOrEqualTo: documentView.trailingAnchor, constant: -32), contentStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 8), contentStack.widthAnchor.constraint(lessThanOrEqualTo: documentView.widthAnchor, constant: -64) ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(profile: SavedProfile, template: CVTemplate) { lastProfile = profile lastTemplate = template for v in contentStack.arrangedSubviews { contentStack.removeArrangedSubview(v) v.removeFromSuperview() } let doc = CVProfileDocumentView(profile: profile, template: template) profileDocumentView = doc contentStack.addArrangedSubview(doc) let profileTitle = profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName titleLabel.stringValue = "\(template.name) · \(profileTitle)" } @objc private func didTapBack() { onDismiss?() } @objc private func didTapExportPDF() { // NSSavePanel and sandboxed file writes must run on the main thread (and after a button // callback can be mis-attributed under Swift’s default actor isolation). DispatchQueue.main.async { [weak self] in self?.runExportPDFOnMainThread() } } private func runExportPDFOnMainThread() { guard let doc = profileDocumentView else { return } doc.layoutSubtreeIfNeeded() let bounds = doc.bounds guard !bounds.isEmpty else { return } // `dataWithPDF` uses the print pipeline and often drops layer-backed chrome (card fill, // borders, hairlines, tinted sidebars) while keeping text — so the PDF looks like plain // text. Rasterising what is actually drawn on screen preserves the full layout. let data = doc.pdfDataMatchingScreenAppearance() ?? doc.dataWithPDF(inside: bounds) guard !data.isEmpty else { presentExportError("The résumé could not be rendered to PDF (empty output). Try scrolling the preview so it lays out, then export again.") return } let panel = NSSavePanel() panel.canCreateDirectories = true panel.allowedContentTypes = [.pdf] let base = lastTemplate?.name ?? "CV" let safe = base.replacingOccurrences(of: "/", with: "-") panel.nameFieldStringValue = "\(safe).pdf" guard let hostWindow = window else { if panel.runModal() == .OK, let url = panel.url { writePDFData(data, to: url) } return } panel.beginSheetModal(for: hostWindow) { [weak self] response in guard let self, response == .OK, let url = panel.url else { return } self.writePDFData(data, to: url) } } private func writePDFData(_ data: Data, to url: URL) { let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } do { try data.write(to: url, options: .atomic) } catch { presentExportError(error.localizedDescription) } } private func presentExportError(_ message: String) { let alert = NSAlert() alert.messageText = "Couldn’t save PDF" alert.informativeText = message alert.alertStyle = .warning alert.addButton(withTitle: "OK") if let window { alert.beginSheetModal(for: window, completionHandler: nil) } else { alert.runModal() } } } // MARK: - PDF export (screen-accurate) private extension NSView { /// Single-page PDF that matches the composited on-screen appearance, including /// `CALayer` backgrounds, borders, and rules. Callers fall back to `dataWithPDF` when this returns nil. func pdfDataMatchingScreenAppearance() -> Data? { layoutSubtreeIfNeeded() let rect = bounds guard rect.width > 1, rect.height > 1 else { return nil } guard let rep = bitmapImageRepForCachingDisplay(in: rect) else { return nil } cacheDisplay(in: rect, to: rep) guard let cgImage = rep.cgImage else { return nil } let data = NSMutableData() guard let consumer = CGDataConsumer(data: data as CFMutableData) else { return nil } var mediaBox = CGRect(origin: .zero, size: NSSize(width: rect.width, height: rect.height)) guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return nil } ctx.beginPDFPage(nil) ctx.interpolationQuality = .high // Draw without an extra Y-flip: `cacheDisplay`’s bitmap + `cgImage` already match PDF user space // on macOS here; a translate/scale(-1) was inverting the whole page in Preview. ctx.draw(cgImage, in: mediaBox) ctx.endPDFPage() ctx.closePDF() let out = data as Data return out.isEmpty ? nil : out } }