Skip to content
Draft
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
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,22 @@ Xcode project.

## Targets

_No apps or modules yet._ Add targets to `Project.swift` using `macApp()` or `framework()` helpers.
| Target | Type | Bundle ID | Platforms |
|--------------------|------------|--------------------------------|----------------------------|
| PhotoFramer | Mobile App | com.stuff.photo-framer | iPhone, iPad, Mac Catalyst |
| PhotoFramerTests | Unit Tests | com.stuff.photo-framer.tests | iPhone, iPad, Mac Catalyst |

Add more targets to `Project.swift` using `mobileApp()`, `macApp()`, or `framework()` helpers.

### PhotoFramer

Photo framing app — import photos from the library, file picker, or drag-and-drop,
then crop-to-fill or fit-with-mat to standard print (4×6, 5×7, 8×10, 11×14) and
social (1:1, 4:5, 16:9, 9:16) sizes. Export to photo library or file system.

Architecture: `@Observable` view model → stateless `FramingService` (pure CGImage
operations) → `PhotoExporter` for I/O. SwiftUI throughout; uses `PhotosPicker` and
`.fileImporter()` (no UIKit bridges for pickers).

## Deployment

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
6 changes: 6 additions & 0 deletions PhotoFramer/Resources/Assets.xcassets/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
10 changes: 10 additions & 0 deletions PhotoFramer/Sources/App/PhotoFramerApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SwiftUI

@main
struct PhotoFramerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
37 changes: 37 additions & 0 deletions PhotoFramer/Sources/Models/FrameSize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

enum FrameSizeCategory: String, CaseIterable, Identifiable, Sendable {
case print = "Print"
case social = "Social"

var id: String { rawValue }
}

struct FrameSize: Identifiable, Hashable, Sendable {
let id: String
let label: String
let category: FrameSizeCategory
let widthRatio: CGFloat
let heightRatio: CGFloat

var aspectRatio: CGFloat { widthRatio / heightRatio }
}

extension FrameSize {
static let allSizes: [FrameSize] = [
// Print
.init(id: "4x6", label: "4 × 6", category: .print, widthRatio: 4, heightRatio: 6),
.init(id: "5x7", label: "5 × 7", category: .print, widthRatio: 5, heightRatio: 7),
.init(id: "8x10", label: "8 × 10", category: .print, widthRatio: 8, heightRatio: 10),
.init(id: "11x14", label: "11 × 14", category: .print, widthRatio: 11, heightRatio: 14),
// Social
.init(id: "1:1", label: "Square (1:1)", category: .social, widthRatio: 1, heightRatio: 1),
.init(id: "4:5", label: "Portrait (4:5)", category: .social, widthRatio: 4, heightRatio: 5),
.init(id: "16:9", label: "Landscape (16:9)", category: .social, widthRatio: 16, heightRatio: 9),
.init(id: "9:16", label: "Story (9:16)", category: .social, widthRatio: 9, heightRatio: 16),
]

static func sizes(for category: FrameSizeCategory) -> [FrameSize] {
allSizes.filter { $0.category == category }
}
}
15 changes: 15 additions & 0 deletions PhotoFramer/Sources/Models/FramingConfiguration.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import SwiftUI

struct FramingConfiguration {
var frameSize: FrameSize
var mode: FramingMode
var matColor: Color
var outputResolution: Int

static let `default` = FramingConfiguration(
frameSize: FrameSize.allSizes[0],
mode: .cropToFill,
matColor: .white,
outputResolution: 3000
)
}
6 changes: 6 additions & 0 deletions PhotoFramer/Sources/Models/FramingMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
enum FramingMode: String, CaseIterable, Identifiable, Sendable {
case cropToFill = "Crop to Fill"
case fitWithMat = "Fit with Mat"

var id: String { rawValue }
}
17 changes: 17 additions & 0 deletions PhotoFramer/Sources/Models/ImportedPhoto.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import CoreGraphics

struct ImportedPhoto {
let cgImage: CGImage
let originalWidth: Int
let originalHeight: Int

var aspectRatio: CGFloat {
CGFloat(originalWidth) / CGFloat(originalHeight)
}

init(cgImage: CGImage) {
self.cgImage = cgImage
self.originalWidth = cgImage.width
self.originalHeight = cgImage.height
}
}
114 changes: 114 additions & 0 deletions PhotoFramer/Sources/Services/FramingService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import CoreGraphics
import SwiftUI

enum FramingService {

/// Apply a full `FramingConfiguration` to an image.
static func frame(image: CGImage, configuration: FramingConfiguration) -> CGImage? {
let targetAR = configuration.frameSize.aspectRatio

switch configuration.mode {
case .cropToFill:
guard let cropped = cropToFill(image: image, targetAspectRatio: targetAR) else {
return nil
}
return cropped.scaled(maxDimension: configuration.outputResolution)

case .fitWithMat:
let outputSize = outputSize(
for: targetAR,
resolution: configuration.outputResolution
)
let cgColor = cgColor(from: configuration.matColor)
return fitWithMat(
image: image,
targetAspectRatio: targetAR,
matColor: cgColor,
outputSize: outputSize
)
}
}

/// Crop the image to exactly fill the target aspect ratio, center-cropping the excess axis.
static func cropToFill(image: CGImage, targetAspectRatio: CGFloat) -> CGImage? {
let imageWidth = CGFloat(image.width)
let imageHeight = CGFloat(image.height)
let imageAR = imageWidth / imageHeight

let cropRect: CGRect
if imageAR > targetAspectRatio {
// Image is wider — crop horizontally
let cropWidth = imageHeight * targetAspectRatio
let originX = (imageWidth - cropWidth) / 2
cropRect = CGRect(x: originX, y: 0, width: cropWidth, height: imageHeight)
} else {
// Image is taller — crop vertically
let cropHeight = imageWidth / targetAspectRatio
let originY = (imageHeight - cropHeight) / 2
cropRect = CGRect(x: 0, y: originY, width: imageWidth, height: cropHeight)
}

return image.cropping(to: cropRect)
}

/// Fit the entire image inside the target aspect ratio with a colored mat around it.
static func fitWithMat(
image: CGImage,
targetAspectRatio: CGFloat,
matColor: CGColor,
outputSize: CGSize
) -> CGImage? {
let canvasWidth = outputSize.width
let canvasHeight = outputSize.height
let canvasAR = canvasWidth / canvasHeight

let imageAR = CGFloat(image.width) / CGFloat(image.height)

let drawRect: CGRect
if imageAR > canvasAR {
// Image is wider than canvas — fit to width
let drawWidth = canvasWidth
let drawHeight = drawWidth / imageAR
let y = (canvasHeight - drawHeight) / 2
drawRect = CGRect(x: 0, y: y, width: drawWidth, height: drawHeight)
} else {
// Image is taller than canvas — fit to height
let drawHeight = canvasHeight
let drawWidth = drawHeight * imageAR
let x = (canvasWidth - drawWidth) / 2
drawRect = CGRect(x: x, y: 0, width: drawWidth, height: drawHeight)
}

return image.drawnOnCanvas(
canvasSize: outputSize,
drawRect: drawRect,
backgroundColor: matColor
)
}

// MARK: - Helpers

/// Calculate the output pixel dimensions for a target aspect ratio and resolution.
static func outputSize(for aspectRatio: CGFloat, resolution: Int) -> CGSize {
if aspectRatio >= 1 {
// Landscape or square: width is the longest edge
let width = CGFloat(resolution)
let height = width / aspectRatio
return CGSize(width: width, height: height)
} else {
// Portrait: height is the longest edge
let height = CGFloat(resolution)
let width = height * aspectRatio
return CGSize(width: width, height: height)
}
}

/// Convert a SwiftUI `Color` to a `CGColor`, falling back to white.
private static func cgColor(from color: Color) -> CGColor {
#if canImport(UIKit)
UIColor(color).cgColor
#else
NSColor(color).cgColor
#endif
}
}
87 changes: 87 additions & 0 deletions PhotoFramer/Sources/Services/PhotoExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import CoreGraphics
import Photos
import SwiftUI
import UIKit
import UniformTypeIdentifiers

// MARK: - Export to Photo Library

enum PhotoExporter {

enum ExportError: LocalizedError {
case notAuthorized
case saveFailed(Error)
case encodingFailed

var errorDescription: String? {
switch self {
case .notAuthorized:
"Photo library access not authorized."
case .saveFailed(let error):
"Failed to save photo: \(error.localizedDescription)"
case .encodingFailed:
"Failed to encode image."
}
}
}

static func saveToPhotoLibrary(_ image: CGImage) async throws {
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
guard status == .authorized || status == .limited else {
throw ExportError.notAuthorized
}

try await PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
guard let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.95) else {
return
}
request.addResource(with: .photo, data: data, options: nil)
}
}

static func saveToFile(_ image: CGImage, url: URL, format: ImageFormat) throws {
let data: Data
let uiImage = UIImage(cgImage: image)

switch format {
case .jpeg(let quality):
guard let encoded = uiImage.jpegData(compressionQuality: quality) else {
throw ExportError.encodingFailed
}
data = encoded
case .png:
guard let encoded = uiImage.pngData() else {
throw ExportError.encodingFailed
}
data = encoded
}

try data.write(to: url, options: .atomic)
}

enum ImageFormat {
case jpeg(quality: CGFloat)
case png
}
}

// MARK: - Transferable for fileExporter

struct FramedImageDocument: FileDocument {
static var readableContentTypes: [UTType] { [.png] }

let imageData: Data

init(image: CGImage) {
self.imageData = UIImage(cgImage: image).pngData() ?? Data()
}

init(configuration: ReadConfiguration) throws {
self.imageData = configuration.file.regularFileContents ?? Data()
}

func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: imageData)
}
}
Loading
Loading