618 lines
26 KiB
Swift
618 lines
26 KiB
Swift
// 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..<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
|
||
|
||
// 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))
|
||
}
|