Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion BifcodePackage/Sources/BifcodeFeature/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
53 changes: 53 additions & 0 deletions BifcodePackage/Sources/BifcodeFeature/Models/SettingsTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import CodeEditLanguages
import Foundation
import PDFKit
import SwiftUI

/// The main view model managing application state and export functionality.
Expand Down Expand Up @@ -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

Expand All @@ -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: [:])
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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."
}
}
Expand Down
30 changes: 26 additions & 4 deletions BifcodePackage/Sources/BifcodeFeature/Views/ToolBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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)
Expand Down