Files
JQC_iOS_App/JanitorialQC/Utils/InspectionPDFGenerator.swift

618 lines
26 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 38MB 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))
}