diff --git a/Sources/Stag/CaptureManager.swift b/Sources/Stag/CaptureManager.swift index 7728906..d2605d0 100644 --- a/Sources/Stag/CaptureManager.swift +++ b/Sources/Stag/CaptureManager.swift @@ -393,10 +393,7 @@ final class CaptureManager { } private func saveJPEG(_ image: NSImage, to url: URL, quality: Double) { - guard let tiff = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiff) else { return } - let props: [NSBitmapImageRep.PropertyKey: Any] = [.compressionFactor: quality] - guard let data = bitmap.representation(using: .jpeg, properties: props) else { return } + guard let data = image.encoded(as: .jpeg(quality: quality)) else { return } try? data.write(to: url) } diff --git a/Sources/Stag/Models/CaptureHistoryStore.swift b/Sources/Stag/Models/CaptureHistoryStore.swift index 3f16296..ca35eeb 100644 --- a/Sources/Stag/Models/CaptureHistoryStore.swift +++ b/Sources/Stag/Models/CaptureHistoryStore.swift @@ -140,20 +140,14 @@ final class CaptureHistoryStore: ObservableObject { } static func writeThumbnail(_ image: NSImage, to url: URL) { - let maxDim: CGFloat = 320 - let w = image.size.width, h = image.size.height - guard w > 0, h > 0 else { return } - let scale = min(maxDim / w, maxDim / h, 1.0) - let thumbSize = CGSize(width: w * scale, height: h * scale) + let thumbSize = ThumbnailGeometry.fittedSize(for: image.size, maxDimension: 320) + guard thumbSize.width > 0 else { return } let thumb = NSImage(size: thumbSize) thumb.lockFocus() image.draw(in: NSRect(origin: .zero, size: thumbSize), from: NSRect(origin: .zero, size: image.size), operation: .copy, fraction: 1) thumb.unlockFocus() - let props: [NSBitmapImageRep.PropertyKey: Any] = [.compressionFactor: 0.7] - guard let tiff = thumb.tiffRepresentation, - let bm = NSBitmapImageRep(data: tiff), - let data = bm.representation(using: .jpeg, properties: props) else { return } + guard let data = thumb.encoded(as: .jpeg(quality: 0.7)) else { return } try? data.write(to: url) } diff --git a/Sources/Stag/Utils/ImageExportFormat.swift b/Sources/Stag/Utils/ImageExportFormat.swift new file mode 100644 index 0000000..1a27724 --- /dev/null +++ b/Sources/Stag/Utils/ImageExportFormat.swift @@ -0,0 +1,34 @@ +import Cocoa + +/// The on-disk encoding for an exported image. Centralizes the format choice and +/// the encode-to-`Data` step that were previously inlined (and, for JPEG, +/// duplicated) across the editor's save path and CaptureManager. +enum ImageExportFormat: Equatable { + case png + case jpeg(quality: Double) + + /// Chooses a format from a destination path's extension: `.jpg`/`.jpeg` → + /// JPEG (quality 0.9), anything else → PNG. + static func forPath(_ path: String) -> ImageExportFormat { + let lower = path.lowercased() + if lower.hasSuffix(".jpg") || lower.hasSuffix(".jpeg") { + return .jpeg(quality: 0.9) + } + return .png + } +} + +extension NSImage { + /// Encodes the image to `Data` in the given format, or `nil` if encoding fails. + func encoded(as format: ImageExportFormat) -> Data? { + switch format { + case .png: + return pngData + case .jpeg(let quality): + let props: [NSBitmapImageRep.PropertyKey: Any] = [.compressionFactor: quality] + return tiffRepresentation + .flatMap { NSBitmapImageRep(data: $0) } + .flatMap { $0.representation(using: .jpeg, properties: props) } + } + } +} diff --git a/Sources/Stag/Views/Editor/EditorView.swift b/Sources/Stag/Views/Editor/EditorView.swift index 9b4c5d7..334eef9 100644 --- a/Sources/Stag/Views/Editor/EditorView.swift +++ b/Sources/Stag/Views/Editor/EditorView.swift @@ -1871,17 +1871,7 @@ struct EditorView: View { @discardableResult private static func writeImageSync(_ image: NSImage, to path: String) -> Bool { let url = URL(fileURLWithPath: path) - let lower = path.lowercased() - let data: Data? - if lower.hasSuffix(".jpg") || lower.hasSuffix(".jpeg") { - let props: [NSBitmapImageRep.PropertyKey: Any] = [.compressionFactor: 0.9] - data = image.tiffRepresentation - .flatMap { NSBitmapImageRep(data: $0) } - .flatMap { $0.representation(using: .jpeg, properties: props) } - } else { - data = image.pngData - } - guard let data else { return false } + guard let data = image.encoded(as: .forPath(path)) else { return false } do { try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) diff --git a/Tests/StagTests/ImageExportFormatTests.swift b/Tests/StagTests/ImageExportFormatTests.swift new file mode 100644 index 0000000..a9ffbb2 --- /dev/null +++ b/Tests/StagTests/ImageExportFormatTests.swift @@ -0,0 +1,56 @@ +import XCTest +import Cocoa +@testable import Stag + +/// Format selection + encoding extracted from EditorView.writeImageSync and +/// unified with CaptureManager.saveJPEG. +final class ImageExportFormatTests: XCTestCase { + + // MARK: forPath + + func testPNGExtension() { + XCTAssertEqual(ImageExportFormat.forPath("/tmp/shot.png"), .png) + } + + func testJPGExtensionIsJPEGAt90() { + XCTAssertEqual(ImageExportFormat.forPath("/tmp/shot.jpg"), .jpeg(quality: 0.9)) + } + + func testJPEGExtension() { + XCTAssertEqual(ImageExportFormat.forPath("/tmp/shot.jpeg"), .jpeg(quality: 0.9)) + } + + func testExtensionMatchIsCaseInsensitive() { + XCTAssertEqual(ImageExportFormat.forPath("/tmp/SHOT.JPG"), .jpeg(quality: 0.9)) + XCTAssertEqual(ImageExportFormat.forPath("/tmp/SHOT.PNG"), .png) + } + + func testUnknownOrMissingExtensionDefaultsToPNG() { + XCTAssertEqual(ImageExportFormat.forPath("/tmp/shot"), .png) + XCTAssertEqual(ImageExportFormat.forPath("/tmp/shot.gif"), .png) + XCTAssertEqual(ImageExportFormat.forPath("/tmp/shot.tiff"), .png) + } + + // MARK: encoded(as:) + + private func solidImage() -> NSImage { + let image = NSImage(size: NSSize(width: 4, height: 4)) + image.lockFocus() + NSColor.red.setFill() + NSRect(x: 0, y: 0, width: 4, height: 4).fill() + image.unlockFocus() + return image + } + + func testEncodePNGHasPNGSignature() { + let data = solidImage().encoded(as: .png) + XCTAssertNotNil(data) + XCTAssertEqual(Array(data!.prefix(4)), [0x89, 0x50, 0x4E, 0x47]) // \x89PNG + } + + func testEncodeJPEGHasJPEGSignature() { + let data = solidImage().encoded(as: .jpeg(quality: 0.8)) + XCTAssertNotNil(data) + XCTAssertEqual(Array(data!.prefix(3)), [0xFF, 0xD8, 0xFF]) // SOI marker + } +}