06/19 Update inspections/issues sent via email, and duplicated issue photos issues

This commit is contained in:
Nguyen Ngo
2026-06-19 17:28:09 -04:00
parent 95c8238c00
commit a57d6f02ba
7 changed files with 1366 additions and 12 deletions
@@ -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 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 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))
}