// Utils/InspectionPDFGenerator.swift // ----------------------------------- // Generates a PDF inspection report that mirrors the web app's layout. // // Layout (portrait, US Letter 612×792 pt): // • Dark header band — title + "Page N" // • Score banner — coloured score + PASS/FAIL // • Meta table — facility, area, date, template, inspector, status // • Form fields — 12-col grid, answered fields only, section headings, // photo fields render the actual image // • Inspector notes — if non-empty // • Footer — generation date // // Photo fields require a network fetch for server-synced photos, so PDF // generation is async: all images are pre-fetched into memory before the // synchronous UIGraphicsPDFRenderer drawing closure runs (PDFKit drawing // callbacks cannot be async). import UIKit // MARK: - Constants private let kW: CGFloat = 612 // US Letter width private let kH: CGFloat = 792 // US Letter height private let kM: CGFloat = 46.8 // left/right margin private let kBodyW: CGFloat = kW - 2 * kM private let kHdrH: CGFloat = 79.2 // 1.1 inch dark header private let kFtrH: CGFloat = 39.6 // footer area height private let kTopY: CGFloat = kHdrH + 14 // first content Y on a fresh page private let kBotY: CGFloat = kH - kFtrH // lowest Y before footer /// Downscale to 700px on the longer side and re-encode as JPEG quality 0.55, /// returning a fresh UIImage built from the compressed bytes. Applied to /// every photo before it's embedded in the PDF — keeps file size minimal. /// nonisolated: called from inside a TaskGroup (concurrent), not @MainActor. private nonisolated func compress(_ image: UIImage) -> UIImage? { let kImgMaxPx: CGFloat = 700 let kImgJPEGQuality: CGFloat = 0.55 let size = image.size guard size.width > 0, size.height > 0 else { return nil } let scale = min(kImgMaxPx / size.width, kImgMaxPx / size.height, 1.0) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let renderer = UIGraphicsImageRenderer(size: newSize) let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } guard let jpegData = resized.jpegData(compressionQuality: kImgJPEGQuality) else { return resized } return UIImage(data: jpegData) ?? resized } // MARK: - Colours private let C_DARK = UIColor(red:0.102, green:0.114, blue:0.137, alpha:1) // #1a1d23 private let C_BLUE = UIColor(red:0.145, green:0.388, blue:0.922, alpha:1) // #2563eb private let C_SLATE = UIColor(red:0.392, green:0.455, blue:0.545, alpha:1) // #64748b private let C_LIGHT = UIColor(red:0.945, green:0.961, blue:0.976, alpha:1) // #f1f5f9 private let C_BORDER = UIColor(red:0.886, green:0.906, blue:0.929, alpha:1) // #e2e8f0 private let C_GREEN = UIColor(red:0.086, green:0.639, blue:0.290, alpha:1) // #16a34a private let C_YELLOW = UIColor(red:0.851, green:0.467, blue:0.024, alpha:1) // #d97706 private let C_RED = UIColor(red:0.863, green:0.149, blue:0.149, alpha:1) // #dc2626 private let C_MUTED = UIColor(red:0.580, green:0.647, blue:0.722, alpha:1) // #94a3b8 private let C_AMBER = UIColor(red:0.973, green:0.620, blue:0.043, alpha:1) // star gold private let C_CELL = UIColor(red:0.973, green:0.984, blue:0.996, alpha:1) // cell bg private func scoreColor(_ s: Double?) -> UIColor { guard let v = s else { return C_SLATE } return v >= 90 ? C_GREEN : v >= 70 ? C_YELLOW : C_RED } // MARK: - Attributed string helpers /// Single-line, truncates with an ellipsis if it doesn't fit. private func A(_ text: String, size: CGFloat, weight: UIFont.Weight = .regular, color: UIColor = C_DARK, align: NSTextAlignment = .left) -> NSAttributedString { let ps = NSMutableParagraphStyle() ps.alignment = align ps.lineBreakMode = .byTruncatingTail return NSAttributedString(string: text, attributes: [ .font: UIFont.systemFont(ofSize: size, weight: weight), .foregroundColor: color, .paragraphStyle: ps, ]) } /// Multiline, word-wraps instead of truncating. Used anywhere content length /// is variable (dates, names, notes) so nothing gets cut off with "...". private func AM(_ text: String, size: CGFloat, weight: UIFont.Weight = .regular, color: UIColor = C_DARK, align: NSTextAlignment = .left) -> NSAttributedString { let ps = NSMutableParagraphStyle() ps.alignment = align ps.lineBreakMode = .byWordWrapping return NSAttributedString(string: text, attributes: [ .font: UIFont.systemFont(ofSize: size, weight: weight), .foregroundColor: color, .paragraphStyle: ps, ]) } /// Height needed to render `text` word-wrapped within `width`. private func wrappedHeight(_ text: String, size: CGFloat, width: CGFloat) -> CGFloat { AM(text, size: size).boundingRect( with: CGSize(width: width, height: 2000), options: .usesLineFragmentOrigin, context: nil ).height } // MARK: - Drawing context wrapper (handles pagination) private final class PDFContext { let renderer: UIGraphicsPDFRendererContext var y: CGFloat = kTopY var page = 1 private let title: String private let generatedAt: String init(_ ctx: UIGraphicsPDFRendererContext, title: String, generatedAt: String) { self.renderer = ctx self.title = title self.generatedAt = generatedAt } var gc: CGContext { renderer.cgContext } var remaining: CGFloat { kBotY - y } /// Ensure `h` points are available; start a new page if not. func need(_ h: CGFloat) { guard remaining < h else { return } renderer.beginPage() page += 1 y = kTopY drawHeader(gc, title: title, page: page) drawFooter(gc, generatedAt: generatedAt) } func advance(_ h: CGFloat) { y += h } } // MARK: - Public API enum InspectionPDFGenerator { /// Generate a PDF for the given inspection, including embedded photos. /// Fetches server-hosted photos over the network before drawing — call /// from a background context (e.g. inside a Task) and not the main actor /// hot path. Local (`local://`) photos are read from disk synchronously. static func generate(inspection: APIInspectionSummary) async -> Data { let images = await fetchImages(schema: inspection.formSchema, values: inspection.formValues) return render(inspection: inspection, images: images) } // ── Image pre-fetch ────────────────────────────────────────────────── // Returns [fieldId: UIImage] for every "image" field that has a value // and could be loaded, either from local disk or the server. // Each image is downscaled and re-encoded as JPEG (see compress()) before // being returned — full camera-resolution photos (often 3–8MB each) would // otherwise be embedded into the PDF content stream at full size, making // an inspection with several photos balloon into a multi-megabyte // attachment. Compression keeps the PDF small regardless of source size. private static func fetchImages( schema: [[String: Any]], values: [String: String] ) async -> [String: UIImage] { var result: [String: UIImage] = [:] await withTaskGroup(of: (String, UIImage?).self) { group in for field in schema { guard field["type"] as? String == "image" else { continue } let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? "" let val = values[fid] ?? "" guard !val.isEmpty else { continue } group.addTask { if val.hasPrefix("local://") { let path = String(val.dropFirst("local://".count)) guard let live = PhotoStore.resolve(path), let raw = UIImage(contentsOfFile: live) else { return (fid, nil) } return (fid, compress(raw)) } else if val.hasPrefix("uploads/") { guard let url = URL(string: "\(ServerConfig.current)/static/\(val)") else { return (fid, nil) } do { let (data, _) = try await URLSession.shared.data(from: url) guard let raw = UIImage(data: data) else { return (fid, nil) } return (fid, compress(raw)) } catch { return (fid, nil) } } return (fid, nil) } } for await (fid, image) in group { if let image { result[fid] = image } } } return result } // ── Synchronous rendering ─────────────────────────────────────────── private static func render(inspection: APIInspectionSummary, images: [String: UIImage]) -> Data { let title = "\(inspection.templateName) — \(inspection.facilityName)" let inspName = KeychainHelper.get(Constants.Keychain.displayName) ?? "Inspector" let generatedAt: String = { let f = DateFormatter(); f.dateStyle = .long; f.timeStyle = .short return f.string(from: Date()) }() let fmt = UIGraphicsPDFRendererFormat() fmt.documentInfo = [ kCGPDFContextTitle as String: "Inspection Report — \(inspection.facilityName)", kCGPDFContextAuthor as String: "JanitorialQC Inspector", ] as [String: Any] let bounds = CGRect(x: 0, y: 0, width: kW, height: kH) let renderer = UIGraphicsPDFRenderer(bounds: bounds, format: fmt) return renderer.pdfData { ctx in ctx.beginPage() let pctx = PDFContext(ctx, title: title, generatedAt: generatedAt) drawHeader(pctx.gc, title: title, page: 1) drawFooter(pctx.gc, generatedAt: generatedAt) drawScoreBanner(pctx, score: inspection.overallScore) drawMetaTable(pctx, inspection: inspection, inspectorName: inspName) drawFormFields(pctx, schema: inspection.formSchema, values: inspection.formValues, images: images) if !inspection.inspectorNotes.isEmpty { drawNotes(pctx, notes: inspection.inspectorNotes) } } } } // MARK: - Header & Footer private func drawHeader(_ gc: CGContext, title: String, page: Int) { gc.setFillColor(C_DARK.cgColor) gc.fill(CGRect(x: 0, y: 0, width: kW, height: kHdrH)) A(title, size: 13, weight: .bold, color: .white) .draw(in: CGRect(x: kM, y: 26, width: kBodyW - 60, height: 20)) A("Janitorial Quality Control System", size: 8, color: C_MUTED) .draw(in: CGRect(x: kM, y: 48, width: kBodyW - 60, height: 14)) A("Page \(page)", size: 8, color: C_MUTED, align: .right) .draw(in: CGRect(x: kM, y: 38, width: kBodyW, height: 14)) } private func drawFooter(_ gc: CGContext, generatedAt: String) { let y = kH - kFtrH gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) gc.move(to: CGPoint(x: kM, y: y + 6)) gc.addLine(to: CGPoint(x: kW - kM, y: y + 6)) gc.strokePath() A("Generated: \(generatedAt) | Janitorial QC System", size: 7, color: C_SLATE) .draw(in: CGRect(x: kM, y: y + 12, width: kBodyW * 0.75, height: 12)) A("CONFIDENTIAL", size: 7, color: C_SLATE, align: .right) .draw(in: CGRect(x: kM, y: y + 12, width: kBodyW, height: 12)) } // MARK: - Score Banner private func drawScoreBanner(_ pctx: PDFContext, score: Double?) { let h: CGFloat = 48 pctx.need(h + 8) let gc = pctx.gc; let y = pctx.y let scoreW = kBodyW * 0.70; let gradeW = kBodyW * 0.30 gc.setFillColor(scoreColor(score).cgColor) gc.fill(CGRect(x: kM, y: y, width: scoreW, height: h)) let isPassing = (score ?? 0) >= 70 gc.setFillColor((isPassing ? C_GREEN : C_RED).cgColor) gc.fill(CGRect(x: kM + scoreW, y: y, width: gradeW, height: h)) let scoreStr = score.map { String(format: "%.1f%%", $0) } ?? "N/A" A("Overall Score", size: 9, weight: .bold, color: .white, align: .center) .draw(in: CGRect(x: kM, y: y + 6, width: scoreW, height: 14)) A(scoreStr, size: 22, weight: .bold, color: .white, align: .center) .draw(in: CGRect(x: kM, y: y + 18, width: scoreW, height: 28)) A(isPassing ? "PASS" : "FAIL", size: 16, weight: .bold, color: .white, align: .center) .draw(in: CGRect(x: kM + scoreW, y: y + 14, width: gradeW, height: 22)) pctx.advance(h + 8) } // MARK: - Meta Table // Each cell word-wraps (AM, not A) and the row height is computed from the // tallest cell content in that row, so full dates ("December 18, 2026 at // 9:45 AM") and long facility/status strings are never truncated. private func drawMetaTable(_ pctx: PDFContext, inspection: APIInspectionSummary, inspectorName: String) { let cols = 6 let colW = kBodyW / CGFloat(cols) let valueW = colW - 10 let labelH: CGFloat = 10 let valueFontSize: CGFloat = 8 let cellVPad: CGFloat = 6 // top padding before label, gap before value let dateStr: String = { guard let d = inspection.inspectionDateParsed else { return "—" } let f = DateFormatter(); f.dateStyle = .long; f.timeStyle = .short return f.string(from: d) }() let completedStr: String = { guard let s = inspection.completedAt, let d = SyncManager.isoFormatter.date(from: s) else { return "—" } let f = DateFormatter(); f.dateStyle = .long; f.timeStyle = .short return f.string(from: d) }() let scoreStr = inspection.overallScore.map { String(format: "%.1f%%", $0) } ?? "—" let statusTxt = inspection.status == "completed" ? "Submitted" : inspection.status.replacingOccurrences(of: "_", with: " ").capitalized let statusScoreStr = "\(statusTxt) \(scoreStr)" // Row definitions: (col, label, value) let row0: [(Int, String, String)] = [ (0, "INSPECTOR", inspectorName), (2, "START DATE", dateStr), (4, "COMPLETED DATE", completedStr), ] let row1: [(Int, String, String)] = [ (0, "FACILITY", inspection.facilityName), (2, "AREA", inspection.areaName ?? "—"), (4, "STATUS / SCORE", statusScoreStr), ] let row2: [(Int, String, String)] = [ (0, "TEMPLATE", inspection.templateName), ] let allRows = [row0, row1, row2] // Compute each row's height from its tallest wrapped value. func rowHeight(_ row: [(Int, String, String)]) -> CGFloat { let maxValH = row.map { wrappedHeight($0.2, size: valueFontSize, width: valueW) } .max() ?? 14 return cellVPad + labelH + 3 + max(maxValH, 12) + 6 } let rowHeights = allRows.map(rowHeight) let tableH = rowHeights.reduce(0, +) pctx.need(tableH + 8) let gc = pctx.gc; let y0 = pctx.y gc.setFillColor(C_LIGHT.cgColor) gc.fill(CGRect(x: kM, y: y0, width: kBodyW, height: tableH)) gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) gc.stroke(CGRect(x: kM, y: y0, width: kBodyW, height: tableH)) for c in 1.. = ["button_submit", "button_print", "button_email"] let unitW = kBodyW / 12.0 let baseRowH: CGFloat = 28 let photoRowH: CGFloat = 92 // taller row to fit an embedded photo // Sort fields by (row, col) before grouping — mirrors the web PDF generator: // form_fields = sorted(schema, key=lambda f: (f['row'], f['col'])) // Without this, schema fields arrive in JSON-array/editor-insertion order, // which can place section headers after the data rows they belong to. let sortedSchema = schema.sorted { let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0 if r0 != r1 { return r0 < r1 } let c0 = $0["col"] as? Int ?? 0, c1 = $1["col"] as? Int ?? 0 return c0 < c1 } // Group by original row, tracking which section header precedes each new row. // Uses a queue (not a single pendingSec variable) so that when a section // field is encountered but the next few fields are on rows already in // rowGroups, the section label is not silently dropped or overwritten by the // next section before it was consumed. var rowGroups: [Int: [[String: Any]]] = [:] var rowOrder: [Int] = [] var sectionQueue: [String] = [] // pending section labels, in schema order var secForRow: [Int: String] = [:] for f in sortedSchema { let ftype = f["type"] as? String ?? "" if skipTypes.contains(ftype) { continue } if ftype == "section" { sectionQueue.append(f["label"] as? String ?? "") continue } let row = f["row"] as? Int ?? 1 if rowGroups[row] == nil { rowOrder.append(row) rowGroups[row] = [] // Assign the oldest pending section label to this new row. if !sectionQueue.isEmpty { secForRow[row] = sectionQueue.removeFirst() } } rowGroups[row]!.append(f) } // Iterate in rowOrder (which is now schema-sorted insertion order = row order) for origRow in rowOrder { guard let fields = rowGroups[origRow] else { continue } // Section banner if let sec = secForRow[origRow] { pctx.need(26 + baseRowH) pctx.advance(6) A(sec, size: 9, weight: .bold) .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 14)) pctx.advance(16) drawHR(pctx.gc, y: pctx.y, w: 0.75) pctx.advance(4) } // Has any visible content? let hasContent = fields.contains { f in let ft = f["type"] as? String ?? "" if ft == "label" { return true } let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" let val = values[fid] ?? "" return ft == "rating" ? (Int(val) ?? 0) > 0 : !val.isEmpty } guard hasContent else { continue } // Row height: taller if this row contains an image field with a // loaded photo, so the photo isn't squeezed into a 28pt strip. let rowHasPhoto = fields.contains { f in guard f["type"] as? String == "image" else { return false } let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" return images[fid] != nil } let rowH = rowHasPhoto ? photoRowH : baseRowH pctx.need(rowH + 2) let gc = pctx.gc; let rowY = pctx.y for f in fields { let ftype = f["type"] as? String ?? "" let col = max(1, f["col"] as? Int ?? 1) let colSpan = max(1, f["colSpan"] as? Int ?? 6) let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" let val = values[fid] ?? "" let lbl = f["label"] as? String ?? "" let cellX = kM + CGFloat(col - 1) * unitW let cellW = CGFloat(colSpan) * unitW - 2 switch ftype { case "label": let fsMap: [String: CGFloat] = ["small":7,"normal":8,"large":10,"x-large":12] let fs = fsMap[f["font_size"] as? String ?? "normal"] ?? 8 let fw: UIFont.Weight = f["font_weight"] as? String == "bold" ? .bold : .regular let txt = f["text_content"] as? String ?? "" AM(txt, size: fs, weight: fw) .draw(in: CGRect(x: cellX + 3, y: rowY + 6, width: cellW - 4, height: rowH - 8)) case "section": continue case "rating": let score = Int(val) ?? 0 guard score > 0 else { continue } let maxR = f["max"] as? Int ?? 5 drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) A(lbl, size: 6.5, color: C_SLATE) .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) let stars = String(repeating: "★", count: score) + String(repeating: "☆", count: maxR - score) NSAttributedString(string: "\(stars) \(score)/\(maxR)", attributes: [ .font: UIFont.systemFont(ofSize: 10), .foregroundColor: C_AMBER, ]).draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: 13)) case "pass_fail": guard !val.isEmpty else { continue } let isPass = ["pass","yes","ok","good","acceptable","compliant"] .contains(val.lowercased()) drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) A(lbl, size: 6.5, color: C_SLATE) .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) A(val, size: 9, weight: .semibold, color: isPass ? C_GREEN : C_RED) .draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: 13)) case "image": guard !val.isEmpty else { continue } drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) A(lbl, size: 6.5, color: C_SLATE) .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) if let img = images[fid] { // Fit the photo into the remaining cell space, preserving // aspect ratio, anchored top-left under the label. let availW = cellW - 8 let availH = rowH - 16 let imgSize = img.size let scale = min(availW / imgSize.width, availH / imgSize.height, 1.0) let drawW = imgSize.width * scale let drawH = imgSize.height * scale let imgRect = CGRect(x: cellX + 4, y: rowY + 13, width: drawW, height: drawH) img.draw(in: imgRect) gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) gc.stroke(imgRect) } else { A("[Photo unavailable]", size: 7.5, color: C_SLATE) .draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: 13)) } case "checkbox": guard val == "true" || val == "yes" else { continue } drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) A(lbl, size: 6.5, color: C_SLATE) .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) A("✓ Yes", size: 9, weight: .semibold, color: C_GREEN) .draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: 13)) default: guard !val.isEmpty else { continue } drawCellBg(gc, x: cellX, y: rowY, w: cellW, h: rowH) A(lbl, size: 6.5, color: C_SLATE) .draw(in: CGRect(x: cellX + 4, y: rowY + 3, width: cellW - 6, height: 9)) AM(val, size: 8.5) .draw(in: CGRect(x: cellX + 4, y: rowY + 13, width: cellW - 6, height: rowH - 16)) } } pctx.advance(rowH + 2) } } // MARK: - Inspector Notes private func drawNotes(_ pctx: PDFContext, notes: String) { pctx.need(40) A("Inspector Notes", size: 10, weight: .bold) .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 16)) pctx.advance(18) drawHR(pctx.gc, y: pctx.y) pctx.advance(4) let boxH = max(wrappedHeight(notes, size: 8.5, width: kBodyW - 12) + 12, 24) pctx.need(boxH + 4) let gc = pctx.gc; let y = pctx.y gc.setFillColor(C_LIGHT.cgColor) gc.fill(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) gc.stroke(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) AM(notes, size: 8.5) .draw(in: CGRect(x: kM + 6, y: y + 4, width: kBodyW - 12, height: boxH)) pctx.advance(boxH + 8) } // MARK: - Micro helpers private func drawHR(_ gc: CGContext, y: CGFloat, w: CGFloat = 1.0) { gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(w) gc.move(to: CGPoint(x: kM, y: y)); gc.addLine(to: CGPoint(x: kM + kBodyW, y: y)) gc.strokePath() } private func drawCellBg(_ gc: CGContext, x: CGFloat, y: CGFloat, w: CGFloat, h: CGFloat) { gc.setFillColor(C_CELL.cgColor) gc.fill(CGRect(x: x + 1, y: y, width: w, height: h)) gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) gc.stroke(CGRect(x: x + 1, y: y, width: w, height: h)) }