482 lines
19 KiB
Swift
482 lines
19 KiB
Swift
// 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()
|
||
}
|