Skip to content
Merged
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
5 changes: 1 addition & 4 deletions Sources/Stag/CaptureManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
12 changes: 3 additions & 9 deletions Sources/Stag/Models/CaptureHistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
34 changes: 34 additions & 0 deletions Sources/Stag/Utils/ImageExportFormat.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
}
12 changes: 1 addition & 11 deletions Sources/Stag/Views/Editor/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions Tests/StagTests/ImageExportFormatTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading