diff --git a/BifcodePackage/Sources/BifcodeFeature/ContentView.swift b/BifcodePackage/Sources/BifcodeFeature/ContentView.swift index 24e6a78..64f7b30 100644 --- a/BifcodePackage/Sources/BifcodeFeature/ContentView.swift +++ b/BifcodePackage/Sources/BifcodeFeature/ContentView.swift @@ -91,6 +91,7 @@ public struct ContentView: View { @AppStorage("fontSize") private var fontSize: Double = 14 @AppStorage("selectedTheme") private var selectedThemeRaw: String = EditorThemeOption.atomOneDark.rawValue @AppStorage("themeMode") private var themeModeRaw: String = ThemeMode.dark.rawValue + @AppStorage("exportFormat") private var exportFormatRaw: String = ExportFormat.png.rawValue private var layout: WindowLayout { WindowLayout(rawValue: windowLayoutRaw) ?? .horizontal @@ -112,6 +113,10 @@ public struct ContentView: View { ThemeMode(rawValue: themeModeRaw) ?? .dark } + private var exportFormat: ExportFormat { + ExportFormat(rawValue: exportFormatRaw) ?? .png + } + public init() {} /// Check if both code panels are empty (export should be disabled) @@ -251,7 +256,7 @@ public struct ContentView: View { } do { - let savedURL = try await viewModel.saveImage(nsImage) + let savedURL = try await viewModel.saveImage(nsImage, format: exportFormat) withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.25)) { exportResult = .success(savedURL) } diff --git a/BifcodePackage/Sources/BifcodeFeature/Models/SettingsTypes.swift b/BifcodePackage/Sources/BifcodeFeature/Models/SettingsTypes.swift index 1ce1ca0..d0728d5 100644 --- a/BifcodePackage/Sources/BifcodeFeature/Models/SettingsTypes.swift +++ b/BifcodePackage/Sources/BifcodeFeature/Models/SettingsTypes.swift @@ -246,6 +246,59 @@ public enum WindowLayout: String, CaseIterable, Sendable { } } +// MARK: - Export Format + +/// The file format for exported images. +/// +/// Controls whether exports are saved as PNG images or PDF documents. +/// +/// ## Usage +/// +/// ```swift +/// @AppStorage("exportFormat") private var exportFormat = ExportFormat.png.rawValue +/// ``` +/// +/// ## Topics +/// +/// ### Formats +/// +/// - ``png`` +/// - ``pdf`` +public enum ExportFormat: String, CaseIterable, Sendable { + /// PNG image format. + /// + /// Raster format with full color support and transparency. + /// Ideal for sharing on social media and web. + case png + + /// PDF document format. + /// + /// Vector format that maintains quality at any zoom level. + /// Ideal for printing and documentation. + case pdf + + /// A human-readable label for display in pickers. + public var label: String { + switch self { + case .png: "PNG" + case .pdf: "PDF" + } + } + + /// The file extension for this format. + public var fileExtension: String { + rawValue + } + + /// The UTType identifier for this format. + public var utType: String { + switch self { + case .png: "public.png" + case .pdf: "com.adobe.pdf" + } + } +} + // MARK: - Theme Mode /// The color mode for the code editor themes. diff --git a/BifcodePackage/Sources/BifcodeFeature/ViewModels/AppViewModel.swift b/BifcodePackage/Sources/BifcodeFeature/ViewModels/AppViewModel.swift index e5d4dc6..dc82993 100644 --- a/BifcodePackage/Sources/BifcodeFeature/ViewModels/AppViewModel.swift +++ b/BifcodePackage/Sources/BifcodeFeature/ViewModels/AppViewModel.swift @@ -7,6 +7,7 @@ import CodeEditLanguages import Foundation +import PDFKit import SwiftUI /// The main view model managing application state and export functionality. @@ -148,11 +149,13 @@ public final class AppViewModel { /// Save an image to the configured save location. /// /// Handles security-scoped resource access for bookmarked locations. - /// - Parameter image: The NSImage to save as PNG - /// - Returns: The URL where the image was saved + /// - Parameters: + /// - image: The NSImage to save + /// - format: The export format (PNG or PDF) + /// - Returns: The URL where the file was saved /// - Throws: `ExportError` if conversion or saving fails @discardableResult - public func saveImage(_ image: NSImage) async throws -> URL { + public func saveImage(_ image: NSImage, format: ExportFormat = .png) async throws -> URL { let location = saveLocation let needsSecurityScope = hasCustomSaveLocation @@ -169,9 +172,21 @@ public final class AppViewModel { } } - let filename = "bifcode-\(Date().timeIntervalSince1970).png" + let filename = "bifcode-\(Date().timeIntervalSince1970).\(format.fileExtension)" let url = location.appendingPathComponent(filename) + switch format { + case .png: + try savePNG(image: image, to: url) + case .pdf: + try savePDF(image: image, to: url) + } + + return url + } + + /// Saves an image as PNG to the specified URL. + private func savePNG(image: NSImage, to url: URL) throws { guard let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData), let pngData = bitmap.representation(using: .png, properties: [:]) @@ -180,7 +195,35 @@ public final class AppViewModel { } try pngData.write(to: url) - return url + } + + /// Saves an image as PDF to the specified URL. + private func savePDF(image: NSImage, to url: URL) throws { + let imageSize = image.size + + // Create PDF data with the image rendered as a PDF page + let pdfData = NSMutableData() + var mediaBox = CGRect(origin: .zero, size: imageSize) + + guard let consumer = CGDataConsumer(data: pdfData as CFMutableData), + let pdfContext = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) + else { + throw ExportError.conversionFailed + } + + pdfContext.beginPDFPage(nil) + + // Draw the image in the PDF context + if let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) { + pdfContext.draw(cgImage, in: mediaBox) + } else { + throw ExportError.conversionFailed + } + + pdfContext.endPDFPage() + pdfContext.closePDF() + + try pdfData.write(to: url) } } @@ -219,8 +262,8 @@ public enum ExportError: LocalizedError { public var errorDescription: String? { switch self { - case .conversionFailed: "Failed to convert image to PNG" - case .saveFailed: "Failed to save image" + case .conversionFailed: "Failed to convert image" + case .saveFailed: "Failed to save file" case .accessDenied: "Cannot access save location. Please choose a new folder." } } diff --git a/BifcodePackage/Sources/BifcodeFeature/Views/ToolBarView.swift b/BifcodePackage/Sources/BifcodeFeature/Views/ToolBarView.swift index 1232ac2..4ac756b 100644 --- a/BifcodePackage/Sources/BifcodeFeature/Views/ToolBarView.swift +++ b/BifcodePackage/Sources/BifcodeFeature/Views/ToolBarView.swift @@ -60,6 +60,7 @@ public struct ToolBarView: View { @AppStorage("fontSize") private var fontSize: Double = 14 @AppStorage("selectedTheme") private var selectedThemeRaw: String = EditorThemeOption.atomOneDark.rawValue @AppStorage("themeMode") private var themeModeRaw: String = ThemeMode.dark.rawValue + @AppStorage("exportFormat") private var exportFormatRaw: String = ExportFormat.png.rawValue @AppStorage("selectedLanguage") private var selectedLanguageRaw: String = "swift" @@ -90,6 +91,11 @@ public struct ToolBarView: View { EditorThemeOption.allCases } + /// Current export format derived from stored raw value + private var currentExportFormat: ExportFormat { + ExportFormat(rawValue: exportFormatRaw) ?? .png + } + @Binding private var doTitleSetting: String @Binding private var dontTitleSetting: String @@ -510,6 +516,20 @@ public struct ToolBarView: View { .accessibilityHint("Title shown above negative code example") } + // MARK: - Export Format + + private var exportFormatPicker: some View { + Picker("", selection: $exportFormatRaw) { + ForEach(ExportFormat.allCases, id: \.rawValue) { format in + Text(format.label).tag(format.rawValue) + } + } + .pickerStyle(.segmented) + .fixedSize() + .accessibilityLabel("Export Format") + .accessibilityHint("Choose PNG or PDF format") + } + // MARK: - Export Button @State private var isExportButtonHovered = false @@ -538,14 +558,16 @@ public struct ToolBarView: View { isExportButtonHovered = hovering } .disabled(isExportDisabled) - .help("Export as PNG") - .accessibilityLabel("Export as PNG") + .help("Export as \(currentExportFormat.label)") + .accessibilityLabel("Export as \(currentExportFormat.label)") .accessibilityHint(isExportDisabled ? "Disabled: Add code to enable export" - : "Save comparison image to selected folder") + : "Save comparison as \(currentExportFormat.label) to selected folder") // Secondary Actions VStack(spacing: 8) { + exportFormatPicker + Button { chooseSaveLocation() } label: { @@ -555,7 +577,7 @@ public struct ToolBarView: View { .buttonStyle(.borderless) .help("Choose save location") .accessibilityLabel("Choose Save Location") - .accessibilityHint("Select folder for exported images") + .accessibilityHint("Select folder for exported files") storeButton .padding(.top, 32)