Files
SonarDesk/SonarDesk/Services/ProjectPDFReportExporter.swift
T
2026-10-01 10:48:59 +01:00

189 lines
13 KiB
Swift

import AppKit
import CoreGraphics
import Foundation
import UniformTypeIdentifiers
@MainActor
enum ProjectPDFReportExporter {
static func export(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws {
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.allowedContentTypes = [.pdf]
panel.nameFieldStringValue = "SonarDesk-\(safe(project.project.name))-\(dateStamp()).pdf"
guard panel.runModal() == .OK, let url = panel.url else { return }
try makePDF(project: project, issues: issues, serverURL: serverURL, serverVersion: serverVersion).write(to: url, options: .atomic)
}
static func makePDF(project: ProjectSnapshot, issues: [Issue], serverURL: String, serverVersion: String?) throws -> Data {
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data as CFMutableData) else { throw ExportError.couldNotCreatePDF }
var mediaBox = CGRect(x: 0, y: 0, width: 595, height: 842)
guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { throw ExportError.couldNotCreatePDF }
let writer = Writer(context: context, page: mediaBox)
writer.begin(title: "Project Quality Report", project: project.project.name)
writer.heading("Executive Summary")
writer.paragraph(summary(project: project, issues: issues))
writer.metricGrid([
("Quality Gate", project.qualityGate == "OK" ? "PASSED" : project.qualityGate),
("Bugs", metric(project, "bugs")),
("Vulnerabilities", metric(project, "vulnerabilities")),
("Code Smells", metric(project, "code_smells")),
("Security Hotspots", metric(project, "security_hotspots")),
("Coverage", metric(project, "coverage") + "%"),
("Duplications", metric(project, "duplicated_lines_density") + "%"),
("Lines of Code", metric(project, "ncloc"))
])
writer.heading("Issue Overview")
let severities = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"].map { severity in
(severity.capitalized, issues.filter { ($0.severity ?? "").uppercased() == severity }.count)
}
writer.barChart(severities)
writer.smallText("Open issues exported: \(issues.count). Counts and classifications are supplied by SonarQube.")
writer.heading("Project Information")
writer.keyValues([
("Project", project.project.name), ("Project key", project.project.key),
("SonarQube server", serverURL), ("SonarQube version", serverVersion ?? "Unknown"),
("Last analysis", project.project.lastAnalysisDate ?? "Unknown"),
("Report generated", Date().formatted(date: .long, time: .shortened))
])
writer.heading("Detailed Findings")
if issues.isEmpty {
writer.paragraph("No unresolved issues were returned by SonarQube for this project.")
} else {
for (index, issue) in sorted(issues).enumerated() {
writer.issue(index: index + 1, issue: issue)
}
}
writer.heading("About this report")
writer.paragraph("This report was generated locally by SonarDesk from data returned by the connected SonarQube server. SonarDesk has not independently audited the source code. Findings, ratings and quality-gate results should be interpreted in the context of the SonarQube configuration and analysis used for this project.")
writer.finish()
context.closePDF()
return data as Data
}
private static func metric(_ p: ProjectSnapshot, _ key: String) -> String { p.measures[key] ?? "0" }
private static func safe(_ value: String) -> String { value.replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") }
private static func dateStamp() -> String { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: Date()) }
private static func sorted(_ issues: [Issue]) -> [Issue] {
let rank = ["BLOCKER": 0, "CRITICAL": 1, "MAJOR": 2, "MINOR": 3, "INFO": 4]
return issues.sorted { (rank[$0.severity?.uppercased() ?? ""] ?? 99) < (rank[$1.severity?.uppercased() ?? ""] ?? 99) }
}
private static func summary(project: ProjectSnapshot, issues: [Issue]) -> String {
let gate = project.qualityGate == "OK" ? "passed" : project.qualityGate.lowercased()
return "The SonarQube quality gate is \(gate). The current analysis reports \(metric(project, "bugs")) bugs, \(metric(project, "vulnerabilities")) vulnerabilities, \(metric(project, "code_smells")) code smells and \(metric(project, "security_hotspots")) security hotspots. Test coverage is \(metric(project, "coverage"))% and duplicated-lines density is \(metric(project, "duplicated_lines_density"))%. This report contains \(issues.count) unresolved findings returned by SonarQube."
}
enum ExportError: LocalizedError { case couldNotCreatePDF; var errorDescription: String? { "SonarDesk could not create the PDF report." } }
private final class Writer {
private let context: CGContext
private let page: CGRect
private let margin: CGFloat = 42
private var y: CGFloat = 0
private var pageNumber = 0
private var projectName = ""
private let accent = NSColor(calibratedRed: 0.95, green: 0.42, blue: 0.12, alpha: 1)
init(context: CGContext, page: CGRect) { self.context = context; self.page = page }
func begin(title: String, project: String) {
projectName = project; newPage()
text("SONARDESK", x: margin, y: y, width: 220, font: .boldSystemFont(ofSize: 13), color: accent); y += 28
text(title, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 30)); y += 42
text(project, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 17), color: .darkGray); y += 36
rule(); y += 18
}
func finish() {
footer()
endPage()
}
private func newPage() {
if pageNumber > 0 {
footer()
endPage()
}
context.beginPDFPage(nil)
context.saveGState()
// Quartz PDF pages use a bottom-left origin. The report layout below is
// intentionally expressed in normal AppKit top-left coordinates. Flip
// the PDF context once per page so NSString/NSBezierPath drawing lands
// on the page instead of being rendered outside/inverted.
context.translateBy(x: 0, y: page.height)
context.scaleBy(x: 1, y: -1)
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
pageNumber += 1
y = 52
if pageNumber > 1 {
text("SONARDESK • \(projectName)", x: margin, y: 25, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 8), color: .gray)
}
}
private func endPage() {
NSGraphicsContext.current = nil
context.restoreGState()
context.endPDFPage()
}
private func footer() {
rule(at: page.height - 37)
text("Generated by SonarDesk • SonarQube analysis data", x: margin, y: page.height - 27, width: 360, font: .systemFont(ofSize: 7.5), color: .gray)
text("Page \(pageNumber)", x: page.width - margin - 60, y: page.height - 27, width: 60, font: .systemFont(ofSize: 7.5), color: .gray, align: .right)
}
private func ensure(_ height: CGFloat) { if y + height > page.height - 58 { newPage(); y = 55 } }
func heading(_ value: String) { ensure(44); y += 10; text(value, x: margin, y: y, width: page.width - margin * 2, font: .boldSystemFont(ofSize: 17)); y += 30 }
func paragraph(_ value: String) { let h = measured(value, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5)) + 8; ensure(h); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 10.5), color: .darkGray); y += h }
func smallText(_ value: String) { ensure(28); text(value, x: margin, y: y, width: page.width - margin * 2, font: .systemFont(ofSize: 8.5), color: .gray); y += 25 }
func metricGrid(_ values: [(String,String)]) {
let w = (page.width - margin * 2 - 12) / 2; let h: CGFloat = 56
for i in stride(from: 0, to: values.count, by: 2) {
ensure(h + 10)
metricBox(values[i], x: margin, width: w, height: h)
if i + 1 < values.count { metricBox(values[i+1], x: margin + w + 12, width: w, height: h) }
y += h + 10
}
}
private func metricBox(_ value: (String,String), x: CGFloat, width: CGFloat, height: CGFloat) {
NSColor(calibratedWhite: 0.96, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: x, y: y, width: width, height: height), xRadius: 7, yRadius: 7).fill()
text(value.0.uppercased(), x: x + 12, y: y + 10, width: width - 24, font: .boldSystemFont(ofSize: 7.5), color: .gray)
text(value.1, x: x + 12, y: y + 27, width: width - 24, font: .boldSystemFont(ofSize: 16))
}
func barChart(_ values: [(String,Int)]) {
ensure(CGFloat(values.count) * 30 + 15); let maxValue = max(1, values.map(\.1).max() ?? 1); let chartW = page.width - margin * 2 - 120
for (label, value) in values {
text(label, x: margin, y: y + 3, width: 75, font: .systemFont(ofSize: 9))
NSColor(calibratedWhite: 0.92, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW, height: 12), xRadius: 3, yRadius: 3).fill()
if value > 0 { accent.setFill(); NSBezierPath(roundedRect: CGRect(x: margin + 82, y: y + 4, width: chartW * CGFloat(value) / CGFloat(maxValue), height: 12), xRadius: 3, yRadius: 3).fill() }
text("\(value)", x: page.width - margin - 30, y: y + 2, width: 30, font: .boldSystemFont(ofSize: 9), align: .right); y += 28
}; y += 4
}
func keyValues(_ values: [(String,String)]) {
for value in values { ensure(25); text(value.0, x: margin, y: y, width: 110, font: .boldSystemFont(ofSize: 9), color: .gray); text(value.1, x: margin + 120, y: y, width: page.width - margin * 2 - 120, font: .systemFont(ofSize: 9)); y += 22 }
}
func issue(index: Int, issue: Issue) {
let message = issue.message ?? "Issue"; let details = "\(issue.type ?? "Unknown") • \(issue.severity ?? "Unknown") • \(issue.status ?? "Unknown")"
let location = [issue.component, issue.line.map { "line \($0)" }].compactMap { $0 }.joined(separator: " • ")
let needed = max(88, measured(message, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11)) + 68); ensure(needed)
NSColor(calibratedWhite: 0.975, alpha: 1).setFill(); NSBezierPath(roundedRect: CGRect(x: margin, y: y, width: page.width - margin * 2, height: needed - 8), xRadius: 6, yRadius: 6).fill()
text("\(index). \(message)", x: margin + 12, y: y + 10, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 11))
text(details, x: margin + 12, y: y + 34, width: page.width - margin * 2 - 24, font: .boldSystemFont(ofSize: 8), color: accent)
text(location.isEmpty ? "Location unavailable" : location, x: margin + 12, y: y + 50, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8.5), color: .darkGray)
text("Rule: \(issue.rule ?? "Unknown") • Effort: \(issue.effort ?? "Unknown")", x: margin + 12, y: y + 66, width: page.width - margin * 2 - 24, font: .systemFont(ofSize: 8), color: .gray)
y += needed
}
private func rule(at value: CGFloat? = nil) { NSColor(calibratedWhite: 0.85, alpha: 1).setStroke(); let p = NSBezierPath(); p.move(to: CGPoint(x: margin, y: value ?? y)); p.line(to: CGPoint(x: page.width - margin, y: value ?? y)); p.lineWidth = 0.6; p.stroke() }
private func measured(_ value: String, width: CGFloat, font: NSFont) -> CGFloat { (value as NSString).boundingRect(with: CGSize(width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font]).height }
private func text(_ value: String, x: CGFloat, y: CGFloat, width: CGFloat, font: NSFont, color: NSColor = .black, align: NSTextAlignment = .left) {
let style = NSMutableParagraphStyle(); style.alignment = align; style.lineBreakMode = .byWordWrapping
(value as NSString).draw(with: CGRect(x: x, y: y, width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style])
}
}
}