1 contributor
import CoreGraphics
import CoreText
import Foundation
// MARK: - Report data (value type, Sendable — passed to background task)
struct SnapshotReportData: Sendable {
let timestamp: Date
let osVersion: String
let deviceName: String
let deviceID: String
let typeCounts: [TypeCountData]
let baseline: BaselineData?
struct TypeCountData: Sendable {
let identifier: String
let displayName: String
let count: Int
}
struct BaselineData: Sendable {
let timestamp: Date
let totalChange: Int
let changedCount: Int
let countByIdentifier: [String: Int]
}
}
// MARK: - Exporter
enum SnapshotPDFExporter {
/// Reads SwiftData models. Must be called on the main actor.
@MainActor
static func extractReportData(
snapshot: HealthSnapshot,
baseline: HealthSnapshot?,
profile: DeviceProfile?
) -> SnapshotReportData {
let profileName: String? = {
guard let n = profile?.name, !n.isEmpty else { return nil }
return n
}()
let typeCounts = (snapshot.typeCounts ?? [])
.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
.map {
SnapshotReportData.TypeCountData(
identifier: $0.typeIdentifier,
displayName: $0.displayName,
count: $0.count
)
}
let baselineData: SnapshotReportData.BaselineData?
if let baseline {
let svc = SnapshotDiffService.shared
baselineData = SnapshotReportData.BaselineData(
timestamp: baseline.timestamp,
totalChange: svc.totalAbsoluteChange(current: snapshot, baseline: baseline),
changedCount: svc.diff(current: snapshot, baseline: baseline)
.filter { $0.previousTracked && $0.delta != 0 }.count,
countByIdentifier: Dictionary(
uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0.count) }
)
)
} else {
baselineData = nil
}
// Device ID is truncated to first 8 chars — enough for local correlation,
// not enough to uniquely identify the device in a shared report.
let rawID = snapshot.deviceID
let displayID = rawID.isEmpty ? "—" : String(rawID.prefix(8)) + "…"
return SnapshotReportData(
timestamp: snapshot.timestamp,
osVersion: snapshot.osVersion.isEmpty ? "—" : snapshot.osVersion,
deviceName: profileName ?? "This Device",
deviceID: displayID,
typeCounts: typeCounts,
baseline: baselineData
)
}
/// Generates PDF using only CoreGraphics + CoreText. Safe to call off the main thread.
nonisolated static func generatePDF(from data: SnapshotReportData) -> Data {
let pageSize = CGSize(width: 595.2, height: 841.8)
let pdfData = NSMutableData()
guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return Data() }
var mediaBox = CGRect(origin: .zero, size: pageSize)
guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return Data() }
let pen = Pen(ctx: ctx, pageSize: pageSize, margin: 48)
pen.beginPage()
drawPageHeader(pen, timestamp: data.timestamp)
drawSummarySection(pen, data: data)
drawDeviceSection(pen, data: data)
if let baseline = data.baseline {
drawComparisonSection(pen, baseline: baseline)
}
drawDataTypesSection(pen, data: data)
pen.endPage()
ctx.closePDF()
return pdfData as Data
}
// MARK: - Sections
private static func drawPageHeader(_ pen: Pen, timestamp: Date) {
text("HealthProbe", x: pen.margin, y: pen.y, font: pf(10), color: .secondary, pen: pen)
pen.advance(16)
text("Snapshot Report", x: pen.margin, y: pen.y, font: pf(22, .bold), color: .primary, pen: pen)
pen.advance(30)
text("Generated \(formatted(Date()))", x: pen.margin, y: pen.y, font: pf(9), color: .secondary, pen: pen)
pen.advance(14)
rule(pen)
pen.advance(16)
}
private static func drawSummarySection(_ pen: Pen, data: SnapshotReportData) {
let total = data.typeCounts.filter { $0.count > 0 }.reduce(0) { $0 + $1.count }
sectionTitle(pen, "Summary")
keyValue(pen, "Captured", value: formatted(data.timestamp))
keyValue(pen, "Tracked Types", value: "\(data.typeCounts.count)")
keyValue(pen, "Total Records", value: "\(total)")
pen.advance(12)
}
private static func drawDeviceSection(_ pen: Pen, data: SnapshotReportData) {
sectionTitle(pen, "Device")
keyValue(pen, "Name", value: data.deviceName)
keyValue(pen, "OS", value: data.osVersion)
keyValue(pen, "Device ID", value: data.deviceID)
pen.advance(12)
}
private static func drawComparisonSection(_ pen: Pen, baseline: SnapshotReportData.BaselineData) {
sectionTitle(pen, "Comparison vs. Baseline")
keyValue(pen, "Baseline Date", value: formatted(baseline.timestamp))
keyValue(pen, "Total Changes", value: baseline.totalChange == 0 ? "None" : "\(baseline.totalChange) records")
keyValue(pen, "Changed Types", value: "\(baseline.changedCount)")
pen.advance(12)
}
private static func drawDataTypesSection(_ pen: Pen, data: SnapshotReportData) {
guard !data.typeCounts.isEmpty else { return }
let hasBaseline = data.baseline != nil
sectionTitle(pen, "Data Types (\(data.typeCounts.count))")
tableHeader(pen, hasBaseline: hasBaseline)
for tc in data.typeCounts {
pen.checkBreak(height: 16)
let delta = data.baseline?.countByIdentifier[tc.identifier].map { tc.count - $0 }
tableRow(pen, tc: tc, delta: delta, hasBaseline: hasBaseline)
}
}
// MARK: - Drawing primitives
private static func sectionTitle(_ pen: Pen, _ title: String) {
pen.checkBreak(height: 40)
text(title, x: pen.margin, y: pen.y, font: pf(12, .semibold), color: .primary, pen: pen)
pen.advance(18)
}
private static func keyValue(_ pen: Pen, _ label: String, value: String) {
pen.checkBreak(height: 18)
let f = pf(10)
text(label, x: pen.margin, y: pen.y, font: f, color: .secondary, pen: pen)
text(value, x: pen.margin + pen.contentWidth - tw(value, font: f), y: pen.y, font: f, color: .primary, pen: pen)
pen.advance(16)
}
private static func tableHeader(_ pen: Pen, hasBaseline: Bool) {
let f = pf(8, .semibold)
let countRight = pen.margin + pen.contentWidth - (hasBaseline ? 100 : 0)
let deltaRight = pen.margin + pen.contentWidth
text("TYPE", x: pen.margin, y: pen.y, font: f, color: .tertiary, pen: pen)
text("COUNT", x: countRight - tw("COUNT", font: f), y: pen.y, font: f, color: .tertiary, pen: pen)
if hasBaseline {
text("DELTA", x: deltaRight - tw("DELTA", font: f), y: pen.y, font: f, color: .tertiary, pen: pen)
}
pen.advance(12)
rule(pen, alpha: 0.35, width: 0.25)
pen.advance(4)
}
private static func tableRow(
_ pen: Pen,
tc: SnapshotReportData.TypeCountData,
delta: Int?,
hasBaseline: Bool
) {
let nf = pf(9)
let mf = mf(9)
let countRight = pen.margin + pen.contentWidth - (hasBaseline ? 100 : 0)
let deltaRight = pen.margin + pen.contentWidth
let maxNameW = countRight - pen.margin - 12
var name = tc.displayName
if tw(name, font: nf) > maxNameW { name = String(name.prefix(45)) + "…" }
text(name, x: pen.margin, y: pen.y, font: nf, color: .primary, pen: pen)
let cs = tc.count < 0 ? "err" : "\(tc.count)"
text(cs, x: countRight - tw(cs, font: mf), y: pen.y, font: mf, color: .primary, pen: pen)
if hasBaseline, let delta {
let ds = delta == 0 ? "—" : (delta > 0 ? "+\(delta)" : "\(delta)")
let col: CGColor = delta > 0 ? .orange : delta < 0 ? .red : .tertiary
text(ds, x: deltaRight - tw(ds, font: mf), y: pen.y, font: mf, color: col, pen: pen)
}
pen.advance(15)
}
private static func rule(_ pen: Pen, alpha: CGFloat = 1, width: CGFloat = 0.5) {
let cgY = pen.pageSize.height - pen.y
pen.ctx.saveGState()
pen.ctx.setStrokeColor(CGColor(gray: 0.75, alpha: alpha))
pen.ctx.setLineWidth(width)
pen.ctx.move(to: CGPoint(x: pen.margin, y: cgY))
pen.ctx.addLine(to: CGPoint(x: pen.margin + pen.contentWidth, y: cgY))
pen.ctx.strokePath()
pen.ctx.restoreGState()
pen.advance(6)
}
// MARK: - CoreText
private static func text(
_ string: String,
x: CGFloat,
y: CGFloat,
font: CTFont,
color: CGColor,
pen: Pen
) {
let attrStr = NSAttributedString(string: string, attributes: [
kCTFontAttributeName as NSAttributedString.Key: font,
kCTForegroundColorAttributeName as NSAttributedString.Key: color
])
let line = CTLineCreateWithAttributedString(attrStr)
let cgY = pen.pageSize.height - y - CTFontGetAscent(font)
pen.ctx.textMatrix = .identity
pen.ctx.textPosition = CGPoint(x: x, y: cgY)
CTLineDraw(line, pen.ctx)
}
private static func tw(_ string: String, font: CTFont) -> CGFloat {
let attrStr = NSAttributedString(string: string, attributes: [
kCTFontAttributeName as NSAttributedString.Key: font
])
return CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(attrStr), nil, nil, nil))
}
// MARK: - Font helpers (CTFont, no UIKit)
private enum Weight { case regular, semibold, bold }
private static func pf(_ size: CGFloat, _ weight: Weight = .regular) -> CTFont {
let name: CFString
switch weight {
case .regular: name = "HelveticaNeue" as CFString
case .semibold: name = "HelveticaNeue-Medium" as CFString
case .bold: name = "HelveticaNeue-Bold" as CFString
}
return CTFontCreateWithName(name, size, nil)
}
private static func mf(_ size: CGFloat) -> CTFont {
CTFontCreateWithName("Menlo-Regular" as CFString, size, nil)
}
private static func formatted(_ date: Date) -> String {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f.string(from: date)
}
}
// MARK: - CGColor shortcuts
private extension CGColor {
static let primary = CGColor(gray: 0.05, alpha: 1)
static let secondary = CGColor(gray: 0.40, alpha: 1)
static let tertiary = CGColor(gray: 0.60, alpha: 1)
static let orange = CGColor(srgbRed: 1.00, green: 0.58, blue: 0.00, alpha: 1)
static let red = CGColor(srgbRed: 1.00, green: 0.23, blue: 0.19, alpha: 1)
}
// MARK: - Pen (page state, CoreGraphics only)
private final class Pen {
let ctx: CGContext
let pageSize: CGSize
let margin: CGFloat
private(set) var y: CGFloat
private(set) var pageNumber: Int = 0
var contentWidth: CGFloat { pageSize.width - margin * 2 }
var bottomBoundary: CGFloat { pageSize.height - margin - 28 }
init(ctx: CGContext, pageSize: CGSize, margin: CGFloat) {
self.ctx = ctx
self.pageSize = pageSize
self.margin = margin
self.y = margin
}
func beginPage() {
ctx.beginPDFPage(nil)
y = margin
pageNumber += 1
}
func endPage() {
drawFooter()
ctx.endPDFPage()
}
func advance(_ delta: CGFloat) { y += delta }
func checkBreak(height: CGFloat) {
guard y + height > bottomBoundary else { return }
endPage()
beginPage()
}
private func drawFooter() {
let footerFont = CTFontCreateWithName("HelveticaNeue" as CFString, 8, nil)
let footerColor = CGColor(gray: 0.6, alpha: 1)
let ascent = CTFontGetAscent(footerFont)
let sepCGY = margin // separator y in CGContext (from bottom)
let textCGY = margin - 10 - ascent // text y in CGContext
// Separator
ctx.saveGState()
ctx.setStrokeColor(CGColor(gray: 0.75, alpha: 0.4))
ctx.setLineWidth(0.5)
ctx.move(to: CGPoint(x: margin, y: sepCGY))
ctx.addLine(to: CGPoint(x: pageSize.width - margin, y: sepCGY))
ctx.strokePath()
ctx.restoreGState()
func footerLine(_ str: String, x: CGFloat) {
let a = NSAttributedString(string: str, attributes: [
kCTFontAttributeName as NSAttributedString.Key: footerFont,
kCTForegroundColorAttributeName as NSAttributedString.Key: footerColor
])
let line = CTLineCreateWithAttributedString(a)
ctx.textMatrix = .identity
ctx.textPosition = CGPoint(x: x, y: textCGY)
CTLineDraw(line, ctx)
}
footerLine("HealthProbe — Snapshot Report", x: margin)
let pageStr = "Page \(pageNumber)"
let pageAttr = NSAttributedString(string: pageStr, attributes: [
kCTFontAttributeName as NSAttributedString.Key: footerFont
])
let pageWidth = CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(pageAttr), nil, nil, nil))
footerLine(pageStr, x: pageSize.width - margin - pageWidth)
}
}