06/19 Update inspections/issues sent via email, and duplicated issue photos issues
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
// 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
|
||||
|
||||
// Image compression — keeps the PDF (and thus the email attachment) small
|
||||
// regardless of source photo resolution. Same values as IssuePDFGenerator.
|
||||
private let kImgMaxPx: CGFloat = 700
|
||||
private let kImgJPEGQuality: CGFloat = 0.55
|
||||
|
||||
/// Downscale to kImgMaxPx on the longer side and re-encode as JPEG at
|
||||
/// kImgJPEGQuality, returning a fresh UIImage built from the compressed
|
||||
/// bytes. Applied to every photo before it's embedded in the PDF — this is
|
||||
/// the main lever for keeping file size minimal.
|
||||
private 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
|
||||
}
|
||||
|
||||
// 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 raw = UIImage(contentsOfFile: path) 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..<cols {
|
||||
let x = kM + CGFloat(c) * colW
|
||||
gc.move(to: CGPoint(x: x, y: y0)); gc.addLine(to: CGPoint(x: x, 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()
|
||||
|
||||
// Draw cells
|
||||
var cursorY = y0
|
||||
for (rowIdx, row) in allRows.enumerated() {
|
||||
let rh = rowHeights[rowIdx]
|
||||
for (col, label, value) in row {
|
||||
let cx = kM + CGFloat(col) * colW + 6
|
||||
A(label, size: 7, color: C_SLATE)
|
||||
.draw(in: CGRect(x: cx, y: cursorY + cellVPad - 2, width: valueW, height: labelH))
|
||||
AM(value, size: valueFontSize, weight: .semibold)
|
||||
.draw(in: CGRect(x: cx, y: cursorY + cellVPad + labelH + 1,
|
||||
width: valueW, height: rh - labelH - cellVPad))
|
||||
}
|
||||
cursorY += rh
|
||||
}
|
||||
|
||||
pctx.advance(tableH + 12)
|
||||
}
|
||||
|
||||
// MARK: - Form Fields
|
||||
|
||||
private func drawFormFields(_ pctx: PDFContext,
|
||||
schema: [[String: Any]],
|
||||
values: [String: String],
|
||||
images: [String: UIImage]) {
|
||||
// Section heading
|
||||
pctx.need(22 + 6)
|
||||
A("Inspection Results", 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 skipTypes: Set<String> = ["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
|
||||
|
||||
// Group by original row
|
||||
var rowGroups: [Int: [[String: Any]]] = [:]
|
||||
var rowOrder: [Int] = []
|
||||
var pendingSec: String? = nil
|
||||
var secForRow: [Int: String] = [:]
|
||||
|
||||
for f in schema {
|
||||
let ftype = f["type"] as? String ?? ""
|
||||
if skipTypes.contains(ftype) { continue }
|
||||
if ftype == "section" { pendingSec = f["label"] as? String ?? ""; continue }
|
||||
let row = f["row"] as? Int ?? 1
|
||||
if rowGroups[row] == nil {
|
||||
rowOrder.append(row)
|
||||
rowGroups[row] = []
|
||||
if let s = pendingSec { secForRow[row] = s; pendingSec = nil }
|
||||
}
|
||||
rowGroups[row]!.append(f)
|
||||
}
|
||||
|
||||
for origRow in rowOrder.sorted() {
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// 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 { UIImage(contentsOfFile: $0) }
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user