diff --git a/Sources/Stag/Capture/ThumbnailGeometry.swift b/Sources/Stag/Capture/ThumbnailGeometry.swift new file mode 100644 index 0000000..673f18b --- /dev/null +++ b/Sources/Stag/Capture/ThumbnailGeometry.swift @@ -0,0 +1,14 @@ +import CoreGraphics + +/// Pure sizing math for capture thumbnails, lifted out of CaptureManager so the +/// aspect-fit rule can be tested without any AppKit drawing. +enum ThumbnailGeometry { + /// Scales `size` down to fit within a `maxDimension` box (applied to each + /// side), preserving aspect ratio. Never upscales. Returns `.zero` for + /// non-positive input so callers can skip drawing. + static func fittedSize(for size: CGSize, maxDimension: CGFloat) -> CGSize { + guard size.width > 0, size.height > 0 else { return .zero } + let scale = min(maxDimension / size.width, maxDimension / size.height, 1.0) + return CGSize(width: size.width * scale, height: size.height * scale) + } +} diff --git a/Sources/Stag/CaptureManager.swift b/Sources/Stag/CaptureManager.swift index b9d1aad..6092014 100644 --- a/Sources/Stag/CaptureManager.swift +++ b/Sources/Stag/CaptureManager.swift @@ -401,11 +401,8 @@ final class CaptureManager { } private func saveJPEGThumbnail(_ 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), diff --git a/Tests/StagTests/ThumbnailGeometryTests.swift b/Tests/StagTests/ThumbnailGeometryTests.swift new file mode 100644 index 0000000..1cd32c8 --- /dev/null +++ b/Tests/StagTests/ThumbnailGeometryTests.swift @@ -0,0 +1,43 @@ +import XCTest +import CoreGraphics +@testable import Stag + +/// Aspect-fit sizing extracted from CaptureManager.saveJPEGThumbnail. +final class ThumbnailGeometryTests: XCTestCase { + + func testLandscapeFitsToWidth() { + let s = ThumbnailGeometry.fittedSize(for: CGSize(width: 640, height: 320), maxDimension: 320) + XCTAssertEqual(s.width, 320, accuracy: 0.001) + XCTAssertEqual(s.height, 160, accuracy: 0.001) + } + + func testPortraitFitsToHeight() { + let s = ThumbnailGeometry.fittedSize(for: CGSize(width: 320, height: 640), maxDimension: 320) + XCTAssertEqual(s.width, 160, accuracy: 0.001) + XCTAssertEqual(s.height, 320, accuracy: 0.001) + } + + func testNeverUpscalesSmallImage() { + let input = CGSize(width: 100, height: 80) + let s = ThumbnailGeometry.fittedSize(for: input, maxDimension: 320) + XCTAssertEqual(s, input) + } + + func testAspectRatioPreserved() { + let s = ThumbnailGeometry.fittedSize(for: CGSize(width: 1000, height: 250), maxDimension: 320) + XCTAssertEqual(s.width / s.height, 4.0, accuracy: 0.001) + XCTAssertLessThanOrEqual(s.width, 320) + XCTAssertLessThanOrEqual(s.height, 320) + } + + func testZeroAndNegativeReturnZero() { + XCTAssertEqual(ThumbnailGeometry.fittedSize(for: .zero, maxDimension: 320), .zero) + XCTAssertEqual(ThumbnailGeometry.fittedSize(for: CGSize(width: -10, height: 50), maxDimension: 320), .zero) + } + + func testSquareScalesUniformly() { + let s = ThumbnailGeometry.fittedSize(for: CGSize(width: 800, height: 800), maxDimension: 320) + XCTAssertEqual(s.width, 320, accuracy: 0.001) + XCTAssertEqual(s.height, 320, accuracy: 0.001) + } +}