From 01c3d342f6980a69feb8df27a3454fd721d6298e Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 17 Jul 2026 11:09:02 -0500 Subject: v4.8.0: Add markdown and PDF export formats - DomainExportFormat gains .markdown and .pdf (CaseIterable, Identifiable, titled), alongside the existing text/csv/json. - Markdown reuses the existing text-export content verbatim via a line-based transform (section "Title\n----" underlines become "## Title", the leading title becomes an H1, other lines become bullets), so the two formats can never drift apart. - PDF renders that Markdown as a simple monospaced multi-page document via UIGraphicsPDFRenderer (mirrors AuditExporter's existing PDF approach; degrades to raw Markdown bytes on non-UIKit platforms). - Replaced the single/batch/tracked-domains/workflow share call sites' hand- written per-format switches with format-agnostic functions (exportSingleReportData, exportBatchReportData, exportTrackedDomainsData, exportWorkflowData) that delegate straight to DomainReportExporter, removing the now-orphaned per-format helper functions those switches used to call. --- DomainReportExporter.swift | 102 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) (limited to 'DomainReportExporter.swift') diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift index 05ecff9..1a04c70 100644 --- a/DomainReportExporter.swift +++ b/DomainReportExporter.swift @@ -1,11 +1,28 @@ import Foundation -enum DomainExportFormat: String { +#if canImport(UIKit) +import UIKit +#endif + +enum DomainExportFormat: String, CaseIterable, Identifiable { case text = "txt" case csv = "csv" case json = "json" + case markdown = "md" + case pdf = "pdf" + var id: String { rawValue } var fileExtension: String { rawValue } + + var title: String { + switch self { + case .text: return "TXT" + case .csv: return "CSV" + case .json: return "JSON" + case .markdown: return "Markdown" + case .pdf: return "PDF" + } + } } enum DomainReportExporter { @@ -17,6 +34,10 @@ enum DomainReportExporter { return Data(csv(for: [report]).utf8) case .json: return try jsonEncoder.encode(report) + case .markdown: + return Data(markdown(for: report).utf8) + case .pdf: + return pdfData(fromMarkdown: markdown(for: report)) } } @@ -28,7 +49,86 @@ enum DomainReportExporter { return Data(csv(for: reports).utf8) case .json: return try jsonEncoder.encode(reports) + case .markdown: + return Data(batchMarkdown(for: reports, title: title).utf8) + case .pdf: + return pdfData(fromMarkdown: batchMarkdown(for: reports, title: title)) + } + } + + /// Renders `text(for:)`'s content as Markdown: the leading title becomes an + /// H1, `appendSection`'s "Title\n----" underlines become H2 headers, and + /// other non-empty lines that aren't already list items become bullets. + /// This reuses the exact same section content as the text export rather + /// than re-deriving it, so the two formats never drift. + static func markdown(for report: DomainReport) -> String { + markdown(fromPlainText: text(for: report), title: "DomainDig Report") + } + + static func batchMarkdown(for reports: [DomainReport], title: String) -> String { + markdown(fromPlainText: batchText(for: reports, title: title), title: title) + } + + private static func markdown(fromPlainText text: String, title: String) -> String { + let lines = text.components(separatedBy: "\n") + var output: [String] = ["# \(title)", ""] + var index = 0 + while index < lines.count { + let line = lines[index] + if index == 0, line == title { + index += 1 + continue + } + if index + 1 < lines.count, !line.isEmpty, lines[index + 1] == String(repeating: "-", count: line.count) { + output.append("") + output.append("## \(line)") + index += 2 + continue + } + if line.isEmpty || line.hasPrefix("-") || line.hasPrefix(" ") { + output.append(line) + } else { + output.append("- \(line)") + } + index += 1 + } + return output.joined(separator: "\n") + } + + /// Renders Markdown as a simple monospaced multi-page PDF. Foundation-only + /// consumers (no UIKit available) get the Markdown bytes back instead. + static func pdfData(fromMarkdown markdown: String) -> Data { + #if canImport(UIKit) + let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792)) + return renderer.pdfData { context in + let lines = markdown.components(separatedBy: .newlines) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular), + .paragraphStyle: paragraphStyle + ] + + var yOffset: CGFloat = 36 + context.beginPage() + + for line in lines { + if yOffset > 744 { + context.beginPage() + yOffset = 36 + } + + let renderedLine = NSString(string: line.isEmpty ? " " : line) + renderedLine.draw( + in: CGRect(x: 36, y: yOffset, width: 540, height: 22), + withAttributes: attributes + ) + yOffset += 16 + } } + #else + return Data(markdown.utf8) + #endif } static func text(for report: DomainReport) -> String { -- cgit v1.2.3