diff --git a/Compositor/IO/ExportPreset.swift b/Compositor/IO/ExportPreset.swift new file mode 100644 index 000000000..9396e4872 --- /dev/null +++ b/Compositor/IO/ExportPreset.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Reusable screen-export settings, independent of any document or printer profile. +nonisolated struct ExportPreset: Codable, Equatable, Identifiable, Sendable { + enum Format: String, Codable, CaseIterable, Sendable { + case png = "PNG", jpeg = "JPEG", tiff = "TIFF", pdf = "PDF" + } + var id = UUID() + var name: String + var format: Format + /// Nil keeps the original size. A limit only reduces images, never enlarges them. + var longestEdge: Int? + var quality: Double + var red: Double + var green: Double + var blue: Double + + var isValid: Bool { + !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && name.count <= 80 + && (longestEdge.map { (1...DocumentLimits.maxSide).contains($0) } ?? true) + && [quality, red, green, blue].allSatisfy { $0.isFinite && (0...1).contains($0) } + } + + func scale(width: Int, height: Int) -> Double { + guard let longestEdge, width > 0, height > 0 else { return 1 } + return min(1, Double(longestEdge) / Double(max(width, height))) + } +} + +nonisolated enum ExportPresetStore { + static let key = "exportPresets.v1" + + static func load(from defaults: UserDefaults = .standard) -> [ExportPreset] { + guard let data = defaults.data(forKey: key), + let presets = try? JSONDecoder().decode([ExportPreset].self, from: data) else { return [] } + return presets.filter(\.isValid) + } + + static func save(_ presets: [ExportPreset], to defaults: UserDefaults = .standard) throws { + defaults.set(try JSONEncoder().encode(presets), forKey: key) + } +} diff --git a/Compositor/UI/ExportAsSheet.swift b/Compositor/UI/ExportAsSheet.swift index 44d0011c8..60b289c5d 100644 --- a/Compositor/UI/ExportAsSheet.swift +++ b/Compositor/UI/ExportAsSheet.swift @@ -28,6 +28,21 @@ struct ExportAsSheet: View { /// JPEG's quality from the last export, which the next one starts from. private static let qualityKey = "jpegExportQuality" + @State private var presets = ExportPresetStore.load() + @State private var savingPreset = false + @State private var presetName = "" + @State private var limitPresetSize = false + @State private var presetEdge = 1600 + @State private var presetError: String? + private var canSavePreset: Bool { + ExportPreset.Format(rawValue: format.rawValue) != nil && settings.cmyk == nil && !settings.sixteenBit + } + private var cleanPresetName: String { presetName.trimmingCharacters(in: .whitespacesAndNewlines) } + private var validPresetName: Bool { + !cleanPresetName.isEmpty && cleanPresetName.count <= 80 + && !presets.contains { $0.name.localizedCaseInsensitiveCompare(cleanPresetName) == .orderedSame } + } + init(raster: ExportRaster, deepRaster: ExportRaster? = nil, hdrRaster: ExportRaster? = nil, snapshot: ProjectSnapshot? = nil, session: EditorSession, format: ExportFormat, finish: @escaping ((data: Data, format: ExportFormat)?) -> Void) { self.raster = raster @@ -137,6 +152,7 @@ struct ExportAsSheet: View { } }.frame(width: JPEGPreview.frame.width, height: JPEGPreview.frame.height).clipped() .help("Enabled masks apply here, as on the canvas. Drag or scroll to move around; double-click switches between Fit and 100%.") + presetControls Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 12) { GridRow { Text("Format") @@ -275,7 +291,6 @@ struct ExportAsSheet: View { } } .onDisappear { session.previewZoom = nil } - .onChange(of: format) { setScale(Double(width) / Double(raster.image.width)) } .task(id: settings) { let requested = settings error = nil @@ -356,6 +371,96 @@ struct ExportAsSheet: View { } } + private var presetControls: some View { + HStack { + Menu("Presets") { + if presets.isEmpty { Text("No saved presets") } + ForEach(presets) { preset in + Button(preset.name) { applyPreset(preset) } + } + if !presets.isEmpty { + Divider() + Menu("Delete Preset") { + ForEach(presets) { preset in + Button(preset.name, role: .destructive) { + storePresets(presets.filter { $0.id != preset.id }) + } + } + } + } + }.fixedSize() + Button("Save Preset…") { + presetName = "" + limitPresetSize = width != raster.image.width || height != raster.image.height + presetEdge = max(width, height) + savingPreset = true + } + .disabled(!canSavePreset) + .help("Save RGB PNG, JPEG, TIFF or PDF settings at 8 bits per channel. Printer profiles and animation settings are not included.") + .popover(isPresented: $savingPreset) { + VStack(alignment: .leading, spacing: 12) { + Text("Save Export Preset").font(.headline) + TextField("Name (unique, up to 80 characters)", text: $presetName) + if !cleanPresetName.isEmpty && !validPresetName { + Text("Use a unique name of up to 80 characters.").font(.callout).foregroundStyle(.orange) + } + Toggle("Limit longest edge", isOn: $limitPresetSize) + if limitPresetSize { + HStack { + TextField("Pixels", value: $presetEdge, format: .number.grouping(.never)).frame(width: 90) + Text("px · Smaller images keep their size") + } + } else { + Text("Keep the original image dimensions.").foregroundStyle(.secondary) + } + Text("Saves format, JPEG quality and background color.").font(.callout).foregroundStyle(.secondary) + HStack { + Button("Cancel") { savingPreset = false } + Spacer() + Button("Save") { savePreset() } + .disabled(!validPresetName || (limitPresetSize && !(1...DocumentLimits.maxSide).contains(presetEdge))) + } + }.textFieldStyle(.roundedBorder).padding(16).frame(width: 380) + } + Spacer() + if let presetError { Text(presetError).font(.callout).foregroundStyle(.red) } + } + } + + private func applyPreset(_ preset: ExportPreset) { + guard let chosen = ExportFormat(rawValue: preset.format.rawValue) else { return } + DialogColorSwatch.closePicker(session) + format = chosen + cmyk = false + sixteenBit = false + options = JPEGOptions(quality: preset.quality, red: CGFloat(preset.red), + green: CGFloat(preset.green), blue: CGFloat(preset.blue)) + setScale(preset.scale(width: raster.image.width, height: raster.image.height)) + zoom = nil + } + + private func savePreset() { + guard canSavePreset, validPresetName, let chosen = ExportPreset.Format(rawValue: format.rawValue) else { return } + let preset = ExportPreset(name: cleanPresetName, format: chosen, + longestEdge: limitPresetSize ? presetEdge : nil, + quality: options.quality, red: Double(options.red), + green: Double(options.green), blue: Double(options.blue)) + guard preset.isValid else { return } + if storePresets(presets + [preset]) { savingPreset = false } + } + + @discardableResult private func storePresets(_ updated: [ExportPreset]) -> Bool { + do { + try ExportPresetStore.save(updated) + presets = updated + presetError = nil + return true + } catch { + presetError = "Could not save export presets." + return false + } + } + private struct GIFPreviewRequest: Equatable { let resultID: UUID? let isPlaying: Bool @@ -436,7 +541,8 @@ struct ExportAsSheet: View { let maximumSide = DocumentLimits.maxSide let largest = min(Double(maximumSide) / Double(max(raster.image.width, raster.image.height)), (Double(DocumentLimits.maxSurfacePixels) / Double(raster.image.width * raster.image.height)).squareRoot()) - let clamped = min(largest, max(0.01, scale.isFinite ? scale : 1)) + let smallest = 1 / Double(max(raster.image.width, raster.image.height)) + let clamped = min(largest, max(smallest, scale.isFinite ? scale : 1)) width = max(1, Int((Double(raster.image.width) * clamped).rounded())) height = max(1, Int((Double(raster.image.height) * clamped).rounded())) } diff --git a/CompositorTests/ExportPresetTests.swift b/CompositorTests/ExportPresetTests.swift new file mode 100644 index 000000000..350df393e --- /dev/null +++ b/CompositorTests/ExportPresetTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import Compositor + +struct ExportPresetTests { + private func preset(edge: Int? = nil) -> ExportPreset { + ExportPreset(name: "Web", format: .jpeg, longestEdge: edge, + quality: 0.85, red: 1, green: 0.5, blue: 0) + } + + @Test func longestEdgeFitsEitherOrientationWithoutUpscaling() { + let saved = preset(edge: 1600) + #expect(saved.scale(width: 4000, height: 3000) == 0.4) + #expect(saved.scale(width: 3000, height: 4000) == 0.4) + #expect(saved.scale(width: 800, height: 600) == 1) + #expect(preset().scale(width: 4000, height: 3000) == 1) + #expect(preset(edge: 100).scale(width: 30_000, height: 20_000) == 1.0 / 300) + } + + @Test func presetsPersistAndDeleteAcrossLoads() throws { + let suite = "ExportPresetTests-\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let saved = preset(edge: 1600) + try ExportPresetStore.save([saved], to: defaults) + #expect(ExportPresetStore.load(from: defaults) == [saved]) + try ExportPresetStore.save([], to: defaults) + #expect(ExportPresetStore.load(from: defaults).isEmpty) + } + + @Test func invalidAndCorruptPreferencesAreIgnored() throws { + let suite = "ExportPresetTests-\(UUID())" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(Data("invalid JSON".utf8), forKey: ExportPresetStore.key) + #expect(ExportPresetStore.load(from: defaults).isEmpty) + var invalid = preset(edge: 0) + #expect(!invalid.isValid) + invalid = preset() + invalid.quality = 1.1 + #expect(!invalid.isValid) + invalid = preset() + invalid.name = " \n" + #expect(!invalid.isValid) + try ExportPresetStore.save([preset(), invalid], to: defaults) + #expect(ExportPresetStore.load(from: defaults).count == 1) + } +} diff --git a/README.md b/README.md index a8ea53110..487491f4f 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ brew install --cask robbietilton-compositor - Sharp high-quality downsampling when zoomed out, and a pixel grid when zoomed in - Import JPEG, PNG, HEIC, TIFF, WebP, SVG, PDF (every page its own layer, at 300 DPI or fitted to the canvas), animated GIF (one layer whose frames show in a strip under the canvas, to play, paint on, duplicate, delete and retime), camera RAW (with a develop step first) and Photoshop PSD and PSB (8-bit RGB; not CMYK). Each Photoshop artboard opens in a tab of its own; folders, masks, blend modes, fill rectangles/ellipses, and simple horizontal text stay editable; other vectors and vertical text become pixels. A conversion report is shown before anything is applied. - Large documents: the memory budget scales with your Mac, and a Photoshop file too big to open has its layers cropped to the canvas instead -- Export PNG (⇧⌘E) and Export As (⇧⌥⌘S, or ⇧⌥⌘W) for PNG, JPEG, a one-page PDF at the print size or, with a GIF layer, an animated GIF, scaled if you like, with a live preview, JPEG quality and file size; Channel Pack in Export As packs layers into a PNG's red, green, blue and alpha for game textures, and RGB Split's Split to Layers turns a layer back into those four; right-click layers in the Layers panel to export each as its own PNG, trimmed to its pixels; Copy Merged +- Export PNG (⇧⌘E), Export JPEG (⇧⌥⌘S), and Export As (⇧⌥⌘W) for PNG, JPEG, a one-page PDF at the print size or, with a GIF layer, an animated GIF, scaled if you like, with a live preview, JPEG quality and file size; Channel Pack in Export As packs layers into a PNG's red, green, blue and alpha for game textures, and RGB Split's Split to Layers turns a layer back into those four; right-click layers in the Layers panel to export each as its own PNG, trimmed to its pixels; Copy Merged +- Named export presets for RGB PNG, JPEG, TIFF and PDF: save format, JPEG quality and background color, with original dimensions or a longest-edge limit that never enlarges smaller images - Keep working while a project saves - Photoshop-style keyboard shortcuts throughout, remappable in Edit > Keyboard Shortcuts - Drag a number's label to scrub its value, as in Photoshop