// Utils/IssuePDFGenerator.swift // ------------------------------ // Generates a PDF issue report mirroring the web app's generate_issue_pdf(). // // Layout (portrait, US Letter 612×792 pt): // • Dark header band — title + "Page N" // • Severity / status banner // • Details grid — contract, facility, area, reporter, assignee, // reported/resolved dates, status // • Description // • Photo Evidence — compact grid, JPEG re-encoded to keep file // size minimal (matches web _compress_image) // • Resolution Details — notes (no resolution photos on iOS model) // • Verification — verifier + date + note // • Footer — generation date // // All photos are re-encoded to JPEG at reduced resolution/quality before // embedding, exactly like the web's _compress_image (800px max side, q=55), // so attaching several photos doesn't bloat the email attachment. import UIKit // MARK: - Constants private let kW: CGFloat = 612 private let kH: CGFloat = 792 private let kM: CGFloat = 46.8 private let kBodyW: CGFloat = kW - 2 * kM private let kHdrH: CGFloat = 79.2 private let kFtrH: CGFloat = 39.6 private let kTopY: CGFloat = kHdrH + 14 private let kBotY: CGFloat = kH - kFtrH // Image compression — matches web pdf_export.py _compress_image private let kImgMaxPx: CGFloat = 700 // max px on the longer side private let kImgJPEGQuality: CGFloat = 0.55 // MARK: - Colours (same palette as InspectionPDFGenerator) private let C_DARK = UIColor(red:0.102, green:0.114, blue:0.137, alpha:1) private let C_SLATE = UIColor(red:0.392, green:0.455, blue:0.545, alpha:1) private let C_LIGHT = UIColor(red:0.945, green:0.961, blue:0.976, alpha:1) private let C_BORDER = UIColor(red:0.886, green:0.906, blue:0.929, alpha:1) private let C_GREEN = UIColor(red:0.086, green:0.639, blue:0.290, alpha:1) private let C_YELLOW = UIColor(red:0.851, green:0.467, blue:0.024, alpha:1) private let C_RED = UIColor(red:0.863, green:0.149, blue:0.149, alpha:1) private let C_ORANGE = UIColor(red:0.918, green:0.345, blue:0.047, alpha:1) private let C_BLUE = UIColor(red:0.145, green:0.388, blue:0.922, alpha:1) private let C_MUTED = UIColor(red:0.580, green:0.647, blue:0.722, alpha:1) private let C_GREENBG = UIColor(red:0.941, green:0.992, blue:0.957, alpha:1) // #f0fdf4 private func severityColor(_ s: String) -> UIColor { switch s.lowercased() { case "critical", "high": return C_RED case "medium": return C_YELLOW default: return C_SLATE } } // MARK: - Attributed string helpers (shared style with InspectionPDFGenerator) 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, ]) } 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, ]) } 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: - Pagination context 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 } 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 IssuePDFGenerator { /// Generate a minimal-size PDF for the given issue, with photo evidence /// re-encoded as compressed JPEGs (matches web _compress_image). /// Fetches any server-hosted photos over the network — call from a /// background Task, not the main-actor hot path. static func generate(issue: LocalIssue, facilityName: String) async -> Data { let photos = await fetchAndCompressPhotos(issue: issue) return render(issue: issue, facilityName: facilityName, photos: photos) } // ── Photo fetch + compression ─────────────────────────────────────── // Returns compressed UIImages ready for low-filesize PDF embedding. // Local photos are read from disk; server photos are downloaded. // Every image is downscaled to kImgMaxPx and re-encoded as JPEG at // kImgJPEGQuality before being wrapped back into a UIImage — this is // what keeps the final PDF (and thus the email attachment) small even // with several photos attached. private static func fetchAndCompressPhotos(issue: LocalIssue) async -> [UIImage] { // Prefer server paths once synced (matches IssueDetailView's own logic); // fall back to local paths while pending/failed. let isSynced = issue.syncStatus == "synced" let serverPaths = issue.photoServerPaths let localPaths = issue.photoLocalPaths var raw: [UIImage] = [] if isSynced && !serverPaths.isEmpty { raw = await withTaskGroup(of: UIImage?.self) { group in for path in serverPaths { group.addTask { guard let url = URL(string: "\(ServerConfig.current)/static/\(path)") else { return nil } do { let (data, _) = try await URLSession.shared.data(from: url) return UIImage(data: data) } catch { return nil } } } var results: [UIImage] = [] for await img in group { if let img { results.append(img) } } return results } } else if !localPaths.isEmpty { raw = localPaths.compactMap { PhotoStore.resolve($0).flatMap(UIImage.init(contentsOfFile:)) } } return raw.compactMap { compress($0) } } /// Downscale to kImgMaxPx on the longer side and re-encode as JPEG at /// kImgJPEGQuality, returning a fresh UIImage built from the compressed /// bytes. This mirrors the web's _compress_image and is the main lever /// for keeping the PDF attachment small. private static func compress(_ image: UIImage) -> UIImage? { 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 } // ── Synchronous rendering ─────────────────────────────────────────── private static func render(issue: LocalIssue, facilityName: String, photos: [UIImage]) -> Data { let title = "Issue Report — Issue #\(issue.serverId ?? 0)" 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: title, 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) drawSeverityBanner(pctx, issue: issue) drawDetailsGrid(pctx, issue: issue, facilityName: facilityName) drawDescription(pctx, text: issue.issueDescription) if !photos.isEmpty { drawPhotoGrid(pctx, title: "Photo Evidence", photos: photos) } if let notes = issue.resultNotes, !notes.isEmpty { drawResolution(pctx, notes: notes) } if let vAt = issue.verifiedAt { drawVerification(pctx, verifiedAt: vAt, note: issue.verificationNote) } } } } // 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: - Severity / Status Banner private func drawSeverityBanner(_ pctx: PDFContext, issue: LocalIssue) { let h: CGFloat = 36 pctx.need(h + 10) let gc = pctx.gc; let y = pctx.y let sevW = kBodyW * 0.20; let midW = kBodyW * 0.55; let statW = kBodyW * 0.25 gc.setFillColor(severityColor(issue.severity).cgColor) gc.fill(CGRect(x: kM, y: y, width: sevW, height: h)) gc.setFillColor(C_DARK.cgColor) gc.fill(CGRect(x: kM + sevW, y: y, width: midW, height: h)) gc.setFillColor(C_SLATE.cgColor) gc.fill(CGRect(x: kM + sevW + midW, y: y, width: statW, height: h)) A(issue.severity.uppercased(), size: 10, weight: .bold, color: .white, align: .center) .draw(in: CGRect(x: kM, y: y + 12, width: sevW, height: 14)) A("Issue #\(issue.serverId ?? 0)", size: 16, weight: .bold, color: .white, align: .center) .draw(in: CGRect(x: kM + sevW, y: y + 9, width: midW, height: 20)) let statTxt = issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized A(statTxt, size: 10, weight: .bold, color: .white, align: .center) .draw(in: CGRect(x: kM + sevW + midW, y: y + 12, width: statW, height: 14)) pctx.advance(h + 10) } // MARK: - Details Grid (4 columns, word-wrapped values) private func drawDetailsGrid(_ pctx: PDFContext, issue: LocalIssue, facilityName: String) { let cols = 4 let colW = kBodyW / CGFloat(cols) let valueW = colW - 12 let labelH: CGFloat = 10 let valFontSize: CGFloat = 8.5 let vPad: CGFloat = 6 let reportedStr: String = { let d = issue.serverReportedAt ?? issue.createdAt let f = DateFormatter(); f.dateStyle = .medium; f.timeStyle = .short return f.string(from: d) }() let assignedStr = issue.assignedToName?.isEmpty == false ? issue.assignedToName! : "— Unassigned —" let reporterStr = issue.reportedByName?.isEmpty == false ? issue.reportedByName! : "—" let areaStr = issue.areaNameCache?.isEmpty == false ? issue.areaNameCache! : "—" let statusStr = issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized let rows: [[(String, String)]] = [ [("FACILITY", facilityName), ("AREA", areaStr)], [("REPORTED BY", reporterStr), ("ASSIGNED TO", assignedStr)], [("REPORTED", reportedStr), ("STATUS", statusStr)], ] func rowHeight(_ row: [(String, String)]) -> CGFloat { let maxValH = row.map { wrappedHeight($0.1, size: valFontSize, width: valueW) }.max() ?? 14 return vPad + labelH + 3 + max(maxValH, 12) + 6 } let rowHeights = rows.map(rowHeight) let tableH = rowHeights.reduce(0, +) pctx.need(tableH + 10) 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)) let x1 = kM + colW gc.move(to: CGPoint(x: x1, y: y0)); gc.addLine(to: CGPoint(x: x1, y: y0 + tableH)) let x2 = kM + colW * 2 gc.move(to: CGPoint(x: x2, y: y0)); gc.addLine(to: CGPoint(x: x2, y: y0 + tableH)) let x3 = kM + colW * 3 gc.move(to: CGPoint(x: x3, y: y0)); gc.addLine(to: CGPoint(x: x3, y: y0 + tableH)) var ry = y0 for h in rowHeights.dropLast() { ry += h gc.move(to: CGPoint(x: kM, y: ry)); gc.addLine(to: CGPoint(x: kM + kBodyW, y: ry)) } gc.strokePath() var cursorY = y0 for (rowIdx, row) in rows.enumerated() { let rh = rowHeights[rowIdx] for (i, pair) in row.enumerated() { // Each (label, value) pair occupies 2 of the 4 columns let col = i * 2 let cx = kM + CGFloat(col) * colW + 6 A(pair.0, size: 7, color: C_SLATE) .draw(in: CGRect(x: cx, y: cursorY + vPad - 2, width: valueW * 2, height: labelH)) AM(pair.1, size: valFontSize, weight: .semibold) .draw(in: CGRect(x: cx, y: cursorY + vPad + labelH + 1, width: valueW, height: rh - labelH - vPad)) } cursorY += rh } pctx.advance(tableH + 12) } // MARK: - Description private func drawDescription(_ pctx: PDFContext, text: String) { pctx.need(24) A("Description", 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(6) let display = text.isEmpty ? "—" : text let boxH = max(wrappedHeight(display, size: 9, width: kBodyW - 16) + 12, 24) pctx.need(boxH + 8) let gc = pctx.gc; let y = pctx.y gc.setFillColor(UIColor(red: 0.973, green: 0.984, blue: 0.996, alpha: 1).cgColor) gc.fill(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) AM(display, size: 9) .draw(in: CGRect(x: kM + 8, y: y + 6, width: kBodyW - 16, height: boxH)) pctx.advance(boxH + 10) } // MARK: - Photo Grid (compact, 3 columns, compressed images already provided) private func drawPhotoGrid(_ pctx: PDFContext, title: String, photos: [UIImage]) { pctx.need(24) A(title, 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(8) let cols = 3 let gap: CGFloat = 8 let cellW = (kBodyW - CGFloat(cols - 1) * gap) / CGFloat(cols) let cellH: CGFloat = 130 // fixed thumbnail height keeps layout predictable var col = 0 for photo in photos { if col == 0 { pctx.need(cellH + gap) } let gc = pctx.gc let cellX = kM + CGFloat(col) * (cellW + gap) let cellY = pctx.y // Fit photo into cell, preserving aspect ratio, centred. let imgSize = photo.size let scale = min(cellW / imgSize.width, cellH / imgSize.height, 1.0) let drawW = imgSize.width * scale let drawH = imgSize.height * scale let drawX = cellX + (cellW - drawW) / 2 let drawY = cellY + (cellH - drawH) / 2 let frameRect = CGRect(x: cellX, y: cellY, width: cellW, height: cellH) gc.setStrokeColor(C_BORDER.cgColor); gc.setLineWidth(0.5) gc.stroke(frameRect) photo.draw(in: CGRect(x: drawX, y: drawY, width: drawW, height: drawH)) col += 1 if col == cols { col = 0 pctx.advance(cellH + gap) } } if col != 0 { pctx.advance(cellH + gap) } // flush partial row pctx.advance(4) } // MARK: - Resolution Details private func drawResolution(_ pctx: PDFContext, notes: String) { pctx.need(24) A("Resolution Details", size: 10, weight: .bold, color: C_GREEN) .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 16)) pctx.advance(18) drawHR(pctx.gc, y: pctx.y, color: C_GREEN) pctx.advance(6) let boxH = max(wrappedHeight(notes, size: 9, width: kBodyW - 16) + 12, 24) pctx.need(boxH + 8) let gc = pctx.gc; let y = pctx.y gc.setFillColor(C_GREENBG.cgColor) gc.fill(CGRect(x: kM, y: y, width: kBodyW, height: boxH)) AM(notes, size: 9) .draw(in: CGRect(x: kM + 8, y: y + 6, width: kBodyW - 16, height: boxH)) pctx.advance(boxH + 10) } // MARK: - Verification private func drawVerification(_ pctx: PDFContext, verifiedAt: Date, note: String?) { pctx.need(24) A("Verification", size: 10, weight: .bold, color: C_GREEN) .draw(in: CGRect(x: kM, y: pctx.y, width: kBodyW, height: 16)) pctx.advance(18) drawHR(pctx.gc, y: pctx.y, color: C_GREEN) pctx.advance(6) let dateStr: String = { let f = DateFormatter(); f.dateStyle = .medium; f.timeStyle = .short return f.string(from: verifiedAt) }() let noteText = (note?.isEmpty == false) ? note! : nil let noteH = noteText.map { wrappedHeight($0, size: 8.5, width: kBodyW - 16) } ?? 0 let boxH = max(14 + (noteText != nil ? noteH + 8 : 0) + 12, 24) pctx.need(boxH + 8) let gc = pctx.gc; let y = pctx.y gc.setFillColor(C_GREENBG.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)) A("Verified on \(dateStr)", size: 9, weight: .semibold) .draw(in: CGRect(x: kM + 8, y: y + 6, width: kBodyW - 16, height: 14)) if let noteText { AM(noteText, size: 8.5, color: C_SLATE) .draw(in: CGRect(x: kM + 8, y: y + 22, width: kBodyW - 16, height: noteH + 4)) } pctx.advance(boxH + 8) } // MARK: - Micro helpers private func drawHR(_ gc: CGContext, y: CGFloat, w: CGFloat = 1.0, color: UIColor = C_BORDER) { gc.setStrokeColor(color.cgColor); gc.setLineWidth(w) gc.move(to: CGPoint(x: kM, y: y)); gc.addLine(to: CGPoint(x: kM + kBodyW, y: y)) gc.strokePath() }