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
42 changes: 42 additions & 0 deletions Compositor/IO/ExportPreset.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
110 changes: 108 additions & 2 deletions Compositor/UI/ExportAsSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()))
}
Expand Down
48 changes: 48 additions & 0 deletions CompositorTests/ExportPresetTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading